feat: 新增数据管理模块(区域管理)及修复前端已知问题

后端(bms-back):
- 新增区域管理完整后端:Domain/Mapper/Service/Controller + MyBatis XML
- 新增 RestTemplateConfig 配置类,用于第三方API调用
- 新增 countries.json 本地国家数据源(105个国家)
- RuoYiApplication 加 @EnableAsync 支持异步任务
- application-druid.yml 修复 Druid 连接池字符集配置(SET NAMES utf8mb4)
- logback.xml 日志路径改为 ${user.dir}/logs 避免沙箱写入受限
- pom.xml 增加 JVM 编码参数(-Dfile.encoding=UTF-8)
- 新增 sql/bms_region.sql 建表脚本+菜单权限

前端(bms-front):
- 新增区域管理页面(列表/编辑/状态切换/同步国家数据/导出)
- 新增同步日志功能(内嵌为区域管理弹窗,移除独立菜单)
- 新增 API 层: region.js / region-log.js
- 修复 BasicForm/index.vue 缺失 import FormControl 导致搜索栏控件不渲染
- 修复 user/role/region 三个页面 el-switch 初始化时弹 undefined 提示
This commit is contained in:
wuweihua
2026-08-31 11:01:25 +08:00
parent b003b73b77
commit 44d2b690de
28 changed files with 2318 additions and 1105 deletions
+5
View File
@@ -49,5 +49,10 @@
},
"resolutions": {
"quill": "2.0.2"
},
"allowScripts": {
"esbuild@0.25.12": true,
"@parcel/watcher@2.6.0": true,
"vue-demi@0.14.10": true
}
}
+26
View File
@@ -0,0 +1,26 @@
import request from '@/utils/request'
// 查询同步日志列表
export function listSyncLog(query) {
return request({
url: '/data/region/log/list',
method: 'get',
params: query
})
}
// 查询同步日志详细
export function getSyncLog(logId) {
return request({
url: '/data/region/log/' + logId,
method: 'get'
})
}
// 删除同步日志
export function delSyncLog(logIds) {
return request({
url: '/data/region/log/' + logIds,
method: 'delete'
})
}
+56
View File
@@ -0,0 +1,56 @@
import request from '@/utils/request'
// 查询区域列表
export function listRegion(query) {
return request({
url: '/data/region/list',
method: 'get',
params: query
})
}
// 查询区域详细
export function getRegion(regionId) {
return request({
url: '/data/region/' + regionId,
method: 'get'
})
}
// 修改区域
export function updateRegion(data) {
return request({
url: '/data/region',
method: 'put',
data: data
})
}
// 删除区域
export function delRegion(regionIds) {
return request({
url: '/data/region/' + regionIds,
method: 'delete'
})
}
// 修改区域状态
export function changeRegionStatus(regionId, status) {
const data = {
regionId,
status
}
return request({
url: '/data/region/changeStatus',
method: 'put',
data: data
})
}
// 同步国家数据
export function syncRegion() {
return request({
url: '/data/region/sync',
method: 'post'
})
}
@@ -91,6 +91,8 @@
* colProps: {} // 透传给 el-col 的属性
* }
*/
import { ref, computed, reactive, isRef, watch, nextTick, getCurrentInstance } from "vue"
import FormControl from "./FormControl.vue"
defineOptions({ name: 'BasicForm' })
+430
View File
@@ -0,0 +1,430 @@
<template>
<div class="app-container">
<basic-table
ref="tableRef"
:columns="columns"
:search-columns="searchColumns"
:query="queryList"
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 }">
<span style="font-size: 18px">{{ 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 } 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: 60 },
{ 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)
}
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>
@@ -0,0 +1,91 @@
<template>
<div class="app-container">
<basic-table
ref="tableRef"
:columns="columns"
:search-columns="searchColumns"
:query="queryList"
row-key="logId"
>
<template #toolbar="{ selection, ids }">
<el-col :span="1.5">
<el-button
type="danger"
plain
icon="Delete"
:disabled="!selection.length"
@click="handleDelete(ids)"
v-hasPermi="['data:region:log:remove']"
>删除</el-button>
</el-col>
</template>
<template #col-status="{ row }">
<el-tag :type="statusType(row.status)">
{{ statusText(row.status) }}
</el-tag>
</template>
</basic-table>
</div>
</template>
<script setup name="RegionSyncLog">
import { listSyncLog, delSyncLog } from "@/api/data/region-log"
const { proxy } = getCurrentInstance()
const tableRef = ref()
// 搜索项配置
const searchColumns = [
{ 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 columns = [
{ 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 queryList(params) {
const p = proxy.addDateRange({ ...params }, params.dateRange)
delete p.dateRange
return listSyncLog(p)
}
/** 状态文本 */
function statusText(status) {
const map = { PENDING: "待处理", PROCESSING: "处理中", SUCCESS: "成功", FAILED: "失败" }
return map[status] || status
}
/** 状态标签类型 */
function statusType(status) {
const map = { PENDING: "info", PROCESSING: "warning", SUCCESS: "success", FAILED: "danger" }
return map[status] || "info"
}
/** 删除按钮操作 */
function handleDelete(logIds) {
proxy.$modal.confirm('是否确认删除日志编号为"' + logIds + '"的数据项?').then(function () {
return delSyncLog(logIds)
}).then(() => {
tableRef.value.refresh()
proxy.$modal.msgSuccess("删除成功")
}).catch(() => {})
}
</script>
File diff suppressed because it is too large Load Diff
+19 -3
View File
@@ -179,6 +179,7 @@
<script setup name="Role">
import { addRole, changeRoleStatus, dataScope, delRole, getRole, listRole, updateRole, deptTreeSelect } from "@/api/system/role"
import { roleMenuTreeselect, treeselect as menuTreeselect } from "@/api/system/menu"
import { nextTick } from "vue"
const router = useRouter()
const { proxy } = getCurrentInstance()
@@ -263,13 +264,28 @@ function handleExport() {
/** 角色状态修改 */
function handleStatusChange(row) {
let text = row.status === "0" ? "启用" : "停用"
proxy.$modal.confirm('确认要"' + text + '""' + row.roleName + '"角色吗?').then(function () {
if (row.roleId === undefined || row.roleId === null || row.roleId === '' ||
row.roleName === undefined || row.roleName === null || row.roleName === '') {
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}${row.roleName}」角色吗?`).then(function () {
return changeRoleStatus(row.roleId, row.status)
}).then(() => {
row.__originalStatus = row.status
proxy.$modal.msgSuccess(text + "成功")
}).catch(function () {
row.status = row.status === "0" ? "1" : "0"
const restored = row.status === "0" ? "1" : "0"
row.__originalStatus = restored
nextTick(() => { row.status = restored })
})
}
+24 -3
View File
@@ -161,6 +161,7 @@ import ExcelImportDialog from "@/components/ExcelImportDialog"
import UserViewDrawer from "./view"
import { usePasswordRule } from "@/utils/passwordRule"
import { changeUserStatus, listUser, resetUserPwd, delUser, getUser, updateUser, addUser, deptTreeSelect } from "@/api/system/user"
import { nextTick } from "vue"
const router = useRouter()
const { proxy } = getCurrentInstance()
@@ -265,13 +266,33 @@ function handleExport() {
/** 用户状态修改 */
function handleStatusChange(row) {
let text = row.status === "0" ? "启用" : "停用"
proxy.$modal.confirm('确认要"' + text + '""' + row.userName + '"用户吗?').then(function () {
// 守卫:页面初始化/数据加载过程中 el-switch 可能发生赋值抖动,
// userId 或 userName 缺失时不弹确认,只把状态反向重置防止显示偏差
if (row.userId === undefined || row.userId === null || row.userId === '' ||
row.userName === undefined || row.userName === null || row.userName === '') {
nextTick(() => {
row.status = row.status === "0" ? "1" : "0"
})
return
}
// 与原始记录中的 status 对比:status 只能是字符串 "0"/"1",若不一致(如 loading 中被改)则回滚
if (row.__originalStatus !== undefined && row.__originalStatus === row.status) {
return
}
// 为当前行记录一次原始值,后面 catch 时与回滚同步,避免重复触发 change
row.__originalStatus = row.status === "0" ? "1" : "0"
const text = row.status === "0" ? "启用" : "停用"
proxy.$modal.confirm(`确认要${text}${row.userName}」用户吗?`).then(function () {
return changeUserStatus(row.userId, row.status)
}).then(() => {
row.__originalStatus = row.status
proxy.$modal.msgSuccess(text + "成功")
}).catch(function () {
row.status = row.status === "0" ? "1" : "0"
const restored = row.status === "0" ? "1" : "0"
row.__originalStatus = restored
// 回滚赋值时短暂忽略 change,防止递归弹框
nextTick(() => { row.status = restored })
})
}