493 lines
18 KiB
Vue
493 lines
18 KiB
Vue
<template>
|
||
<div class="app-container">
|
||
<basic-table
|
||
ref="tableRef"
|
||
:columns="columns"
|
||
:search-columns="searchColumns"
|
||
:query="queryList"
|
||
:span-method="objectSpanMethod"
|
||
row-key="regionId"
|
||
>
|
||
<template #toolbar="{ selection, ids }">
|
||
<el-col :span="1.5">
|
||
<el-button
|
||
type="primary"
|
||
plain
|
||
icon="Refresh"
|
||
:loading="syncLoading"
|
||
@click="handleSync"
|
||
v-hasPermi="['data:region:sync']"
|
||
>同步国家数据</el-button>
|
||
</el-col>
|
||
<el-col :span="1.5">
|
||
<el-button
|
||
type="info"
|
||
plain
|
||
icon="List"
|
||
@click="openSyncLogDialog"
|
||
v-hasPermi="['data:region:log:list']"
|
||
>同步日志</el-button>
|
||
</el-col>
|
||
<el-col :span="1.5">
|
||
<el-button
|
||
type="success"
|
||
plain
|
||
icon="Edit"
|
||
:disabled="selection.length !== 1"
|
||
@click="handleUpdate(selection[0])"
|
||
v-hasPermi="['data:region:edit']"
|
||
>修改</el-button>
|
||
</el-col>
|
||
<el-col :span="1.5">
|
||
<el-button
|
||
type="danger"
|
||
plain
|
||
icon="Delete"
|
||
:disabled="!selection.length"
|
||
@click="handleDelete(ids)"
|
||
v-hasPermi="['data:region:remove']"
|
||
>删除</el-button>
|
||
</el-col>
|
||
<el-col :span="1.5">
|
||
<el-button
|
||
type="warning"
|
||
plain
|
||
icon="Download"
|
||
@click="handleExport"
|
||
v-hasPermi="['data:region:list']"
|
||
>导出</el-button>
|
||
</el-col>
|
||
</template>
|
||
|
||
<template #col-status="{ row }">
|
||
<el-switch
|
||
v-model="row.status"
|
||
active-value="0"
|
||
inactive-value="1"
|
||
@change="handleStatusChange(row)"
|
||
v-hasPermi="['data:region:edit']"
|
||
/>
|
||
</template>
|
||
|
||
<template #col-flagEmoji="{ row }">
|
||
<img v-if="row.flagUrl" :src="row.flagUrl" alt="" class="region-flag" />
|
||
<span v-else class="region-flag-emoji">{{ row.flagEmoji }}</span>
|
||
</template>
|
||
|
||
<template #col-action="{ row }">
|
||
<el-button link type="primary" icon="Edit" @click="handleUpdate(row)" v-hasPermi="['data:region:edit']">修改</el-button>
|
||
<el-button link type="primary" icon="Delete" @click="handleDelete(row.regionId)" v-hasPermi="['data:region:remove']">删除</el-button>
|
||
</template>
|
||
</basic-table>
|
||
|
||
<!-- 修改区域对话框 -->
|
||
<el-dialog :title="title" v-model="open" width="600px" append-to-body>
|
||
<el-form ref="regionRef" :model="form" :rules="rules" label-width="100px">
|
||
<el-row>
|
||
<el-col :span="12">
|
||
<el-form-item label="中文名" prop="countryNameCn">
|
||
<el-input v-model="form.countryNameCn" placeholder="请输入中文名" />
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-form-item label="英文名" prop="countryNameEn">
|
||
<el-input v-model="form.countryNameEn" placeholder="请输入英文名" />
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
<el-row>
|
||
<el-col :span="12">
|
||
<el-form-item label="ISO两位码" prop="countryCode2">
|
||
<el-input v-model="form.countryCode2" placeholder="如: DE" />
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-form-item label="ISO三位码" prop="countryCode3">
|
||
<el-input v-model="form.countryCode3" placeholder="如: DEU" />
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
<el-row>
|
||
<el-col :span="12">
|
||
<el-form-item label="洲/大区" prop="continent">
|
||
<el-input v-model="form.continent" placeholder="如: Europe" />
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-form-item label="子区域" prop="subRegion">
|
||
<el-input v-model="form.subRegion" placeholder="如: Western Europe" />
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
<el-row>
|
||
<el-col :span="12">
|
||
<el-form-item label="货币代码" prop="currencyCode">
|
||
<el-input v-model="form.currencyCode" placeholder="如: EUR" />
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-form-item label="电话前缀" prop="phonePrefix">
|
||
<el-input v-model="form.phonePrefix" placeholder="如: +49" />
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
<el-row>
|
||
<el-col :span="12">
|
||
<el-form-item label="是否独立" prop="isIndependent">
|
||
<el-radio-group v-model="form.isIndependent">
|
||
<el-radio value="1">是</el-radio>
|
||
<el-radio value="0">否</el-radio>
|
||
</el-radio-group>
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-form-item label="是否欧盟" prop="isEu">
|
||
<el-radio-group v-model="form.isEu">
|
||
<el-radio value="1">是</el-radio>
|
||
<el-radio value="0">否</el-radio>
|
||
</el-radio-group>
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
<el-form-item label="备注" prop="remark">
|
||
<el-input v-model="form.remark" type="textarea" placeholder="请输入内容" />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<div class="dialog-footer">
|
||
<el-button type="primary" @click="submitForm">确 定</el-button>
|
||
<el-button @click="cancel">取 消</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 同步日志对话框 -->
|
||
<el-dialog title="同步日志" v-model="logDialogVisible" width="1200px" append-to-body top="8vh">
|
||
<basic-table
|
||
ref="logTableRef"
|
||
:columns="logColumns"
|
||
:search-columns="logSearchColumns"
|
||
:query="logQueryList"
|
||
row-key="logId"
|
||
:show-search="true"
|
||
>
|
||
<template #toolbar="{ selection, ids }">
|
||
<el-col :span="1.5">
|
||
<el-button
|
||
type="danger"
|
||
plain
|
||
icon="Delete"
|
||
:disabled="!selection.length"
|
||
@click="handleLogDelete(ids)"
|
||
v-hasPermi="['data:region:log:remove']"
|
||
>删除</el-button>
|
||
</el-col>
|
||
</template>
|
||
<template #col-status="{ row }">
|
||
<el-tag :type="logStatusType(row.status)">
|
||
{{ logStatusText(row.status) }}
|
||
</el-tag>
|
||
</template>
|
||
</basic-table>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup name="Region">
|
||
import { listRegion, getRegion, updateRegion, delRegion, changeRegionStatus, syncRegion } from "@/api/data/region"
|
||
import { getSyncLog, listSyncLog, delSyncLog } from "@/api/data/region-log"
|
||
import { nextTick, onBeforeUnmount, onMounted, ref, reactive, toRefs, watch, getCurrentInstance } from "vue"
|
||
|
||
const { proxy } = getCurrentInstance()
|
||
const tableRef = ref()
|
||
const open = ref(false)
|
||
const title = ref("")
|
||
const syncLoading = ref(false)
|
||
const syncLogId = ref(null)
|
||
const syncTimer = ref(null)
|
||
|
||
// ====== 同步日志对话框 ======
|
||
const logDialogVisible = ref(false)
|
||
const logTableRef = ref()
|
||
const logSearchColumns = [
|
||
{ label: "状态", prop: "status", component: "select", options: [
|
||
{ label: "待处理", value: "PENDING" },
|
||
{ label: "处理中", value: "PROCESSING" },
|
||
{ label: "成功", value: "SUCCESS" },
|
||
{ label: "失败", value: "FAILED" }
|
||
], placeholder: "状态", width: "160px" },
|
||
{ label: "创建时间", prop: "dateRange", component: "daterange", width: "320px" }
|
||
]
|
||
const logColumns = [
|
||
{ label: "日志ID", prop: "logId", align: "center", width: 80 },
|
||
{ label: "同步类型", prop: "syncType", align: "center", width: 100 },
|
||
{ label: "状态", prop: "status", align: "center", width: 100 },
|
||
{ label: "总数", prop: "totalCount", align: "center", width: 80 },
|
||
{ label: "新增数", prop: "insertCount", align: "center", width: 80 },
|
||
{ label: "更新数", prop: "updateCount", align: "center", width: 80 },
|
||
{ label: "失败数", prop: "failCount", align: "center", width: 80 },
|
||
{ label: "耗时(ms)", prop: "durationMs", align: "center", width: 100 },
|
||
{ label: "错误信息", prop: "errorMsg", align: "center", showOverflowTooltip: true },
|
||
{ label: "创建时间", prop: "createTime", align: "center", width: 180, parseTime: true }
|
||
]
|
||
function logQueryList(params) {
|
||
const p = proxy.addDateRange({ ...params }, params.dateRange)
|
||
delete p.dateRange
|
||
return listSyncLog(p)
|
||
}
|
||
function logStatusText(status) {
|
||
const map = { PENDING: "待处理", PROCESSING: "处理中", SUCCESS: "成功", FAILED: "失败" }
|
||
return map[status] || status
|
||
}
|
||
function logStatusType(status) {
|
||
const map = { PENDING: "info", PROCESSING: "warning", SUCCESS: "success", FAILED: "danger" }
|
||
return map[status] || "info"
|
||
}
|
||
function handleLogDelete(logIds) {
|
||
proxy.$modal.confirm('是否确认删除日志编号为"' + logIds + '"的数据项?').then(function () {
|
||
return delSyncLog(logIds)
|
||
}).then(() => {
|
||
logTableRef.value.refresh()
|
||
proxy.$modal.msgSuccess("删除成功")
|
||
}).catch(() => {})
|
||
}
|
||
function openSyncLogDialog() {
|
||
logDialogVisible.value = true
|
||
nextTick(() => { logTableRef.value && logTableRef.value.reload() })
|
||
}
|
||
|
||
// 搜索项配置
|
||
const searchColumns = [
|
||
{ label: "中文名", prop: "countryNameCn", component: "input", placeholder: "请输入中文名", width: "200px" },
|
||
{ label: "英文名", prop: "countryNameEn", component: "input", placeholder: "请输入英文名", width: "200px" },
|
||
{ label: "国家代码", prop: "countryCode2", component: "input", placeholder: "如: DE", width: "160px" },
|
||
{ label: "洲/大区", prop: "continent", component: "input", placeholder: "如: Europe", width: "200px" },
|
||
{ label: "状态", prop: "status", component: "select", options: [{ label: "正常", value: "0" }, { label: "停用", value: "1" }], placeholder: "状态", width: "140px" }
|
||
]
|
||
|
||
// 表格列配置
|
||
const columns = [
|
||
{ label: "区域ID", prop: "regionId", align: "center", width: 80 },
|
||
{ label: "国旗", prop: "flagEmoji", align: "center", width: 70 },
|
||
{ label: "中文名", prop: "countryNameCn", align: "center" },
|
||
{ label: "英文名", prop: "countryNameEn", align: "center" },
|
||
{ label: "国家代码", prop: "countryCode2", align: "center", width: 80 },
|
||
{ label: "ISO三位码", prop: "countryCode3", align: "center", width: 90 },
|
||
{ label: "洲/大区", prop: "continent", align: "center", width: 120 },
|
||
{ label: "子区域", prop: "subRegion", align: "center", width: 140 },
|
||
{ label: "货币", prop: "currencyCode", align: "center", width: 80 },
|
||
{ label: "电话前缀", prop: "phonePrefix", align: "center", width: 90 },
|
||
{ label: "状态", prop: "status", align: "center", width: 80 },
|
||
{ label: "操作", prop: "action", align: "center", width: 150, showInToolbar: false }
|
||
]
|
||
|
||
// 查询函数
|
||
function queryList(params) {
|
||
return listRegion(params)
|
||
}
|
||
|
||
// 单元格合并:按「洲/大区」continent 合并相同大区的行(仅作用于 continent 列)
|
||
// 关键:el-table 在 :data 引用变化时会重新调用 span-method,
|
||
// 但这里 BasicTable 内部的 tableData 是异步请求回来的,父组件拿不到其引用,
|
||
// 因此用 watch + nextTick + el-table.doLayout() 强制刷新。
|
||
const spanMap = ref([])
|
||
function rebuildSpanMap() {
|
||
const rows = tableRef.value?.getData?.() || []
|
||
const map = new Array(rows.length)
|
||
for (let i = 0; i < rows.length; i++) {
|
||
const cur = rows[i]?.continent
|
||
if (i > 0 && rows[i - 1]?.continent === cur) {
|
||
map[i] = { rowspan: 0, colspan: 0 }
|
||
} else {
|
||
let count = 1
|
||
while (i + count < rows.length && rows[i + count]?.continent === cur) count++
|
||
map[i] = { rowspan: count, colspan: 1 }
|
||
}
|
||
}
|
||
spanMap.value = map
|
||
// 强制 el-table 重新调用 span-method
|
||
tableRef.value?.getTableRef?.()?.doLayout?.()
|
||
}
|
||
function objectSpanMethod({ column, rowIndex }) {
|
||
if (column.property !== 'continent') return { rowspan: 1, colspan: 1 }
|
||
return spanMap.value[rowIndex] || { rowspan: 1, colspan: 1 }
|
||
}
|
||
// 监听 BasicTable 数据变化
|
||
// 关键:expose.data 是 ref 对象;getData() 是 ()=>tableData.value 函数
|
||
// watch 源用 getData() 函数(Vue 会调用 source getter 并比较返回值),
|
||
// 每次 tableData.value 被重新赋值(分页/搜索/刷新),返回值(新数组)变化,watch 触发
|
||
onMounted(() => {
|
||
let lastLen = -1
|
||
watch(
|
||
() => {
|
||
const ref = tableRef.value?.data
|
||
return ref?.value || []
|
||
},
|
||
(rows) => {
|
||
if (rows.length !== lastLen) {
|
||
lastLen = rows.length
|
||
nextTick(rebuildSpanMap)
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
})
|
||
|
||
const data = reactive({
|
||
form: {},
|
||
rules: {
|
||
countryNameEn: [{ required: true, message: "英文名不能为空", trigger: "blur" }],
|
||
countryCode2: [{ required: true, message: "ISO两位码不能为空", trigger: "blur" }]
|
||
}
|
||
})
|
||
|
||
const { form, rules } = toRefs(data)
|
||
|
||
/** 取消按钮 */
|
||
function cancel() {
|
||
open.value = false
|
||
reset()
|
||
}
|
||
|
||
/** 表单重置 */
|
||
function reset() {
|
||
form.value = {
|
||
regionId: undefined,
|
||
countryNameCn: undefined,
|
||
countryNameEn: undefined,
|
||
countryCode2: undefined,
|
||
countryCode3: undefined,
|
||
continent: undefined,
|
||
subRegion: undefined,
|
||
currencyCode: undefined,
|
||
phonePrefix: undefined,
|
||
isIndependent: "1",
|
||
isEu: "0",
|
||
remark: undefined
|
||
}
|
||
proxy.resetForm("regionRef")
|
||
}
|
||
|
||
/** 修改按钮操作 */
|
||
function handleUpdate(row) {
|
||
reset()
|
||
const regionId = row.regionId
|
||
getRegion(regionId).then(response => {
|
||
form.value = response.data
|
||
open.value = true
|
||
title.value = "修改区域"
|
||
})
|
||
}
|
||
|
||
/** 提交按钮 */
|
||
function submitForm() {
|
||
proxy.$refs["regionRef"].validate(valid => {
|
||
if (valid) {
|
||
updateRegion(form.value).then(() => {
|
||
proxy.$modal.msgSuccess("修改成功")
|
||
open.value = false
|
||
tableRef.value.refresh()
|
||
})
|
||
}
|
||
})
|
||
}
|
||
|
||
/** 删除按钮操作 */
|
||
function handleDelete(regionIds) {
|
||
proxy.$modal.confirm('是否确认删除区域编号为"' + regionIds + '"的数据项?').then(function () {
|
||
return delRegion(regionIds)
|
||
}).then(() => {
|
||
tableRef.value.refresh()
|
||
proxy.$modal.msgSuccess("删除成功")
|
||
}).catch(() => {})
|
||
}
|
||
|
||
/** 修改状态 */
|
||
function handleStatusChange(row) {
|
||
// 优先显示:中文名 > 英文名 > 国家代码
|
||
const displayName = row.countryNameCn || row.countryNameEn || row.countryCode2 || '';
|
||
if (row.regionId === undefined || row.regionId === null || row.regionId === '' || !displayName) {
|
||
nextTick(() => {
|
||
row.status = row.status === "0" ? "1" : "0"
|
||
})
|
||
return
|
||
}
|
||
if (row.__originalStatus !== undefined && row.__originalStatus === row.status) {
|
||
return
|
||
}
|
||
row.__originalStatus = row.status === "0" ? "1" : "0"
|
||
|
||
const text = row.status === "0" ? "启用" : "停用"
|
||
proxy.$modal.confirm(`确认要${text}「${displayName}」国家/区域吗?`).then(function () {
|
||
return changeRegionStatus(row.regionId, row.status)
|
||
}).then(() => {
|
||
row.__originalStatus = row.status
|
||
proxy.$modal.msgSuccess(text + "成功")
|
||
}).catch(function () {
|
||
const restored = row.status === "0" ? "1" : "0"
|
||
row.__originalStatus = restored
|
||
nextTick(() => { row.status = restored })
|
||
})
|
||
}
|
||
|
||
/** 同步国家数据 */
|
||
function handleSync() {
|
||
proxy.$modal.confirm('确认从第三方API同步国家数据?').then(function () {
|
||
syncLoading.value = true
|
||
return syncRegion()
|
||
}).then(res => {
|
||
syncLogId.value = res.logId
|
||
proxy.$modal.msgSuccess("同步任务已创建,正在后台执行...")
|
||
// 轮询同步状态
|
||
syncTimer.value = setInterval(() => {
|
||
getSyncLog(syncLogId.value).then(response => {
|
||
const log = response.data
|
||
if (log.status === "SUCCESS") {
|
||
clearInterval(syncTimer.value)
|
||
syncTimer.value = null
|
||
syncLoading.value = false
|
||
tableRef.value.refresh()
|
||
proxy.$modal.msgSuccess(`同步完成!总计${log.totalCount}条,新增${log.insertCount}条,更新${log.updateCount}条,失败${log.failCount}条`)
|
||
} else if (log.status === "FAILED") {
|
||
clearInterval(syncTimer.value)
|
||
syncTimer.value = null
|
||
syncLoading.value = false
|
||
proxy.$modal.msgError("同步失败:" + log.errorMsg)
|
||
}
|
||
})
|
||
}, 3000)
|
||
}).catch(() => {
|
||
syncLoading.value = false
|
||
})
|
||
}
|
||
|
||
/** 导出按钮操作 */
|
||
function handleExport() {
|
||
proxy.download("data/region/export", {
|
||
...tableRef.value.getQueryParams()
|
||
}, `region_${new Date().getTime()}.xlsx`)
|
||
}
|
||
|
||
/** 组件卸载前清理定时器 */
|
||
onBeforeUnmount(() => {
|
||
if (syncTimer.value) {
|
||
clearInterval(syncTimer.value)
|
||
syncTimer.value = null
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.region-flag {
|
||
width: 30px;
|
||
height: 30px;
|
||
object-fit: contain;
|
||
display: block;
|
||
margin: 0 auto;
|
||
}
|
||
.region-flag-emoji {
|
||
font-size: 18px;
|
||
line-height: 30px;
|
||
}
|
||
</style>
|