MappingImport.vue 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867
  1. <template>
  2. <div class="mapping-container">
  3. <div class="header">
  4. <div class="title-group">
  5. <el-button @click="$router.push('/dashboard/apps')" icon="ArrowLeft" circle />
  6. <h2>账号映射管理 - {{ appName }}</h2>
  7. </div>
  8. </div>
  9. <el-tabs v-model="activeTab" class="mapping-tabs">
  10. <!-- Tab 1: Mapping List Management -->
  11. <el-tab-pane label="映射列表" name="list">
  12. <div class="toolbar">
  13. <el-button type="primary" icon="Plus" @click="openAddDialog">手动添加</el-button>
  14. <el-button type="success" icon="Download" @click="handleExport">导出 Excel</el-button>
  15. </div>
  16. <el-table :data="mappings" v-loading="loading" stripe border style="width: 100%; margin-top: 15px;">
  17. <el-table-column prop="user_mobile" label="用户手机号" />
  18. <el-table-column prop="user_status" label="统一认证账号状态" width="150">
  19. <template #default="scope">
  20. <el-tag :type="scope.row.user_status === 'ACTIVE' ? 'success' : 'danger'">
  21. {{ scope.row.user_status === 'ACTIVE' ? '已激活' : scope.row.user_status }}
  22. </el-tag>
  23. </template>
  24. </el-table-column>
  25. <el-table-column prop="mapped_key" label="第三方系统账号 (Key)" />
  26. <el-table-column prop="mapped_email" label="第三方系统邮箱" />
  27. <el-table-column label="操作" width="180">
  28. <template #default="scope">
  29. <el-button type="primary" size="small" icon="Edit" @click="handleEdit(scope.row)">编辑</el-button>
  30. <el-button type="danger" size="small" icon="Delete" @click="handleDelete(scope.row)">删除</el-button>
  31. </template>
  32. </el-table-column>
  33. </el-table>
  34. <div class="pagination">
  35. <el-pagination
  36. v-model:current-page="currentPage"
  37. v-model:page-size="pageSize"
  38. :page-sizes="[10, 20, 50, 100]"
  39. layout="total, sizes, prev, pager, next, jumper"
  40. :total="total"
  41. @size-change="handleSizeChange"
  42. @current-change="handleCurrentChange"
  43. />
  44. </div>
  45. </el-tab-pane>
  46. <!-- Tab 2: Excel Import -->
  47. <el-tab-pane label="Excel 导入" name="import">
  48. <div class="import-panel">
  49. <div v-if="!importResult">
  50. <el-alert
  51. title="导入说明"
  52. type="info"
  53. description="请上传 Excel 文件 (.xlsx, .xls)。第一列为手机号,第二列为映射账号,第三列为映射邮箱(可选)。"
  54. show-icon
  55. :closable="false"
  56. style="margin-bottom: 20px"
  57. />
  58. <el-upload
  59. class="upload-demo"
  60. drag
  61. action="#"
  62. :auto-upload="false"
  63. :on-change="handleFileChange"
  64. :limit="1"
  65. >
  66. <el-icon class="el-icon--upload"><upload-filled /></el-icon>
  67. <div class="el-upload__text">
  68. 拖拽文件到此处或 <em>点击上传</em>
  69. </div>
  70. </el-upload>
  71. <!-- Preview Area -->
  72. <div v-if="previewData" class="preview-area">
  73. <h3>预览结果</h3>
  74. <div class="stat-cards">
  75. <el-card shadow="never" class="stat-card valid">
  76. <div class="number">{{ previewData.valid_count }}</div>
  77. <div class="label">有效数据</div>
  78. </el-card>
  79. <el-card shadow="never" class="stat-card error">
  80. <div class="number">{{ previewData.error_count }}</div>
  81. <div class="label">错误数据</div>
  82. </el-card>
  83. <el-card shadow="never" class="stat-card new">
  84. <div class="number">{{ previewData.new_count }}</div>
  85. <div class="label">新增</div>
  86. </el-card>
  87. <el-card shadow="never" class="stat-card update">
  88. <div class="number">{{ previewData.update_count }}</div>
  89. <div class="label">更新</div>
  90. </el-card>
  91. </div>
  92. <el-table :data="previewData.preview_rows" height="300" style="margin-top: 20px" border>
  93. <el-table-column prop="row_index" label="行号" width="80" />
  94. <el-table-column prop="mobile" label="手机号" width="150" />
  95. <el-table-column prop="mapped_key" label="映射账号" width="150" />
  96. <el-table-column prop="mapped_email" label="映射邮箱" width="150" />
  97. <el-table-column prop="status" label="状态" width="100">
  98. <template #default="scope">
  99. <el-tag :type="getStatusType(scope.row.status)">{{ scope.row.status }}</el-tag>
  100. </template>
  101. </el-table-column>
  102. <el-table-column prop="message" label="信息" />
  103. </el-table>
  104. <div class="action-footer">
  105. <div class="verification-area">
  106. <el-input
  107. v-model="verificationCode"
  108. placeholder="管理员手机验证码"
  109. style="width: 200px; margin-right: 10px;"
  110. />
  111. <el-button type="primary" plain :disabled="cooldown > 0" @click="sendCode">
  112. {{ cooldown > 0 ? `${cooldown}s` : '发送验证码' }}
  113. </el-button>
  114. </div>
  115. <el-radio-group v-model="importStrategy" style="margin-right: 20px">
  116. <el-radio label="SKIP">跳过已存在</el-radio>
  117. <el-radio label="OVERWRITE">覆盖已存在</el-radio>
  118. </el-radio-group>
  119. <el-button type="primary" @click="handleImport" :loading="importing" :disabled="previewData.valid_count === 0">
  120. 确认导入
  121. </el-button>
  122. </div>
  123. </div>
  124. </div>
  125. <!-- Import Result Area -->
  126. <div v-else class="result-area">
  127. <div class="result-header">
  128. <h3>导入完成</h3>
  129. <el-button @click="resetImport">返回导入</el-button>
  130. </div>
  131. <div class="stat-cards">
  132. <el-card shadow="never" class="stat-card valid">
  133. <div class="number">{{ importResult.summary.inserted + importResult.summary.updated }}</div>
  134. <div class="label">成功</div>
  135. </el-card>
  136. <el-card shadow="never" class="stat-card error">
  137. <div class="number">{{ importResult.summary.failed }}</div>
  138. <div class="label">失败</div>
  139. </el-card>
  140. <el-card shadow="never" class="stat-card">
  141. <div class="number">{{ importResult.summary.total_processed }}</div>
  142. <div class="label">总处理</div>
  143. </el-card>
  144. <div style="flex: 1; display: flex; align-items: center; justify-content: center;">
  145. <el-button type="success" size="large" icon="Download" @click="exportResultLogs(importResult.logs)">导出导入日志</el-button>
  146. </div>
  147. </div>
  148. <el-table :data="importResult.logs" height="500" stripe border style="margin-top: 20px">
  149. <el-table-column prop="mobile" label="手机号" width="130" />
  150. <el-table-column prop="mapped_key" label="映射账号" width="130" />
  151. <el-table-column prop="status" label="状态" width="100">
  152. <template #default="scope">
  153. <el-tag :type="scope.row.status === 'Success' ? 'success' : (scope.row.status === 'Skipped' ? 'info' : 'danger')">
  154. {{ scope.row.status === 'Success' ? '成功' : (scope.row.status === 'Skipped' ? '跳过' : '失败') }}
  155. </el-tag>
  156. </template>
  157. </el-table-column>
  158. <el-table-column prop="message" label="详情/失败原因" />
  159. <el-table-column label="新账号密码" width="140">
  160. <template #default="scope">
  161. <span v-if="scope.row.generated_password" style="color: #f56c6c; font-weight: bold;">
  162. {{ scope.row.generated_password }}
  163. </span>
  164. <span v-else>-</span>
  165. </template>
  166. </el-table-column>
  167. </el-table>
  168. </div>
  169. </div>
  170. </el-tab-pane>
  171. <!-- Tab 3: Operation Logs -->
  172. <el-tab-pane label="映射账号操作日志" name="logs">
  173. <div class="toolbar">
  174. <el-input v-model="logKeyword" placeholder="搜索目标手机号" style="width: 200px; margin-right: 10px;" clearable @clear="fetchLogs" @keyup.enter="fetchLogs" />
  175. <el-select v-model="logActionType" placeholder="操作类型" clearable @change="fetchLogs" style="width: 170px; margin-right: 10px;">
  176. <el-option label="手动新增" value="MANUAL_ADD" />
  177. <el-option label="删除" value="DELETE" />
  178. <el-option label="修改" value="UPDATE" />
  179. <el-option label="Excel 导入" value="IMPORT" />
  180. <el-option label="M2M 账号同步" value="SYNC_M2M" />
  181. <el-option label="控制台同步用户" value="SYNC" />
  182. </el-select>
  183. <el-date-picker
  184. v-model="logDateRange"
  185. type="daterange"
  186. range-separator="至"
  187. start-placeholder="开始日期"
  188. end-placeholder="结束日期"
  189. style="width: 240px; margin-right: 10px;"
  190. value-format="YYYY-MM-DD"
  191. @change="fetchLogs"
  192. />
  193. <el-button type="primary" @click="fetchLogs" icon="Search">查询</el-button>
  194. </div>
  195. <el-table :data="logs" v-loading="logsLoading" stripe border style="width: 100%; margin-top: 15px;">
  196. <el-table-column prop="created_at" label="操作时间" width="180">
  197. <template #default="scope">{{ formatDate(scope.row.created_at) }}</template>
  198. </el-table-column>
  199. <el-table-column prop="action_type" label="操作类型" width="120">
  200. <template #default="scope">
  201. <el-tag :type="getActionTypeTag(scope.row.action_type)">
  202. {{ getActionTypeText(scope.row.action_type) }}
  203. </el-tag>
  204. </template>
  205. </el-table-column>
  206. <el-table-column prop="operator_mobile" label="操作人手机号" width="140" />
  207. <el-table-column prop="ip_address" label="来源 IP" width="130">
  208. <template #default="scope">
  209. {{ scope.row.ip_address || '-' }}
  210. </template>
  211. </el-table-column>
  212. <el-table-column prop="target_mobile" label="目标手机号" width="140">
  213. <template #default="scope">
  214. {{ scope.row.target_mobile || (scope.row.action_type === 'IMPORT' || scope.row.action_type === 'SYNC' ? '批量操作' : '-') }}
  215. </template>
  216. </el-table-column>
  217. <el-table-column label="详情" min-width="260">
  218. <template #default="scope">
  219. <div v-if="scope.row.action_type === 'IMPORT'">
  220. <el-button type="primary" link @click="showImportDetails(scope.row)">查看导入详情</el-button>
  221. </div>
  222. <div v-else-if="scope.row.action_type === 'SYNC_M2M'" class="log-details">
  223. 账号同步接口 · 外部账号 {{ scope.row.details?.mapped_key ?? '-' }}
  224. <span v-if="scope.row.details?.mapped_email"> · 邮箱 {{ scope.row.details.mapped_email }}</span>
  225. <span v-if="scope.row.details?.new_user_created"> · 新建用户</span>
  226. <span v-if="scope.row.details?.new_mapping_created"> · 新建映射</span>
  227. <span v-if="scope.row.details && 'is_active' in scope.row.details">
  228. · 映射{{ scope.row.details.is_active ? '启用' : '停用' }}
  229. </span>
  230. </div>
  231. <div v-else-if="scope.row.action_type === 'SYNC'" class="log-details">
  232. 控制台同步 · 模式 {{ scope.row.details?.mode ?? '-' }}
  233. · 尝试 {{ scope.row.details?.total_attempted ?? '-' }}
  234. · 成功 {{ scope.row.details?.success ?? '-' }}
  235. · 失败 {{ scope.row.details?.failed ?? '-' }}
  236. </div>
  237. <div v-else class="log-details">
  238. <span v-if="scope.row.action_type === 'DELETE'">
  239. <template v-if="scope.row.details?.action === 'M2M_DELETE'">
  240. 账号同步接口删除 · 外部账号 {{ scope.row.details?.mapped_key }}
  241. </template>
  242. <template v-else>
  243. 删除映射 ID: {{ scope.row.details?.mapping_id }}
  244. </template>
  245. </span>
  246. <span v-else-if="scope.row.action_type === 'UPDATE'">
  247. 变更前: {{ scope.row.details?.old?.mapped_key || '空' }} -> 变更后: {{ scope.row.details?.new?.mapped_key || '空' }}
  248. </span>
  249. <span v-else-if="scope.row.action_type === 'MANUAL_ADD'">
  250. 新增映射: {{ scope.row.details?.mapped_key }} ({{ scope.row.details?.new_user_created ? '新建用户' : '已有用户' }})
  251. </span>
  252. </div>
  253. </template>
  254. </el-table-column>
  255. </el-table>
  256. <div class="pagination">
  257. <el-pagination
  258. v-model:current-page="logPage"
  259. v-model:page-size="logPageSize"
  260. :page-sizes="[10, 20, 50]"
  261. layout="total, sizes, prev, pager, next, jumper"
  262. :total="logTotal"
  263. @size-change="fetchLogs"
  264. @current-change="fetchLogs"
  265. />
  266. </div>
  267. </el-tab-pane>
  268. </el-tabs>
  269. <!-- Manual Add Dialog -->
  270. <el-dialog v-model="addDialogVisible" title="添加映射" width="400px">
  271. <el-form :model="addForm" label-width="100px">
  272. <el-form-item label="用户手机号" required>
  273. <el-input v-model="addForm.mobile" placeholder="系统内用户手机号" />
  274. </el-form-item>
  275. <el-form-item label="映射账号">
  276. <el-input v-model="addForm.mapped_key" placeholder="第三方系统账号ID" />
  277. </el-form-item>
  278. <el-form-item label="映射邮箱">
  279. <el-input
  280. v-model="addForm.mapped_email"
  281. placeholder="第三方系统邮箱(可选)"
  282. :name="dynamicFields.email"
  283. autocomplete="off"
  284. />
  285. </el-form-item>
  286. <el-form-item label="管理员密码" required>
  287. <el-input
  288. v-model="addForm.password"
  289. type="password"
  290. placeholder="请输入您的登录密码确认"
  291. show-password
  292. :name="dynamicFields.password"
  293. autocomplete="new-password"
  294. />
  295. </el-form-item>
  296. </el-form>
  297. <template #footer>
  298. <span class="dialog-footer">
  299. <el-button @click="addDialogVisible = false">取消</el-button>
  300. <el-button type="primary" @click="confirmAdd" :loading="adding">确定</el-button>
  301. </span>
  302. </template>
  303. </el-dialog>
  304. <!-- Edit Dialog -->
  305. <el-dialog v-model="editDialogVisible" title="编辑映射" width="400px">
  306. <el-form :model="editForm" label-width="100px">
  307. <el-form-item label="用户手机号">
  308. <el-input v-model="editForm.mobile" disabled />
  309. </el-form-item>
  310. <el-form-item label="映射账号">
  311. <el-input v-model="editForm.mapped_key" placeholder="第三方系统账号ID" />
  312. </el-form-item>
  313. <el-form-item label="映射邮箱">
  314. <el-input v-model="editForm.mapped_email" placeholder="第三方系统邮箱(可选)" />
  315. </el-form-item>
  316. <el-form-item label="管理员密码" required>
  317. <el-input v-model="editForm.password" type="password" placeholder="请输入您的登录密码确认" show-password />
  318. </el-form-item>
  319. </el-form>
  320. <template #footer>
  321. <span class="dialog-footer">
  322. <el-button @click="editDialogVisible = false">取消</el-button>
  323. <el-button type="primary" @click="confirmEdit" :loading="editing">确定</el-button>
  324. </span>
  325. </template>
  326. </el-dialog>
  327. <!-- Import Details Dialog -->
  328. <el-dialog v-model="importDetailVisible" title="导入详情" width="900px">
  329. <div v-if="selectedImportLog">
  330. <div class="stat-cards" style="margin-bottom: 15px;">
  331. <el-card shadow="never" class="stat-card">
  332. <div class="number">{{ selectedImportLog.details?.summary?.total_processed || 0 }}</div>
  333. <div class="label">总数</div>
  334. </el-card>
  335. <el-card shadow="never" class="stat-card valid">
  336. <div class="number">{{ (selectedImportLog.details?.summary?.inserted || 0) + (selectedImportLog.details?.summary?.updated || 0) }}</div>
  337. <div class="label">成功</div>
  338. </el-card>
  339. <el-card shadow="never" class="stat-card error">
  340. <div class="number">{{ selectedImportLog.details?.summary?.failed || 0 }}</div>
  341. <div class="label">失败</div>
  342. </el-card>
  343. <div style="flex: 1; display: flex; align-items: center; justify-content: flex-end;">
  344. <el-button type="success" icon="Download" @click="exportResultLogs(selectedImportLog.details?.logs)">导出此记录</el-button>
  345. </div>
  346. </div>
  347. <el-table :data="selectedImportLog.details?.logs || []" height="400" stripe border>
  348. <el-table-column prop="mobile" label="手机号" width="130" />
  349. <el-table-column prop="mapped_key" label="映射账号" width="130" />
  350. <el-table-column prop="status" label="状态" width="100">
  351. <template #default="scope">
  352. <el-tag :type="scope.row.status === 'Success' ? 'success' : (scope.row.status === 'Skipped' ? 'info' : 'danger')">
  353. {{ scope.row.status === 'Success' ? '成功' : (scope.row.status === 'Skipped' ? '跳过' : '失败') }}
  354. </el-tag>
  355. </template>
  356. </el-table-column>
  357. <el-table-column prop="message" label="详情/失败原因" />
  358. </el-table>
  359. </div>
  360. </el-dialog>
  361. </div>
  362. </template>
  363. <script setup lang="ts">
  364. import { ref, onMounted, reactive, onUnmounted } from 'vue'
  365. import { useRoute } from 'vue-router'
  366. import { UploadFilled, ArrowLeft, Plus, Download, Delete, Edit, Search } from '@element-plus/icons-vue'
  367. import { previewMapping, importMapping, sendImportVerificationCode, ImportLogResponse } from '../../api/mapping'
  368. import { getMappings, createMapping, deleteMapping, updateMapping, exportMappings, MappingResponse, getOperationLogs, OperationLog } from '../../api/apps'
  369. import { ElMessage, ElMessageBox } from 'element-plus'
  370. const route = useRoute()
  371. const appId = Number(route.params.id)
  372. const appName = route.query.name as string
  373. const activeTab = ref('list')
  374. // --- List Logic ---
  375. const mappings = ref<MappingResponse[]>([])
  376. const loading = ref(false)
  377. const total = ref(0)
  378. const currentPage = ref(1)
  379. const pageSize = ref(10)
  380. const fetchMappings = async () => {
  381. loading.value = true
  382. try {
  383. const skip = (currentPage.value - 1) * pageSize.value
  384. const res = await getMappings(appId, skip, pageSize.value)
  385. mappings.value = res.data.items
  386. total.value = res.data.total
  387. } catch (e) {
  388. console.error(e)
  389. } finally {
  390. loading.value = false
  391. }
  392. }
  393. const handleSizeChange = (val: number) => {
  394. pageSize.value = val
  395. fetchMappings()
  396. }
  397. const handleCurrentChange = (val: number) => {
  398. currentPage.value = val
  399. fetchMappings()
  400. }
  401. const handleDelete = (row: MappingResponse) => {
  402. ElMessageBox.prompt(`请输入您的登录密码以确认删除 ${row.user_mobile} 的映射`, '安全验证', {
  403. confirmButtonText: '删除',
  404. cancelButtonText: '取消',
  405. inputType: 'password',
  406. inputPattern: /.+/,
  407. inputErrorMessage: '密码不能为空',
  408. type: 'warning'
  409. }).then(async ({ value }) => {
  410. try {
  411. await deleteMapping(appId, row.id, value)
  412. ElMessage.success('删除成功')
  413. fetchMappings()
  414. } catch (e: any) {
  415. if (e.response && e.response.status === 401) {
  416. ElMessage.error('密码错误')
  417. } else {
  418. ElMessage.error('删除失败')
  419. }
  420. }
  421. }).catch(() => {
  422. // cancelled
  423. })
  424. }
  425. const handleExport = async () => {
  426. try {
  427. const res = await exportMappings(appId)
  428. // Create blob link to download
  429. const url = window.URL.createObjectURL(new Blob([res.data]))
  430. const link = document.createElement('a')
  431. link.href = url
  432. link.setAttribute('download', `mappings_${appName}_${new Date().toISOString().slice(0,10)}.xlsx`)
  433. document.body.appendChild(link)
  434. link.click()
  435. document.body.removeChild(link)
  436. } catch (e) {
  437. ElMessage.error('导出失败')
  438. }
  439. }
  440. // --- Manual Add Logic ---
  441. const addDialogVisible = ref(false)
  442. const adding = ref(false)
  443. const addForm = reactive({
  444. mobile: '',
  445. mapped_key: '',
  446. mapped_email: '',
  447. password: ''
  448. })
  449. const dynamicFields = reactive({
  450. email: 'mapped_email',
  451. password: 'admin_password'
  452. })
  453. const openAddDialog = () => {
  454. addForm.mobile = ''
  455. addForm.mapped_key = ''
  456. addForm.mapped_email = ''
  457. addForm.password = ''
  458. // Generate random field names to prevent auto-fill
  459. const randomSuffix = Math.random().toString(36).slice(2, 8)
  460. dynamicFields.email = `email_${randomSuffix}`
  461. dynamicFields.password = `pwd_${randomSuffix}`
  462. addDialogVisible.value = true
  463. }
  464. const confirmAdd = async () => {
  465. if (!addForm.mobile || !addForm.mapped_key || !addForm.password) {
  466. ElMessage.warning('请填写完整,包括管理员密码')
  467. return
  468. }
  469. adding.value = true
  470. try {
  471. const res = await createMapping(appId, addForm)
  472. addDialogVisible.value = false
  473. fetchMappings()
  474. if (res.data.new_user_created && res.data.generated_password) {
  475. ElMessageBox.alert(
  476. `
  477. <div>已自动为您创建统一认证账号:</div>
  478. <div style="margin-top: 10px;"><b>手机号:</b> ${res.data.user_mobile}</div>
  479. <div style="margin-top: 5px;"><b>密码:</b> <span style="color: #f56c6c; font-weight: bold;">${res.data.generated_password}</span></div>
  480. <div style="margin-top: 10px; color: #909399; font-size: 12px;">请妥善保存密码。</div>
  481. `,
  482. '账号创建成功',
  483. {
  484. dangerouslyUseHTMLString: true,
  485. confirmButtonText: '知道了',
  486. type: 'success'
  487. }
  488. )
  489. } else {
  490. ElMessage.success('映射添加成功 (统一认证账号已存在)')
  491. }
  492. } catch (e: any) {
  493. if (e.response && e.response.status === 401) {
  494. ElMessage.error('密码错误')
  495. }
  496. // other errors handled by interceptor
  497. } finally {
  498. adding.value = false
  499. }
  500. }
  501. // --- Edit Logic ---
  502. const editDialogVisible = ref(false)
  503. const editing = ref(false)
  504. const editForm = reactive({
  505. id: 0,
  506. mobile: '',
  507. mapped_key: '',
  508. mapped_email: '',
  509. password: ''
  510. })
  511. const handleEdit = (row: MappingResponse) => {
  512. editForm.id = row.id
  513. editForm.mobile = row.user_mobile
  514. editForm.mapped_key = row.mapped_key
  515. editForm.mapped_email = row.mapped_email || ''
  516. editForm.password = ''
  517. editDialogVisible.value = true
  518. }
  519. const confirmEdit = async () => {
  520. if (!editForm.password) {
  521. ElMessage.warning('请输入密码')
  522. return
  523. }
  524. editing.value = true
  525. try {
  526. await updateMapping(appId, editForm.id, {
  527. mapped_key: editForm.mapped_key || undefined,
  528. mapped_email: editForm.mapped_email || undefined,
  529. password: editForm.password
  530. })
  531. ElMessage.success('更新成功')
  532. editDialogVisible.value = false
  533. fetchMappings()
  534. } catch (e: any) {
  535. if (e.response && e.response.status === 401) {
  536. ElMessage.error('密码错误')
  537. }
  538. // Other errors handled by interceptor
  539. } finally {
  540. editing.value = false
  541. }
  542. }
  543. // --- Import Logic ---
  544. const previewData = ref<any>(null)
  545. const selectedFile = ref<File | null>(null)
  546. const importing = ref(false)
  547. const importStrategy = ref('SKIP')
  548. // Verification Code Logic
  549. const verificationCode = ref('')
  550. const cooldown = ref(0)
  551. let timer: any = null
  552. const sendCode = async () => {
  553. try {
  554. await sendImportVerificationCode()
  555. ElMessage.success('验证码已发送')
  556. cooldown.value = 60
  557. timer = setInterval(() => {
  558. cooldown.value--
  559. if (cooldown.value <= 0) {
  560. clearInterval(timer)
  561. }
  562. }, 1000)
  563. } catch (e) {
  564. // handled
  565. }
  566. }
  567. onUnmounted(() => {
  568. if (timer) clearInterval(timer)
  569. })
  570. // Result View
  571. const importResult = ref<ImportLogResponse | null>(null)
  572. const handleFileChange = async (file: any) => {
  573. selectedFile.value = file.raw
  574. if (!selectedFile.value) return
  575. // Auto preview
  576. try {
  577. const res = await previewMapping(appId, selectedFile.value)
  578. previewData.value = res.data
  579. importResult.value = null // reset result
  580. } catch (e) {
  581. ElMessage.error('预览失败,请检查文件格式')
  582. }
  583. }
  584. const getStatusType = (status: string) => {
  585. switch (status) {
  586. case 'NEW': return 'success'
  587. case 'UPDATE': return 'warning'
  588. case 'ERROR': return 'danger'
  589. default: return 'info'
  590. }
  591. }
  592. const handleImport = async () => {
  593. if (!selectedFile.value) return
  594. if (!verificationCode.value) {
  595. ElMessage.warning('请输入管理员手机验证码')
  596. return
  597. }
  598. importing.value = true
  599. try {
  600. const res = await importMapping(appId, selectedFile.value, importStrategy.value, verificationCode.value)
  601. importResult.value = res.data
  602. ElMessage.success(`导入完成`)
  603. fetchMappings()
  604. } catch (e: any) {
  605. if (e.response && e.response.status === 400 && e.response.data.detail.includes('验证码')) {
  606. ElMessage.error('验证码无效或已过期')
  607. } else {
  608. ElMessage.error('导入失败')
  609. }
  610. } finally {
  611. importing.value = false
  612. }
  613. }
  614. const resetImport = () => {
  615. importResult.value = null
  616. previewData.value = null
  617. selectedFile.value = null
  618. verificationCode.value = ''
  619. }
  620. const exportResultLogs = (logs: any[]) => {
  621. if (!logs || !logs.length) return
  622. // Convert to CSV
  623. const headers = ['导入时间', '手机号', '映射账号', '映射邮箱', '状态', '详情', '是否新增统一认证', '新账号密码']
  624. const rows = logs.map(log => {
  625. let statusText = log.status
  626. if (log.status === 'Success') statusText = '成功'
  627. if (log.status === 'Failed') statusText = '失败'
  628. if (log.status === 'Skipped') statusText = '跳过'
  629. return [
  630. new Date(log.import_time).toLocaleString(),
  631. log.mobile,
  632. log.mapped_key,
  633. log.mapped_email || '',
  634. statusText,
  635. log.message,
  636. log.is_unified_account_created ? '是' : '否',
  637. log.generated_password || ''
  638. ]
  639. })
  640. // Add BOM for Excel utf-8 compatibility
  641. let csvContent = "\uFEFF" + headers.join(",") + "\n"
  642. rows.forEach(row => {
  643. // Handle commas in content by quoting if necessary
  644. const processedRow = row.map(cell => {
  645. const cellStr = String(cell)
  646. if (cellStr.includes(',') || cellStr.includes('"') || cellStr.includes('\n')) {
  647. return `"${cellStr.replace(/"/g, '""')}"`
  648. }
  649. return cellStr
  650. })
  651. csvContent += processedRow.join(",") + "\n"
  652. })
  653. const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
  654. const url = URL.createObjectURL(blob)
  655. const link = document.createElement("a")
  656. link.setAttribute("href", url)
  657. link.setAttribute("download", `import_logs_${new Date().toISOString().slice(0,19).replace(/:/g, "-")}.csv`)
  658. document.body.appendChild(link)
  659. link.click()
  660. document.body.removeChild(link)
  661. }
  662. // --- Operation Logs Logic ---
  663. const logs = ref<OperationLog[]>([])
  664. const logsLoading = ref(false)
  665. const logPage = ref(1)
  666. const logPageSize = ref(10)
  667. const logTotal = ref(0)
  668. const logActionType = ref('')
  669. const logKeyword = ref('')
  670. const logDateRange = ref<[string, string] | null>(null)
  671. const fetchLogs = async () => {
  672. logsLoading.value = true
  673. try {
  674. const skip = (logPage.value - 1) * logPageSize.value
  675. const params: any = {
  676. skip,
  677. limit: logPageSize.value,
  678. keyword: logKeyword.value || undefined,
  679. action_type: logActionType.value || undefined
  680. }
  681. if (logDateRange.value) {
  682. params.start_date = logDateRange.value[0]
  683. params.end_date = logDateRange.value[1]
  684. }
  685. const res = await getOperationLogs(appId, params)
  686. logs.value = res.data.items
  687. logTotal.value = res.data.total
  688. } catch (e) {
  689. console.error(e)
  690. } finally {
  691. logsLoading.value = false
  692. }
  693. }
  694. // Helper to format date
  695. const formatDate = (dateStr: string) => {
  696. return new Date(dateStr).toLocaleString()
  697. }
  698. // Helper for action type text
  699. const getActionTypeText = (type: string) => {
  700. switch(type) {
  701. case 'MANUAL_ADD': return '手动新增'
  702. case 'DELETE': return '删除'
  703. case 'UPDATE': return '修改'
  704. case 'SYNC_M2M': return 'M2M 账号同步'
  705. case 'SYNC': return '控制台同步'
  706. case 'IMPORT': return 'Excel 导入'
  707. default: return type
  708. }
  709. }
  710. const getActionTypeTag = (type: string) => {
  711. switch(type) {
  712. case 'MANUAL_ADD': return 'success'
  713. case 'DELETE': return 'danger'
  714. case 'UPDATE': return 'warning'
  715. case 'SYNC_M2M': return 'primary'
  716. case 'SYNC': return 'success'
  717. case 'IMPORT': return 'info'
  718. default: return ''
  719. }
  720. }
  721. // Import Details Dialog Logic
  722. const importDetailVisible = ref(false)
  723. const selectedImportLog = ref<OperationLog | null>(null)
  724. const showImportDetails = (log: OperationLog) => {
  725. selectedImportLog.value = log
  726. importDetailVisible.value = true
  727. }
  728. onMounted(() => {
  729. fetchMappings()
  730. fetchLogs() // load logs initially too
  731. })
  732. </script>
  733. <style scoped>
  734. .mapping-container {
  735. padding: 20px;
  736. background-color: #fff;
  737. border-radius: 4px;
  738. }
  739. .header {
  740. margin-bottom: 20px;
  741. border-bottom: 1px solid #eee;
  742. padding-bottom: 15px;
  743. }
  744. .title-group {
  745. display: flex;
  746. align-items: center;
  747. gap: 15px;
  748. }
  749. .toolbar {
  750. margin-bottom: 15px;
  751. display: flex;
  752. gap: 10px;
  753. }
  754. .pagination {
  755. margin-top: 20px;
  756. display: flex;
  757. justify-content: flex-end;
  758. }
  759. .import-panel {
  760. max-width: 900px;
  761. margin: 0 auto;
  762. }
  763. .stat-cards {
  764. display: flex;
  765. gap: 20px;
  766. margin-bottom: 20px;
  767. }
  768. .stat-card {
  769. flex: 1;
  770. text-align: center;
  771. }
  772. .stat-card .number {
  773. font-size: 24px;
  774. font-weight: bold;
  775. }
  776. .stat-card.valid .number { color: #67c23a; }
  777. .stat-card.error .number { color: #f56c6c; }
  778. .stat-card.new .number { color: #409eff; }
  779. .stat-card.update .number { color: #e6a23c; }
  780. .action-footer {
  781. margin-top: 20px;
  782. display: flex;
  783. justify-content: flex-end;
  784. align-items: center;
  785. }
  786. .verification-area {
  787. display: flex;
  788. align-items: center;
  789. margin-right: 20px;
  790. }
  791. .result-header {
  792. display: flex;
  793. justify-content: space-between;
  794. align-items: center;
  795. margin-bottom: 20px;
  796. }
  797. .log-details {
  798. font-size: 13px;
  799. color: #606266;
  800. }
  801. </style>