基本已能进入生产环境

This commit is contained in:
TermiNexus committed 2026-08-05 23:33:06 +08:00
1 parent bb77b8caf7
commit 611aef54bd
36 files changed
+2965 -357

No files matched your search

+240 -2
View File
@@ -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",
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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(/&nbsp;/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
View File
@@ -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>
+74
View File
@@ -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>
+22 -1
View File
@@ -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>
+10 -6
View File
@@ -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 或裸数组
+51 -7
View File
@@ -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 切换瞬时无遮罩) -->
+14
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+37 -1
View File
@@ -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 }>
}
}
/**
@@ -141,7 +175,9 @@ export interface ColumnSchema {
/** 是否可编辑(默认 true) */
editable?: boolean
/** 表单中隐藏 */
hideInForm?: boolean
hideInForm?: boolean
/** 仅在新增表单显示,编辑表单隐藏(虚拟级联列等场景) */
hideInEditForm?: boolean
/** 是否必填 */
required?: boolean
/** 必填但允许提交空值(原生特有,跳过 required 校验) */
+61
View File
@@ -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())}`
}
+56 -1
View File
@@ -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>
+209 -71
View File
@@ -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>
+22 -21
View File
@@ -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>
+21 -28
View File
@@ -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>
+20 -28
View File
@@ -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>
+7
View File
@@ -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>
+19 -18
View File
@@ -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>
+54 -16
View File
@@ -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]"
+20 -14
View File
@@ -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>
+19 -6
View File
@@ -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>
+16 -12
View File
@@ -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"
/>
<!--
+3 -5
View File
@@ -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)
})
+12 -2
View File
@@ -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>
+18 -20
View File
@@ -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>
+22 -7
View File
@@ -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
+15
View File
@@ -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>
+1 -8
View File
@@ -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)
}
// === 数据加载 ===
/** 加载待审核租户列表 */
+18 -17
View File
@@ -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>
+204 -6
View File
@@ -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>
+8 -1
View File
@@ -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>
+407
View File
@@ -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
View File
@@ -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'
},
},
},
},
})