基本已能进入生产环境
This commit is contained in:
1 parent
bb77b8caf7
commit
611aef54bd
36 files changed
+2964
-356
No files matched your search
Generated
+240
-2
@@ -21,14 +21,17 @@
|
||||
"vxe-table": "^4.20.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tinymce/tinymce-vue": "^6.3.0",
|
||||
"@types/node": "^24.13.3",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"sass-embedded": "^1.100.0",
|
||||
"tinymce": "^8.8.2",
|
||||
"typescript": "~6.0.2",
|
||||
"unplugin-auto-import": "^21.0.0",
|
||||
"unplugin-vue-components": "^32.1.0",
|
||||
"vite": "^8.2.0",
|
||||
"vite-plugin-static-copy": "^4.1.1",
|
||||
"vue-tsc": "^3.3.8"
|
||||
}
|
||||
},
|
||||
@@ -982,6 +985,22 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tinymce/tinymce-vue": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/@tinymce/tinymce-vue/-/tinymce-vue-6.3.0.tgz",
|
||||
"integrity": "sha512-DSP8Jhd3XqCCliTnusfbmz3D8GqQ4iRzkc4aadYHDcJPVjkaqopJ61McOdH82CSy599vGLkPjGzqJYWJkRMiUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"tinymce": "^8.0.0 || ^7.0.0 || ^6.0.0 || ^5.5.1",
|
||||
"vue": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"tinymce": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.3",
|
||||
"resolved": "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||
@@ -1357,6 +1376,33 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/anymatch": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz",
|
||||
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"normalize-path": "^3.0.0",
|
||||
"picomatch": "^2.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/anymatch/node_modules/picomatch": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-kit": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/ast-kit/-/ast-kit-2.2.0.tgz",
|
||||
@@ -1414,6 +1460,19 @@
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/birpc": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmmirror.com/birpc/-/birpc-2.9.0.tgz",
|
||||
@@ -1423,6 +1482,19 @@
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz",
|
||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fill-range": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
@@ -1669,6 +1741,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz",
|
||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
@@ -1765,6 +1850,19 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -1852,12 +1950,25 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"binary-extensions": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -1866,8 +1977,8 @@
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz",
|
||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"is-extglob": "^2.1.1"
|
||||
},
|
||||
@@ -1875,6 +1986,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-number": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz",
|
||||
@@ -2334,6 +2455,16 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-wheel-es": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
|
||||
@@ -2360,6 +2491,19 @@
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/p-map": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmmirror.com/p-map/-/p-map-7.0.6.tgz",
|
||||
"integrity": "sha512-I4Prw6ivkd6p8PiYR1tXASOAOBzIJwu0TB7fqaX0c/8c3QAehNYmX57EijyGGGBt3c/BIowGwV03RVBtXvHEVg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/path-browserify": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||
@@ -2985,6 +3129,26 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinymce": {
|
||||
"version": "8.8.2",
|
||||
"resolved": "https://registry.npmmirror.com/tinymce/-/tinymce-8.8.2.tgz",
|
||||
"integrity": "sha512-wcUGaaAf4XFrQtbZtClbDN/QCqklXLlPt3+7UWliEoosuhnTOJUkXwf7Fzlq7agPnwkKKp1e4JGOhx3O1gWH3g==",
|
||||
"dev": true,
|
||||
"license": "SEE LICENSE IN license.md"
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-number": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
|
||||
@@ -3308,6 +3472,80 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-static-copy": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/vite-plugin-static-copy/-/vite-plugin-static-copy-4.1.1.tgz",
|
||||
"integrity": "sha512-GrlA8YklrAfSyxJ4M3fdQLOo9oNkp56IM9FYgX/WtEgeIFkPwhu4wzpufBCIuNKCa6Fn77FkRdYxkHqV0FwjAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chokidar": "^3.6.0",
|
||||
"p-map": "^7.0.4",
|
||||
"picocolors": "^1.1.1",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.0.0 || >=24.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/sapphi-red"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-static-copy/node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz",
|
||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"anymatch": "~3.1.2",
|
||||
"braces": "~3.0.2",
|
||||
"glob-parent": "~5.1.2",
|
||||
"is-binary-path": "~2.1.0",
|
||||
"is-glob": "~4.0.1",
|
||||
"normalize-path": "~3.0.0",
|
||||
"readdirp": "~3.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-static-copy/node_modules/picomatch": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-static-copy/node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz",
|
||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"picomatch": "^2.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz",
|
||||
|
||||
@@ -22,14 +22,17 @@
|
||||
"vxe-table": "^4.20.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tinymce/tinymce-vue": "^6.3.0",
|
||||
"@types/node": "^24.13.3",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"sass-embedded": "^1.100.0",
|
||||
"tinymce": "^8.8.2",
|
||||
"typescript": "~6.0.2",
|
||||
"unplugin-auto-import": "^21.0.0",
|
||||
"unplugin-vue-components": "^32.1.0",
|
||||
"vite": "^8.2.0",
|
||||
"vite-plugin-static-copy": "^4.1.1",
|
||||
"vue-tsc": "^3.3.8"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -73,7 +73,7 @@ request.interceptors.response.use(
|
||||
// 标准 {code, data} 结构:返回 data
|
||||
if ('data' in result) return result.data as unknown as T
|
||||
// 兼容扁平结构(如 GetELTUserInfo 返回 {code, UserUUID, UserName, ...} 无 data 字段)
|
||||
// 返回整个 result(含 code,调用方按需取业务字段),避免拿到 undefined 导致缓存写不进 userInfo
|
||||
// 返回整个 result(含 code),调用方按需取业务字段,避免拿到 undefined 导致缓存写不进 userInfo
|
||||
return result as unknown as T
|
||||
}
|
||||
|
||||
|
||||
+614
-17
@@ -1,9 +1,17 @@
|
||||
<script setup lang="ts" generic="T extends Record<string, unknown>">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useCrudTable, type UseCrudTableOptions } from '@/composables/useCrudTable'
|
||||
import type { ColumnSchema } from '@/types/api'
|
||||
import { formatUtcTime, nowUtcString } from '@/utils/dateTime'
|
||||
import { formApi } from '@/api/form'
|
||||
import EntityPicker from './EntityPicker.vue'
|
||||
|
||||
/** 自动时间戳字段(CreateTime 等):由服务器填写,前端自动预填当前时间且不要求输入 */
|
||||
function isAutoTimestampField(field: string): boolean {
|
||||
return /CreateTime$/.test(field) || field === 'AvatarUploadTime'
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用 CRUD 表格组件
|
||||
* 基于 vxe-table + Element Plus,配合后端 FormAPI 协议
|
||||
@@ -42,6 +50,33 @@ const props = defineProps<{
|
||||
* 避免所有行撑开导致页面无限加长。不传则不限制。
|
||||
*/
|
||||
maxHeight?: number
|
||||
/**
|
||||
* 卡片模式:以卡片网格展示数据(替代 vxe-table 表格)。
|
||||
* 保留搜索栏/新增/批量删除/刷新/分页/行内编辑/删除/查看。
|
||||
* 用于公司/租户等字段少、适合卡片阅读的页面。
|
||||
*/
|
||||
cardMode?: boolean
|
||||
/** 卡片主标题字段(默认取首个非 pk 文本列,如 CompanyName/TenantName) */
|
||||
cardTitleField?: string
|
||||
/** 卡片展示字段(默认取全部可见列;传了则按此顺序展示,标题字段除外) */
|
||||
cardFields?: string[]
|
||||
/**
|
||||
* 卡片字段值映射(优先级高于列 options,用于后端未配置 options 的状态码等):
|
||||
* 如 { ReviewStatus: { 0: '待审核', 1: '已通过', 2: '已拒绝' } }
|
||||
*/
|
||||
cardValueMaps?: Record<string, Record<string | number, string>>
|
||||
/**
|
||||
* 卡片树状依赖展示:指定父级字段名(如 Company 的 ParentCompanyUUID)时,
|
||||
* 卡片模式自动按该字段组装树,子卡片缩进 + 连接线展示层级关系。
|
||||
* 开启后建议 pageable=false(树需全量数据)。
|
||||
*/
|
||||
cardTreeParentField?: string
|
||||
/** 卡片标题旁图标字段(如 CompanyIcon:值为图片 URL,有值则显示图片,无则忽略) */
|
||||
cardIconField?: string
|
||||
/** 卡片图标点击回调(如公司列表点击图标 → 上传/修改图标)。传了则图标可点击并显示遮罩 */
|
||||
onCardIconClick?: (row: T) => void
|
||||
/** 默认每页条数(默认 20;树形卡片等需全量数据的场景可调大,如 1000) */
|
||||
defaultPageSize?: number
|
||||
}>()
|
||||
|
||||
// 表格实际使用的最大高度:显式 maxHeight 优先;
|
||||
@@ -51,7 +86,9 @@ const tableMaxHeight = computed(() => props.maxHeight ?? (props.stretch ? Math.f
|
||||
|
||||
const options: UseCrudTableOptions<T> = {
|
||||
table: props.table,
|
||||
query: props.query,
|
||||
// 以 Ref 传入:父组件筛选条件变化(如工单状态筛选)时,fetchData 能读到最新 query
|
||||
query: computed(() => props.query),
|
||||
defaultPageSize: props.defaultPageSize,
|
||||
pkField: props.pkField,
|
||||
selectable: props.selectable,
|
||||
onView: props.onView,
|
||||
@@ -84,6 +121,8 @@ const {
|
||||
handleReset,
|
||||
handlePageChange,
|
||||
handleSizeChange,
|
||||
getPkField,
|
||||
isEdit,
|
||||
} = useCrudTable<T>(options)
|
||||
|
||||
// vxe-table 实例引用(用于获取选中行)
|
||||
@@ -109,25 +148,36 @@ const tableColumns = computed(() =>
|
||||
columns.value.filter((c: ColumnSchema) => c.visible !== false && c.type !== 'hidden'),
|
||||
)
|
||||
|
||||
// 弹窗表单可见列(排除隐藏/主键隐藏/不可编辑/divider/html/父级注入字段)
|
||||
// 弹窗表单可见列(排除隐藏/主键隐藏/不可编辑/divider/html/父级注入字段;
|
||||
// 编辑态额外排除 hideInEditForm 的虚拟级联列)
|
||||
// 自动时间戳字段(CreateTime 等)强制 required=false:由服务器填写,不要求用户输入
|
||||
const formColumns = computed(() => {
|
||||
const pkField = columns.value.find((c) => c.isPrimary || c.primaryKey)?.field || 'Id'
|
||||
return columns.value.filter(
|
||||
(c: ColumnSchema) =>
|
||||
c.editable !== false &&
|
||||
!c.hideInForm &&
|
||||
c.type !== 'hidden' &&
|
||||
c.type !== 'divider' &&
|
||||
c.type !== 'html' &&
|
||||
c.field !== pkField &&
|
||||
!(c.field in inheritedQueryFields.value),
|
||||
)
|
||||
return columns.value
|
||||
.filter(
|
||||
(c: ColumnSchema) =>
|
||||
c.editable !== false &&
|
||||
!c.hideInForm &&
|
||||
!(isEdit.value && c.hideInEditForm) &&
|
||||
c.type !== 'hidden' &&
|
||||
c.type !== 'divider' &&
|
||||
c.type !== 'html' &&
|
||||
c.field !== pkField &&
|
||||
!(c.field in inheritedQueryFields.value),
|
||||
)
|
||||
.map((c: ColumnSchema) => (isAutoTimestampField(c.field) ? { ...c, required: false } : c))
|
||||
})
|
||||
|
||||
// 新增:先走 useCrudTable 初始化,再注入父级上下文关联字段
|
||||
// 新增:先走 useCrudTable 初始化,再注入父级上下文关联字段;
|
||||
// 自动时间戳字段(CreateTime 等)预填当前 UTC 时间——不要求用户输入(后端仍以服务器时间为准)
|
||||
function onHandleCreate(): void {
|
||||
handleCreate()
|
||||
Object.assign(editingRow, inheritedQueryFields.value)
|
||||
for (const col of columns.value) {
|
||||
if (isAutoTimestampField(col.field) && (editingRow[col.field] == null || editingRow[col.field] === '')) {
|
||||
editingRow[col.field] = nowUtcString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 提交:保证父级上下文关联字段始终存在(编辑时用户可能误删)
|
||||
@@ -136,6 +186,39 @@ function onHandleSubmit(): void {
|
||||
handleSubmit()
|
||||
}
|
||||
|
||||
// 查询条件变化(如工单管理页的状态筛选):重置到第一页并重新加载。
|
||||
// 父组件传响应式 query(computed)时触发;无 query 的页面不受影响。
|
||||
watch(
|
||||
() => props.query,
|
||||
() => {
|
||||
currentPage.value = 1
|
||||
void fetchData()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// 多选暂存批量新增:EntityPicker 树面板"全部提交"触发,为每个暂存成员创建一行
|
||||
// items[].type 为暂存区设置的类型(RoleType,默认 0=成员)
|
||||
async function handlePickerSubmitMulti(
|
||||
field: string,
|
||||
items: Array<{ value: string; label: string; secondary?: string; type?: number }>,
|
||||
): Promise<void> {
|
||||
if (isEdit.value || !items?.length) return
|
||||
Object.assign(editingRow, inheritedQueryFields.value)
|
||||
let created = 0
|
||||
for (const item of items) {
|
||||
const row: Record<string, unknown> = { ...editingRow }
|
||||
row[field] = item.value
|
||||
row[`${field}_label`] = item.label
|
||||
if (item.type !== undefined) row.RoleType = item.type
|
||||
await formApi.create(props.table, row as Partial<T>)
|
||||
created += 1
|
||||
}
|
||||
ElMessage.success(`成功新增 ${created} 条`)
|
||||
dialogVisible.value = false
|
||||
await fetchData()
|
||||
}
|
||||
|
||||
// vxe-table checkbox 变化:同步到 selectedRows
|
||||
function onCheckboxChange(): void {
|
||||
const records = xTableRef.value?.getCheckboxRecords?.() || []
|
||||
@@ -143,11 +226,22 @@ function onCheckboxChange(): void {
|
||||
}
|
||||
|
||||
// 实体选择器回显标签:优先 row[field+'_label'],否则前 8 位 + ...
|
||||
// UserRole 多对多:_label=角色名 + _UserName=用户名,拼接为"角色名(用户名)"
|
||||
// 非 UUID 值(如 RoleName 覆盖名)原样显示,不做截断
|
||||
function entityLabel(row: Record<string, unknown>, field: string): string {
|
||||
const label = row[`${field}_label`] ?? row[`${field}__label`]
|
||||
if (label) return String(label)
|
||||
const userName = row[`${field}_UserName`] ?? row[`${field}__UserName`]
|
||||
if (label) {
|
||||
const base = String(label)
|
||||
return userName ? `${base}(${String(userName)})` : base
|
||||
}
|
||||
const val = row[field]
|
||||
return val ? String(val).slice(0, 8) + '...' : ''
|
||||
if (!val) return ''
|
||||
const s = String(val)
|
||||
// UUID 格式(8-4-4-4-12)才截断,普通字符串原样显示
|
||||
return /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(s)
|
||||
? s.slice(0, 8) + '...'
|
||||
: s
|
||||
}
|
||||
|
||||
// tags 渲染:字符串按分隔符拆分
|
||||
@@ -156,6 +250,25 @@ function renderTags(value: unknown, separator = ','): string[] {
|
||||
return String(value).split(separator).map((s) => s.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
// tags 表单:逗号分隔字符串 ↔ 多选数组互转
|
||||
function splitTags(value: unknown, separator = ','): string[] {
|
||||
return renderTags(value, separator)
|
||||
}
|
||||
function joinTags(values: string[], separator = ','): string {
|
||||
return (values || []).filter(Boolean).join(separator)
|
||||
}
|
||||
|
||||
// richtext 预览:去除 HTML 标签保留纯文本,超长截断
|
||||
function stripHtml(value: unknown): string {
|
||||
if (!value) return ''
|
||||
const text = String(value)
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
return text.length > 120 ? `${text.slice(0, 120)}...` : text
|
||||
}
|
||||
|
||||
// uuid 渲染:前 8 位 + ...
|
||||
function renderUuid(value: unknown): string {
|
||||
return value ? String(value).slice(0, 8) + '...' : ''
|
||||
@@ -166,6 +279,140 @@ function renderSelectLabel(col: ColumnSchema, value: unknown): string {
|
||||
const opt = col.options?.find((o) => String(o.value) === String(value))
|
||||
return opt ? opt.label : value == null || value === '' ? '' : String(value)
|
||||
}
|
||||
|
||||
// === 卡片模式 ===
|
||||
|
||||
// 卡片主标题字段:显式 cardTitleField > 首个非 pk 文本列 > 首个可见列
|
||||
const cardTitleField = computed(() => {
|
||||
if (props.cardTitleField) return props.cardTitleField
|
||||
const pkField = columns.value.find((c) => c.isPrimary || c.primaryKey)?.field || ''
|
||||
const visible = tableColumns.value
|
||||
const textCol = visible.find(
|
||||
(c) => c.field !== pkField && (c.type === 'text' || c.type === 'string' || !c.type) && c.field !== 'CompanyUUID',
|
||||
)
|
||||
return textCol?.field || visible[0]?.field || ''
|
||||
})
|
||||
|
||||
// 卡片展示字段:显式 cardFields > 全部可见列(排除主键、标题字段、hideInForm 的关联字段)
|
||||
const cardDisplayFields = computed(() => {
|
||||
if (props.cardFields && props.cardFields.length > 0) return props.cardFields
|
||||
const pkField = columns.value.find((c) => c.isPrimary || c.primaryKey)?.field || ''
|
||||
return tableColumns.value
|
||||
.filter((c) => c.field !== pkField && c.field !== cardTitleField.value && c.visible !== false)
|
||||
.map((c) => c.field)
|
||||
})
|
||||
|
||||
// 卡片字段值格式化:cardValueMaps > 列 options(select/status)> 原值
|
||||
function formatCardValue(row: Record<string, unknown>, field: string): string {
|
||||
const value = row[field]
|
||||
if (value == null || value === '') return '-'
|
||||
// 显式映射优先(后端可能未配置 options 的状态码)
|
||||
const map = props.cardValueMaps?.[field]
|
||||
if (map && map[String(value)] !== undefined) return map[String(value)]
|
||||
const col = columns.value.find((c) => c.field === field)
|
||||
if (!col) return String(value)
|
||||
if (col.type === 'select' || col.type === 'status') {
|
||||
const opt = col.options?.find((o) => String(o.value) === String(value))
|
||||
if (opt) return opt.label
|
||||
}
|
||||
// entityPicker:用回显 label(row[field+'_label'])
|
||||
if (col.type === 'entityPicker') {
|
||||
const label = entityLabel(row, field)
|
||||
return label || String(value)
|
||||
}
|
||||
// uuid:截断
|
||||
if (col.type === 'uuid') return renderUuid(value)
|
||||
return String(value)
|
||||
}
|
||||
|
||||
// 卡片字段中文标题:从列定义取 title
|
||||
function columnTitle(field: string): string {
|
||||
return columns.value.find((c) => c.field === field)?.title || field
|
||||
}
|
||||
|
||||
// 卡片复选:同步到 selectedRows(对齐 vxe-table checkbox)
|
||||
function onCardCheck(row: T, checked: boolean): void {
|
||||
const rows = checked
|
||||
? [...selectedRows.value.filter((r) => r !== row), row]
|
||||
: selectedRows.value.filter((r) => r !== row)
|
||||
handleSelectionChange(rows)
|
||||
}
|
||||
|
||||
// === 卡片树状依赖 ===
|
||||
|
||||
/** 树节点:原数据 + 子节点 */
|
||||
interface CardTreeNode {
|
||||
row: T
|
||||
depth: number
|
||||
children: CardTreeNode[]
|
||||
}
|
||||
|
||||
// 扁平化的树节点列表(含 depth),供卡片模式按层级缩进渲染
|
||||
const cardTreeNodes = computed<CardTreeNode[]>(() => {
|
||||
const parentField = props.cardTreeParentField
|
||||
if (!parentField) return []
|
||||
const pkField = getPkField()
|
||||
const byPk = new Map<string, T>()
|
||||
for (const r of dataList.value) {
|
||||
const pk = (r as Record<string, unknown>)[pkField]
|
||||
if (pk != null) byPk.set(String(pk), r)
|
||||
}
|
||||
// 构建子映射:parent → children(当前页数据范围内)
|
||||
const childrenMap = new Map<string, T[]>()
|
||||
const roots: T[] = []
|
||||
for (const r of dataList.value) {
|
||||
const parent = (r as Record<string, unknown>)[parentField]
|
||||
if (parent && byPk.has(String(parent))) {
|
||||
const key = String(parent)
|
||||
if (!childrenMap.has(key)) childrenMap.set(key, [])
|
||||
childrenMap.get(key)!.push(r)
|
||||
} else {
|
||||
roots.push(r) // 无父或父不在当前页 → 视为根
|
||||
}
|
||||
}
|
||||
// DFS 组装树(限制层级防环,默认 10 层)
|
||||
// 前序遍历:先输出父节点,再递归子节点 → 顺序为"父→子→孙"
|
||||
const MAX_DEPTH = 10
|
||||
const result: CardTreeNode[] = []
|
||||
const visited = new Set<string>()
|
||||
function build(nodes: T[], depth: number): void {
|
||||
if (depth > MAX_DEPTH) return
|
||||
for (const n of nodes) {
|
||||
const pk = String((n as Record<string, unknown>)[pkField])
|
||||
if (visited.has(pk)) continue
|
||||
visited.add(pk)
|
||||
// 先输出当前节点(父),再递归子节点
|
||||
result.push({ row: n, depth, children: [] })
|
||||
const kids = childrenMap.get(pk)
|
||||
if (kids && kids.length) build(kids, depth + 1)
|
||||
}
|
||||
}
|
||||
build(roots, 0)
|
||||
// 若存在环导致部分节点未收录,把未访问的节点追加为根(兜底)
|
||||
for (const r of dataList.value) {
|
||||
const pk = String((r as Record<string, unknown>)[pkField])
|
||||
if (!visited.has(pk)) {
|
||||
result.push({ row: r, depth: 0, children: [] })
|
||||
visited.add(pk)
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
// 卡片树是否启用(有父级字段且组装出节点)
|
||||
const isCardTree = computed(() => !!props.cardTreeParentField)
|
||||
|
||||
// 树内单条记录的 key(pk 兜底 JSON)
|
||||
function cardRowKey(row: Record<string, unknown>): string {
|
||||
const pk = row[getPkField() ?? '']
|
||||
return String(pk ?? JSON.stringify(row))
|
||||
}
|
||||
|
||||
// 树节点当前是否选中(用于卡片高亮)
|
||||
function isCardSelected(row: Record<string, unknown>): boolean {
|
||||
const pk = row[getPkField() ?? '']
|
||||
return selectedRows.value.some((r) => (r as Record<string, unknown>)[getPkField() ?? ''] === pk)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -210,7 +457,9 @@ function renderSelectLabel(col: ColumnSchema, value: unknown): string {
|
||||
</template>
|
||||
|
||||
<div class="table-body">
|
||||
<!-- ===== 表格模式(默认) ===== -->
|
||||
<vxe-table
|
||||
v-if="!cardMode"
|
||||
ref="xTableRef"
|
||||
:data="dataList"
|
||||
v-loading="loading"
|
||||
@@ -271,6 +520,10 @@ function renderSelectLabel(col: ColumnSchema, value: unknown): string {
|
||||
<span v-else-if="col.type === 'uuid'">{{ renderUuid((row as Record<string, unknown>)[col.field]) }}</span>
|
||||
<!-- select:options 匹配显示 label(状态码等) -->
|
||||
<span v-else-if="col.type === 'select'">{{ renderSelectLabel(col, (row as Record<string, unknown>)[col.field]) }}</span>
|
||||
<!-- datetime:后端 UTC-0 → 用户本地时区显示 -->
|
||||
<span v-else-if="col.type === 'datetime' || col.type === 'date'">{{ formatUtcTime((row as Record<string, unknown>)[col.field]) }}</span>
|
||||
<!-- richtext:去 HTML 纯文本预览 -->
|
||||
<span v-else-if="col.type === 'richtext'">{{ stripHtml((row as Record<string, unknown>)[col.field]) }}</span>
|
||||
<!-- 默认:原值 -->
|
||||
<span v-else>{{ (row as Record<string, unknown>)[col.field] }}</span>
|
||||
</template>
|
||||
@@ -286,6 +539,159 @@ function renderSelectLabel(col: ColumnSchema, value: unknown): string {
|
||||
</vxe-column>
|
||||
</vxe-table>
|
||||
|
||||
<!-- ===== 卡片模式:卡片网格展示(替代表格) ===== -->
|
||||
<div v-else-if="cardMode" v-loading="loading" class="card-grid">
|
||||
<!-- 空态 -->
|
||||
<el-empty
|
||||
v-if="!loading && dataList.length === 0"
|
||||
description="暂无数据"
|
||||
:image-size="80"
|
||||
/>
|
||||
|
||||
<!-- 树状依赖模式:按 DFS 前序顺序线性排列(父→子→孙,同级按数据顺序) -->
|
||||
<template v-if="isCardTree">
|
||||
<div
|
||||
v-for="node in cardTreeNodes"
|
||||
:key="cardRowKey(node.row as Record<string, unknown>)"
|
||||
class="crud-card"
|
||||
:class="{ 'crud-card-selectable': selectable && isCardSelected(node.row as Record<string, unknown>) }"
|
||||
@click="handleRowClick(node.row as T)"
|
||||
>
|
||||
<!-- 卡片头部:复选 + 层级标签 + 标题 + 子节点数 -->
|
||||
<div class="crud-card-header">
|
||||
<div class="crud-card-title-wrap">
|
||||
<el-checkbox
|
||||
v-if="selectable"
|
||||
class="crud-card-check"
|
||||
:model-value="isCardSelected(node.row as Record<string, unknown>)"
|
||||
@change="(v: unknown) => onCardCheck(node.row as T, Boolean(v))"
|
||||
@click.stop
|
||||
/>
|
||||
<el-tag
|
||||
v-if="node.depth > 0"
|
||||
size="small"
|
||||
type="warning"
|
||||
effect="plain"
|
||||
>
|
||||
{{ node.depth === 1 ? '子级' : `第${node.depth}层` }}
|
||||
</el-tag>
|
||||
<div
|
||||
v-if="cardIconField"
|
||||
class="crud-card-icon-wrap"
|
||||
:class="{ 'crud-card-icon-clickable': !!props.onCardIconClick }"
|
||||
:title="props.onCardIconClick ? '点击设置图标' : ''"
|
||||
@click.stop="props.onCardIconClick?.(node.row as T)"
|
||||
>
|
||||
<img
|
||||
v-if="(node.row as Record<string, unknown>)[cardIconField]"
|
||||
:src="String((node.row as Record<string, unknown>)[cardIconField])"
|
||||
class="crud-card-icon"
|
||||
alt=""
|
||||
/>
|
||||
<el-icon v-else class="crud-card-icon-placeholder"><OfficeBuilding /></el-icon>
|
||||
<span v-if="props.onCardIconClick" class="crud-card-icon-mask">
|
||||
<el-icon><Camera /></el-icon>
|
||||
</span>
|
||||
</div>
|
||||
<span class="crud-card-title">{{ (node.row as Record<string, unknown>)[cardTitleField] || '-' }}</span>
|
||||
<el-tag v-if="node.children.length" type="info" size="small" effect="plain">
|
||||
{{ node.children.length }} 子级
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-button link type="primary" @click.stop="handleView(node.row as T)">查看</el-button>
|
||||
</div>
|
||||
<!-- 卡片主体:字段键值列表 -->
|
||||
<div class="crud-card-body">
|
||||
<div
|
||||
v-for="field in cardDisplayFields"
|
||||
:key="field"
|
||||
class="crud-card-field"
|
||||
>
|
||||
<span class="crud-card-field-label">{{ columnTitle(field) }}</span>
|
||||
<span class="crud-card-field-value" :title="formatCardValue(node.row as Record<string, unknown>, field)">
|
||||
{{ formatCardValue(node.row as Record<string, unknown>, field) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 卡片操作:编辑 / 删除 -->
|
||||
<div class="crud-card-actions">
|
||||
<el-button link type="primary" @click.stop="handleEdit(node.row as T)">
|
||||
<el-icon><Edit /></el-icon>编辑
|
||||
</el-button>
|
||||
<el-button link type="danger" @click.stop="handleDelete(node.row as T)">
|
||||
<el-icon><Delete /></el-icon>删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 普通卡片模式(无树) -->
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="row in dataList"
|
||||
:key="String((row as Record<string, unknown>)[getPkField() ?? ''] ?? JSON.stringify(row))"
|
||||
class="crud-card"
|
||||
:class="{ 'crud-card-selectable': selectable && selectedRows.some((r) => (r as Record<string, unknown>)[getPkField() ?? ''] === (row as Record<string, unknown>)[getPkField() ?? '']) }"
|
||||
@click="handleRowClick(row as T)"
|
||||
>
|
||||
<!-- 卡片头部:复选 + 标题 + 状态标签 -->
|
||||
<div class="crud-card-header">
|
||||
<div class="crud-card-title-wrap">
|
||||
<el-checkbox
|
||||
v-if="selectable"
|
||||
class="crud-card-check"
|
||||
:model-value="selectedRows.some((r) => (r as Record<string, unknown>)[getPkField() ?? ''] === (row as Record<string, unknown>)[getPkField() ?? ''])"
|
||||
@change="(v: unknown) => onCardCheck(row as T, Boolean(v))"
|
||||
@click.stop
|
||||
/>
|
||||
<div
|
||||
v-if="cardIconField"
|
||||
class="crud-card-icon-wrap"
|
||||
:class="{ 'crud-card-icon-clickable': !!props.onCardIconClick }"
|
||||
:title="props.onCardIconClick ? '点击设置图标' : ''"
|
||||
@click.stop="props.onCardIconClick?.(row as T)"
|
||||
>
|
||||
<img
|
||||
v-if="(row as Record<string, unknown>)[cardIconField]"
|
||||
:src="String((row as Record<string, unknown>)[cardIconField])"
|
||||
class="crud-card-icon"
|
||||
alt=""
|
||||
/>
|
||||
<el-icon v-else class="crud-card-icon-placeholder"><OfficeBuilding /></el-icon>
|
||||
<span v-if="props.onCardIconClick" class="crud-card-icon-mask">
|
||||
<el-icon><Camera /></el-icon>
|
||||
</span>
|
||||
</div>
|
||||
<span class="crud-card-title">{{ (row as Record<string, unknown>)[cardTitleField] || '-' }}</span>
|
||||
</div>
|
||||
<el-button link type="primary" @click.stop="handleView(row as T)">查看</el-button>
|
||||
</div>
|
||||
<!-- 卡片主体:字段键值列表 -->
|
||||
<div class="crud-card-body">
|
||||
<div
|
||||
v-for="field in cardDisplayFields"
|
||||
:key="field"
|
||||
class="crud-card-field"
|
||||
>
|
||||
<span class="crud-card-field-label">{{ columnTitle(field) }}</span>
|
||||
<span class="crud-card-field-value" :title="formatCardValue(row as Record<string, unknown>, field)">
|
||||
{{ formatCardValue(row as Record<string, unknown>, field) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 卡片操作:编辑 / 删除 -->
|
||||
<div class="crud-card-actions">
|
||||
<el-button link type="primary" @click.stop="handleEdit(row as T)">
|
||||
<el-icon><Edit /></el-icon>编辑
|
||||
</el-button>
|
||||
<el-button link type="danger" @click.stop="handleDelete(row as T)">
|
||||
<el-icon><Delete /></el-icon>删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="pageable" class="pagination">
|
||||
<el-pagination
|
||||
@@ -311,15 +717,18 @@ function renderSelectLabel(col: ColumnSchema, value: unknown): string {
|
||||
:label="col.title"
|
||||
:required="col.required"
|
||||
>
|
||||
<!-- entityPicker:EntityPicker 组件 -->
|
||||
<!-- entityPicker:EntityPicker 组件(支持级联:dependsOn 字段值 + dependCompany 公司值) -->
|
||||
<EntityPicker
|
||||
v-if="col.type === 'entityPicker' && col.entityPicker"
|
||||
v-model="editingRow[col.field]"
|
||||
:config="col.entityPicker"
|
||||
:existing-label="editingRow[`${col.field}_label`] as string"
|
||||
:existing-label="(editingRow[`${col.field}_label`] as string) || (col.entityPicker.valueField === col.field ? (editingRow[col.field] as string) : '')"
|
||||
:placeholder="col.placeholder"
|
||||
:disabled="col.disabled"
|
||||
:depend-value="col.entityPicker.dependsOn ? (editingRow[col.entityPicker.dependsOn] as string) : undefined"
|
||||
:depend-company-value="col.entityPicker.dependCompany ? (editingRow.CompanyUUID as string) : undefined"
|
||||
@update:label="(label: string) => (editingRow[`${col.field}_label`] = label)"
|
||||
@submit-multi="(items) => handlePickerSubmitMulti(col.field, items)"
|
||||
/>
|
||||
<el-input
|
||||
v-else-if="!col.type || col.type === 'text' || col.type === 'string'"
|
||||
@@ -343,6 +752,14 @@ function renderSelectLabel(col: ColumnSchema, value: unknown): string {
|
||||
:rows="col.rows || 3"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<!-- richtext:富文本编辑器(tinymce 自托管) -->
|
||||
<RichTextEditor
|
||||
v-else-if="col.type === 'richtext'"
|
||||
v-model="editingRow[col.field]"
|
||||
:placeholder="col.placeholder"
|
||||
:rows="col.rows"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<el-input
|
||||
v-else-if="col.type === 'password'"
|
||||
v-model="editingRow[col.field]"
|
||||
@@ -364,6 +781,26 @@ function renderSelectLabel(col: ColumnSchema, value: unknown): string {
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<!-- tags:多选标签(预设可选项 + 允许自定义新增),逗号分隔存储 -->
|
||||
<el-select
|
||||
v-else-if="col.type === 'tags'"
|
||||
:model-value="splitTags(editingRow[col.field], col.tagsSeparator)"
|
||||
@update:model-value="(v: string[]) => (editingRow[col.field] = joinTags(v, col.tagsSeparator))"
|
||||
:placeholder="col.placeholder"
|
||||
:disabled="col.disabled"
|
||||
multiple
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in col.options ?? []"
|
||||
:key="String(opt.value)"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-radio-group
|
||||
v-else-if="col.type === 'radio'"
|
||||
v-model="editingRow[col.field]"
|
||||
@@ -492,4 +929,164 @@ function renderSelectLabel(col: ColumnSchema, value: unknown): string {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
// ===== 卡片模式 =====
|
||||
|
||||
// 卡片网格:响应式列数,min-width 自动换行
|
||||
// 树状依赖模式复用同一网格:节点按 DFS 前序顺序输出,顺序即"父→子→孙"
|
||||
.card-grid {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 14px;
|
||||
padding: 2px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
// 单张卡片
|
||||
.crud-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 10px;
|
||||
background: var(--el-bg-color);
|
||||
transition:
|
||||
box-shadow 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08);
|
||||
border-color: var(--el-color-primary-light-5);
|
||||
}
|
||||
|
||||
// 选中态:主色描边
|
||||
&.crud-card-selectable {
|
||||
border-color: var(--el-color-primary);
|
||||
box-shadow: 0 0 0 1px var(--el-color-primary) inset;
|
||||
}
|
||||
}
|
||||
|
||||
// 卡片头部:标题 + 查看
|
||||
.crud-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 12px 14px 8px;
|
||||
border-bottom: 1px dashed var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.crud-card-title-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.crud-card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// 卡片标题旁的公司图标
|
||||
.crud-card-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// 图标容器(可点击上传时显示遮罩)
|
||||
.crud-card-icon-wrap {
|
||||
position: relative;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
// 无图标占位:主色淡底 + 建筑图标
|
||||
.crud-card-icon-placeholder {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
// 可点击图标:悬停显示相机遮罩
|
||||
.crud-card-icon-clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover .crud-card-icon-mask {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.crud-card-icon-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
// 卡片主体:键值列表
|
||||
.crud-card-body {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.crud-card-field {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.crud-card-field-label {
|
||||
flex-shrink: 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.crud-card-field-value {
|
||||
text-align: right;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// 卡片操作:编辑 / 删除
|
||||
.crud-card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
padding: 6px 10px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
</style>
|
||||
+560
-37
@@ -27,6 +27,15 @@ interface EntityOption {
|
||||
label: string
|
||||
/** 副文本(secondaryField,可选) */
|
||||
secondary?: string
|
||||
/** 树分组字段值(treeGroup 模式:如 DeptUUID),用于归入分组 */
|
||||
groupKey?: string
|
||||
}
|
||||
|
||||
/** 树分组节点(部门树:uuid/name/parentUuid 扁平结构,前端组装) */
|
||||
interface TreeGroupNode {
|
||||
uuid: string
|
||||
name: string
|
||||
parentUuid?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
@@ -42,17 +51,34 @@ const props = withDefaults(defineProps<{
|
||||
existingLabel?: string
|
||||
/** 是否可清空 */
|
||||
clearable?: boolean
|
||||
/**
|
||||
* 级联依赖值(可选):config.dependsOn 指定依赖字段时,把表单中该字段的当前值作为
|
||||
* 同名 query 参数一并发送(如 UserRole 的 RoleUUID 选择依赖 UserUUID)。
|
||||
* 依赖值为空时不发起搜索(强制先选依赖字段)。
|
||||
*/
|
||||
dependValue?: string
|
||||
/**
|
||||
* 级联公司值(可选):config.dependCompany=true 时,把表单中 CompanyUUID 当前值作为
|
||||
* 同名 query 参数一并发送(该用户已绑定且属于目标公司的过滤)。
|
||||
* 值为空时不发送该参数(后端回退当前角色公司)。
|
||||
*/
|
||||
dependCompanyValue?: string
|
||||
}>(), {
|
||||
modelValue: '',
|
||||
disabled: false,
|
||||
placeholder: '',
|
||||
existingLabel: '',
|
||||
clearable: false,
|
||||
// 默认可清空:搜索/浏览选中的实体允许点 × 取消(用户反馈无法取消)
|
||||
clearable: true,
|
||||
dependValue: '',
|
||||
dependCompanyValue: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', uuid: string): void
|
||||
(e: 'update:label', label: string): void
|
||||
/** 多选暂存提交:批量返回已选成员(value/label/secondary/type) */
|
||||
(e: 'submit-multi', items: Array<{ value: string; label: string; secondary?: string; type?: number }>): void
|
||||
}>()
|
||||
|
||||
/** 主下拉选项列表(搜索用) */
|
||||
@@ -70,20 +96,40 @@ const paged = computed(() => props.config.paged !== false)
|
||||
/** 占位符:props.placeholder > config.placeholder > 默认 */
|
||||
const placeholderText = computed(() => props.placeholder || props.config.placeholder || '请选择')
|
||||
|
||||
/** 树分组配置(配置了才启用树形浏览面板) */
|
||||
const treeGroupConfig = computed(() => props.config.treeGroup || undefined)
|
||||
|
||||
/** 防抖定时器句柄 */
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/** 级联依赖是否就绪:dependsOn 依赖字段有值才允许搜索 */
|
||||
function isDependReady(): boolean {
|
||||
return !props.config.dependsOn || !!props.dependValue
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一请求 Search 接口(GET/POST 兼容),返回解析后的选项与 has_more
|
||||
* @param keyword 搜索关键字(空串=全部)
|
||||
* @param offset 分页偏移(加载更多用)
|
||||
*/
|
||||
async function fetchOptions(keyword: string, offset: number): Promise<{ items: EntityOption[]; more: boolean }> {
|
||||
async function fetchOptions(keyword: string, offset: number): Promise<{ items: EntityOption[]; more: boolean; tree?: TreeGroupNode[] }> {
|
||||
const cfg = props.config
|
||||
const searchParam = cfg.searchParam || 'keyword'
|
||||
const method = cfg.method || 'GET'
|
||||
const params: Record<string, unknown> = { [searchParam]: keyword }
|
||||
if (offset > 0) params.offset = offset
|
||||
// 级联依赖:dependsOn 字段当前值作为同名参数(如 UserUUID)
|
||||
if (cfg.dependsOn) {
|
||||
params[cfg.dependsOn] = props.dependValue
|
||||
}
|
||||
// 级联公司:dependCompany=true 时附带 CompanyUUID(值为空则不发送,后端回退当前角色公司)
|
||||
if (cfg.dependCompany && props.dependCompanyValue) {
|
||||
params.CompanyUUID = props.dependCompanyValue
|
||||
}
|
||||
// 树分组模式:请求附带 tree=1(后端返回部门树 + 成员)
|
||||
if (cfg.treeGroup) {
|
||||
params.tree = '1'
|
||||
}
|
||||
let res: unknown
|
||||
if (method === 'POST') {
|
||||
res = await post<unknown>(cfg.api, params)
|
||||
@@ -93,6 +139,7 @@ async function fetchOptions(keyword: string, offset: number): Promise<{ items: E
|
||||
return {
|
||||
items: parseOptions(res, cfg),
|
||||
more: parseHasMore(res),
|
||||
tree: props.config.treeGroup ? parseTree(res, props.config.treeGroup.treeField) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +147,7 @@ async function fetchOptions(keyword: string, offset: number): Promise<{ items: E
|
||||
function parseHasMore(res: unknown): boolean {
|
||||
if (res && typeof res === 'object') {
|
||||
const obj = res as Record<string, unknown>
|
||||
// 数组上的挂载属性(后端把 has_more/total 挂到 data 数组上,拦截器解包后仍保留)
|
||||
if (typeof obj.has_more === 'boolean') return obj.has_more
|
||||
if (typeof obj.hasMore === 'boolean') return obj.hasMore
|
||||
// 兼容 total 字段:total > 当前已加载数即还有更多
|
||||
@@ -108,6 +156,93 @@ function parseHasMore(res: unknown): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析响应为选项数组
|
||||
* 响应拦截器已解包 result.data:可能是
|
||||
* - 纯数组(无分页元数据)
|
||||
* - 包裹对象 { list, has_more, total }(后端 Search 分页元数据)
|
||||
* - 配置了 resultPath 的对象
|
||||
*/
|
||||
function parseOptions(res: unknown, cfg: EntityPickerConfig): EntityOption[] {
|
||||
let list: unknown = res
|
||||
// 包裹结构(后端 Search 返回 {list:[...], has_more}):取 list 字段
|
||||
if (res && typeof res === 'object' && !Array.isArray(res)) {
|
||||
const obj = res as Record<string, unknown>
|
||||
if ('list' in obj) {
|
||||
list = obj.list
|
||||
} else if (cfg.resultPath) {
|
||||
list = obj[cfg.resultPath]
|
||||
} else if ('data' in obj) {
|
||||
list = obj.data
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(list)) return []
|
||||
return list.map((item) => {
|
||||
const row = item as Record<string, unknown>
|
||||
return {
|
||||
value: String(row[cfg.valueField] ?? ''),
|
||||
label: String(row[cfg.labelField] ?? ''),
|
||||
secondary: cfg.secondaryField ? String(row[cfg.secondaryField] ?? '') : undefined,
|
||||
groupKey: cfg.treeGroup ? String(row[cfg.treeGroup.groupField] ?? '') : undefined,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 解析树分组数据(treeGroup 模式):从响应提取树节点数组 */
|
||||
function parseTree(res: unknown, treeField?: string): TreeGroupNode[] {
|
||||
const nodes: TreeGroupNode[] = []
|
||||
if (!res || typeof res !== 'object' || Array.isArray(res)) return nodes
|
||||
const obj = res as Record<string, unknown>
|
||||
const field = treeField || 'tree'
|
||||
const raw = obj[field]
|
||||
if (!Array.isArray(raw)) return nodes
|
||||
for (const n of raw) {
|
||||
if (!n || typeof n !== 'object') continue
|
||||
const node = n as Record<string, unknown>
|
||||
nodes.push({
|
||||
uuid: String(node.uuid ?? node.UUID ?? ''),
|
||||
name: String(node.name ?? node.label ?? ''),
|
||||
parentUuid: node.parentUuid ? String(node.parentUuid) : undefined,
|
||||
})
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
/** 将扁平树节点组装为嵌套结构(供 el-tree) */
|
||||
function buildTreeNodes(nodes: TreeGroupNode[]): TreeNodeVM[] {
|
||||
const byUuid = new Map<string, TreeNodeVM>()
|
||||
const roots: TreeNodeVM[] = []
|
||||
for (const n of nodes) {
|
||||
byUuid.set(n.uuid, { key: n.uuid, title: n.name, children: [] })
|
||||
}
|
||||
for (const n of nodes) {
|
||||
const vm = byUuid.get(n.uuid)
|
||||
if (!vm) continue
|
||||
const parent = n.parentUuid ? byUuid.get(n.parentUuid) : undefined
|
||||
if (parent) parent.children.push(vm)
|
||||
else roots.push(vm)
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
/** 树节点视图模型 */
|
||||
interface TreeNodeVM {
|
||||
key: string
|
||||
title: string
|
||||
children: TreeNodeVM[]
|
||||
}
|
||||
|
||||
/** 树分组模式下:成员按部门 UUID 分组(key=部门UUID 或 '' 未分组) */
|
||||
const browseGroupedMembers = computed<Record<string, EntityOption[]>>(() => {
|
||||
const map: Record<string, EntityOption[]> = {}
|
||||
for (const opt of browseList.value) {
|
||||
const key = opt.groupKey || ''
|
||||
if (!map[key]) map[key] = []
|
||||
map[key].push(opt)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
/** 防抖触发远程搜索(300ms) */
|
||||
function debounceSearch(keyword: string): void {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
@@ -116,12 +251,17 @@ function debounceSearch(keyword: string): void {
|
||||
}, 300)
|
||||
}
|
||||
|
||||
/** 执行远程搜索(重置分页状态) */
|
||||
/** 执行远程搜索(重置分页状态);级联依赖未就绪时跳过(列表置空) */
|
||||
async function doSearch(keyword: string): Promise<void> {
|
||||
loading.value = true
|
||||
loadedCount.value = 0
|
||||
hasMore.value = false
|
||||
try {
|
||||
// 级联依赖:依赖字段未选全时不允许搜索(强制先选依赖字段)
|
||||
if (!isDependReady()) {
|
||||
options.value = []
|
||||
return
|
||||
}
|
||||
const { items, more } = await fetchOptions(keyword, 0)
|
||||
options.value = items
|
||||
hasMore.value = more
|
||||
@@ -142,29 +282,6 @@ function remoteSearch(keyword: string): void {
|
||||
debounceSearch(keyword)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析响应为选项数组
|
||||
* 响应拦截器已解包 data,res 通常直接是数组;
|
||||
* 兼容配置了 resultPath 或对象包裹的情况
|
||||
*/
|
||||
function parseOptions(res: unknown, cfg: EntityPickerConfig): EntityOption[] {
|
||||
let list: unknown = res
|
||||
// 非数组时按 resultPath(默认 data)取字段
|
||||
if (!Array.isArray(res) && res && typeof res === 'object') {
|
||||
const obj = res as Record<string, unknown>
|
||||
list = cfg.resultPath ? obj[cfg.resultPath] : obj.data
|
||||
}
|
||||
if (!Array.isArray(list)) return []
|
||||
return list.map((item) => {
|
||||
const row = item as Record<string, unknown>
|
||||
return {
|
||||
value: String(row[cfg.valueField] ?? ''),
|
||||
label: String(row[cfg.labelField] ?? ''),
|
||||
secondary: cfg.secondaryField ? String(row[cfg.secondaryField] ?? '') : undefined,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 选中变化处理 */
|
||||
function handleChange(value: string): void {
|
||||
emit('update:modelValue', value)
|
||||
@@ -195,6 +312,24 @@ function syncDisplayLabel(): void {
|
||||
watch(() => props.existingLabel, syncDisplayLabel, { immediate: true })
|
||||
watch(() => props.modelValue, syncDisplayLabel)
|
||||
|
||||
// 级联依赖值变化:清空已选值(旧依赖下的选择不再有效),并重置搜索态
|
||||
watch(
|
||||
() => props.dependValue,
|
||||
(newVal, oldVal) => {
|
||||
if (newVal === oldVal) return
|
||||
if (props.config.dependsOn) {
|
||||
// 依赖字段变化 → 已选值失效,清空
|
||||
if (props.modelValue) {
|
||||
emit('update:modelValue', '')
|
||||
emit('update:label', '')
|
||||
}
|
||||
options.value = []
|
||||
searched.value = false
|
||||
displayLabel.value = ''
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// ===== 浏览面板(弹窗内列出全部可见实体,分页加载) =====
|
||||
/** 面板显示状态 */
|
||||
const browseVisible = ref(false)
|
||||
@@ -210,6 +345,10 @@ const browseLoadingMore = ref(false)
|
||||
const browseHasMore = ref(false)
|
||||
/** 面板内已加载条数 */
|
||||
const browseLoadedCount = ref(0)
|
||||
/** 面板内部门树(treeGroup 模式) */
|
||||
const browseTree = ref<TreeGroupNode[]>([])
|
||||
/** 面板内组装好的树节点(el-tree 用) */
|
||||
const browseTreeNodes = ref<TreeNodeVM[]>([])
|
||||
|
||||
/** 最近一次搜索关键字(供加载更多沿用) */
|
||||
const lastKeyword = ref('')
|
||||
@@ -224,8 +363,19 @@ function openBrowse(): void {
|
||||
browseVisible.value = true
|
||||
browseKeyword.value = ''
|
||||
browseList.value = []
|
||||
browseTree.value = []
|
||||
browseTreeNodes.value = []
|
||||
browseLoadedCount.value = 0
|
||||
browseHasMore.value = false
|
||||
browseStaged.value = []
|
||||
stagedTypes.value = {}
|
||||
browseMulti.value = false
|
||||
// 级联依赖:依赖字段未选全时面板内直接提示先选依赖字段(不发起请求)
|
||||
if (!isDependReady()) {
|
||||
browseList.value = []
|
||||
browseHasMore.value = false
|
||||
return
|
||||
}
|
||||
void loadBrowsePage(0, '')
|
||||
}
|
||||
|
||||
@@ -243,7 +393,7 @@ async function loadBrowsePage(offset: number, keyword: string): Promise<void> {
|
||||
if (isFirst) browseLoading.value = true
|
||||
else browseLoadingMore.value = true
|
||||
try {
|
||||
const { items, more } = await fetchOptions(keyword, offset)
|
||||
const { items, more, tree } = await fetchOptions(keyword, offset)
|
||||
if (isFirst) {
|
||||
browseList.value = items
|
||||
} else {
|
||||
@@ -251,8 +401,17 @@ async function loadBrowsePage(offset: number, keyword: string): Promise<void> {
|
||||
const existing = new Set(browseList.value.map((o) => o.value))
|
||||
browseList.value = [...browseList.value, ...items.filter((o) => !existing.has(o.value))]
|
||||
}
|
||||
// treeGroup 模式:保存部门树(仅首屏加载,后续分页不覆盖)
|
||||
if (tree && tree.length && isFirst) {
|
||||
browseTree.value = tree
|
||||
browseTreeNodes.value = buildTreeNodes(tree)
|
||||
}
|
||||
browseHasMore.value = more
|
||||
browseLoadedCount.value += items.length
|
||||
// treeGroup 模式:自动加载全部页,保证部门树成员完整(无需手动"加载更多")
|
||||
if (more && props.config.treeGroup) {
|
||||
await loadBrowsePage(browseLoadedCount.value, keyword)
|
||||
}
|
||||
} catch {
|
||||
if (isFirst) browseList.value = []
|
||||
} finally {
|
||||
@@ -272,9 +431,84 @@ function browseSelect(opt: EntityOption): void {
|
||||
emit('update:modelValue', opt.value)
|
||||
emit('update:label', opt.label)
|
||||
displayLabel.value = opt.label
|
||||
// 把选中项并入主下拉选项,确保 el-select 能找到匹配 option:
|
||||
// 否则 clearable 的 × 不显示(selected=null),浏览面板选中的项无法取消
|
||||
// 同时置 searched=true,让该项作为普通选项显示(而非仅回显占位)
|
||||
if (!options.value.some((o) => o.value === opt.value)) {
|
||||
options.value = [...options.value, opt]
|
||||
}
|
||||
searched.value = true
|
||||
browseVisible.value = false
|
||||
}
|
||||
|
||||
/** 清除当前选择(面板内取消按钮,不依赖 el-select hover 显示的 ×) */
|
||||
function browseClear(): void {
|
||||
emit('update:modelValue', '')
|
||||
emit('update:label', '')
|
||||
displayLabel.value = ''
|
||||
options.value = []
|
||||
searched.value = false
|
||||
browseVisible.value = false
|
||||
}
|
||||
|
||||
// ===== 多选暂存(treeGroup 多选模式) =====
|
||||
/** 多选开关是否开启 */
|
||||
const browseMulti = ref(false)
|
||||
/** 暂存区(已选成员) */
|
||||
const browseStaged = ref<EntityOption[]>([])
|
||||
/** 暂存区成员类型(key=成员 value,value=RoleType,默认 0=成员) */
|
||||
const stagedTypes = ref<Record<string, number>>({})
|
||||
|
||||
/** 成员类型下拉选项(配置了 roleTypeOptions 才显示类型选择) */
|
||||
const roleTypeOptions = computed(() => treeGroupConfig.value?.roleTypeOptions || [])
|
||||
|
||||
/** 切换成员进入/移出暂存区 */
|
||||
function toggleStaged(opt: EntityOption): void {
|
||||
const idx = browseStaged.value.findIndex((o) => o.value === opt.value)
|
||||
if (idx >= 0) {
|
||||
browseStaged.value.splice(idx, 1)
|
||||
delete stagedTypes.value[opt.value]
|
||||
} else {
|
||||
browseStaged.value.push(opt)
|
||||
stagedTypes.value[opt.value] = 0 // 默认成员
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否已在暂存区 */
|
||||
function isStaged(opt: EntityOption): boolean {
|
||||
return browseStaged.value.some((o) => o.value === opt.value)
|
||||
}
|
||||
|
||||
/** 从暂存区移除 */
|
||||
function removeStaged(opt: EntityOption): void {
|
||||
const idx = browseStaged.value.findIndex((o) => o.value === opt.value)
|
||||
if (idx >= 0) browseStaged.value.splice(idx, 1)
|
||||
delete stagedTypes.value[opt.value]
|
||||
}
|
||||
|
||||
/** 多选全部提交:批量返回(含各自类型)并关闭面板 */
|
||||
function submitMulti(): void {
|
||||
if (!browseStaged.value.length) return
|
||||
emit(
|
||||
'submit-multi',
|
||||
browseStaged.value.map((o) => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
secondary: o.secondary,
|
||||
type: stagedTypes.value[o.value] ?? 0,
|
||||
})),
|
||||
)
|
||||
browseVisible.value = false
|
||||
browseStaged.value = []
|
||||
stagedTypes.value = {}
|
||||
}
|
||||
|
||||
/** 成员点击:多选模式进暂存,单选模式直接选中 */
|
||||
function onMemberClick(opt: EntityOption): void {
|
||||
if (browseMulti.value) toggleStaged(opt)
|
||||
else browseSelect(opt)
|
||||
}
|
||||
|
||||
// 组件卸载前清理防抖定时器
|
||||
onBeforeUnmount(() => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
@@ -338,6 +572,59 @@ onBeforeUnmount(() => {
|
||||
destroy-on-close
|
||||
>
|
||||
<div class="entity-picker-browse">
|
||||
<!-- 当前选中项 + 清除按钮:取消不依赖 el-select hover 显示的 × -->
|
||||
<div v-if="modelValue" class="entity-picker-browse-current">
|
||||
<span class="entity-picker-browse-current-label">
|
||||
当前选择:{{ displayLabel || modelValue }}
|
||||
</span>
|
||||
<el-button
|
||||
type="danger"
|
||||
plain
|
||||
size="small"
|
||||
@click="browseClear"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>清除选择
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 多选开关(treeGroup 多选模式) -->
|
||||
<div v-if="treeGroupConfig?.multiSelect" class="entity-picker-browse-multi-toggle">
|
||||
<el-switch v-model="browseMulti" size="small" />
|
||||
<span>多选(点成员进入暂存区,可批量添加)</span>
|
||||
</div>
|
||||
|
||||
<!-- 暂存区(多选模式已选成员) -->
|
||||
<div v-if="browseMulti && browseStaged.length" class="entity-picker-browse-staged">
|
||||
<div class="entity-picker-browse-staged-title">暂存区({{ browseStaged.length }})</div>
|
||||
<div
|
||||
v-for="opt in browseStaged"
|
||||
:key="opt.value"
|
||||
class="entity-picker-browse-staged-item"
|
||||
>
|
||||
<span class="entity-picker-browse-staged-name">
|
||||
{{ opt.label }}{{ opt.secondary ? `(${opt.secondary})` : '' }}
|
||||
</span>
|
||||
<!-- 成员类型:配置了 roleTypeOptions 才显示下拉,默认 0=成员 -->
|
||||
<el-select
|
||||
v-if="roleTypeOptions.length"
|
||||
v-model="stagedTypes[opt.value]"
|
||||
size="small"
|
||||
class="entity-picker-browse-staged-type"
|
||||
:title="`${opt.label} 的类型`"
|
||||
>
|
||||
<el-option
|
||||
v-for="rt in roleTypeOptions"
|
||||
:key="rt.value"
|
||||
:value="rt.value"
|
||||
:label="rt.label"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button link type="danger" size="small" @click="removeStaged(opt)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="entity-picker-browse-search">
|
||||
<el-input
|
||||
v-model="browseKeyword"
|
||||
@@ -355,19 +642,119 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div v-loading="browseLoading" class="entity-picker-browse-list">
|
||||
<el-empty
|
||||
v-if="!browseLoading && browseList.length === 0"
|
||||
v-if="!browseLoading && browseList.length === 0 && !(treeGroupConfig && browseTreeNodes.length)"
|
||||
description="暂无数据"
|
||||
:image-size="60"
|
||||
/>
|
||||
<div
|
||||
v-for="opt in browseList"
|
||||
:key="opt.value"
|
||||
class="entity-picker-browse-item"
|
||||
@click="browseSelect(opt)"
|
||||
>
|
||||
<span class="entity-picker-browse-label">{{ opt.label }}</span>
|
||||
<span v-if="opt.secondary" class="entity-picker-browse-secondary">{{ opt.secondary }}</span>
|
||||
</div>
|
||||
|
||||
<!-- treeGroup 模式:部门树 → 成员 两级结构 -->
|
||||
<template v-if="treeGroupConfig">
|
||||
<div v-if="browseTreeNodes.length" class="entity-picker-tree">
|
||||
<div
|
||||
v-for="dept in browseTreeNodes"
|
||||
:key="dept.key"
|
||||
class="entity-picker-tree-branch"
|
||||
>
|
||||
<div class="entity-picker-tree-dept">
|
||||
<el-icon><OfficeBuilding /></el-icon>
|
||||
<span>{{ dept.title }}</span>
|
||||
</div>
|
||||
<div class="entity-picker-tree-members">
|
||||
<!-- 本部门直属成员 -->
|
||||
<div
|
||||
v-for="opt in browseGroupedMembers[dept.key] || []"
|
||||
:key="opt.value"
|
||||
:class="['entity-picker-browse-item', 'entity-picker-tree-member', { 'is-staged': isStaged(opt) }]"
|
||||
@click="onMemberClick(opt)"
|
||||
>
|
||||
<span class="entity-picker-browse-label">{{ opt.label }}</span>
|
||||
<span v-if="opt.secondary" class="entity-picker-browse-secondary">{{ opt.secondary }}</span>
|
||||
</div>
|
||||
<!-- 子部门递归 -->
|
||||
<div
|
||||
v-for="child in dept.children"
|
||||
:key="child.key"
|
||||
class="entity-picker-tree-branch"
|
||||
>
|
||||
<div class="entity-picker-tree-dept">
|
||||
<el-icon><OfficeBuilding /></el-icon>
|
||||
<span>{{ child.title }}</span>
|
||||
</div>
|
||||
<div class="entity-picker-tree-members">
|
||||
<div
|
||||
v-for="opt in browseGroupedMembers[child.key] || []"
|
||||
:key="opt.value"
|
||||
:class="['entity-picker-browse-item', 'entity-picker-tree-member', { 'is-staged': isStaged(opt) }]"
|
||||
@click="onMemberClick(opt)"
|
||||
>
|
||||
<span class="entity-picker-browse-label">{{ opt.label }}</span>
|
||||
<span v-if="opt.secondary" class="entity-picker-browse-secondary">{{ opt.secondary }}</span>
|
||||
</div>
|
||||
<!-- 三级及以上:直接展开子部门的成员(扁平追加) -->
|
||||
<div
|
||||
v-for="grand in child.children"
|
||||
:key="grand.key"
|
||||
class="entity-picker-tree-branch"
|
||||
>
|
||||
<div class="entity-picker-tree-dept">
|
||||
<el-icon><OfficeBuilding /></el-icon>
|
||||
<span>{{ grand.title }}</span>
|
||||
</div>
|
||||
<div class="entity-picker-tree-members">
|
||||
<div
|
||||
v-for="opt in browseGroupedMembers[grand.key] || []"
|
||||
:key="opt.value"
|
||||
:class="['entity-picker-browse-item', 'entity-picker-tree-member', { 'is-staged': isStaged(opt) }]"
|
||||
@click="onMemberClick(opt)"
|
||||
>
|
||||
<span class="entity-picker-browse-label">{{ opt.label }}</span>
|
||||
<span v-if="opt.secondary" class="entity-picker-browse-secondary">{{ opt.secondary }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 未分组成员兜底 -->
|
||||
<div v-if="(browseGroupedMembers[''] || []).length" class="entity-picker-tree-branch">
|
||||
<div class="entity-picker-tree-dept">
|
||||
<el-icon><OfficeBuilding /></el-icon>
|
||||
<span>{{ treeGroupConfig.ungroupedTitle || '未分组' }}</span>
|
||||
</div>
|
||||
<div class="entity-picker-tree-members">
|
||||
<div
|
||||
v-for="opt in browseGroupedMembers['']"
|
||||
:key="opt.value"
|
||||
:class="['entity-picker-browse-item', 'entity-picker-tree-member', { 'is-staged': isStaged(opt) }]"
|
||||
@click="onMemberClick(opt)"
|
||||
>
|
||||
<span class="entity-picker-browse-label">{{ opt.label }}</span>
|
||||
<span v-if="opt.secondary" class="entity-picker-browse-secondary">{{ opt.secondary }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 树加载了但无成员 -->
|
||||
<el-empty
|
||||
v-else-if="!browseLoading && browseTreeNodes.length === 0 && browseList.length === 0"
|
||||
description="暂无部门或成员"
|
||||
:image-size="60"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 普通模式:扁平列表 -->
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="opt in browseList"
|
||||
:key="opt.value"
|
||||
:class="['entity-picker-browse-item', { 'is-staged': isStaged(opt) }]"
|
||||
@click="onMemberClick(opt)"
|
||||
>
|
||||
<span class="entity-picker-browse-label">{{ opt.label }}</span>
|
||||
<span v-if="opt.secondary" class="entity-picker-browse-secondary">{{ opt.secondary }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="paged && browseHasMore" class="entity-picker-browse-more">
|
||||
@@ -375,6 +762,13 @@ onBeforeUnmount(() => {
|
||||
加载更多
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 多选全部提交 -->
|
||||
<div v-if="browseMulti" class="entity-picker-browse-multi-actions">
|
||||
<el-button type="primary" :disabled="!browseStaged.length" @click="submitMulti">
|
||||
全部提交({{ browseStaged.length }})
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
@@ -428,6 +822,26 @@ html.dark {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 当前选中项 + 清除按钮行 */
|
||||
.entity-picker-browse-current {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 6px;
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.entity-picker-browse-current-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.entity-picker-browse-search {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -443,6 +857,42 @@ html.dark {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ===== 树分组模式:部门树 → 成员 ===== */
|
||||
.entity-picker-tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.entity-picker-tree-branch {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.entity-picker-tree-dept {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.entity-picker-tree-members {
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.entity-picker-tree-member {
|
||||
border-bottom: 1px dashed var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.entity-picker-tree-member:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* 列表项:hover 高亮 */
|
||||
.entity-picker-browse-item {
|
||||
display: flex;
|
||||
@@ -498,4 +948,77 @@ html.dark {
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ===== 多选暂存 ===== */
|
||||
.entity-picker-browse-multi-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.entity-picker-browse-staged {
|
||||
border: 1px dashed var(--el-color-primary-light-5);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
background: var(--el-color-primary-light-9);
|
||||
flex-shrink: 0;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.entity-picker-browse-staged-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--el-color-primary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.entity-picker-browse-staged-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.entity-picker-browse-staged-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.entity-picker-browse-staged-type {
|
||||
flex-shrink: 0;
|
||||
width: 92px;
|
||||
}
|
||||
|
||||
/* 深色模式:暂存区背景用半透明主色(light-9 在深色下过亮) */
|
||||
html.dark {
|
||||
.entity-picker-browse-staged {
|
||||
background: color-mix(in srgb, var(--el-color-primary) 10%, var(--el-fill-color-blank));
|
||||
border-color: color-mix(in srgb, var(--el-color-primary) 45%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
/* 已进入暂存区的成员:主色高亮 */
|
||||
.entity-picker-browse-item.is-staged {
|
||||
background: var(--el-color-primary-light-8);
|
||||
}
|
||||
|
||||
html.dark {
|
||||
.entity-picker-browse-item.is-staged {
|
||||
background: color-mix(in srgb, var(--el-color-primary) 24%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
/* 多选全部提交 */
|
||||
.entity-picker-browse-multi-actions {
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* RichTextEditor 富文本编辑器
|
||||
*
|
||||
* 基于 @tinymce/tinymce-vue + tinymce(npm 自托管,离线可用)。
|
||||
* 打包要点:
|
||||
* - 显式导入 model/theme/icons 与所需 plugins(tinymce 8 不会自动加载)
|
||||
* - skin:false + content_css:false:皮肤/内容样式通过 import CSS 直接打进产物,
|
||||
* 避免运行时按 baseURL 加载皮肤资源失败
|
||||
* - @tinymce/tinymce-vue 通过 window.tinymce 获取实例,导入后手动挂到全局
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import Editor from '@tinymce/tinymce-vue'
|
||||
import tinymce from 'tinymce'
|
||||
import 'tinymce/models/dom'
|
||||
import 'tinymce/themes/silver'
|
||||
import 'tinymce/icons/default'
|
||||
import 'tinymce/plugins/advlist'
|
||||
import 'tinymce/plugins/lists'
|
||||
import 'tinymce/plugins/link'
|
||||
import 'tinymce/plugins/image'
|
||||
import 'tinymce/plugins/table'
|
||||
import 'tinymce/plugins/code'
|
||||
import 'tinymce/plugins/wordcount'
|
||||
import 'tinymce/plugins/autoresize'
|
||||
import 'tinymce/skins/ui/oxide/skin.css'
|
||||
import 'tinymce/skins/content/default/content.css'
|
||||
|
||||
// @tinymce/tinymce-vue 依赖全局 tinymce(见其 TinyMCE.js:global.tinymce)
|
||||
;(window as unknown as { tinymce: typeof tinymce }).tinymce = tinymce
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 富文本 HTML 内容(v-model) */
|
||||
modelValue?: string
|
||||
/** 占位符 */
|
||||
placeholder?: string
|
||||
/** 高度行数(默认 8,约 40px/行) */
|
||||
rows?: number
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{ modelValue: '', placeholder: '请输入内容', rows: 8, disabled: false },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
/** tinymce init 配置(皮肤/内容样式已打包,禁用运行时加载) */
|
||||
const editorInit = computed(() => ({
|
||||
skin: false,
|
||||
content_css: false,
|
||||
height: Math.max((props.rows || 8) * 40, 200),
|
||||
menubar: false,
|
||||
branding: false,
|
||||
promotion: false,
|
||||
resize: true,
|
||||
plugins: 'advlist lists link image table code wordcount autoresize',
|
||||
toolbar:
|
||||
'undo redo | blocks | bold italic underline strikethrough | bullist numlist | alignleft aligncenter alignright | link image table | removeformat code',
|
||||
placeholder: props.placeholder,
|
||||
readonly: props.disabled,
|
||||
}))
|
||||
|
||||
/** 内容变化回写 v-model */
|
||||
function onChange(content: string): void {
|
||||
emit('update:modelValue', content)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Editor :model-value="modelValue" :init="editorInit" @update:model-value="onChange" />
|
||||
</template>
|
||||
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* 数据由 stores/tabs.ts 管理,路由 afterEach 自动 addTab。
|
||||
*/
|
||||
import { ref, onBeforeUnmount } from 'vue'
|
||||
import { ref, nextTick, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useTabsStore } from '@/stores/tabs'
|
||||
|
||||
@@ -70,6 +70,24 @@ function handleCloseAll(): void {
|
||||
closeCtxMenu()
|
||||
}
|
||||
|
||||
/** 刷新当前 tab:递增刷新版本号,强制组件重挂载(v-show 常驻方案下 key 变化驱动重建) */
|
||||
function handleRefresh(): void {
|
||||
const target = ctxMenuPath.value
|
||||
// 先切换(若右键的是非激活 tab),再递增版本号触发重挂载
|
||||
if (tabsStore.activePath !== target) {
|
||||
const tab = tabsStore.tabs.find((t) => t.path === target)
|
||||
if (tab) {
|
||||
closeCtxMenu()
|
||||
router.push(tab.fullPath)
|
||||
// 路由跳转后组件重挂载;版本号在跳转后递增(nextTick 确保组件已重建)
|
||||
void nextTick(() => tabsStore.refreshTab(target))
|
||||
return
|
||||
}
|
||||
}
|
||||
tabsStore.refreshTab(target)
|
||||
closeCtxMenu()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', closeCtxMenu)
|
||||
})
|
||||
@@ -108,6 +126,9 @@ onBeforeUnmount(() => {
|
||||
class="tab-ctx-menu"
|
||||
:style="{ left: ctxMenuX + 'px', top: ctxMenuY + 'px' }"
|
||||
>
|
||||
<li @click="handleRefresh">
|
||||
<el-icon><Refresh /></el-icon>刷新
|
||||
</li>
|
||||
<li @click="handleCloseOthers">
|
||||
<el-icon><CircleClose /></el-icon>关闭其他
|
||||
</li>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ref, reactive, onMounted, type Ref } from 'vue'
|
||||
import { ref, reactive, onMounted, isRef, unref, type Ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { formApi, type TableName, type QueryParams } from '@/api/form'
|
||||
import { useProgressStore } from '@/stores/progress'
|
||||
@@ -8,8 +8,10 @@ import type { ColumnSchema, PagedData } from '@/types/api'
|
||||
export interface UseCrudTableOptions<T> {
|
||||
/** 表名(对应后端实体) */
|
||||
table: TableName
|
||||
/** 查询参数 */
|
||||
query?: QueryParams
|
||||
/** 查询参数(支持传 Ref:父组件筛选条件变化时 fetchData 读到最新值) */
|
||||
query?: QueryParams | Ref<QueryParams | undefined>
|
||||
/** 默认每页条数(默认 20;树形卡片等需全量数据的场景可调大) */
|
||||
defaultPageSize?: number
|
||||
/** 主键字段名(不传则从 columns 推导 isPrimary/primaryKey,默认 Id) */
|
||||
pkField?: string
|
||||
/** 是否显示复选列(行选择) */
|
||||
@@ -42,7 +44,9 @@ export interface UseCrudTableOptions<T> {
|
||||
export function useCrudTable<T extends Record<string, unknown> = Record<string, unknown>>(
|
||||
options: UseCrudTableOptions<T>,
|
||||
) {
|
||||
const { table, query = {} } = options
|
||||
const { table } = options
|
||||
// 查询参数:支持 Ref(父组件筛选联动)或普通对象;fetchData 时统一取最新值
|
||||
const query = isRef(options.query) ? options.query : ref(options.query ?? {})
|
||||
|
||||
// === 状态 ===
|
||||
const loading = ref(false)
|
||||
@@ -51,7 +55,7 @@ export function useCrudTable<T extends Record<string, unknown> = Record<string,
|
||||
const dataList = ref<T[]>([]) as Ref<T[]>
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const pageSize = ref(options.defaultPageSize ?? 20)
|
||||
const sortField = ref('')
|
||||
const sortOrder = ref<'asc' | 'desc'>('asc')
|
||||
const searchKeyword = ref('')
|
||||
@@ -93,7 +97,7 @@ export function useCrudTable<T extends Record<string, unknown> = Record<string,
|
||||
sortField: sortField.value || undefined,
|
||||
sortOrder: sortOrder.value,
|
||||
keyword: searchKeyword.value || undefined,
|
||||
...query,
|
||||
...(unref(query) ?? {}),
|
||||
}
|
||||
const result = await formApi.list<T>(table, params)
|
||||
// 兼容两种返回结构:PagedData 或裸数组
|
||||
|
||||
@@ -8,12 +8,14 @@
|
||||
* - TabBar:多标签页切换栏
|
||||
* - 头像下拉菜单:用户信息头部 + 个人中心 + 系统设置 + 主题切换 + 退出登录
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useTabsStore } from '@/stores/tabs'
|
||||
import { getAvatarUrl } from '@/api/auth'
|
||||
import request from '@/api/request'
|
||||
import type { AxiosResponse } from 'axios'
|
||||
import TabBar from '@/components/TabBar.vue'
|
||||
import RouteProgress from '@/components/RouteProgress.vue'
|
||||
import PageMask from '@/components/PageMask.vue'
|
||||
@@ -45,6 +47,32 @@ const roleLabel = computed(() => {
|
||||
const roles = computed(() => userStore.roles)
|
||||
const roleSwitcherVisible = ref(false)
|
||||
|
||||
// === 顶部通知:站内信未读角标(连接 /API/Message 真实数据) ===
|
||||
const unreadCount = ref(0)
|
||||
let unreadTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
/**
|
||||
* 加载未读数。未读接口顶层字段 UnreadCount 会被全局响应拦截器剥离(code===0 仅返回 data),
|
||||
* 故用 responseType:'text' 绕过剥离,自行 JSON.parse 取完整响应体(与 Message.vue 同法)。
|
||||
*/
|
||||
async function loadUnreadCount(): Promise<void> {
|
||||
try {
|
||||
const resp = (await request.get('/API/Message', {
|
||||
params: { action: 'unread_count' },
|
||||
responseType: 'text',
|
||||
})) as unknown as AxiosResponse<string>
|
||||
const body = JSON.parse(resp.data) as { code: number; UnreadCount?: number }
|
||||
if (body.code === 0) unreadCount.value = body.UnreadCount || 0
|
||||
} catch {
|
||||
// 未读数加载失败静默处理,角标保持上次值
|
||||
}
|
||||
}
|
||||
|
||||
/** 通知铃铛点击:跳转站内信页 */
|
||||
function goNotify(): void {
|
||||
router.push('/system/message')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
appStore.initTheme()
|
||||
// 获取角色列表(用于切换身份对话框)
|
||||
@@ -53,8 +81,27 @@ onMounted(() => {
|
||||
// 获取角色失败静默处理
|
||||
})
|
||||
}
|
||||
// 通知角标:初始加载 + 每 60s 轮询
|
||||
void loadUnreadCount()
|
||||
unreadTimer = setInterval(() => {
|
||||
void loadUnreadCount()
|
||||
}, 60000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (unreadTimer) clearInterval(unreadTimer)
|
||||
})
|
||||
|
||||
// 路由切换后延迟刷新未读数(站内信页内已读/删除后返回,角标立即更新)
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
setTimeout(() => {
|
||||
void loadUnreadCount()
|
||||
}, 800)
|
||||
},
|
||||
)
|
||||
|
||||
/** 打开切换角色对话框 */
|
||||
function openRoleSwitcher(): void {
|
||||
// 打开前刷新角色列表,保证最新
|
||||
@@ -177,11 +224,8 @@ const isFaIcon = (icon: string | undefined | null): boolean => {
|
||||
|
||||
<div class="gerp-header-right">
|
||||
<div class="gerp-header-actions">
|
||||
<el-button text circle title="搜索">
|
||||
<el-icon><Search /></el-icon>
|
||||
</el-button>
|
||||
<el-badge :value="3" class="gerp-notify-badge">
|
||||
<el-button text circle title="通知">
|
||||
<el-badge :value="unreadCount" :hidden="unreadCount === 0" :max="99" class="gerp-notify-badge">
|
||||
<el-button text circle title="站内信" @click="goNotify">
|
||||
<el-icon><Bell /></el-icon>
|
||||
</el-button>
|
||||
</el-badge>
|
||||
@@ -300,7 +344,7 @@ const isFaIcon = (icon: string | undefined | null): boolean => {
|
||||
<component
|
||||
:is="resolveTabComponent(tab.path)"
|
||||
v-if="tab.loaded"
|
||||
:key="tab.path"
|
||||
:key="`${tab.path}-${tabsStore.getRefreshVersion(tab.path)}`"
|
||||
/>
|
||||
</div>
|
||||
<!-- 路由切换遮罩层(首次加载 tab 时显示,已缓存 tab 切换瞬时无遮罩) -->
|
||||
|
||||
@@ -43,6 +43,12 @@ export const staticRoutes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/workorder/My.vue'),
|
||||
meta: { title: '我的工单', icon: 'Document', requiresAuth: true, menuGuarded: true },
|
||||
},
|
||||
{
|
||||
path: 'workorder/detail/:id',
|
||||
name: 'WorkorderDetail',
|
||||
component: () => import('@/views/workorder/WorkOrderDetail.vue'),
|
||||
meta: { title: '工单详情', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'system/roles',
|
||||
name: 'SystemRoles',
|
||||
@@ -369,6 +375,14 @@ router.beforeEach(async (to, _from, next) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 已登录但 CheckAdmin 未加载(强刷后从 localStorage 缓存恢复会话的路径):
|
||||
// 补发 fetchAdminCheck,确保 adminChecked 置位、isAdmin 正确。
|
||||
// 页面组件据此区分"加载中"与"确实无权限"(如 Database/DbTable 等管理员页)。
|
||||
// 幂等:登录流程/restoreSession 已调用过则 adminChecked=true,这里不再重复请求。
|
||||
if (userStore.isLoggedIn && !userStore.adminChecked) {
|
||||
void userStore.fetchAdminCheck()
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
|
||||
+30
-1
@@ -53,6 +53,8 @@ export const useTabsStore = defineStore('tabs', () => {
|
||||
const tabs = ref<TabItem[]>([{ ...HOME_TAB }])
|
||||
/** 当前激活的 tab path */
|
||||
const activePath = ref('/dashboard')
|
||||
/** 各 tab 的刷新版本号(refreshTab 时递增,驱动组件重挂载) */
|
||||
const refreshVersions = ref<Record<string, number>>({})
|
||||
|
||||
/** 判断图标是否 FontAwesome 类名(与 DefaultLayout.isFaIcon 一致) */
|
||||
function isFaIcon(icon: string | undefined | null): boolean {
|
||||
@@ -136,11 +138,38 @@ export const useTabsStore = defineStore('tabs', () => {
|
||||
activePath.value = '/dashboard'
|
||||
}
|
||||
|
||||
/** 获取 tab 当前刷新版本号(不存在则初始化为 0) */
|
||||
function getRefreshVersion(path: string): number {
|
||||
if (!(path in refreshVersions.value)) refreshVersions.value[path] = 0
|
||||
return refreshVersions.value[path]
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新指定 tab:递增版本号,驱动 DefaultLayout 中组件 key 变化强制重挂载
|
||||
* (v-show 常驻方案下组件不随 tab 切换重挂载,刷新只能靠 key 变化重建)
|
||||
*/
|
||||
function refreshTab(path: string): void {
|
||||
refreshVersions.value[path] = (refreshVersions.value[path] ?? 0) + 1
|
||||
}
|
||||
|
||||
/** 重置(登出时调用) */
|
||||
function reset(): void {
|
||||
tabs.value = [{ ...HOME_TAB }]
|
||||
activePath.value = '/dashboard'
|
||||
refreshVersions.value = {}
|
||||
}
|
||||
|
||||
return { tabs, activePath, addTab, removeTab, removeOthers, removeAll, reset, isFaIcon }
|
||||
return {
|
||||
tabs,
|
||||
activePath,
|
||||
refreshVersions,
|
||||
addTab,
|
||||
removeTab,
|
||||
removeOthers,
|
||||
removeAll,
|
||||
getRefreshVersion,
|
||||
refreshTab,
|
||||
reset,
|
||||
isFaIcon,
|
||||
}
|
||||
})
|
||||
+52
-2
@@ -3,6 +3,25 @@ import { ref, computed } from 'vue'
|
||||
import type { UserInfo, MenuItem } from '@/types/api'
|
||||
import { getCurrentUser, getUserRoles, switchRole as apiSwitchRole, login as apiLogin, logout as apiLogout, type LoginParams, type RoleItem } from '@/api/auth'
|
||||
import { getMenuList } from '@/api/menu'
|
||||
import { get } from '@/api/request'
|
||||
|
||||
/** CheckAdmin 接口返回的管理员状态(对齐后端 /API/FormAPI/CheckAdmin) */
|
||||
export interface AdminCheckResult {
|
||||
is_admin?: boolean
|
||||
is_superuser?: boolean
|
||||
is_super_admin?: boolean
|
||||
is_tenant_admin?: boolean
|
||||
current_role_name?: string
|
||||
current_role_uuid?: string
|
||||
role_scope?: {
|
||||
Type?: string
|
||||
TypeLabel?: string
|
||||
IsAdmin?: boolean
|
||||
IsDeptManager?: boolean
|
||||
IsWorkgroupLead?: boolean
|
||||
IsMember?: boolean
|
||||
} | null
|
||||
}
|
||||
|
||||
/** 用户状态管理:当前登录用户、角色、菜单、权限 */
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
@@ -12,6 +31,10 @@ export const useUserStore = defineStore('user', () => {
|
||||
const menus = ref<MenuItem[]>([])
|
||||
const token = ref<string>('')
|
||||
const loading = ref(false)
|
||||
// CheckAdmin 结果(后端按当前角色权限码判定管理员,不依赖 GetELTUserInfo 的 IsAdmin 字段)
|
||||
const adminCheck = ref<AdminCheckResult | null>(null)
|
||||
// CheckAdmin 是否已加载完成(成功或失败都置 true,供页面区分"加载中"与"确实无权限")
|
||||
const adminChecked = ref(false)
|
||||
|
||||
// === localStorage 持久化缓存 ===
|
||||
// 目的:刷新页面后从缓存恢复 userInfo/menus,避免重复请求 GetELTUserInfo/MenuList
|
||||
@@ -51,7 +74,9 @@ export const useUserStore = defineStore('user', () => {
|
||||
|
||||
// === 计算属性 ===
|
||||
const isLoggedIn = computed(() => !!userInfo.value)
|
||||
const isAdmin = computed(() => userInfo.value?.IsAdmin === true)
|
||||
// 管理员判定优先用 CheckAdmin(后端按当前角色 admin:super/admin:tenant 权限码判定);
|
||||
// 未加载 CheckAdmin 时回退 userInfo.IsAdmin(旧缓存数据)
|
||||
const isAdmin = computed(() => adminCheck.value?.is_admin === true || userInfo.value?.IsAdmin === true)
|
||||
const currentRole = computed(() => userInfo.value?.CurrentRole || '')
|
||||
const avatar = computed(() => userInfo.value?.Avatar || '')
|
||||
const displayName = computed(() => userInfo.value?.DisplayName || userInfo.value?.UserName || '未登录')
|
||||
@@ -92,6 +117,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
await fetchUserInfo()
|
||||
await fetchMenus()
|
||||
await fetchRoles()
|
||||
await fetchAdminCheck()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -114,6 +140,18 @@ export const useUserStore = defineStore('user', () => {
|
||||
return info
|
||||
}
|
||||
|
||||
/** 获取管理员状态(CheckAdmin,按当前角色权限码判定,失败不阻塞主流程) */
|
||||
async function fetchAdminCheck(): Promise<void> {
|
||||
try {
|
||||
adminCheck.value = await get<AdminCheckResult>('/API/FormAPI/CheckAdmin')
|
||||
} catch {
|
||||
// 失败保持上一次结果(或 null),不阻塞登录/恢复流程
|
||||
} finally {
|
||||
// 无论成功失败都标记加载完成,页面据此区分"加载中"与"确实无权限"
|
||||
adminChecked.value = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 静默恢复会话(路由守卫用):成功则填充用户信息,失败不弹全局提示
|
||||
* 优先级:内存态 > localStorage 缓存 > 后端请求
|
||||
@@ -127,13 +165,18 @@ export const useUserStore = defineStore('user', () => {
|
||||
if (userInfo.value) return true
|
||||
// 尝试从缓存恢复
|
||||
loadFromCache()
|
||||
if (userInfo.value) return true
|
||||
if (userInfo.value) {
|
||||
// 缓存恢复后异步刷新管理员状态(保证刷新后 isAdmin 正确)
|
||||
void fetchAdminCheck()
|
||||
return true
|
||||
}
|
||||
// 缓存也没有:请求后端
|
||||
try {
|
||||
const info = await getCurrentUser(true)
|
||||
userInfo.value = info
|
||||
await fetchMenus()
|
||||
saveToCache()
|
||||
await fetchAdminCheck()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
@@ -154,6 +197,8 @@ export const useUserStore = defineStore('user', () => {
|
||||
// 刷新用户信息和菜单(不同角色可能有不同菜单权限)
|
||||
await fetchUserInfo()
|
||||
await fetchMenus()
|
||||
// 管理员状态按新角色重新判定
|
||||
await fetchAdminCheck()
|
||||
}
|
||||
|
||||
/** 将后端 Page(如 pages/workorder/admin.html)规范化为 Vue 路由(/workorder/admin) */
|
||||
@@ -193,6 +238,8 @@ export const useUserStore = defineStore('user', () => {
|
||||
roles.value = []
|
||||
menus.value = []
|
||||
token.value = ''
|
||||
adminCheck.value = null
|
||||
adminChecked.value = false
|
||||
localStorage.removeItem(CACHE_KEY)
|
||||
}
|
||||
|
||||
@@ -203,6 +250,8 @@ export const useUserStore = defineStore('user', () => {
|
||||
menus,
|
||||
token,
|
||||
loading,
|
||||
adminCheck,
|
||||
adminChecked,
|
||||
// 计算属性
|
||||
isLoggedIn,
|
||||
isAdmin,
|
||||
@@ -215,6 +264,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
login,
|
||||
logout,
|
||||
fetchUserInfo,
|
||||
fetchAdminCheck,
|
||||
fetchRoles,
|
||||
fetchMenus,
|
||||
switchRole,
|
||||
|
||||
@@ -71,6 +71,40 @@ export interface EntityPickerConfig {
|
||||
paged?: boolean
|
||||
/** 关联码表名(可选,用于回显翻译) */
|
||||
codeTable?: string
|
||||
/**
|
||||
* 级联依赖字段名(可选):请求搜索时把表单中该字段的当前值作为同名 query 参数一并发送。
|
||||
* 用于"选用户后角色只显示该用户已绑定角色"的级联选择场景(如 UserRole 的 RoleUUID 列 dependsOn='UserUUID')。
|
||||
*/
|
||||
dependsOn?: string
|
||||
/**
|
||||
* 是否附带公司参数(可选):请求搜索时把表单中 CompanyUUID 当前值作为同名 query 参数发送,
|
||||
* 用于"该用户已绑定且属于目标公司"的过滤。表单中无 CompanyUUID 时不发送(后端回退当前角色公司)。
|
||||
*/
|
||||
dependCompany?: boolean
|
||||
/**
|
||||
* 树分组字段(可选):配置后浏览面板改为"树分组 + 成员"两级结构展示。
|
||||
* 数据源:搜索接口响应需含 tree 字段([{uuid,name,parentUuid,children?}] 或扁平+parentUuid),
|
||||
* 每条记录含该分组字段(如 DeptUUID)以归入对应分组。
|
||||
* 用于"按部门树展示用户-角色成员"的选择场景。
|
||||
*/
|
||||
treeGroup?: {
|
||||
/** 分组字段名(记录上用于归组的字段,如 DeptUUID) */
|
||||
groupField: string
|
||||
/** 树数据字段名(响应中树节点数组),默认 'tree' */
|
||||
treeField?: string
|
||||
/** 无分组记录的兜底分组标题(默认 '未分组') */
|
||||
ungroupedTitle?: string
|
||||
/**
|
||||
* 是否支持多选暂存(可选):开启后浏览面板出现"多选"开关,
|
||||
* 点成员进入暂存区,底部"全部提交"批量返回(emit submit-multi)。
|
||||
*/
|
||||
multiSelect?: boolean
|
||||
/**
|
||||
* 多选暂存区成员类型选项(可选):如 [{value:0,label:'成员'},{value:1,label:'负责人'}]。
|
||||
* 配置后暂存区每个成员可单独设置类型,随 submit-multi 一并返回。
|
||||
*/
|
||||
roleTypeOptions?: Array<{ value: number; label: string }>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,6 +176,8 @@ export interface ColumnSchema {
|
||||
editable?: boolean
|
||||
/** 表单中隐藏 */
|
||||
hideInForm?: boolean
|
||||
/** 仅在新增表单显示,编辑表单隐藏(虚拟级联列等场景) */
|
||||
hideInEditForm?: boolean
|
||||
/** 是否必填 */
|
||||
required?: boolean
|
||||
/** 必填但允许提交空值(原生特有,跳过 required 校验) */
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 统一时间工具:后端存储 UTC-0(naive datetime,如 "2026-08-05 09:53:06"),
|
||||
* 前端展示时按用户本地时区转换,避免 naive 字符串被误当本地时间显示。
|
||||
*/
|
||||
|
||||
/** 当前用户时区(IETF,如 Asia/Shanghai),默认 Asia/Shanghai */
|
||||
export function getUserTimeZone(): string {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Shanghai'
|
||||
} catch {
|
||||
return 'Asia/Shanghai'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将后端 UTC naive 时间字符串解析为 Date 对象(按 UTC 解析)。
|
||||
* 兼容带时区后缀的 ISO 串(aware,直接使用)。
|
||||
*/
|
||||
export function parseUtcTime(val: unknown): Date | null {
|
||||
if (!val) return null
|
||||
const s = String(val)
|
||||
try {
|
||||
// 带时区后缀(如 Z / +08:00):直接解析
|
||||
if (/[zZ]|[+-]\d{2}:\d{2}$/.test(s)) {
|
||||
const d = new Date(s)
|
||||
return isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
// naive:无分隔符(20260805095306)先标准化
|
||||
const normalized = /^\d{14}$/.test(s)
|
||||
? `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)} ${s.slice(8, 10)}:${s.slice(10, 12)}:${s.slice(12, 14)}`
|
||||
: s.replace('T', ' ')
|
||||
// 按 UTC 解析(末尾补 Z),得到正确的 UTC 时刻
|
||||
const d = new Date(`${normalized.replace(/\.\d+$/, '')}Z`)
|
||||
return isNaN(d.getTime()) ? null : d
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化后端 UTC 时间为用户本地时区字符串。
|
||||
* @param val 后端时间值(UTC naive 或 aware ISO)
|
||||
* @param withSeconds 是否显示秒,默认 true
|
||||
*/
|
||||
export function formatUtcTime(val: unknown, withSeconds = true): string {
|
||||
const d = parseUtcTime(val)
|
||||
if (!d) return val == null || val === '' ? '-' : String(val)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
const base = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
return withSeconds ? `${base}:${pad(d.getSeconds())}` : base
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前 UTC 时间(naive UTC 字符串,对齐后端存储格式,如 "2026-08-05 09:53:06")。
|
||||
* 用于新增表单自动时间戳字段的预填占位(后端提交时仍以服务器时间为准覆盖)。
|
||||
*/
|
||||
export function nowUtcString(): string {
|
||||
const d = new Date()
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`
|
||||
}
|
||||
@@ -24,9 +24,12 @@
|
||||
* - role_scope 细粒度判断暂用 userStore.isAdmin 二分(TODO 待 checkAdmin 接入)
|
||||
* - onView 跳 Vue 路由 /company/company-detail?CompanyUUID=xxx(替代 GERP.Navigate)
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import CrudTable from '@/components/CrudTable.vue'
|
||||
import AdminBadge from '@/components/AdminBadge.vue'
|
||||
import { upload } from '@/api/request'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
|
||||
setPageTitle('公司管理')
|
||||
@@ -48,6 +51,41 @@ function handleView(row: CompanyRow): void {
|
||||
query: { CompanyUUID: row.CompanyUUID || '' },
|
||||
})
|
||||
}
|
||||
|
||||
// === 列表卡片图标上传(点击卡片图标 → 选择文件 → 上传 128x128) ===
|
||||
/** 当前上传图标的公司 UUID(用于上传后定位刷新) */
|
||||
const iconTarget = ref<CompanyRow | null>(null)
|
||||
/** 图标上传 input 引用 */
|
||||
const iconInputRef = ref<HTMLInputElement>()
|
||||
|
||||
/** 点击卡片图标:记录目标公司并触发文件选择 */
|
||||
function handleIconClick(row: CompanyRow): void {
|
||||
iconTarget.value = row
|
||||
iconInputRef.value?.click()
|
||||
}
|
||||
|
||||
/** 选择文件后上传;成功后更新本地行图标(无需整页刷新) */
|
||||
async function handleIconChange(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = '' // 允许重复选择同一文件
|
||||
if (!file || !iconTarget.value?.CompanyUUID) return
|
||||
const company = iconTarget.value
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('CompanyUUID', company.CompanyUUID as string)
|
||||
fd.append('file', file)
|
||||
const res = await upload<{ CompanyIcon?: string }>('/API/Company/UploadIcon', fd)
|
||||
if (res?.CompanyIcon) {
|
||||
company.CompanyIcon = res.CompanyIcon
|
||||
ElMessage.success('图标设置成功')
|
||||
}
|
||||
} catch {
|
||||
// 错误提示已由响应拦截器统一弹出
|
||||
} finally {
|
||||
iconTarget.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -59,10 +97,27 @@ function handleView(row: CompanyRow): void {
|
||||
title="公司管理"
|
||||
pk-field="CompanyUUID"
|
||||
searchable
|
||||
pageable
|
||||
stretch
|
||||
selectable
|
||||
card-mode
|
||||
card-title-field="CompanyName"
|
||||
card-icon-field="CompanyIcon"
|
||||
card-tree-parent-field="ParentCompanyUUID"
|
||||
:default-page-size="1000"
|
||||
:card-fields="['CompanyShort', 'CompanyStatus', 'CreateTime']"
|
||||
:card-value-maps="{
|
||||
CompanyStatus: { 0: '禁用', 1: '启用' },
|
||||
}"
|
||||
:on-view="handleView"
|
||||
:on-card-icon-click="handleIconClick"
|
||||
/>
|
||||
<!-- 隐藏的图标上传 input(点击卡片图标触发) -->
|
||||
<input
|
||||
ref="iconInputRef"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style="display: none"
|
||||
@change="handleIconChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -22,14 +22,16 @@
|
||||
* - 编辑按钮复用 DetailPage 的 actions 插槽,弹窗内根据 formApi.getColumns 动态
|
||||
* 渲染表单,保存调用 formApi.update(对齐已迁移详情页的 Modify 全量更新约定)。
|
||||
*/
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import DetailPage from '@/components/DetailPage.vue'
|
||||
import CrudTable from '@/components/CrudTable.vue'
|
||||
import { formApi } from '@/api/form'
|
||||
import { upload } from '@/api/request'
|
||||
import { useResolveUUIDs } from '@/composables/useResolveUUIDs'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
import { formatUtcTime as formatTime } from '@/utils/dateTime'
|
||||
import type { ColumnSchema } from '@/types/api'
|
||||
|
||||
/** 公司记录类型(仅声明详情页用到的字段) */
|
||||
@@ -57,8 +59,6 @@ const loading = ref(false)
|
||||
const companyData = ref<CompanyRecord | null>(null)
|
||||
/** 解析后的名称映射:{ parentCompany: { uuid: name }, tenant: { uuid: name } } */
|
||||
const resolvedNames = ref<Record<string, Record<string, string>>>({})
|
||||
/** 错误信息(缺参 / 加载失败时展示) */
|
||||
const errorMsg = ref('')
|
||||
|
||||
/** 从路由 query 读取公司 UUID(computed 响应式:同一 tab 复用且 query 变化时自动更新) */
|
||||
const companyUUID = computed(() => (route.query.CompanyUUID as string) || '')
|
||||
@@ -77,14 +77,42 @@ function asStr(val: unknown): string {
|
||||
return val == null || val === '' ? '' : String(val)
|
||||
}
|
||||
|
||||
/** 格式化时间(对齐原生 formatTime,空值返回 -) */
|
||||
function formatTime(val: unknown): string {
|
||||
if (!val) return '-'
|
||||
const s = String(val)
|
||||
const d = new Date(s)
|
||||
if (isNaN(d.getTime())) return s
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
// === 公司图标 ===
|
||||
/** 图标上传中 */
|
||||
const iconUploading = ref(false)
|
||||
/** 图标上传 input 引用 */
|
||||
const iconInputRef = ref<HTMLInputElement>()
|
||||
|
||||
/** 选择图标文件 → 上传到后端(后端统一缩放为 128x128) */
|
||||
async function handleIconChange(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = '' // 允许重复选择同一文件
|
||||
if (!file) return
|
||||
if (!companyUUID.value || !companyData.value) return
|
||||
iconUploading.value = true
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('CompanyUUID', companyUUID.value)
|
||||
fd.append('file', file)
|
||||
// 响应拦截器已解包为 data:{ CompanyIcon }
|
||||
const res = await upload<{ CompanyIcon?: string }>('/API/Company/UploadIcon', fd)
|
||||
const url = res?.CompanyIcon
|
||||
if (url) {
|
||||
companyData.value.CompanyIcon = url
|
||||
editForm.CompanyIcon = url // 编辑弹窗中同步显示新图标
|
||||
ElMessage.success('图标设置成功')
|
||||
}
|
||||
} catch {
|
||||
// 错误提示已由响应拦截器统一弹出
|
||||
} finally {
|
||||
iconUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 触发隐藏的图标上传 input */
|
||||
function triggerIconSelect(): void {
|
||||
iconInputRef.value?.click()
|
||||
}
|
||||
|
||||
/** 提取分页响应中的列表(兼容数组和 PagedData 两种返回) */
|
||||
@@ -97,11 +125,12 @@ function pickList<T = RowData>(res: unknown): T[] {
|
||||
/** 拉取公司主数据 + 解析关联 UUID */
|
||||
async function loadCompany(): Promise<void> {
|
||||
if (!companyUUID.value) {
|
||||
errorMsg.value = '缺少 CompanyUUID 参数,无法加载公司详情。'
|
||||
// 无 CompanyUUID 参数:不弹警告(首次无参进入/菜单误入均静默),显示空态引导
|
||||
companyData.value = null
|
||||
setPageTitle('公司详情')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const res = await formApi.list<CompanyRecord>('Company', {
|
||||
CompanyUUID: companyUUID.value,
|
||||
@@ -110,7 +139,6 @@ async function loadCompany(): Promise<void> {
|
||||
const list = pickList<CompanyRecord>(res)
|
||||
const data = list.find((c) => c.CompanyUUID === companyUUID.value) || list[0]
|
||||
if (!data) {
|
||||
errorMsg.value = '未找到公司数据'
|
||||
return
|
||||
}
|
||||
companyData.value = data
|
||||
@@ -127,8 +155,8 @@ async function loadCompany(): Promise<void> {
|
||||
if (Object.keys(uuidMap).length) {
|
||||
resolvedNames.value = await resolveUUIDs(uuidMap)
|
||||
}
|
||||
} catch (e) {
|
||||
errorMsg.value = `加载失败: ${e instanceof Error ? e.message : String(e)}`
|
||||
} catch {
|
||||
// 错误提示已由响应拦截器统一弹出,数据未加载成功时展示空态
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -154,7 +182,7 @@ const editSubmitting = ref(false)
|
||||
const editColumns = ref<ColumnSchema[]>([])
|
||||
const editForm = reactive<Record<string, any>>({})
|
||||
|
||||
/** 弹窗内可见的表单字段(排除主键 / 隐藏 / 不可编辑 / 分隔与 HTML) */
|
||||
/** 弹窗内可见的表单字段(排除主键 / 公司图标(单独上传控件) / 隐藏 / 不可编辑 / 分隔与 HTML) */
|
||||
const editFormColumns = computed<ColumnSchema[]>(() =>
|
||||
editColumns.value.filter(
|
||||
(c) =>
|
||||
@@ -163,7 +191,8 @@ const editFormColumns = computed<ColumnSchema[]>(() =>
|
||||
c.type !== 'hidden' &&
|
||||
c.type !== 'divider' &&
|
||||
c.type !== 'html' &&
|
||||
c.field !== PK_FIELD,
|
||||
c.field !== PK_FIELD &&
|
||||
c.field !== 'CompanyIcon',
|
||||
),
|
||||
)
|
||||
|
||||
@@ -198,26 +227,14 @@ async function saveEdit(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 复制 UUID 到剪贴板 */
|
||||
async function copyUUID(uuid: string): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(uuid)
|
||||
ElMessage.success('UUID 已复制')
|
||||
} catch {
|
||||
ElMessage.warning('复制失败,请手动选择复制')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadCompany)
|
||||
|
||||
// 同一 tab 复用(v-show 常驻)时 query 变化:重新加载(防展示旧公司数据 / 缺参报错)
|
||||
// immediate 使首次挂载也走统一逻辑(与 onMounted 的 loadCompany 幂等);
|
||||
// 空值场景静默显示空态(loadCompany 内处理),不再弹警告
|
||||
watch(companyUUID, () => {
|
||||
if (!companyUUID.value) {
|
||||
errorMsg.value = '缺少 CompanyUUID 参数,无法加载公司详情。'
|
||||
return
|
||||
}
|
||||
void loadCompany()
|
||||
})
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -229,14 +246,31 @@ watch(companyUUID, () => {
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<!-- 错误提示(缺参 / 加载失败) -->
|
||||
<el-alert v-if="errorMsg" type="error" :title="errorMsg" show-icon :closable="false" />
|
||||
<!-- 无 CompanyUUID 参数或数据未加载成功:空态引导 -->
|
||||
<el-empty
|
||||
v-if="!companyData"
|
||||
description="公司数据未加载成功,请确认登录状态后从公司列表进入详情"
|
||||
:image-size="80"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<!-- 主表基本信息:公司头部卡 -->
|
||||
<el-card v-if="companyData" shadow="never" class="company-hero">
|
||||
<el-card shadow="never" class="company-hero">
|
||||
<div class="company-hero-body">
|
||||
<div class="company-avatar">
|
||||
<el-icon :size="26"><OfficeBuilding /></el-icon>
|
||||
<div class="company-avatar" @click="triggerIconSelect" title="点击设置公司图标">
|
||||
<!-- 有图标:显示图片;无图标:默认建筑图标 -->
|
||||
<img
|
||||
v-if="asStr(companyData.CompanyIcon)"
|
||||
:src="asStr(companyData.CompanyIcon)"
|
||||
alt="公司图标"
|
||||
class="company-avatar-img"
|
||||
/>
|
||||
<el-icon v-else :size="26"><OfficeBuilding /></el-icon>
|
||||
<!-- 悬停遮罩:设置图标 -->
|
||||
<div class="company-avatar-mask">
|
||||
<el-icon v-if="iconUploading" class="is-loading"><Loading /></el-icon>
|
||||
<el-icon v-else><Camera /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="company-hero-main">
|
||||
<div class="company-hero-title-row">
|
||||
@@ -249,22 +283,16 @@ watch(companyUUID, () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 隐藏的图标上传 input -->
|
||||
<input
|
||||
ref="iconInputRef"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style="display: none"
|
||||
@change="handleIconChange"
|
||||
/>
|
||||
|
||||
<el-descriptions class="company-meta" :column="3" border>
|
||||
<el-descriptions-item label="公司 UUID">
|
||||
<span class="uuid-cell">
|
||||
<span class="uuid-text">{{ asStr(companyData.CompanyUUID) || '-' }}</span>
|
||||
<el-button
|
||||
v-if="asStr(companyData.CompanyUUID)"
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
title="复制 UUID"
|
||||
@click="copyUUID(asStr(companyData.CompanyUUID))"
|
||||
>
|
||||
<el-icon><CopyDocument /></el-icon>
|
||||
</el-button>
|
||||
</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="上级公司">
|
||||
{{ resolvedNames.parentCompany?.[companyData.ParentCompanyUUID] || asStr(companyData.ParentCompanyUUID) || '-' }}
|
||||
</el-descriptions-item>
|
||||
@@ -276,7 +304,7 @@ watch(companyUUID, () => {
|
||||
</el-card>
|
||||
|
||||
<!-- 子表 1:部门 -->
|
||||
<el-card v-if="companyData" shadow="never" class="detail-card">
|
||||
<el-card shadow="never" class="detail-card">
|
||||
<template #header>
|
||||
<div class="detail-card-header">
|
||||
<el-icon><Share /></el-icon>
|
||||
@@ -296,7 +324,7 @@ watch(companyUUID, () => {
|
||||
</el-card>
|
||||
|
||||
<!-- 子表 2:工作组 -->
|
||||
<el-card v-if="companyData" shadow="never" class="detail-card">
|
||||
<el-card shadow="never" class="detail-card">
|
||||
<template #header>
|
||||
<div class="detail-card-header">
|
||||
<el-icon><Avatar /></el-icon>
|
||||
@@ -316,7 +344,7 @@ watch(companyUUID, () => {
|
||||
</el-card>
|
||||
|
||||
<!-- 子表 3:员工(UserRole 关联表) -->
|
||||
<el-card v-if="companyData" shadow="never" class="detail-card">
|
||||
<el-card shadow="never" class="detail-card">
|
||||
<template #header>
|
||||
<div class="detail-card-header">
|
||||
<el-icon><User /></el-icon>
|
||||
@@ -334,10 +362,35 @@ watch(companyUUID, () => {
|
||||
:on-view="goEmployeeDetail"
|
||||
/>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<!-- 主表编辑弹窗 -->
|
||||
<el-dialog v-model="editVisible" title="编辑公司" width="600px" destroy-on-close>
|
||||
<el-form :model="editForm" label-width="120px" label-position="right">
|
||||
<!-- 公司图标:点击上传/替换(后端统一缩放 128x128) -->
|
||||
<el-form-item label="公司图标">
|
||||
<div class="edit-icon-row">
|
||||
<div class="edit-icon-preview" @click="triggerIconSelect" title="点击上传/替换图标">
|
||||
<img
|
||||
v-if="asStr(editForm.CompanyIcon)"
|
||||
:src="asStr(editForm.CompanyIcon)"
|
||||
alt="公司图标"
|
||||
class="edit-icon-img"
|
||||
/>
|
||||
<el-icon v-else :size="22"><OfficeBuilding /></el-icon>
|
||||
<div class="edit-icon-mask">
|
||||
<el-icon v-if="iconUploading" class="is-loading"><Loading /></el-icon>
|
||||
<el-icon v-else><Upload /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="edit-icon-tips">
|
||||
<el-button size="small" :loading="iconUploading" @click="triggerIconSelect">
|
||||
上传图标
|
||||
</el-button>
|
||||
<p class="edit-icon-hint">支持图片格式,自动裁剪为 128x128</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-for="col in editFormColumns"
|
||||
:key="col.field"
|
||||
@@ -447,8 +500,9 @@ watch(companyUUID, () => {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
// 公司图标:主色淡底圆角块
|
||||
// 公司图标:主色淡底圆角块(可点击设置,悬停显示遮罩)
|
||||
.company-avatar {
|
||||
position: relative;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 12px;
|
||||
@@ -458,6 +512,39 @@ watch(companyUUID, () => {
|
||||
color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 0 2px var(--el-color-primary-light-5);
|
||||
}
|
||||
}
|
||||
|
||||
// 图标图片:填充整个圆角块
|
||||
.company-avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// 悬停遮罩:提示可设置图标
|
||||
.company-avatar-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.company-avatar:hover .company-avatar-mask {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.company-hero-main {
|
||||
@@ -492,20 +579,71 @@ watch(companyUUID, () => {
|
||||
}
|
||||
}
|
||||
|
||||
// UUID 单元格:等宽字体 + 复制按钮
|
||||
.uuid-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.uuid-text {
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
// ===== 子表卡片 =====
|
||||
// 使用全局 .detail-card / .detail-card-header(见 global.scss),此处无需重复定义
|
||||
|
||||
// ===== 编辑弹窗图标上传 =====
|
||||
.edit-icon-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
// 预览块:可点击上传,悬停显示遮罩
|
||||
.edit-icon-preview {
|
||||
position: relative;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
border: 1px dashed var(--el-border-color);
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
&:hover .edit-icon-mask {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-icon-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.edit-icon-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.edit-icon-tips {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.edit-icon-hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -27,6 +27,7 @@ import EntityPicker from '@/components/EntityPicker.vue'
|
||||
import { formApi } from '@/api/form'
|
||||
import { useResolveUUIDs } from '@/composables/useResolveUUIDs'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
import { formatUtcTime as formatTime } from '@/utils/dateTime'
|
||||
import type { ColumnSchema } from '@/types/api'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -64,20 +65,13 @@ function deptStatusTag(v: unknown): { text: string; type: 'success' | 'danger' |
|
||||
return { text: String(v ?? '-'), type: 'info' }
|
||||
}
|
||||
|
||||
/** 时间格式化 */
|
||||
function formatTime(v: unknown): string {
|
||||
if (!v) return '-'
|
||||
const d = new Date(v as string)
|
||||
if (isNaN(d.getTime())) return String(v)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
// === 数据加载 ===
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
if (!deptUUID.value) {
|
||||
ElMessage.warning('缺少 DeptUUID 参数,无法加载部门详情')
|
||||
// 无 DeptUUID 参数:不弹警告(首次无参进入/菜单误入均静默),显示空态引导
|
||||
data.value = null
|
||||
setPageTitle('部门详情')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
@@ -165,23 +159,29 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
// 同一 tab 复用(v-show 常驻)时 query 变化:重新加载(防展示旧部门数据 / 缺参报错)
|
||||
// immediate 使首次挂载也走统一逻辑(与 onMounted 的 loadData 幂等);
|
||||
// 空值场景静默显示空态(loadData 内处理),不再弹警告
|
||||
watch(deptUUID, () => {
|
||||
if (!deptUUID.value) {
|
||||
ElMessage.warning('缺少 DeptUUID 参数,无法加载部门详情')
|
||||
return
|
||||
}
|
||||
void loadData()
|
||||
})
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DetailPage :title="title" :loading="loading">
|
||||
<template #actions>
|
||||
<el-button type="primary" @click="handleEdit">
|
||||
<el-button v-if="data" type="primary" @click="handleEdit">
|
||||
<el-icon><Edit /></el-icon>编辑
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<!-- 无 DeptUUID 参数或数据未加载成功:空态引导 -->
|
||||
<el-empty
|
||||
v-if="!data"
|
||||
description="部门数据未加载成功,请确认登录状态后从部门列表进入详情"
|
||||
:image-size="80"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<!-- 基本信息 -->
|
||||
<InfoGrid title="基本信息" :columns="2">
|
||||
<el-descriptions-item label="部门名称">{{ data?.DeptName || '-' }}</el-descriptions-item>
|
||||
@@ -194,10 +194,10 @@ watch(deptUUID, () => {
|
||||
{{ parentDeptName || data?.ParentDeptUUID || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="排序">{{ data?.DeptSort ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTime(data?.DeptCreateTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTime(data?.DeptCreateTime, false) }}</el-descriptions-item>
|
||||
</InfoGrid>
|
||||
|
||||
<!-- 部门员工子表 -->
|
||||
<!-- 部门员工子表(UserRoleDepartment 表:用户-角色对 ↔ 部门多对多) -->
|
||||
<el-card shadow="never" class="detail-card">
|
||||
<template #header>
|
||||
<div class="detail-card-header">
|
||||
@@ -207,16 +207,17 @@ watch(deptUUID, () => {
|
||||
</template>
|
||||
<CrudTable
|
||||
v-if="deptUUID"
|
||||
table="UserRole"
|
||||
table="UserRoleDepartment"
|
||||
title="部门员工"
|
||||
:query="{ DeptUUID: deptUUID }"
|
||||
:pk-field="'UserRoleUUID'"
|
||||
:query="{ DeptUUID: deptUUID, CompanyUUID: data?.CompanyUUID || '' }"
|
||||
:pk-field="'UserRoleDepartmentUUID'"
|
||||
selectable
|
||||
pageable
|
||||
:max-height="400"
|
||||
:on-view="goUserDetail"
|
||||
/>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<el-dialog v-model="editDialogVisible" title="编辑部门" width="600px" destroy-on-close>
|
||||
|
||||
@@ -30,6 +30,7 @@ import CrudTable from '@/components/CrudTable.vue'
|
||||
import { formApi } from '@/api/form'
|
||||
import { get } from '@/api/request'
|
||||
import { setPageTitle, getServerScopeParams } from '@/utils/serverScope'
|
||||
import { formatUtcTime as formatTime } from '@/utils/dateTime'
|
||||
import type { ColumnSchema } from '@/types/api'
|
||||
|
||||
/** 用户记录类型(仅声明详情页用到的字段) */
|
||||
@@ -69,8 +70,6 @@ const loading = ref(false)
|
||||
const userData = ref<UserRecord | null>(null)
|
||||
/** 2FA 信息(null 表示未获取或未配置) */
|
||||
const twoFaData = ref<TwoFaInfo | null>(null)
|
||||
/** 错误信息 */
|
||||
const errorMsg = ref('')
|
||||
|
||||
/** 从路由 query 读取用户 UUID(computed 响应式:同一 tab 复用且 query 变化时自动更新) */
|
||||
const userUUID = computed(() => (route.query.UserUUID as string) || '')
|
||||
@@ -119,16 +118,6 @@ function asStr(val: unknown): string {
|
||||
return val == null || val === '' ? '' : String(val)
|
||||
}
|
||||
|
||||
/** 格式化时间(空值返回 -) */
|
||||
function formatTime(val: unknown): string {
|
||||
if (!val) return '-'
|
||||
const s = String(val)
|
||||
const d = new Date(s)
|
||||
if (isNaN(d.getTime())) return s
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
/** 提取分页响应中的列表(兼容数组和 PagedData) */
|
||||
function pickList<T = RowData>(res: unknown): T[] {
|
||||
if (Array.isArray(res)) return res as T[]
|
||||
@@ -144,18 +133,18 @@ function isTruthy(val: unknown): boolean {
|
||||
/** 拉取用户主数据 + 2FA 信息(2FA 失败不阻塞) */
|
||||
async function loadUser(): Promise<void> {
|
||||
if (!userUUID.value) {
|
||||
errorMsg.value = '缺少 UserUUID 参数,无法加载员工详情。'
|
||||
// 无 UserUUID 参数:不弹警告(首次无参进入/菜单误入均静默),显示空态引导
|
||||
userData.value = null
|
||||
setPageTitle('员工详情')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
twoFaData.value = null
|
||||
try {
|
||||
const res = await formApi.list<UserRecord>('User', { UserUUID: userUUID.value, pageSize: 1 })
|
||||
const list = pickList<UserRecord>(res)
|
||||
const data = list.find((u) => u.UserUUID === userUUID.value) || list[0]
|
||||
if (!data) {
|
||||
errorMsg.value = '未找到用户数据'
|
||||
return
|
||||
}
|
||||
userData.value = data
|
||||
@@ -169,8 +158,8 @@ async function loadUser(): Promise<void> {
|
||||
} catch {
|
||||
twoFaData.value = null
|
||||
}
|
||||
} catch (e) {
|
||||
errorMsg.value = `加载失败: ${e instanceof Error ? e.message : String(e)}`
|
||||
} catch {
|
||||
// 错误提示已由响应拦截器统一弹出,数据未加载成功时展示空态
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -235,13 +224,11 @@ async function saveEdit(): Promise<void> {
|
||||
onMounted(loadUser)
|
||||
|
||||
// 同一 tab 复用(v-show 常驻)时 query 变化:重新加载(防展示旧员工数据 / 缺参报错)
|
||||
// immediate 使首次挂载也走统一逻辑(与 onMounted 的 loadUser 幂等);
|
||||
// 空值场景静默显示空态(loadUser 内处理),不再弹警告
|
||||
watch(userUUID, () => {
|
||||
if (!userUUID.value) {
|
||||
errorMsg.value = '缺少 UserUUID 参数,无法加载员工详情。'
|
||||
return
|
||||
}
|
||||
void loadUser()
|
||||
})
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -253,11 +240,16 @@ watch(userUUID, () => {
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<el-alert v-if="errorMsg" type="error" :title="errorMsg" show-icon :closable="false" />
|
||||
<!-- 无 UserUUID 参数或数据未加载成功:空态引导 -->
|
||||
<el-empty
|
||||
v-if="!userData"
|
||||
description="员工数据未加载成功,请确认登录状态后从员工列表进入详情"
|
||||
:image-size="80"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<!-- 基本信息(头像 + 信息网格横向排列,直接置于 DetailPage,不包 el-card) -->
|
||||
<div v-if="userData" class="profile-row">
|
||||
<div class="profile-row">
|
||||
<!-- 头像:失败时显示默认图标 -->
|
||||
<el-avatar :size="96" :src="avatarUrl" class="profile-avatar">
|
||||
<el-icon :size="32"><User /></el-icon>
|
||||
@@ -295,7 +287,7 @@ watch(userUUID, () => {
|
||||
</div>
|
||||
|
||||
<!-- 安全信息(2FA,InfoGrid 直接置于 DetailPage,不包 el-card) -->
|
||||
<InfoGrid v-if="userData" title="安全信息" :columns="2">
|
||||
<InfoGrid title="安全信息" :columns="2">
|
||||
<el-descriptions-item label="2FA状态">
|
||||
<span v-if="twoFaData" :style="{ color: twoFaData.IsEnabled === 1 ? '#166534' : '#991b1b' }">
|
||||
{{ twoFaData.IsEnabled === 1 ? '已启用' : '未启用' }}
|
||||
@@ -317,7 +309,7 @@ watch(userUUID, () => {
|
||||
</InfoGrid>
|
||||
|
||||
<!-- 子表 1:角色与组织(UserRole) -->
|
||||
<el-card v-if="userData" shadow="never" class="detail-card">
|
||||
<el-card shadow="never" class="detail-card">
|
||||
<template #header>
|
||||
<div class="detail-card-header">
|
||||
<el-icon><Avatar /></el-icon>
|
||||
@@ -336,7 +328,7 @@ watch(userUUID, () => {
|
||||
</el-card>
|
||||
|
||||
<!-- 子表 2:部门关联(UserDept) -->
|
||||
<el-card v-if="userData" shadow="never" class="detail-card">
|
||||
<el-card shadow="never" class="detail-card">
|
||||
<template #header>
|
||||
<div class="detail-card-header">
|
||||
<el-icon><Share /></el-icon>
|
||||
@@ -354,6 +346,7 @@ watch(userUUID, () => {
|
||||
:on-view="goDeptDetail"
|
||||
/>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<!-- 主表编辑弹窗 -->
|
||||
<el-dialog v-model="editVisible" title="编辑员工" width="600px" destroy-on-close>
|
||||
|
||||
@@ -29,6 +29,7 @@ import InfoGrid from '@/components/InfoGrid.vue'
|
||||
import CrudTable from '@/components/CrudTable.vue'
|
||||
import { formApi } from '@/api/form'
|
||||
import { setPageTitle, isDefaultServer, hasAdminVerifyArg } from '@/utils/serverScope'
|
||||
import { formatUtcTime as formatTime } from '@/utils/dateTime'
|
||||
import type { ColumnSchema } from '@/types/api'
|
||||
|
||||
/** 角色记录类型(仅声明详情页用到的字段) */
|
||||
@@ -52,8 +53,6 @@ const route = useRoute()
|
||||
const loading = ref(false)
|
||||
/** 角色主数据 */
|
||||
const roleData = ref<RoleRecord | null>(null)
|
||||
/** 错误信息 */
|
||||
const errorMsg = ref('')
|
||||
|
||||
/** 从路由 query 读取角色 UUID(computed 响应式:同一 tab 复用且 query 变化时自动更新) */
|
||||
const roleUUID = computed(() => (route.query.RoleUUID as string) || '')
|
||||
@@ -88,16 +87,6 @@ function asStr(val: unknown): string {
|
||||
return val == null || val === '' ? '' : String(val)
|
||||
}
|
||||
|
||||
/** 格式化时间(空值返回 -) */
|
||||
function formatTime(val: unknown): string {
|
||||
if (!val) return '-'
|
||||
const s = String(val)
|
||||
const d = new Date(s)
|
||||
if (isNaN(d.getTime())) return s
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
/** 提取分页响应中的列表(兼容数组和 PagedData) */
|
||||
function pickList<T = RowData>(res: unknown): T[] {
|
||||
if (Array.isArray(res)) return res as T[]
|
||||
@@ -108,23 +97,23 @@ function pickList<T = RowData>(res: unknown): T[] {
|
||||
/** 拉取角色主数据 */
|
||||
async function loadRole(): Promise<void> {
|
||||
if (!roleUUID.value) {
|
||||
errorMsg.value = '缺少 RoleUUID 参数,无法加载角色详情。'
|
||||
// 无 RoleUUID 参数:不弹警告(首次无参进入/菜单误入均静默),显示空态引导
|
||||
roleData.value = null
|
||||
setPageTitle('角色详情')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const res = await formApi.list<RoleRecord>('Role', { RoleUUID: roleUUID.value, pageSize: 1 })
|
||||
const list = pickList<RoleRecord>(res)
|
||||
const data = list.find((r) => r.RoleUUID === roleUUID.value) || list[0]
|
||||
if (!data) {
|
||||
errorMsg.value = '未找到角色数据'
|
||||
return
|
||||
}
|
||||
roleData.value = data
|
||||
setPageTitle(asStr(data.RoleName) || '角色详情')
|
||||
} catch (e) {
|
||||
errorMsg.value = `加载失败: ${e instanceof Error ? e.message : String(e)}`
|
||||
} catch {
|
||||
// 错误提示已由响应拦截器统一弹出,数据未加载成功时展示空态
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -183,13 +172,11 @@ async function saveEdit(): Promise<void> {
|
||||
onMounted(loadRole)
|
||||
|
||||
// 同一 tab 复用(v-show 常驻)时 query 变化:重新加载(防展示旧角色数据 / 缺参报错)
|
||||
// immediate 使首次挂载也走统一逻辑(与 onMounted 的 loadRole 幂等);
|
||||
// 空值场景静默显示空态(loadRole 内处理),不再弹警告
|
||||
watch(roleUUID, () => {
|
||||
if (!roleUUID.value) {
|
||||
errorMsg.value = '缺少 RoleUUID 参数,无法加载角色详情。'
|
||||
return
|
||||
}
|
||||
void loadRole()
|
||||
})
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -201,11 +188,16 @@ watch(roleUUID, () => {
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<el-alert v-if="errorMsg" type="error" :title="errorMsg" show-icon :closable="false" />
|
||||
<!-- 无 RoleUUID 参数或数据未加载成功:空态引导 -->
|
||||
<el-empty
|
||||
v-if="!roleData"
|
||||
description="角色数据未加载成功,请确认登录状态后从角色列表进入详情"
|
||||
:image-size="80"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<!-- 主表基本信息(InfoGrid 直接置于 DetailPage,不包 el-card) -->
|
||||
<InfoGrid v-if="roleData" title="基本信息" :columns="2">
|
||||
<InfoGrid title="基本信息" :columns="2">
|
||||
<el-descriptions-item label="角色名称">{{ asStr(roleData.RoleName) || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="角色类型">{{ roleTypeMap[String(roleData.RoleType)] ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
@@ -220,7 +212,6 @@ watch(roleUUID, () => {
|
||||
|
||||
<!-- 作用域范围提示横幅(scope-banner) -->
|
||||
<el-alert
|
||||
v-if="roleData"
|
||||
:type="isAdminMode ? 'warning' : 'info'"
|
||||
:closable="false"
|
||||
show-icon
|
||||
@@ -230,7 +221,7 @@ watch(roleUUID, () => {
|
||||
</el-alert>
|
||||
|
||||
<!-- 子表 1:权限列表 -->
|
||||
<el-card v-if="roleData" shadow="never" class="detail-card">
|
||||
<el-card shadow="never" class="detail-card">
|
||||
<CrudTable
|
||||
table="RolePermission"
|
||||
title="权限列表"
|
||||
@@ -243,7 +234,7 @@ watch(roleUUID, () => {
|
||||
</el-card>
|
||||
|
||||
<!-- 子表 2:关联用户 -->
|
||||
<el-card v-if="roleData" shadow="never" class="detail-card">
|
||||
<el-card shadow="never" class="detail-card">
|
||||
<CrudTable
|
||||
table="UserRole"
|
||||
title="关联用户"
|
||||
@@ -254,6 +245,7 @@ watch(roleUUID, () => {
|
||||
pk-field="UserRoleUUID"
|
||||
/>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<!-- 主表编辑弹窗 -->
|
||||
<el-dialog v-model="editVisible" title="编辑角色" width="600px" destroy-on-close>
|
||||
|
||||
@@ -57,6 +57,13 @@ function handleView(row: TenantRow): void {
|
||||
pageable
|
||||
stretch
|
||||
selectable
|
||||
card-mode
|
||||
card-title-field="TenantName"
|
||||
:card-fields="['TenantStatus', 'ReviewStatus', 'ReviewRemark', 'TenantExpireTime', 'CreateTime']"
|
||||
:card-value-maps="{
|
||||
TenantStatus: { 0: '禁用', 1: '启用' },
|
||||
ReviewStatus: { 0: '待审核', 1: '已通过', 2: '已拒绝' },
|
||||
}"
|
||||
:on-view="handleView"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,7 @@ import CrudTable from '@/components/CrudTable.vue'
|
||||
import EntityPicker from '@/components/EntityPicker.vue'
|
||||
import { formApi } from '@/api/form'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
import { formatUtcTime as formatTime } from '@/utils/dateTime'
|
||||
import type { ColumnSchema } from '@/types/api'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -67,15 +68,6 @@ function reviewStatusTag(v: unknown): { text: string; type: 'success' | 'danger'
|
||||
return { text: String(v ?? '-'), type: 'info' }
|
||||
}
|
||||
|
||||
/** 时间格式化 */
|
||||
function formatTime(v: unknown): string {
|
||||
if (!v) return '-'
|
||||
const d = new Date(v as string)
|
||||
if (isNaN(d.getTime())) return String(v)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
// 编辑弹窗可见列
|
||||
const formColumns = computed(() =>
|
||||
columns.value.filter(
|
||||
@@ -93,7 +85,9 @@ const formColumns = computed(() =>
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
if (!tenantUUID.value) {
|
||||
ElMessage.warning('缺少 TenantUUID 参数,无法加载租户详情')
|
||||
// 无 TenantUUID 参数:不弹警告(首次无参进入/菜单误入均静默),显示空态引导
|
||||
data.value = null
|
||||
setPageTitle('租户详情')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
@@ -153,13 +147,11 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
// 同一 tab 复用(v-show 常驻)时 query 变化:重新加载(防展示旧租户数据 / 缺参报错)
|
||||
// immediate 使首次挂载也走统一逻辑(与 onMounted 的 loadData 幂等);
|
||||
// 空值场景静默显示空态(loadData 内处理),不再弹警告
|
||||
watch(tenantUUID, () => {
|
||||
if (!tenantUUID.value) {
|
||||
ElMessage.warning('缺少 TenantUUID 参数,无法加载租户详情')
|
||||
return
|
||||
}
|
||||
void loadData()
|
||||
})
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -170,6 +162,14 @@ watch(tenantUUID, () => {
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<!-- 无 TenantUUID 参数或数据未加载成功:空态引导 -->
|
||||
<el-empty
|
||||
v-if="!data"
|
||||
description="租户数据未加载成功,请确认登录状态后从租户列表进入详情"
|
||||
:image-size="80"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<!-- 基本信息 -->
|
||||
<InfoGrid title="基本信息" :columns="2">
|
||||
<el-descriptions-item label="租户名称">{{ data?.TenantName || '-' }}</el-descriptions-item>
|
||||
@@ -178,8 +178,8 @@ watch(tenantUUID, () => {
|
||||
{{ tenantStatusTag(data?.TenantStatus).text }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="到期时间">{{ formatTime(data?.TenantExpireTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTime(data?.CreateTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="到期时间">{{ formatTime(data?.TenantExpireTime, false) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTime(data?.CreateTime, false) }}</el-descriptions-item>
|
||||
</InfoGrid>
|
||||
|
||||
<!-- 审核信息 -->
|
||||
@@ -189,7 +189,7 @@ watch(tenantUUID, () => {
|
||||
{{ reviewStatusTag(data?.ReviewStatus).text }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="审核时间">{{ formatTime(data?.ReviewTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="审核时间">{{ formatTime(data?.ReviewTime, false) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="审核备注">{{ data?.ReviewRemark || '-' }}</el-descriptions-item>
|
||||
</InfoGrid>
|
||||
|
||||
@@ -213,6 +213,7 @@ watch(tenantUUID, () => {
|
||||
:on-view="goCompanyDetail"
|
||||
/>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<el-dialog v-model="editDialogVisible" title="编辑租户" width="600px" destroy-on-close>
|
||||
|
||||
@@ -50,6 +50,15 @@ const editForm = reactive<Record<string, any>>({})
|
||||
// 主键字段
|
||||
const PK_FIELD = 'WorkgroupUUID'
|
||||
|
||||
// tags 表单:逗号分隔字符串 ↔ 多选数组互转(工作组标签 WorkgroupTags)
|
||||
function splitTags(value: unknown, separator = ','): string[] {
|
||||
if (!value) return []
|
||||
return String(value).split(separator).map((s) => s.trim()).filter(Boolean)
|
||||
}
|
||||
function joinTags(values: string[], separator = ','): string {
|
||||
return (values || []).filter(Boolean).join(separator)
|
||||
}
|
||||
|
||||
// 页面标题
|
||||
const title = computed(() => data.value?.WorkgroupName || '工作组详情')
|
||||
|
||||
@@ -57,7 +66,9 @@ const title = computed(() => data.value?.WorkgroupName || '工作组详情')
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
if (!workgroupUUID.value) {
|
||||
ElMessage.warning('缺少 WorkgroupUUID 参数,无法加载工作组详情')
|
||||
// 无 WorkgroupUUID 参数:不弹警告(首次无参进入/菜单误入均静默),显示空态引导
|
||||
data.value = null
|
||||
setPageTitle('工作组详情')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
@@ -135,9 +146,9 @@ async function handleSubmit(): Promise<void> {
|
||||
|
||||
// === 子表跳转 ===
|
||||
|
||||
/** WorkgroupRole 子表查看 → 角色详情 */
|
||||
function goRoleDetail(row: Record<string, any>): void {
|
||||
router.push({ path: '/system/role-detail', query: { RoleUUID: row.RoleUUID || '' } })
|
||||
/** UserRole 子表查看 → 用户详情 */
|
||||
function goUserDetail(row: Record<string, any>): void {
|
||||
router.push({ path: '/system/user-detail', query: { UserUUID: row.UserUUID || '' } })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -145,13 +156,11 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
// 同一 tab 复用(v-show 常驻)时 query 变化:重新加载(防展示旧工作组数据 / 缺参报错)
|
||||
// immediate 使首次挂载也走统一逻辑(与 onMounted 的 loadData 幂等);
|
||||
// 空值场景静默显示空态(loadData 内处理),不再弹警告
|
||||
watch(workgroupUUID, () => {
|
||||
if (!workgroupUUID.value) {
|
||||
ElMessage.warning('缺少 WorkgroupUUID 参数,无法加载工作组详情')
|
||||
return
|
||||
}
|
||||
void loadData()
|
||||
})
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -162,6 +171,14 @@ watch(workgroupUUID, () => {
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<!-- 无 WorkgroupUUID 参数或数据未加载成功:空态引导 -->
|
||||
<el-empty
|
||||
v-if="!data"
|
||||
description="工作组数据未加载成功,请确认登录状态后从工作组列表进入详情"
|
||||
:image-size="80"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<!-- 基本信息 -->
|
||||
<InfoGrid title="基本信息" :columns="2">
|
||||
<el-descriptions-item label="工作组名称">{{ data?.WorkgroupName || '-' }}</el-descriptions-item>
|
||||
@@ -172,26 +189,27 @@ watch(workgroupUUID, () => {
|
||||
<el-descriptions-item label="工作组介绍">{{ data?.WorkgroupIntroduction || '-' }}</el-descriptions-item>
|
||||
</InfoGrid>
|
||||
|
||||
<!-- 工作组角色子表 -->
|
||||
<!-- 工作组员工子表(UserRoleWorkgroup 表:用户-角色对 ↔ 工作组多对多) -->
|
||||
<el-card shadow="never" class="detail-card">
|
||||
<template #header>
|
||||
<div class="detail-card-header">
|
||||
<el-icon><Avatar /></el-icon>
|
||||
<span>工作组角色</span>
|
||||
<span>工作组员工</span>
|
||||
</div>
|
||||
</template>
|
||||
<CrudTable
|
||||
v-if="workgroupUUID"
|
||||
table="WorkgroupRole"
|
||||
title="工作组角色"
|
||||
:query="{ WorkgroupUUID: workgroupUUID }"
|
||||
:pk-field="'WorkgroupRoleUUID'"
|
||||
table="UserRoleWorkgroup"
|
||||
title="工作组员工"
|
||||
:query="{ WorkgroupUUID: workgroupUUID, CompanyUUID: data?.CompanyUUID || '' }"
|
||||
:pk-field="'UserRoleWorkgroupUUID'"
|
||||
selectable
|
||||
pageable
|
||||
:max-height="400"
|
||||
:on-view="goRoleDetail"
|
||||
:on-view="goUserDetail"
|
||||
/>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<el-dialog v-model="editDialogVisible" title="编辑工作组" width="600px" destroy-on-close>
|
||||
@@ -247,6 +265,26 @@ watch(workgroupUUID, () => {
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<!-- tags:多选标签(预设可选项 + 允许自定义新增),逗号分隔存储 -->
|
||||
<el-select
|
||||
v-else-if="col.type === 'tags'"
|
||||
:model-value="splitTags(editForm[col.field], col.tagsSeparator)"
|
||||
@update:model-value="(v: string[]) => (editForm[col.field] = joinTags(v, col.tagsSeparator))"
|
||||
:placeholder="col.placeholder"
|
||||
:disabled="col.disabled"
|
||||
multiple
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in col.options ?? []"
|
||||
:key="String(opt.value)"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-date-picker
|
||||
v-else-if="col.type === 'date' || col.type === 'datetime'"
|
||||
v-model="editForm[col.field]"
|
||||
|
||||
@@ -18,9 +18,10 @@
|
||||
* - 日期筛选改用 el-date-picker daterange,提交时拆分为 CreateTime__gte / CreateTime__lte
|
||||
* - 响应格式兼容:后端可能返回数组或 { records, total },统一适配
|
||||
*/
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { get } from '@/api/request'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
import { formatUtcTime as formatTime } from '@/utils/dateTime'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import AdminBadge from '@/components/AdminBadge.vue'
|
||||
|
||||
@@ -29,8 +30,8 @@ setPageTitle('操作审计日志')
|
||||
const userStore = useUserStore()
|
||||
/** 是否管理员(前端预拦截,后端仍校验) */
|
||||
const isAdmin = computed(() => userStore.isAdmin)
|
||||
/** 无权访问标记:非管理员进入时置 true */
|
||||
const denied = ref(false)
|
||||
/** 无权访问:CheckAdmin 加载完成且确认非管理员(加载中不显示无权访问,避免刷新后闪误判) */
|
||||
const denied = computed(() => userStore.adminChecked && !isAdmin.value)
|
||||
|
||||
/** 审计日志记录(对齐后端字段,PascalCase) */
|
||||
interface AuditLogRow {
|
||||
@@ -93,12 +94,6 @@ function actionTagType(action?: string): 'success' | 'warning' | 'danger' | 'inf
|
||||
return (action && map[action]) || 'info'
|
||||
}
|
||||
|
||||
/** 时间格式化:去掉 T,截取到秒 */
|
||||
function formatTime(t?: string): string {
|
||||
if (!t) return '-'
|
||||
return String(t).replace('T', ' ').substring(0, 19)
|
||||
}
|
||||
|
||||
/** 安全 JSON 解析:字符串解析失败时原样返回 */
|
||||
function safeJsonParse(str: unknown): unknown {
|
||||
if (typeof str !== 'string') return str
|
||||
@@ -213,13 +208,24 @@ function prettyJson(data: unknown): string {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 非管理员直接拦截,展示无权访问
|
||||
if (!isAdmin.value) {
|
||||
denied.value = true
|
||||
return
|
||||
// 等 CheckAdmin 加载完成后确认管理员再加载日志(刷新后 adminChecked 异步恢复)
|
||||
if (userStore.adminChecked) {
|
||||
loadIfAdmin()
|
||||
} else {
|
||||
watch(
|
||||
() => userStore.adminChecked,
|
||||
(checked) => {
|
||||
if (checked) loadIfAdmin()
|
||||
},
|
||||
)
|
||||
}
|
||||
loadAuditLogs()
|
||||
})
|
||||
|
||||
/** CheckAdmin 确认管理员后加载数据(非管理员不加载,展示无权访问) */
|
||||
function loadIfAdmin(): void {
|
||||
if (!isAdmin.value) return
|
||||
loadAuditLogs()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
* - 跳转改用 vue-router:/system/dbtable?table=xxx&title=xxx(子服务器追加 server)
|
||||
* - 返回服务器列表改用 router.push(替代 GERP.Navigate)
|
||||
*/
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { get } from '@/api/request'
|
||||
import { setPageTitle, getServer, isDefaultServer } from '@/utils/serverScope'
|
||||
@@ -28,7 +28,8 @@ import AdminBadge from '@/components/AdminBadge.vue'
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const isAdmin = computed(() => userStore.isAdmin)
|
||||
const denied = ref(false)
|
||||
// 无权访问:CheckAdmin 加载完成且确认非管理员(加载中不显示无权访问,避免刷新后闪误判)
|
||||
const denied = computed(() => userStore.adminChecked && !isAdmin.value)
|
||||
|
||||
/** 服务器标识(主实例为 gvsds-master) */
|
||||
const serverName = getServer()
|
||||
@@ -111,16 +112,28 @@ function tableMeta(table: TableItem): string {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!isAdmin.value) {
|
||||
denied.value = true
|
||||
return
|
||||
// 等 CheckAdmin 加载完成后确认管理员再加载表列表(刷新后 adminChecked 异步恢复)
|
||||
if (userStore.adminChecked) {
|
||||
loadIfAdmin()
|
||||
} else {
|
||||
watch(
|
||||
() => userStore.adminChecked,
|
||||
(checked) => {
|
||||
if (checked) loadIfAdmin()
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
/** CheckAdmin 确认管理员后加载数据(非管理员不加载,展示无权访问) */
|
||||
function loadIfAdmin(): void {
|
||||
if (!isAdmin.value) return
|
||||
if (isMain) {
|
||||
loadMainTables()
|
||||
} else {
|
||||
loadSubTables()
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
* - 子服务器 subproxy 模式 CrudTable 暂不支持,标 TODO 待 DbTableHelper 迁移
|
||||
* - TODO: 完整迁移 DbTableHelper(列定义动态渲染、子服务器代理、表结构编辑等高级能力)
|
||||
*/
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { setPageTitle, getServer, isDefaultServer } from '@/utils/serverScope'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
@@ -31,21 +31,28 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const isAdmin = computed(() => userStore.isAdmin)
|
||||
const denied = ref(false)
|
||||
// 无权访问:CheckAdmin 加载完成且确认非管理员(加载中不显示无权访问,避免刷新后闪误判)
|
||||
const denied = computed(() => userStore.adminChecked && !isAdmin.value)
|
||||
|
||||
/** 服务器标识(主实例为 gvsds-master) */
|
||||
const serverName = getServer()
|
||||
/** 是否主实例 */
|
||||
const isMain = isDefaultServer()
|
||||
/** 表名(路由 query) */
|
||||
const tableName = (route.query.table as string) || ''
|
||||
/** 表名(路由 query,computed 响应式:同一 tab 复用且 query 变化时自动更新) */
|
||||
const tableName = computed(() => (route.query.table as string) || '')
|
||||
/** 表标题(路由 query,缺省回退表名) */
|
||||
const tableTitle = (route.query.title as string) || tableName
|
||||
const tableTitle = computed(() => (route.query.title as string) || tableName.value)
|
||||
|
||||
/** 页面标题 */
|
||||
const pageTitle = computed(() => (isMain ? tableTitle : `${serverName} — ${tableTitle}`))
|
||||
const pageTitle = computed(() => (isMain ? tableTitle.value : `${serverName} — ${tableTitle.value}`))
|
||||
setPageTitle(pageTitle.value)
|
||||
|
||||
/** 表格最大高度:视口高度 60%(窗口尺寸变化时响应式更新) */
|
||||
const tableMaxHeight = ref(Math.floor(window.innerHeight * 0.6))
|
||||
window.addEventListener('resize', () => {
|
||||
tableMaxHeight.value = Math.floor(window.innerHeight * 0.6)
|
||||
})
|
||||
|
||||
/** 返回数据库管理页 */
|
||||
function goBack(): void {
|
||||
const query: Record<string, string> = {}
|
||||
@@ -53,12 +60,7 @@ function goBack(): void {
|
||||
router.push({ path: '/system/database', query })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 非管理员直接拦截,展示无权访问
|
||||
if (!isAdmin.value) {
|
||||
denied.value = true
|
||||
}
|
||||
})
|
||||
// denied 已改为 computed(adminChecked && !isAdmin),无需 onMounted 拦截
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -109,12 +111,14 @@ onMounted(() => {
|
||||
-->
|
||||
<CrudTable
|
||||
v-if="isMain"
|
||||
:key="tableName"
|
||||
:table="tableName"
|
||||
:title="tableTitle"
|
||||
searchable
|
||||
pageable
|
||||
selectable
|
||||
stretch
|
||||
:max-height="tableMaxHeight"
|
||||
/>
|
||||
|
||||
<!--
|
||||
|
||||
@@ -42,7 +42,8 @@ setPageTitle('菜单编辑器')
|
||||
|
||||
const userStore = useUserStore()
|
||||
const isAdmin = computed(() => userStore.isAdmin)
|
||||
const denied = ref(false)
|
||||
// 无权访问:CheckAdmin 加载完成且确认非管理员(加载中不显示无权访问,避免刷新后闪误判)
|
||||
const denied = computed(() => userStore.adminChecked && !isAdmin.value)
|
||||
|
||||
/** 菜单节点(对齐后端 PascalCase 字段) */
|
||||
interface MenuNode {
|
||||
@@ -743,10 +744,7 @@ function renderIcon(icon?: string): string {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!isAdmin.value) {
|
||||
denied.value = true
|
||||
return
|
||||
}
|
||||
// 键盘/点击事件监听(页面是否可见由 denied computed 控制,不阻塞注册)
|
||||
document.addEventListener('keydown', onKeydown)
|
||||
document.addEventListener('click', onDocClick)
|
||||
})
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
* - 业务错误(code!==0)由拦截器统一 ElMessage.error 并 reject,本文件 catch 后恢复按钮状态
|
||||
* - AdminBadge 自动根据 server/admin_verify 显示特权横幅
|
||||
*/
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { get, post } from '@/api/request'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
@@ -249,7 +249,17 @@ async function deleteConfig(uuid: string, platform: string): Promise<void> {
|
||||
|
||||
onMounted(() => {
|
||||
setPageTitle('支付网关配置')
|
||||
loadConfigs()
|
||||
// 等 CheckAdmin 加载完成后确认管理员再加载(刷新后 adminChecked 异步恢复)
|
||||
if (userStore.adminChecked) {
|
||||
loadConfigs()
|
||||
} else {
|
||||
watch(
|
||||
() => userStore.adminChecked,
|
||||
(checked) => {
|
||||
if (checked) loadConfigs()
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import EntityPicker from '@/components/EntityPicker.vue'
|
||||
import { formApi } from '@/api/form'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
import { formatUtcTime as formatTime } from '@/utils/dateTime'
|
||||
import type { ColumnSchema } from '@/types/api'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -86,15 +87,6 @@ function assignScopeText(s: unknown): { text: string; type: 'success' | 'primary
|
||||
return { text: '-', type: 'info' }
|
||||
}
|
||||
|
||||
/** 时间格式化 */
|
||||
function formatTime(v: unknown): string {
|
||||
if (!v) return '-'
|
||||
const d = new Date(v as string)
|
||||
if (isNaN(d.getTime())) return String(v)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
// 编辑弹窗可见列
|
||||
const formColumns = computed(() =>
|
||||
columns.value.filter(
|
||||
@@ -112,7 +104,9 @@ const formColumns = computed(() =>
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
if (!roleUUID.value) {
|
||||
ElMessage.warning('缺少 RoleUUID 参数,无法加载角色详情')
|
||||
// 无 RoleUUID 参数:不弹警告(首次无参进入/菜单误入均静默),显示空态引导
|
||||
data.value = null
|
||||
setPageTitle('角色详情')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
@@ -164,10 +158,7 @@ async function handleSubmit(): Promise<void> {
|
||||
|
||||
/** 跳转权限树分配页(RolePermissions) */
|
||||
function goPermissionTree(): void {
|
||||
if (!roleUUID.value) {
|
||||
ElMessage.warning('缺少 RoleUUID')
|
||||
return
|
||||
}
|
||||
if (!roleUUID.value) return
|
||||
router.push({ path: '/system/role-permissions', query: { RoleUUID: roleUUID.value } })
|
||||
}
|
||||
|
||||
@@ -183,13 +174,11 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
// 同一 tab 复用(v-show 常驻)时 query 变化:重新加载(防展示旧角色数据 / 缺参报错)
|
||||
// immediate 使首次挂载也走统一逻辑(与 onMounted 的 loadData 幂等);
|
||||
// 空值场景静默显示空态(loadData 内处理),不再弹警告
|
||||
watch(roleUUID, () => {
|
||||
if (!roleUUID.value) {
|
||||
ElMessage.warning('缺少 RoleUUID 参数,无法加载角色详情')
|
||||
return
|
||||
}
|
||||
void loadData()
|
||||
})
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -208,6 +197,14 @@ watch(roleUUID, () => {
|
||||
{{ isolationHint }}
|
||||
</el-alert>
|
||||
|
||||
<!-- 无 RoleUUID 参数或数据未加载成功:空态引导 -->
|
||||
<el-empty
|
||||
v-if="!data"
|
||||
description="角色数据未加载成功,请确认登录状态后从角色列表进入详情"
|
||||
:image-size="80"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<!-- 基本信息 -->
|
||||
<InfoGrid title="基本信息" :columns="2">
|
||||
<el-descriptions-item label="角色名称">{{ data?.RoleName || '-' }}</el-descriptions-item>
|
||||
@@ -223,7 +220,7 @@ watch(roleUUID, () => {
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="角色描述">{{ data?.RoleDesc || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTime(data?.CreateTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTime(data?.CreateTime, false) }}</el-descriptions-item>
|
||||
</InfoGrid>
|
||||
|
||||
<!-- 关联用户子表 -->
|
||||
@@ -270,6 +267,7 @@ watch(roleUUID, () => {
|
||||
添加 <code>admin:super</code> 权限可使该角色成为超级管理员
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<el-dialog v-model="editDialogVisible" title="编辑角色" width="600px" destroy-on-close>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
* - GET /API/Role/PermissionsTree?RoleUUID=&CompanyUUID= → { Groups: [{ Category, Permissions: [{ PermUUID, PermName, PermCode, PermDesc, Checked }] }] }
|
||||
* - POST /API/Role/SavePermissions { RoleUUID, CompanyUUID, PermUUIDs } → { Added, Removed, Kept }
|
||||
*/
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
|
||||
import { useRoute, onBeforeRouteLeave } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import DetailPage from '@/components/DetailPage.vue'
|
||||
@@ -47,7 +47,8 @@ const companies = ref<Array<Record<string, any>>>([])
|
||||
const permissionTree = ref<PermGroup[]>([])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const denied = ref(false)
|
||||
// 无权访问:CheckAdmin 加载完成且确认非管理员(加载中不显示无权访问,避免刷新后闪误判)
|
||||
const denied = computed(() => userStore.adminChecked && !isAdmin.value)
|
||||
const hasChanges = ref(false)
|
||||
|
||||
// 原始勾选集合(用于变更检测)
|
||||
@@ -251,14 +252,28 @@ onBeforeRouteLeave((_to, _from, next) => {
|
||||
}
|
||||
})
|
||||
|
||||
/** CheckAdmin 确认管理员后加载数据(非管理员不加载,展示无权访问) */
|
||||
async function loadIfAdmin(): Promise<void> {
|
||||
if (!isAdmin.value) return
|
||||
await Promise.all([loadRoles(), loadCompanies()])
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
setPageTitle('角色权限分配')
|
||||
// 入口校验:仅管理员可访问
|
||||
if (!isAdmin.value) {
|
||||
denied.value = true
|
||||
return
|
||||
// 等 CheckAdmin 加载完成后确认管理员再加载(刷新后 adminChecked 异步恢复)
|
||||
if (userStore.adminChecked) {
|
||||
await loadIfAdmin()
|
||||
} else {
|
||||
await new Promise<void>((resolve) => {
|
||||
watch(
|
||||
() => userStore.adminChecked,
|
||||
(checked) => {
|
||||
if (checked) resolve()
|
||||
},
|
||||
)
|
||||
})
|
||||
await loadIfAdmin()
|
||||
}
|
||||
await Promise.all([loadRoles(), loadCompanies()])
|
||||
// 自动选中初始角色 / 公司
|
||||
if (initRoleUUID) selectedRoleUUID.value = initRoleUUID
|
||||
if (initCompanyUUID) selectedCompanyUUID.value = initCompanyUUID
|
||||
|
||||
@@ -241,10 +241,25 @@ watch(
|
||||
|
||||
onMounted(async () => {
|
||||
const s = await loadSettings()
|
||||
// 主题一致性:右上角"一键切换深/浅"是运行时最直接的意图,
|
||||
// 若当前已有运行时主题则优先采用,避免后端持久化的 themeMode 在打开设置页时
|
||||
// 回写覆盖用户刚切换的主题(两处状态源分叉导致"疑似冲突"的根因)。
|
||||
if (appStore.theme !== 'default') {
|
||||
s.themeMode = appStore.theme
|
||||
}
|
||||
Object.assign(settings, s)
|
||||
applySettingsToRuntime(settings)
|
||||
initialized.value = true
|
||||
})
|
||||
|
||||
// 顶栏一键切换(appStore.toggleTheme)后,设置页主题下拉实时跟随,
|
||||
// 保证两处状态始终一致(不触发保存,避免与 onThemeChange 的即时保存互相干扰)
|
||||
watch(
|
||||
() => appStore.theme,
|
||||
(val) => {
|
||||
settings.themeMode = val
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { get, post } from '@/api/request'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
import { formatUtcTime as formatTime } from '@/utils/dateTime'
|
||||
|
||||
// === 类型定义 ===
|
||||
|
||||
@@ -57,14 +58,6 @@ const rejectReason = ref('')
|
||||
/** 拒绝提交中 */
|
||||
const rejecting = ref(false)
|
||||
|
||||
// === 工具函数 ===
|
||||
|
||||
/** 时间格式化:去掉 T 分隔,截取到秒 */
|
||||
function formatTime(t: string): string {
|
||||
if (!t) return '-'
|
||||
return t.replace('T', ' ').substring(0, 19)
|
||||
}
|
||||
|
||||
// === 数据加载 ===
|
||||
|
||||
/** 加载待审核租户列表 */
|
||||
|
||||
@@ -31,6 +31,7 @@ import { formApi } from '@/api/form'
|
||||
import { get } from '@/api/request'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
import { formatUtcTime as formatTime } from '@/utils/dateTime'
|
||||
import type { ColumnSchema } from '@/types/api'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -102,15 +103,6 @@ function accountLicenseTag(v: unknown): { text: string; type: 'success' | 'dange
|
||||
return { text: String(v ?? '-'), type: 'warning' }
|
||||
}
|
||||
|
||||
/** 时间格式化:ISO/时间戳 → YYYY-MM-DD HH:mm,无法解析则原样返回 */
|
||||
function formatTime(v: unknown): string {
|
||||
if (!v) return '-'
|
||||
const d = new Date(v as string)
|
||||
if (isNaN(d.getTime())) return String(v)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
// 编辑弹窗可见列:排除隐藏 / 主键 / 不可编辑 / 非表单类型
|
||||
const formColumns = computed(() =>
|
||||
columns.value.filter(
|
||||
@@ -129,7 +121,9 @@ const formColumns = computed(() =>
|
||||
/** 加载用户主表单条数据 */
|
||||
async function loadData(): Promise<void> {
|
||||
if (!userUUID.value) {
|
||||
ElMessage.warning('缺少 UserUUID 参数,无法加载用户详情')
|
||||
// 无 UserUUID 参数:不弹警告(首次无参进入/菜单误入均静默),显示空态引导
|
||||
data.value = null
|
||||
setPageTitle('用户详情')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
@@ -210,13 +204,11 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
// 同一 tab 复用(v-show 常驻)时 query 变化:重新加载(防展示旧用户数据 / 缺参报错)
|
||||
// immediate 使首次挂载也走统一逻辑(与 onMounted 的 loadData 幂等);
|
||||
// 空值场景静默显示空态(loadData 内处理),不再弹警告
|
||||
watch(userUUID, () => {
|
||||
if (!userUUID.value) {
|
||||
ElMessage.warning('缺少 UserUUID 参数,无法加载用户详情')
|
||||
return
|
||||
}
|
||||
void loadData()
|
||||
})
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -232,6 +224,14 @@ watch(userUUID, () => {
|
||||
{{ isolationHint }}
|
||||
</el-alert>
|
||||
|
||||
<!-- 无 UserUUID 参数或数据未加载成功:空态引导 -->
|
||||
<el-empty
|
||||
v-if="!data"
|
||||
description="用户数据未加载成功,请确认登录状态后从用户列表进入详情"
|
||||
:image-size="80"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<!-- 基本信息 -->
|
||||
<InfoGrid title="基本信息" :columns="2">
|
||||
<el-descriptions-item label="用户名">{{ data?.UserName || '-' }}</el-descriptions-item>
|
||||
@@ -255,8 +255,8 @@ watch(userUUID, () => {
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="是否员工">{{ yesNoText(data?.IsStaff) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="是否超级用户">{{ yesNoText(data?.IsSuperuser) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTime(data?.UserCreateTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最后登录">{{ formatTime(data?.UserLastLoginDate) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTime(data?.UserCreateTime, false) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最后登录">{{ formatTime(data?.UserLastLoginDate, false) }}</el-descriptions-item>
|
||||
</InfoGrid>
|
||||
|
||||
<!-- 2FA 双因素信息(TODO:契约确认后补全字段) -->
|
||||
@@ -312,6 +312,7 @@ watch(userUUID, () => {
|
||||
:on-view="goDeptDetail"
|
||||
/>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<!-- 编辑弹窗:基于列定义动态渲染 -->
|
||||
<el-dialog v-model="editDialogVisible" title="编辑用户" width="600px" destroy-on-close>
|
||||
|
||||
@@ -2,21 +2,149 @@
|
||||
/**
|
||||
* 工单管理(管理员视图)
|
||||
*
|
||||
* 迁移自 TempLate/GERP/pages/workorder/admin.html(55 行)
|
||||
* 原生实现:拉取 /API/FormAPI/Columns/WorkOrder 后实例化 TableEngine,全权委托 CRUD
|
||||
*
|
||||
* Vue 版:直接挂载 CrudTable,表名 WorkOrder,开启分页/搜索/行选择
|
||||
* server / admin_verify 由 request.ts 拦截器统一注入,无需页面关心
|
||||
* 迁移自 TempLate/GERP/pages/workorder/admin.html(55 行),并补充工单中心专属能力:
|
||||
* - 统计仪表盘:总工单 + 按状态/优先级/分类计数卡片(数据来自 /API/FormAPI/WorkOrderStats)
|
||||
* - 状态筛选:点击状态卡片按 Status 过滤列表(CrudTable query 联动)
|
||||
* - 详情跳转:点"查看"进入工单详情页(状态流转/富文本展示)
|
||||
* - 底层表格:CrudTable(搜索/分页/新增/编辑/删除)
|
||||
*/
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import CrudTable from '@/components/CrudTable.vue'
|
||||
import { get } from '@/api/request'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
|
||||
setPageTitle('工单管理')
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
/** 状态卡片配置(0 待处理 / 1 处理中 / 2 已完成 / 3 已关闭) */
|
||||
const STATUS_CARDS = [
|
||||
{ status: null, label: '全部', key: 'total', type: 'primary' as const },
|
||||
{ status: 0, label: '待处理', key: 'byStatus', code: 0, type: 'warning' as const },
|
||||
{ status: 1, label: '处理中', key: 'byStatus', code: 1, type: 'primary' as const },
|
||||
{ status: 2, label: '已完成', key: 'byStatus', code: 2, type: 'success' as const },
|
||||
{ status: 3, label: '已关闭', key: 'byStatus', code: 3, type: 'info' as const },
|
||||
]
|
||||
|
||||
/** 优先级卡片配置(0 普通 / 1 重要 / 2 紧急) */
|
||||
const PRIORITY_CARDS = [
|
||||
{ status: 0, label: '普通', code: 0, type: 'info' as const },
|
||||
{ status: 1, label: '重要', code: 1, type: 'warning' as const },
|
||||
{ status: 2, label: '紧急', code: 2, type: 'danger' as const },
|
||||
]
|
||||
|
||||
/** 统计接口返回结构 */
|
||||
interface WorkOrderStats {
|
||||
total: number
|
||||
byStatus: Record<number, number>
|
||||
byPriority: Record<number, number>
|
||||
byCategory: Record<string, number>
|
||||
statusOptions?: Array<{ value: number; label: string }>
|
||||
priorityOptions?: Array<{ value: number; label: string }>
|
||||
categoryOptions?: Array<{ value: string; label: string }>
|
||||
}
|
||||
|
||||
/** 统计结果 */
|
||||
const stats = ref<WorkOrderStats>({ total: 0, byStatus: {}, byPriority: {}, byCategory: {} })
|
||||
/** 统计加载态 */
|
||||
const statsLoading = ref(false)
|
||||
|
||||
/** 当前状态筛选(null=全部),驱动 CrudTable query */
|
||||
const activeStatus = ref<number | null>(null)
|
||||
|
||||
/** CrudTable 查询条件:状态筛选透传给后端精确过滤 */
|
||||
const query = computed(() => (activeStatus.value === null ? {} : { Status: activeStatus.value }))
|
||||
|
||||
/** 加载统计 */
|
||||
async function loadStats(): Promise<void> {
|
||||
statsLoading.value = true
|
||||
try {
|
||||
const data = await get<WorkOrderStats>('/API/FormAPI/WorkOrderStats')
|
||||
stats.value = data || { total: 0, byStatus: {}, byPriority: {}, byCategory: {} }
|
||||
} catch {
|
||||
stats.value = { total: 0, byStatus: {}, byPriority: {}, byCategory: {} }
|
||||
} finally {
|
||||
statsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 状态卡片数字 */
|
||||
function statusCardCount(card: (typeof STATUS_CARDS)[number]): number {
|
||||
if (card.status === null) return stats.value.total
|
||||
return stats.value.byStatus[card.code as number] ?? 0
|
||||
}
|
||||
|
||||
/** 优先级卡片数字 */
|
||||
function priorityCardCount(card: (typeof PRIORITY_CARDS)[number]): number {
|
||||
return stats.value.byPriority[card.code] ?? 0
|
||||
}
|
||||
|
||||
/** 分类标签:后端统计可能含历史 'other' 之外的自定义值,原样展示 */
|
||||
const categoryEntries = computed(() => Object.entries(stats.value.byCategory).sort((a, b) => b[1] - a[1]))
|
||||
|
||||
/** 点击状态卡片:切换筛选 */
|
||||
function selectStatus(status: number | null): void {
|
||||
activeStatus.value = status
|
||||
}
|
||||
|
||||
/** 查看 → 工单详情页 */
|
||||
function goDetail(row: Record<string, unknown>): void {
|
||||
router.push(`/workorder/detail/${String(row.OrderUUID ?? '')}`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wo-admin-page">
|
||||
<CrudTable table="WorkOrder" title="工单管理" searchable pageable selectable stretch />
|
||||
<!-- 统计仪表盘 -->
|
||||
<div v-loading="statsLoading" class="wo-stats">
|
||||
<!-- 状态卡片(可点击筛选) -->
|
||||
<div class="wo-stats-row">
|
||||
<div
|
||||
v-for="card in STATUS_CARDS"
|
||||
:key="String(card.status)"
|
||||
class="wo-stat-card"
|
||||
:class="['wo-stat-card--' + card.type, { active: activeStatus === card.status }]"
|
||||
:title="card.status === null ? '显示全部工单' : `筛选状态:${card.label}`"
|
||||
@click="selectStatus(card.status)"
|
||||
>
|
||||
<div class="wo-stat-num">{{ statusCardCount(card) }}</div>
|
||||
<div class="wo-stat-label">{{ card.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 优先级 + 分类 -->
|
||||
<div class="wo-stats-row wo-stats-row--secondary">
|
||||
<el-tag
|
||||
v-for="card in PRIORITY_CARDS"
|
||||
:key="card.code"
|
||||
:type="card.type"
|
||||
effect="plain"
|
||||
>
|
||||
{{ card.label }} {{ priorityCardCount(card) }}
|
||||
</el-tag>
|
||||
<span class="wo-stats-divider" />
|
||||
<el-tag v-for="[cat, count] in categoryEntries" :key="cat" type="info" effect="plain">
|
||||
{{ cat }} {{ count }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 工单列表 -->
|
||||
<CrudTable
|
||||
table="WorkOrder"
|
||||
:query="query"
|
||||
title="工单管理"
|
||||
searchable
|
||||
pageable
|
||||
stretch
|
||||
selectable
|
||||
:on-view="goDetail"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -26,6 +154,76 @@ setPageTitle('工单管理')
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
box-sizing: border-box;
|
||||
|
||||
// 统计区撑满后 CrudTable 用 flex:1 吃掉剩余空间
|
||||
.wo-stats {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.wo-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.wo-stats-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&--secondary {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
// 状态统计卡片:可点击筛选,选中态主色描边
|
||||
.wo-stat-card {
|
||||
min-width: 110px;
|
||||
padding: 14px 18px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
background: var(--el-bg-color);
|
||||
border: 2px solid var(--el-border-color-light);
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--el-color-primary);
|
||||
box-shadow: 0 0 0 1px var(--el-color-primary) inset;
|
||||
}
|
||||
|
||||
.wo-stat-num {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.wo-stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&--primary .wo-stat-num { color: var(--el-color-primary); }
|
||||
&--warning .wo-stat-num { color: var(--el-color-warning); }
|
||||
&--success .wo-stat-num { color: var(--el-color-success); }
|
||||
&--info .wo-stat-num { color: var(--el-text-color-regular); }
|
||||
}
|
||||
|
||||
.wo-stats-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--el-border-color);
|
||||
}
|
||||
</style>
|
||||
@@ -13,11 +13,13 @@
|
||||
* - 不开启行选择(个人工单无需批量删除)
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import CrudTable from '@/components/CrudTable.vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
setPageTitle('我的工单')
|
||||
|
||||
// 以当前用户 UUID 作为过滤条件;未登录时为空对象(CrudTable 会拉全部,但路由守卫已保证登录)
|
||||
@@ -25,11 +27,16 @@ const query = computed(() => {
|
||||
const uuid = userStore.userInfo?.UserUUID
|
||||
return uuid ? { CreatorUUID: uuid } : {}
|
||||
})
|
||||
|
||||
// 查看 → 工单详情页
|
||||
function goDetail(row: Record<string, unknown>): void {
|
||||
router.push(`/workorder/detail/${String(row.OrderUUID ?? '')}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wo-my-page">
|
||||
<CrudTable table="WorkOrder" title="我的工单" :query="query" searchable pageable stretch :selectable="false" />
|
||||
<CrudTable table="WorkOrder" title="我的工单" :query="query" searchable pageable stretch :selectable="false" :on-view="goDetail" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 工单详情(WorkOrderDetail)
|
||||
*
|
||||
* 迁移自 TempLate/GERP/pages/workorder/admin.html 的"查看"语义,补充工单中心专属能力:
|
||||
* - 完整只读展示:标题/状态/优先级/分类/创建人/指派人/时间轴/描述/解决方案(富文本)
|
||||
* - 状态流转按钮:按当前状态给出可执行动作(开始处理/标记完成/关闭/重新打开),
|
||||
* 后端拦截 Status 变化自动维护 UpdateTime/CloseTime 并站内信通知创建人/指派人
|
||||
* - 编辑弹窗:复用后端 Columns 定义渲染(select/richtext/EntityPicker),主键与时间戳字段排除
|
||||
*/
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { formApi } from '@/api/form'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
import { formatUtcTime } from '@/utils/dateTime'
|
||||
import type { ColumnSchema } from '@/types/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const orderUUID = String(route.params.id || '')
|
||||
|
||||
setPageTitle('工单详情')
|
||||
|
||||
/** 工单状态 → 标签配置(0 待处理 / 1 处理中 / 2 已完成 / 3 已关闭) */
|
||||
const STATUS_MAP: Record<number, { label: string; type: 'info' | 'primary' | 'success' | 'danger' | 'warning' }> = {
|
||||
0: { label: '待处理', type: 'warning' },
|
||||
1: { label: '处理中', type: 'primary' },
|
||||
2: { label: '已完成', type: 'success' },
|
||||
3: { label: '已关闭', type: 'info' },
|
||||
}
|
||||
|
||||
/** 工单优先级 → 标签配置(0 普通 / 1 重要 / 2 紧急) */
|
||||
const PRIORITY_MAP: Record<number, { label: string; type: 'info' | 'success' | 'warning' | 'danger' }> = {
|
||||
0: { label: '普通', type: 'info' },
|
||||
1: { label: '重要', type: 'warning' },
|
||||
2: { label: '紧急', type: 'danger' },
|
||||
}
|
||||
|
||||
/** 工单分类 → 中文名 */
|
||||
const CATEGORY_MAP: Record<string, string> = {
|
||||
bug: '缺陷',
|
||||
feature: '功能需求',
|
||||
question: '咨询',
|
||||
suggestion: '建议',
|
||||
other: '其他',
|
||||
}
|
||||
|
||||
/** 单条工单记录(Record<string, any> 兼容 v-model 与未知字段) */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const record = ref<Record<string, any>>({})
|
||||
const loading = ref(false)
|
||||
|
||||
/** 加载详情(按 OrderUUID 精确过滤取一条) */
|
||||
async function loadDetail(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await formApi.list('WorkOrder', {
|
||||
OrderUUID: orderUUID,
|
||||
PageIndex: 1,
|
||||
PageSize: 1,
|
||||
})
|
||||
const list = Array.isArray(res) ? res : ((res as { list?: unknown[] }).list ?? [])
|
||||
record.value = (list[0] as Record<string, unknown>) || {}
|
||||
} catch {
|
||||
record.value = {}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前状态(数字,兼容字符串) */
|
||||
const status = computed(() => Number(record.value.Status ?? 0))
|
||||
|
||||
/** 状态流转动作:根据当前状态给出可执行动作列表 */
|
||||
const flowActions = computed(() => {
|
||||
const actions: Array<{ to: number; label: string; type: 'primary' | 'success' | 'danger' | 'warning' }> = []
|
||||
switch (status.value) {
|
||||
case 0:
|
||||
actions.push({ to: 1, label: '开始处理', type: 'primary' })
|
||||
actions.push({ to: 3, label: '关闭工单', type: 'danger' })
|
||||
break
|
||||
case 1:
|
||||
actions.push({ to: 2, label: '标记完成', type: 'success' })
|
||||
actions.push({ to: 3, label: '关闭工单', type: 'danger' })
|
||||
break
|
||||
case 2:
|
||||
case 3:
|
||||
actions.push({ to: 1, label: '重新打开', type: 'warning' })
|
||||
break
|
||||
}
|
||||
return actions
|
||||
})
|
||||
|
||||
/** 状态流转:提交新状态,后端拦截自动维护时间戳 + 站内信通知 */
|
||||
async function doFlow(to: number): Promise<void> {
|
||||
try {
|
||||
await formApi.update('WorkOrder', orderUUID, { Status: to } as Partial<Record<string, unknown>>)
|
||||
ElMessage.success(`工单已更新为「${STATUS_MAP[to]?.label ?? to}」`)
|
||||
await loadDetail()
|
||||
} catch {
|
||||
// 业务错误已由拦截器统一提示
|
||||
}
|
||||
}
|
||||
|
||||
/** 返回列表 */
|
||||
function goBack(): void {
|
||||
router.back()
|
||||
}
|
||||
|
||||
// === 编辑弹窗 ===
|
||||
const editVisible = ref(false)
|
||||
const editSaving = ref(false)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const editRow = reactive<Record<string, any>>({})
|
||||
/** 后端列定义(编辑表单按列渲染) */
|
||||
const editColumns = ref<ColumnSchema[]>([])
|
||||
|
||||
/** 编辑表单可用列:排除主键/隐藏/自动时间戳(CreateTime/UpdateTime/CloseTime 由服务器维护) */
|
||||
const editableColumns = computed(() => {
|
||||
const pk = editColumns.value.find((c) => c.isPrimary || c.primaryKey)?.field || 'OrderUUID'
|
||||
return editColumns.value.filter(
|
||||
(c) =>
|
||||
c.editable !== false &&
|
||||
!c.hideInForm &&
|
||||
c.type !== 'hidden' &&
|
||||
c.type !== 'divider' &&
|
||||
c.type !== 'html' &&
|
||||
c.field !== pk &&
|
||||
!/CreateTime$/.test(c.field) &&
|
||||
c.field !== 'UpdateTime' &&
|
||||
c.field !== 'CloseTime',
|
||||
)
|
||||
})
|
||||
|
||||
/** 打开编辑:拉取列定义(首次)并回填当前记录 */
|
||||
async function openEdit(): Promise<void> {
|
||||
if (!editColumns.value.length) {
|
||||
editColumns.value = await formApi.getColumns('WorkOrder')
|
||||
}
|
||||
Object.keys(editRow).forEach((k) => delete editRow[k])
|
||||
Object.assign(editRow, JSON.parse(JSON.stringify(record.value)))
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
/** 提交编辑 */
|
||||
async function submitEdit(): Promise<void> {
|
||||
editSaving.value = true
|
||||
try {
|
||||
await formApi.update('WorkOrder', orderUUID, { ...editRow } as Partial<Record<string, unknown>>)
|
||||
ElMessage.success('更新成功')
|
||||
editVisible.value = false
|
||||
await loadDetail()
|
||||
} catch {
|
||||
// 业务错误已由拦截器统一提示
|
||||
} finally {
|
||||
editSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 格式化时间显示(UTC → 本地时区) */
|
||||
function fmt(val: unknown): string {
|
||||
return formatUtcTime(val)
|
||||
}
|
||||
|
||||
/** 富文本内容安全渲染:服务端存 HTML,仅对描述/解决方案使用 v-html */
|
||||
function renderHtml(val: unknown): string {
|
||||
return val == null || val === '' ? '' : String(val)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadDetail()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" class="wo-detail-page">
|
||||
<!-- 页头:返回 + 标题 + 状态/优先级 -->
|
||||
<div class="wo-detail-header">
|
||||
<el-button text circle title="返回" @click="goBack">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
</el-button>
|
||||
<div class="wo-detail-heading">
|
||||
<h1 class="wo-detail-title">{{ record.Title || '(无标题)' }}</h1>
|
||||
<div class="wo-detail-tags">
|
||||
<el-tag :type="STATUS_MAP[status]?.type || 'info'" size="small">
|
||||
{{ STATUS_MAP[status]?.label ?? record.Status }}
|
||||
</el-tag>
|
||||
<el-tag :type="PRIORITY_MAP[Number(record.Priority ?? 0)]?.type || 'info'" size="small" effect="plain">
|
||||
优先级:{{ PRIORITY_MAP[Number(record.Priority ?? 0)]?.label ?? record.Priority }}
|
||||
</el-tag>
|
||||
<el-tag type="info" size="small" effect="plain">
|
||||
分类:{{ CATEGORY_MAP[String(record.Category ?? '')] ?? record.Category ?? '其他' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wo-detail-actions">
|
||||
<el-button type="primary" @click="openEdit">
|
||||
<el-icon><Edit /></el-icon>编辑
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态流转按钮区 -->
|
||||
<div v-if="flowActions.length" class="wo-detail-flow">
|
||||
<span class="wo-detail-flow-label">流转:</span>
|
||||
<el-button
|
||||
v-for="act in flowActions"
|
||||
:key="act.to"
|
||||
:type="act.type"
|
||||
size="small"
|
||||
@click="doFlow(act.to)"
|
||||
>
|
||||
{{ act.label }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 元信息卡片 -->
|
||||
<el-card shadow="never" class="wo-detail-card">
|
||||
<template #header><span>工单信息</span></template>
|
||||
<div class="wo-detail-meta">
|
||||
<div class="wo-detail-meta-item">
|
||||
<span class="wo-detail-meta-label">创建人</span>
|
||||
<span class="wo-detail-meta-value">{{ record.CreatorUUID_label || record.CreatorUUID || '-' }}</span>
|
||||
</div>
|
||||
<div class="wo-detail-meta-item">
|
||||
<span class="wo-detail-meta-label">指派人</span>
|
||||
<span class="wo-detail-meta-value">{{ record.AssigneeUUID_label || record.AssigneeUUID || '-' }}</span>
|
||||
</div>
|
||||
<div class="wo-detail-meta-item">
|
||||
<span class="wo-detail-meta-label">创建时间</span>
|
||||
<span class="wo-detail-meta-value">{{ fmt(record.CreateTime) }}</span>
|
||||
</div>
|
||||
<div class="wo-detail-meta-item">
|
||||
<span class="wo-detail-meta-label">更新时间</span>
|
||||
<span class="wo-detail-meta-value">{{ fmt(record.UpdateTime) }}</span>
|
||||
</div>
|
||||
<div class="wo-detail-meta-item">
|
||||
<span class="wo-detail-meta-label">关闭时间</span>
|
||||
<span class="wo-detail-meta-value">{{ fmt(record.CloseTime) }}</span>
|
||||
</div>
|
||||
<div class="wo-detail-meta-item">
|
||||
<span class="wo-detail-meta-label">工单编号</span>
|
||||
<span class="wo-detail-meta-value">{{ record.OrderUUID || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 描述 -->
|
||||
<el-card shadow="never" class="wo-detail-card">
|
||||
<template #header><span>描述</span></template>
|
||||
<div v-if="renderHtml(record.Description)" class="wo-detail-content" v-html="renderHtml(record.Description)" />
|
||||
<el-empty v-else description="暂无描述" :image-size="50" />
|
||||
</el-card>
|
||||
|
||||
<!-- 解决方案 -->
|
||||
<el-card shadow="never" class="wo-detail-card">
|
||||
<template #header><span>解决方案</span></template>
|
||||
<div v-if="renderHtml(record.Resolution)" class="wo-detail-content" v-html="renderHtml(record.Resolution)" />
|
||||
<el-empty v-else description="暂无解决方案" :image-size="50" />
|
||||
</el-card>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<el-dialog v-model="editVisible" title="编辑工单" width="720px" destroy-on-close>
|
||||
<el-form :model="editRow" label-width="110px" label-position="right">
|
||||
<el-form-item v-for="col in editableColumns" :key="col.field" :label="col.title">
|
||||
<!-- entityPicker:指派人等关联实体选择 -->
|
||||
<EntityPicker
|
||||
v-if="col.type === 'entityPicker' && col.entityPicker"
|
||||
v-model="editRow[col.field]"
|
||||
:config="col.entityPicker"
|
||||
:existing-label="editRow[`${col.field}_label`] || (col.entityPicker.valueField === col.field ? editRow[col.field] : '')"
|
||||
:placeholder="col.placeholder"
|
||||
:disabled="col.disabled"
|
||||
:depend-company-value="col.entityPicker.dependCompany ? editRow.CompanyUUID : undefined"
|
||||
@update:label="(label: string) => (editRow[`${col.field}_label`] = label)"
|
||||
/>
|
||||
<!-- 富文本:描述/解决方案 -->
|
||||
<RichTextEditor
|
||||
v-else-if="col.type === 'richtext'"
|
||||
v-model="editRow[col.field]"
|
||||
:placeholder="col.placeholder"
|
||||
:rows="col.rows"
|
||||
/>
|
||||
<!-- 下拉:状态/优先级/分类 -->
|
||||
<el-select
|
||||
v-else-if="col.type === 'select'"
|
||||
v-model="editRow[col.field]"
|
||||
style="width: 100%"
|
||||
:disabled="col.disabled"
|
||||
>
|
||||
<el-option v-for="opt in col.options" :key="String(opt.value)" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
<!-- 文本/多行文本 -->
|
||||
<el-input
|
||||
v-else-if="!col.type || col.type === 'text' || col.type === 'string'"
|
||||
v-model="editRow[col.field]"
|
||||
:placeholder="col.placeholder"
|
||||
/>
|
||||
<el-input
|
||||
v-else-if="col.type === 'textarea'"
|
||||
v-model="editRow[col.field]"
|
||||
type="textarea"
|
||||
:rows="col.rows || 3"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="editSaving" @click="submitEdit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.wo-detail-page {
|
||||
padding: 20px;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.wo-detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.wo-detail-heading {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wo-detail-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.wo-detail-tags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wo-detail-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wo-detail-flow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.wo-detail-flow-label {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.wo-detail-card {
|
||||
:deep(.el-card__header) {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.wo-detail-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.wo-detail-meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.wo-detail-meta-label {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.wo-detail-meta-value {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.wo-detail-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: var(--el-text-color-primary);
|
||||
word-break: break-word;
|
||||
|
||||
:deep(img) {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+25
-1
@@ -65,6 +65,30 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
chunkSizeWarningLimit: 1500,
|
||||
chunkSizeWarningLimit: 2000,
|
||||
// 合并全部 CSS 为单个文件:消灭按需组件产生的零碎 .css 与 css-*.js(可省约 90 个请求)
|
||||
cssCodeSplit: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// 归并 node_modules 公共依赖,消灭 0.1-10kB 的细碎 chunk(lodash 工具 / element-plus 小组件等)
|
||||
manualChunks(id: string) {
|
||||
if (!id.includes('node_modules')) return undefined
|
||||
if (id.includes('element-plus') || id.includes('@element-plus')) return 'element-plus'
|
||||
if (id.includes('vxe-table')) return 'vxe-table'
|
||||
if (id.includes('@formkit') || id.includes('formkit')) return 'formkit'
|
||||
if (id.includes('lodash') || id.includes('xe-utils')) return 'utils'
|
||||
if (
|
||||
id.includes('/node_modules/vue') ||
|
||||
id.includes('/node_modules/vue-router') ||
|
||||
id.includes('/node_modules/pinia') ||
|
||||
id.includes('/node_modules/@vue') ||
|
||||
id.includes('/node_modules/@vueuse')
|
||||
) {
|
||||
return 'vue-vendor'
|
||||
}
|
||||
return 'vendor'
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user