| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867 |
- <template>
- <div class="mapping-container">
- <div class="header">
- <div class="title-group">
- <el-button @click="$router.push('/dashboard/apps')" icon="ArrowLeft" circle />
- <h2>账号映射管理 - {{ appName }}</h2>
- </div>
- </div>
- <el-tabs v-model="activeTab" class="mapping-tabs">
- <!-- Tab 1: Mapping List Management -->
- <el-tab-pane label="映射列表" name="list">
- <div class="toolbar">
- <el-button type="primary" icon="Plus" @click="openAddDialog">手动添加</el-button>
- <el-button type="success" icon="Download" @click="handleExport">导出 Excel</el-button>
- </div>
- <el-table :data="mappings" v-loading="loading" stripe border style="width: 100%; margin-top: 15px;">
- <el-table-column prop="user_mobile" label="用户手机号" />
- <el-table-column prop="user_status" label="统一认证账号状态" width="150">
- <template #default="scope">
- <el-tag :type="scope.row.user_status === 'ACTIVE' ? 'success' : 'danger'">
- {{ scope.row.user_status === 'ACTIVE' ? '已激活' : scope.row.user_status }}
- </el-tag>
- </template>
- </el-table-column>
- <el-table-column prop="mapped_key" label="第三方系统账号 (Key)" />
- <el-table-column prop="mapped_email" label="第三方系统邮箱" />
- <el-table-column label="操作" width="180">
- <template #default="scope">
- <el-button type="primary" size="small" icon="Edit" @click="handleEdit(scope.row)">编辑</el-button>
- <el-button type="danger" size="small" icon="Delete" @click="handleDelete(scope.row)">删除</el-button>
- </template>
- </el-table-column>
- </el-table>
- <div class="pagination">
- <el-pagination
- v-model:current-page="currentPage"
- v-model:page-size="pageSize"
- :page-sizes="[10, 20, 50, 100]"
- layout="total, sizes, prev, pager, next, jumper"
- :total="total"
- @size-change="handleSizeChange"
- @current-change="handleCurrentChange"
- />
- </div>
- </el-tab-pane>
- <!-- Tab 2: Excel Import -->
- <el-tab-pane label="Excel 导入" name="import">
- <div class="import-panel">
- <div v-if="!importResult">
- <el-alert
- title="导入说明"
- type="info"
- description="请上传 Excel 文件 (.xlsx, .xls)。第一列为手机号,第二列为映射账号,第三列为映射邮箱(可选)。"
- show-icon
- :closable="false"
- style="margin-bottom: 20px"
- />
-
- <el-upload
- class="upload-demo"
- drag
- action="#"
- :auto-upload="false"
- :on-change="handleFileChange"
- :limit="1"
- >
- <el-icon class="el-icon--upload"><upload-filled /></el-icon>
- <div class="el-upload__text">
- 拖拽文件到此处或 <em>点击上传</em>
- </div>
- </el-upload>
- <!-- Preview Area -->
- <div v-if="previewData" class="preview-area">
- <h3>预览结果</h3>
- <div class="stat-cards">
- <el-card shadow="never" class="stat-card valid">
- <div class="number">{{ previewData.valid_count }}</div>
- <div class="label">有效数据</div>
- </el-card>
- <el-card shadow="never" class="stat-card error">
- <div class="number">{{ previewData.error_count }}</div>
- <div class="label">错误数据</div>
- </el-card>
- <el-card shadow="never" class="stat-card new">
- <div class="number">{{ previewData.new_count }}</div>
- <div class="label">新增</div>
- </el-card>
- <el-card shadow="never" class="stat-card update">
- <div class="number">{{ previewData.update_count }}</div>
- <div class="label">更新</div>
- </el-card>
- </div>
- <el-table :data="previewData.preview_rows" height="300" style="margin-top: 20px" border>
- <el-table-column prop="row_index" label="行号" width="80" />
- <el-table-column prop="mobile" label="手机号" width="150" />
- <el-table-column prop="mapped_key" label="映射账号" width="150" />
- <el-table-column prop="mapped_email" label="映射邮箱" width="150" />
- <el-table-column prop="status" label="状态" width="100">
- <template #default="scope">
- <el-tag :type="getStatusType(scope.row.status)">{{ scope.row.status }}</el-tag>
- </template>
- </el-table-column>
- <el-table-column prop="message" label="信息" />
- </el-table>
- <div class="action-footer">
- <div class="verification-area">
- <el-input
- v-model="verificationCode"
- placeholder="管理员手机验证码"
- style="width: 200px; margin-right: 10px;"
- />
- <el-button type="primary" plain :disabled="cooldown > 0" @click="sendCode">
- {{ cooldown > 0 ? `${cooldown}s` : '发送验证码' }}
- </el-button>
- </div>
- <el-radio-group v-model="importStrategy" style="margin-right: 20px">
- <el-radio label="SKIP">跳过已存在</el-radio>
- <el-radio label="OVERWRITE">覆盖已存在</el-radio>
- </el-radio-group>
- <el-button type="primary" @click="handleImport" :loading="importing" :disabled="previewData.valid_count === 0">
- 确认导入
- </el-button>
- </div>
- </div>
- </div>
- <!-- Import Result Area -->
- <div v-else class="result-area">
- <div class="result-header">
- <h3>导入完成</h3>
- <el-button @click="resetImport">返回导入</el-button>
- </div>
-
- <div class="stat-cards">
- <el-card shadow="never" class="stat-card valid">
- <div class="number">{{ importResult.summary.inserted + importResult.summary.updated }}</div>
- <div class="label">成功</div>
- </el-card>
- <el-card shadow="never" class="stat-card error">
- <div class="number">{{ importResult.summary.failed }}</div>
- <div class="label">失败</div>
- </el-card>
- <el-card shadow="never" class="stat-card">
- <div class="number">{{ importResult.summary.total_processed }}</div>
- <div class="label">总处理</div>
- </el-card>
- <div style="flex: 1; display: flex; align-items: center; justify-content: center;">
- <el-button type="success" size="large" icon="Download" @click="exportResultLogs(importResult.logs)">导出导入日志</el-button>
- </div>
- </div>
- <el-table :data="importResult.logs" height="500" stripe border style="margin-top: 20px">
- <el-table-column prop="mobile" label="手机号" width="130" />
- <el-table-column prop="mapped_key" label="映射账号" width="130" />
- <el-table-column prop="status" label="状态" width="100">
- <template #default="scope">
- <el-tag :type="scope.row.status === 'Success' ? 'success' : (scope.row.status === 'Skipped' ? 'info' : 'danger')">
- {{ scope.row.status === 'Success' ? '成功' : (scope.row.status === 'Skipped' ? '跳过' : '失败') }}
- </el-tag>
- </template>
- </el-table-column>
- <el-table-column prop="message" label="详情/失败原因" />
- <el-table-column label="新账号密码" width="140">
- <template #default="scope">
- <span v-if="scope.row.generated_password" style="color: #f56c6c; font-weight: bold;">
- {{ scope.row.generated_password }}
- </span>
- <span v-else>-</span>
- </template>
- </el-table-column>
- </el-table>
- </div>
- </div>
- </el-tab-pane>
- <!-- Tab 3: Operation Logs -->
- <el-tab-pane label="映射账号操作日志" name="logs">
- <div class="toolbar">
- <el-input v-model="logKeyword" placeholder="搜索目标手机号" style="width: 200px; margin-right: 10px;" clearable @clear="fetchLogs" @keyup.enter="fetchLogs" />
- <el-select v-model="logActionType" placeholder="操作类型" clearable @change="fetchLogs" style="width: 170px; margin-right: 10px;">
- <el-option label="手动新增" value="MANUAL_ADD" />
- <el-option label="删除" value="DELETE" />
- <el-option label="修改" value="UPDATE" />
- <el-option label="Excel 导入" value="IMPORT" />
- <el-option label="M2M 账号同步" value="SYNC_M2M" />
- <el-option label="控制台同步用户" value="SYNC" />
- </el-select>
- <el-date-picker
- v-model="logDateRange"
- type="daterange"
- range-separator="至"
- start-placeholder="开始日期"
- end-placeholder="结束日期"
- style="width: 240px; margin-right: 10px;"
- value-format="YYYY-MM-DD"
- @change="fetchLogs"
- />
- <el-button type="primary" @click="fetchLogs" icon="Search">查询</el-button>
- </div>
- <el-table :data="logs" v-loading="logsLoading" stripe border style="width: 100%; margin-top: 15px;">
- <el-table-column prop="created_at" label="操作时间" width="180">
- <template #default="scope">{{ formatDate(scope.row.created_at) }}</template>
- </el-table-column>
- <el-table-column prop="action_type" label="操作类型" width="120">
- <template #default="scope">
- <el-tag :type="getActionTypeTag(scope.row.action_type)">
- {{ getActionTypeText(scope.row.action_type) }}
- </el-tag>
- </template>
- </el-table-column>
- <el-table-column prop="operator_mobile" label="操作人手机号" width="140" />
- <el-table-column prop="ip_address" label="来源 IP" width="130">
- <template #default="scope">
- {{ scope.row.ip_address || '-' }}
- </template>
- </el-table-column>
- <el-table-column prop="target_mobile" label="目标手机号" width="140">
- <template #default="scope">
- {{ scope.row.target_mobile || (scope.row.action_type === 'IMPORT' || scope.row.action_type === 'SYNC' ? '批量操作' : '-') }}
- </template>
- </el-table-column>
- <el-table-column label="详情" min-width="260">
- <template #default="scope">
- <div v-if="scope.row.action_type === 'IMPORT'">
- <el-button type="primary" link @click="showImportDetails(scope.row)">查看导入详情</el-button>
- </div>
- <div v-else-if="scope.row.action_type === 'SYNC_M2M'" class="log-details">
- 账号同步接口 · 外部账号 {{ scope.row.details?.mapped_key ?? '-' }}
- <span v-if="scope.row.details?.mapped_email"> · 邮箱 {{ scope.row.details.mapped_email }}</span>
- <span v-if="scope.row.details?.new_user_created"> · 新建用户</span>
- <span v-if="scope.row.details?.new_mapping_created"> · 新建映射</span>
- <span v-if="scope.row.details && 'is_active' in scope.row.details">
- · 映射{{ scope.row.details.is_active ? '启用' : '停用' }}
- </span>
- </div>
- <div v-else-if="scope.row.action_type === 'SYNC'" class="log-details">
- 控制台同步 · 模式 {{ scope.row.details?.mode ?? '-' }}
- · 尝试 {{ scope.row.details?.total_attempted ?? '-' }}
- · 成功 {{ scope.row.details?.success ?? '-' }}
- · 失败 {{ scope.row.details?.failed ?? '-' }}
- </div>
- <div v-else class="log-details">
- <span v-if="scope.row.action_type === 'DELETE'">
- <template v-if="scope.row.details?.action === 'M2M_DELETE'">
- 账号同步接口删除 · 外部账号 {{ scope.row.details?.mapped_key }}
- </template>
- <template v-else>
- 删除映射 ID: {{ scope.row.details?.mapping_id }}
- </template>
- </span>
- <span v-else-if="scope.row.action_type === 'UPDATE'">
- 变更前: {{ scope.row.details?.old?.mapped_key || '空' }} -> 变更后: {{ scope.row.details?.new?.mapped_key || '空' }}
- </span>
- <span v-else-if="scope.row.action_type === 'MANUAL_ADD'">
- 新增映射: {{ scope.row.details?.mapped_key }} ({{ scope.row.details?.new_user_created ? '新建用户' : '已有用户' }})
- </span>
- </div>
- </template>
- </el-table-column>
- </el-table>
- <div class="pagination">
- <el-pagination
- v-model:current-page="logPage"
- v-model:page-size="logPageSize"
- :page-sizes="[10, 20, 50]"
- layout="total, sizes, prev, pager, next, jumper"
- :total="logTotal"
- @size-change="fetchLogs"
- @current-change="fetchLogs"
- />
- </div>
- </el-tab-pane>
- </el-tabs>
- <!-- Manual Add Dialog -->
- <el-dialog v-model="addDialogVisible" title="添加映射" width="400px">
- <el-form :model="addForm" label-width="100px">
- <el-form-item label="用户手机号" required>
- <el-input v-model="addForm.mobile" placeholder="系统内用户手机号" />
- </el-form-item>
- <el-form-item label="映射账号">
- <el-input v-model="addForm.mapped_key" placeholder="第三方系统账号ID" />
- </el-form-item>
- <el-form-item label="映射邮箱">
- <el-input
- v-model="addForm.mapped_email"
- placeholder="第三方系统邮箱(可选)"
- :name="dynamicFields.email"
- autocomplete="off"
- />
- </el-form-item>
- <el-form-item label="管理员密码" required>
- <el-input
- v-model="addForm.password"
- type="password"
- placeholder="请输入您的登录密码确认"
- show-password
- :name="dynamicFields.password"
- autocomplete="new-password"
- />
- </el-form-item>
- </el-form>
- <template #footer>
- <span class="dialog-footer">
- <el-button @click="addDialogVisible = false">取消</el-button>
- <el-button type="primary" @click="confirmAdd" :loading="adding">确定</el-button>
- </span>
- </template>
- </el-dialog>
- <!-- Edit Dialog -->
- <el-dialog v-model="editDialogVisible" title="编辑映射" width="400px">
- <el-form :model="editForm" label-width="100px">
- <el-form-item label="用户手机号">
- <el-input v-model="editForm.mobile" disabled />
- </el-form-item>
- <el-form-item label="映射账号">
- <el-input v-model="editForm.mapped_key" placeholder="第三方系统账号ID" />
- </el-form-item>
- <el-form-item label="映射邮箱">
- <el-input v-model="editForm.mapped_email" placeholder="第三方系统邮箱(可选)" />
- </el-form-item>
- <el-form-item label="管理员密码" required>
- <el-input v-model="editForm.password" type="password" placeholder="请输入您的登录密码确认" show-password />
- </el-form-item>
- </el-form>
- <template #footer>
- <span class="dialog-footer">
- <el-button @click="editDialogVisible = false">取消</el-button>
- <el-button type="primary" @click="confirmEdit" :loading="editing">确定</el-button>
- </span>
- </template>
- </el-dialog>
- <!-- Import Details Dialog -->
- <el-dialog v-model="importDetailVisible" title="导入详情" width="900px">
- <div v-if="selectedImportLog">
- <div class="stat-cards" style="margin-bottom: 15px;">
- <el-card shadow="never" class="stat-card">
- <div class="number">{{ selectedImportLog.details?.summary?.total_processed || 0 }}</div>
- <div class="label">总数</div>
- </el-card>
- <el-card shadow="never" class="stat-card valid">
- <div class="number">{{ (selectedImportLog.details?.summary?.inserted || 0) + (selectedImportLog.details?.summary?.updated || 0) }}</div>
- <div class="label">成功</div>
- </el-card>
- <el-card shadow="never" class="stat-card error">
- <div class="number">{{ selectedImportLog.details?.summary?.failed || 0 }}</div>
- <div class="label">失败</div>
- </el-card>
- <div style="flex: 1; display: flex; align-items: center; justify-content: flex-end;">
- <el-button type="success" icon="Download" @click="exportResultLogs(selectedImportLog.details?.logs)">导出此记录</el-button>
- </div>
- </div>
- <el-table :data="selectedImportLog.details?.logs || []" height="400" stripe border>
- <el-table-column prop="mobile" label="手机号" width="130" />
- <el-table-column prop="mapped_key" label="映射账号" width="130" />
- <el-table-column prop="status" label="状态" width="100">
- <template #default="scope">
- <el-tag :type="scope.row.status === 'Success' ? 'success' : (scope.row.status === 'Skipped' ? 'info' : 'danger')">
- {{ scope.row.status === 'Success' ? '成功' : (scope.row.status === 'Skipped' ? '跳过' : '失败') }}
- </el-tag>
- </template>
- </el-table-column>
- <el-table-column prop="message" label="详情/失败原因" />
- </el-table>
- </div>
- </el-dialog>
- </div>
- </template>
- <script setup lang="ts">
- import { ref, onMounted, reactive, onUnmounted } from 'vue'
- import { useRoute } from 'vue-router'
- import { UploadFilled, ArrowLeft, Plus, Download, Delete, Edit, Search } from '@element-plus/icons-vue'
- import { previewMapping, importMapping, sendImportVerificationCode, ImportLogResponse } from '../../api/mapping'
- import { getMappings, createMapping, deleteMapping, updateMapping, exportMappings, MappingResponse, getOperationLogs, OperationLog } from '../../api/apps'
- import { ElMessage, ElMessageBox } from 'element-plus'
- const route = useRoute()
- const appId = Number(route.params.id)
- const appName = route.query.name as string
- const activeTab = ref('list')
- // --- List Logic ---
- const mappings = ref<MappingResponse[]>([])
- const loading = ref(false)
- const total = ref(0)
- const currentPage = ref(1)
- const pageSize = ref(10)
- const fetchMappings = async () => {
- loading.value = true
- try {
- const skip = (currentPage.value - 1) * pageSize.value
- const res = await getMappings(appId, skip, pageSize.value)
- mappings.value = res.data.items
- total.value = res.data.total
- } catch (e) {
- console.error(e)
- } finally {
- loading.value = false
- }
- }
- const handleSizeChange = (val: number) => {
- pageSize.value = val
- fetchMappings()
- }
- const handleCurrentChange = (val: number) => {
- currentPage.value = val
- fetchMappings()
- }
- const handleDelete = (row: MappingResponse) => {
- ElMessageBox.prompt(`请输入您的登录密码以确认删除 ${row.user_mobile} 的映射`, '安全验证', {
- confirmButtonText: '删除',
- cancelButtonText: '取消',
- inputType: 'password',
- inputPattern: /.+/,
- inputErrorMessage: '密码不能为空',
- type: 'warning'
- }).then(async ({ value }) => {
- try {
- await deleteMapping(appId, row.id, value)
- ElMessage.success('删除成功')
- fetchMappings()
- } catch (e: any) {
- if (e.response && e.response.status === 401) {
- ElMessage.error('密码错误')
- } else {
- ElMessage.error('删除失败')
- }
- }
- }).catch(() => {
- // cancelled
- })
- }
- const handleExport = async () => {
- try {
- const res = await exportMappings(appId)
- // Create blob link to download
- const url = window.URL.createObjectURL(new Blob([res.data]))
- const link = document.createElement('a')
- link.href = url
- link.setAttribute('download', `mappings_${appName}_${new Date().toISOString().slice(0,10)}.xlsx`)
- document.body.appendChild(link)
- link.click()
- document.body.removeChild(link)
- } catch (e) {
- ElMessage.error('导出失败')
- }
- }
- // --- Manual Add Logic ---
- const addDialogVisible = ref(false)
- const adding = ref(false)
- const addForm = reactive({
- mobile: '',
- mapped_key: '',
- mapped_email: '',
- password: ''
- })
- const dynamicFields = reactive({
- email: 'mapped_email',
- password: 'admin_password'
- })
- const openAddDialog = () => {
- addForm.mobile = ''
- addForm.mapped_key = ''
- addForm.mapped_email = ''
- addForm.password = ''
-
- // Generate random field names to prevent auto-fill
- const randomSuffix = Math.random().toString(36).slice(2, 8)
- dynamicFields.email = `email_${randomSuffix}`
- dynamicFields.password = `pwd_${randomSuffix}`
-
- addDialogVisible.value = true
- }
- const confirmAdd = async () => {
- if (!addForm.mobile || !addForm.mapped_key || !addForm.password) {
- ElMessage.warning('请填写完整,包括管理员密码')
- return
- }
- adding.value = true
- try {
- const res = await createMapping(appId, addForm)
- addDialogVisible.value = false
- fetchMappings()
-
- if (res.data.new_user_created && res.data.generated_password) {
- ElMessageBox.alert(
- `
- <div>已自动为您创建统一认证账号:</div>
- <div style="margin-top: 10px;"><b>手机号:</b> ${res.data.user_mobile}</div>
- <div style="margin-top: 5px;"><b>密码:</b> <span style="color: #f56c6c; font-weight: bold;">${res.data.generated_password}</span></div>
- <div style="margin-top: 10px; color: #909399; font-size: 12px;">请妥善保存密码。</div>
- `,
- '账号创建成功',
- {
- dangerouslyUseHTMLString: true,
- confirmButtonText: '知道了',
- type: 'success'
- }
- )
- } else {
- ElMessage.success('映射添加成功 (统一认证账号已存在)')
- }
- } catch (e: any) {
- if (e.response && e.response.status === 401) {
- ElMessage.error('密码错误')
- }
- // other errors handled by interceptor
- } finally {
- adding.value = false
- }
- }
- // --- Edit Logic ---
- const editDialogVisible = ref(false)
- const editing = ref(false)
- const editForm = reactive({
- id: 0,
- mobile: '',
- mapped_key: '',
- mapped_email: '',
- password: ''
- })
- const handleEdit = (row: MappingResponse) => {
- editForm.id = row.id
- editForm.mobile = row.user_mobile
- editForm.mapped_key = row.mapped_key
- editForm.mapped_email = row.mapped_email || ''
- editForm.password = ''
- editDialogVisible.value = true
- }
- const confirmEdit = async () => {
- if (!editForm.password) {
- ElMessage.warning('请输入密码')
- return
- }
- editing.value = true
- try {
- await updateMapping(appId, editForm.id, {
- mapped_key: editForm.mapped_key || undefined,
- mapped_email: editForm.mapped_email || undefined,
- password: editForm.password
- })
- ElMessage.success('更新成功')
- editDialogVisible.value = false
- fetchMappings()
- } catch (e: any) {
- if (e.response && e.response.status === 401) {
- ElMessage.error('密码错误')
- }
- // Other errors handled by interceptor
- } finally {
- editing.value = false
- }
- }
- // --- Import Logic ---
- const previewData = ref<any>(null)
- const selectedFile = ref<File | null>(null)
- const importing = ref(false)
- const importStrategy = ref('SKIP')
- // Verification Code Logic
- const verificationCode = ref('')
- const cooldown = ref(0)
- let timer: any = null
- const sendCode = async () => {
- try {
- await sendImportVerificationCode()
- ElMessage.success('验证码已发送')
- cooldown.value = 60
- timer = setInterval(() => {
- cooldown.value--
- if (cooldown.value <= 0) {
- clearInterval(timer)
- }
- }, 1000)
- } catch (e) {
- // handled
- }
- }
- onUnmounted(() => {
- if (timer) clearInterval(timer)
- })
- // Result View
- const importResult = ref<ImportLogResponse | null>(null)
- const handleFileChange = async (file: any) => {
- selectedFile.value = file.raw
- if (!selectedFile.value) return
-
- // Auto preview
- try {
- const res = await previewMapping(appId, selectedFile.value)
- previewData.value = res.data
- importResult.value = null // reset result
- } catch (e) {
- ElMessage.error('预览失败,请检查文件格式')
- }
- }
- const getStatusType = (status: string) => {
- switch (status) {
- case 'NEW': return 'success'
- case 'UPDATE': return 'warning'
- case 'ERROR': return 'danger'
- default: return 'info'
- }
- }
- const handleImport = async () => {
- if (!selectedFile.value) return
- if (!verificationCode.value) {
- ElMessage.warning('请输入管理员手机验证码')
- return
- }
-
- importing.value = true
- try {
- const res = await importMapping(appId, selectedFile.value, importStrategy.value, verificationCode.value)
- importResult.value = res.data
- ElMessage.success(`导入完成`)
- fetchMappings()
- } catch (e: any) {
- if (e.response && e.response.status === 400 && e.response.data.detail.includes('验证码')) {
- ElMessage.error('验证码无效或已过期')
- } else {
- ElMessage.error('导入失败')
- }
- } finally {
- importing.value = false
- }
- }
- const resetImport = () => {
- importResult.value = null
- previewData.value = null
- selectedFile.value = null
- verificationCode.value = ''
- }
- const exportResultLogs = (logs: any[]) => {
- if (!logs || !logs.length) return
-
- // Convert to CSV
- const headers = ['导入时间', '手机号', '映射账号', '映射邮箱', '状态', '详情', '是否新增统一认证', '新账号密码']
- const rows = logs.map(log => {
- let statusText = log.status
- if (log.status === 'Success') statusText = '成功'
- if (log.status === 'Failed') statusText = '失败'
- if (log.status === 'Skipped') statusText = '跳过'
- return [
- new Date(log.import_time).toLocaleString(),
- log.mobile,
- log.mapped_key,
- log.mapped_email || '',
- statusText,
- log.message,
- log.is_unified_account_created ? '是' : '否',
- log.generated_password || ''
- ]
- })
-
- // Add BOM for Excel utf-8 compatibility
- let csvContent = "\uFEFF" + headers.join(",") + "\n"
-
- rows.forEach(row => {
- // Handle commas in content by quoting if necessary
- const processedRow = row.map(cell => {
- const cellStr = String(cell)
- if (cellStr.includes(',') || cellStr.includes('"') || cellStr.includes('\n')) {
- return `"${cellStr.replace(/"/g, '""')}"`
- }
- return cellStr
- })
- csvContent += processedRow.join(",") + "\n"
- })
-
- const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
- const url = URL.createObjectURL(blob)
- const link = document.createElement("a")
- link.setAttribute("href", url)
- link.setAttribute("download", `import_logs_${new Date().toISOString().slice(0,19).replace(/:/g, "-")}.csv`)
- document.body.appendChild(link)
- link.click()
- document.body.removeChild(link)
- }
- // --- Operation Logs Logic ---
- const logs = ref<OperationLog[]>([])
- const logsLoading = ref(false)
- const logPage = ref(1)
- const logPageSize = ref(10)
- const logTotal = ref(0)
- const logActionType = ref('')
- const logKeyword = ref('')
- const logDateRange = ref<[string, string] | null>(null)
- const fetchLogs = async () => {
- logsLoading.value = true
- try {
- const skip = (logPage.value - 1) * logPageSize.value
- const params: any = {
- skip,
- limit: logPageSize.value,
- keyword: logKeyword.value || undefined,
- action_type: logActionType.value || undefined
- }
- if (logDateRange.value) {
- params.start_date = logDateRange.value[0]
- params.end_date = logDateRange.value[1]
- }
-
- const res = await getOperationLogs(appId, params)
- logs.value = res.data.items
- logTotal.value = res.data.total
- } catch (e) {
- console.error(e)
- } finally {
- logsLoading.value = false
- }
- }
- // Helper to format date
- const formatDate = (dateStr: string) => {
- return new Date(dateStr).toLocaleString()
- }
- // Helper for action type text
- const getActionTypeText = (type: string) => {
- switch(type) {
- case 'MANUAL_ADD': return '手动新增'
- case 'DELETE': return '删除'
- case 'UPDATE': return '修改'
- case 'SYNC_M2M': return 'M2M 账号同步'
- case 'SYNC': return '控制台同步'
- case 'IMPORT': return 'Excel 导入'
- default: return type
- }
- }
- const getActionTypeTag = (type: string) => {
- switch(type) {
- case 'MANUAL_ADD': return 'success'
- case 'DELETE': return 'danger'
- case 'UPDATE': return 'warning'
- case 'SYNC_M2M': return 'primary'
- case 'SYNC': return 'success'
- case 'IMPORT': return 'info'
- default: return ''
- }
- }
- // Import Details Dialog Logic
- const importDetailVisible = ref(false)
- const selectedImportLog = ref<OperationLog | null>(null)
- const showImportDetails = (log: OperationLog) => {
- selectedImportLog.value = log
- importDetailVisible.value = true
- }
- onMounted(() => {
- fetchMappings()
- fetchLogs() // load logs initially too
- })
- </script>
- <style scoped>
- .mapping-container {
- padding: 20px;
- background-color: #fff;
- border-radius: 4px;
- }
- .header {
- margin-bottom: 20px;
- border-bottom: 1px solid #eee;
- padding-bottom: 15px;
- }
- .title-group {
- display: flex;
- align-items: center;
- gap: 15px;
- }
- .toolbar {
- margin-bottom: 15px;
- display: flex;
- gap: 10px;
- }
- .pagination {
- margin-top: 20px;
- display: flex;
- justify-content: flex-end;
- }
- .import-panel {
- max-width: 900px;
- margin: 0 auto;
- }
- .stat-cards {
- display: flex;
- gap: 20px;
- margin-bottom: 20px;
- }
- .stat-card {
- flex: 1;
- text-align: center;
- }
- .stat-card .number {
- font-size: 24px;
- font-weight: bold;
- }
- .stat-card.valid .number { color: #67c23a; }
- .stat-card.error .number { color: #f56c6c; }
- .stat-card.new .number { color: #409eff; }
- .stat-card.update .number { color: #e6a23c; }
- .action-footer {
- margin-top: 20px;
- display: flex;
- justify-content: flex-end;
- align-items: center;
- }
- .verification-area {
- display: flex;
- align-items: center;
- margin-right: 20px;
- }
- .result-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 20px;
- }
- .log-details {
- font-size: 13px;
- color: #606266;
- }
- </style>
|