基本上完成了 GERP 的迁移,目前正在解决 table 虚拟滚动故障问题,以及其它琐碎修改
This commit is contained in:
1 parent
2b7e02108c
commit
dbf30a9bc0
62 files changed
+15953
-643
No files matched your search
+28
-24
@@ -1,24 +1,28 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# 自动生成的类型声明(由 unplugin-auto-import / unplugin-vue-components 生成)
|
||||
src/types/auto-imports.d.ts
|
||||
src/types/components.d.ts
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
Generated
+26
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@formkit/vue": "^2.1.2",
|
||||
"@vxe-ui/core": "^4.4.19",
|
||||
"@vxe-ui/plugin-render-element": "^4.4.1",
|
||||
"axios": "^1.19.0",
|
||||
"element-plus": "^2.14.3",
|
||||
@@ -1296,6 +1297,19 @@
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vxe-ui/core": {
|
||||
"version": "4.4.19",
|
||||
"resolved": "https://registry.npmmirror.com/@vxe-ui/core/-/core-4.4.19.tgz",
|
||||
"integrity": "sha512-Ahx7WWS43tkT6JzVbfZsZ0+81xmVDuzIw0ZXhbUAYqGd98DPX3pnMOdYCHe9OzgpQWkDwsmXF0zuPcEIMnUdQQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dom-zindex": "^1.0.7",
|
||||
"xe-utils": "^4.0.12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vxe-ui/plugin-render-element": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmmirror.com/@vxe-ui/plugin-render-element/-/plugin-render-element-4.4.1.tgz",
|
||||
@@ -1500,6 +1514,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-zindex": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmmirror.com/dom-zindex/-/dom-zindex-1.0.7.tgz",
|
||||
"integrity": "sha512-cKU/h8v8IPBgdZOTPbPmq3Ib+Ac5C+kKoh9I4LbGR9BM3GwbmB16KYWKJcj5M2BavnA66EbgYzxYDLd1IytnlQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -3391,6 +3411,12 @@
|
||||
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xe-utils": {
|
||||
"version": "4.0.12",
|
||||
"resolved": "https://registry.npmmirror.com/xe-utils/-/xe-utils-4.0.12.tgz",
|
||||
"integrity": "sha512-4y2OXEVlXHoBMSyzdAcXDM9oyb8EL9U4LIEq642JO0liULVpC9U5aEiVv2xbS9hhoebYCMdZZYASqh/7H5Wd0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.9.0.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@formkit/vue": "^2.1.2",
|
||||
"@vxe-ui/core": "^4.4.19",
|
||||
"@vxe-ui/plugin-render-element": "^4.4.1",
|
||||
"axios": "^1.19.0",
|
||||
"element-plus": "^2.14.3",
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* EntityPicker 实体选择器预设配置
|
||||
*
|
||||
* 对齐原生 CompanyCommon.EntityPickerConfigs:预置 6 类常用实体的搜索配置,
|
||||
* 供 EntityPicker 组件及 epColumn 列定义辅助函数使用。
|
||||
*/
|
||||
import type { EntityPickerConfig } from '@/types/api'
|
||||
|
||||
/**
|
||||
* 6 类实体的 EntityPicker 预设配置(对齐原生 CompanyCommon.EntityPickerConfigs)
|
||||
* key 为实体名,value 为对应的搜索配置
|
||||
*/
|
||||
export const entityPickerPresets: Record<string, EntityPickerConfig> = {
|
||||
// 用户:副文本展示邮箱
|
||||
User: { api: '/API/FormAPI/UserSearch', labelField: 'UserName', valueField: 'UserUUID', secondaryField: 'UserEmail', placeholder: '搜索用户' },
|
||||
// 角色
|
||||
Role: { api: '/API/FormAPI/RoleSearch', labelField: 'RoleName', valueField: 'RoleUUID', placeholder: '搜索角色' },
|
||||
// 公司
|
||||
Company: { api: '/API/FormAPI/CompanySearch', labelField: 'CompanyName', valueField: 'CompanyUUID', placeholder: '搜索公司' },
|
||||
// 部门
|
||||
Department: { api: '/API/FormAPI/DepartmentSearch', labelField: 'DeptName', valueField: 'DeptUUID', placeholder: '搜索部门' },
|
||||
// 工作组
|
||||
Workgroup: { api: '/API/FormAPI/WorkgroupSearch', labelField: 'WorkgroupName', valueField: 'WorkgroupUUID', placeholder: '搜索工作组' },
|
||||
// 权限:副文本展示权限码
|
||||
Permission: { api: '/API/FormAPI/PermissionSearch', labelField: 'PermName', valueField: 'PermUUID', secondaryField: 'PermCode', placeholder: '搜索权限' },
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 entityPicker 类型列定义
|
||||
* 对齐原生 CompanyUtils.epColumn(field, title, entityName, extra)
|
||||
*
|
||||
* @param field 列字段名
|
||||
* @param title 列标题
|
||||
* @param entityName 实体名(需存在于 entityPickerPresets)
|
||||
* @param extra 附加列配置(如 width、required 等)
|
||||
* @returns ColumnSchema 片段(type 固定为 entityPicker)
|
||||
*/
|
||||
export function epColumn(field: string, title: string, entityName: string, extra: Record<string, unknown> = {}) {
|
||||
const config = entityPickerPresets[entityName]
|
||||
if (!config) throw new Error(`EntityPicker 预设不存在: ${entityName}`)
|
||||
return { field, title, type: 'entityPicker', entityPicker: config, ...extra }
|
||||
}
|
||||
+7
-3
@@ -1,4 +1,4 @@
|
||||
import { get, post, put, del } from './request'
|
||||
import { get, post, put, del, patch } from './request'
|
||||
import type { ColumnSchema, PagedData } from '@/types/api'
|
||||
|
||||
/** 表名(对应后端实体) */
|
||||
@@ -57,10 +57,14 @@ export const formApi = {
|
||||
return put<T>(`/API/FormAPI/Modify/${table}/${id}`, data)
|
||||
},
|
||||
|
||||
/** 部分更新记录(合并) */
|
||||
patch: <T = Record<string, unknown>>(table: TableName, id: string | number, data: Partial<T>): Promise<T> => {
|
||||
/** 整行编辑(POST JSON,对应原生 showEditDialog 的 ModifyItem 提交) */
|
||||
modifyItem: <T = Record<string, unknown>>(table: TableName, id: string | number, data: Partial<T>): Promise<T> => {
|
||||
return post<T>(`/API/FormAPI/ModifyItem/${table}/${id}`, data)
|
||||
},
|
||||
/** 单字段内联编辑(PATCH,对应原生 TableEngine 单元格 modify) */
|
||||
patchItem: <T = Record<string, unknown>>(table: TableName, id: string | number, data: Partial<T>): Promise<T> => {
|
||||
return patch<T>(`/API/FormAPI/ModifyItem/${table}/${id}`, data)
|
||||
},
|
||||
|
||||
/** 删除记录 */
|
||||
remove: (table: TableName, id: string | number): Promise<void> => {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './request'
|
||||
export * as authApi from './auth'
|
||||
export * as menuApi from './menu'
|
||||
export { formApi } from './form'
|
||||
export type * from '@/types/api'
|
||||
+12
-2
@@ -6,6 +6,7 @@ import axios, {
|
||||
} from 'axios'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { ApiResult } from '@/types/api'
|
||||
import { getServerScopeParams } from '@/utils/serverScope'
|
||||
|
||||
/** 后端响应统一格式 */
|
||||
type BackendResponse<T> = AxiosResponse<ApiResult<T>>
|
||||
@@ -30,11 +31,20 @@ const request: AxiosInstance = axios.create({
|
||||
},
|
||||
})
|
||||
|
||||
/** 请求拦截器:注入时区头、可在此追加 Token */
|
||||
/**
|
||||
* 请求拦截器:
|
||||
* - 注入时区头(每次刷新,用户可能跨时区)
|
||||
* - 注入 server 作用域参数(server/admin_verify),对齐原生 CompanyCommon.buildApiUrl
|
||||
* 主实例不注入 server;URL 含 admin_verify=1 时注入特权标记
|
||||
*/
|
||||
request.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
// 每次请求实时刷新时区(用户可能跨时区使用)
|
||||
config.headers['X-User-Timezone'] = Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Shanghai'
|
||||
// 合并 server 作用域参数到 query(不覆盖调用方已显式传入的同名参数)
|
||||
const scopeParams = getServerScopeParams()
|
||||
if (Object.keys(scopeParams).length) {
|
||||
config.params = { ...scopeParams, ...(config.params as Record<string, unknown>) }
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error),
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="usLine truncated
|
||||
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 496 B |
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* AdminBadge 管理员特权模式横幅
|
||||
*
|
||||
* 对齐原生 CompanyCommon 的 adminBadge 横幅,在管理员特权模式下显眼提示
|
||||
* 跨租户/公司隔离已关闭,避免误操作。
|
||||
*
|
||||
* 显示条件:
|
||||
* - visible 不为 false(外部可控)
|
||||
* - 且(非默认服务器 或 携带 admin_verify=1 标记)
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { isDefaultServer, hasAdminVerifyArg } from '@/utils/serverScope'
|
||||
|
||||
const props = defineProps<{
|
||||
/** 是否显示,默认 true。设为 false 可强制隐藏 */
|
||||
visible?: boolean
|
||||
}>()
|
||||
|
||||
// 综合判断:外部未隐藏,且满足特权模式触发条件之一
|
||||
const show = computed(
|
||||
() => props.visible !== false && (!isDefaultServer() || hasAdminVerifyArg())
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 警告色横幅,居中展示,不可关闭 -->
|
||||
<el-alert
|
||||
v-if="show"
|
||||
type="warning"
|
||||
center
|
||||
:closable="false"
|
||||
class="admin-badge"
|
||||
>
|
||||
管理员特权模式 - 跨租户/公司隔离已关闭
|
||||
</el-alert>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// 横幅底部留白,与下方内容分隔
|
||||
.admin-badge {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,395 @@
|
||||
<script setup lang="ts" generic="T extends Record<string, unknown>">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useCrudTable, type UseCrudTableOptions } from '@/composables/useCrudTable'
|
||||
import type { ColumnSchema } from '@/types/api'
|
||||
import EntityPicker from './EntityPicker.vue'
|
||||
|
||||
/**
|
||||
* 通用 CRUD 表格组件
|
||||
* 基于 vxe-table + Element Plus,配合后端 FormAPI 协议
|
||||
*
|
||||
* 用法:<CrudTable table="User" />
|
||||
* 带行选择与详情跳转:<CrudTable table="User" selectable :on-view="goDetail" />
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** 表名(对应后端实体) */
|
||||
table: string
|
||||
/** 标题 */
|
||||
title?: string
|
||||
/** 额外查询参数 */
|
||||
query?: Record<string, unknown>
|
||||
/** 是否显示搜索栏 */
|
||||
searchable?: boolean
|
||||
/** 是否显示分页 */
|
||||
pageable?: boolean
|
||||
/** 是否显示复选列(行选择 + 批量删除) */
|
||||
selectable?: boolean
|
||||
/** 主键字段名(不传则从 columns 推导) */
|
||||
pkField?: string
|
||||
/** 查看按钮回调(详情页跳转) */
|
||||
onView?: (row: T) => void
|
||||
/** 行点击回调 */
|
||||
onRowClick?: (row: T) => void
|
||||
}>()
|
||||
|
||||
const options: UseCrudTableOptions<T> = {
|
||||
table: props.table,
|
||||
query: props.query,
|
||||
pkField: props.pkField,
|
||||
selectable: props.selectable,
|
||||
onView: props.onView,
|
||||
onRowClick: props.onRowClick,
|
||||
}
|
||||
|
||||
const {
|
||||
loading,
|
||||
submitting,
|
||||
columns,
|
||||
dataList,
|
||||
total,
|
||||
currentPage,
|
||||
pageSize,
|
||||
searchKeyword,
|
||||
selectedRows,
|
||||
dialogVisible,
|
||||
dialogTitle,
|
||||
editingRow,
|
||||
fetchData,
|
||||
handleCreate,
|
||||
handleEdit,
|
||||
handleView,
|
||||
handleRowClick,
|
||||
handleSubmit,
|
||||
handleDelete,
|
||||
handleBatchDelete,
|
||||
handleSelectionChange,
|
||||
handleSearch,
|
||||
handleReset,
|
||||
handlePageChange,
|
||||
handleSizeChange,
|
||||
} = useCrudTable<T>(options)
|
||||
|
||||
// vxe-table 实例引用(用于获取选中行)
|
||||
const xTableRef = ref()
|
||||
|
||||
// 可见表格列(排除 hidden)
|
||||
const tableColumns = computed(() =>
|
||||
columns.value.filter((c: ColumnSchema) => c.visible !== false && c.type !== 'hidden'),
|
||||
)
|
||||
|
||||
// 弹窗表单可见列(排除隐藏/主键隐藏/不可编辑/divider/html)
|
||||
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,
|
||||
)
|
||||
})
|
||||
|
||||
// vxe-table checkbox 变化:同步到 selectedRows
|
||||
function onCheckboxChange(): void {
|
||||
const records = xTableRef.value?.getCheckboxRecords?.() || []
|
||||
handleSelectionChange(records as T[])
|
||||
}
|
||||
|
||||
// 实体选择器回显标签:优先 row[field+'_label'],否则前 8 位 + ...
|
||||
function entityLabel(row: Record<string, unknown>, field: string): string {
|
||||
const label = row[`${field}_label`] ?? row[`${field}__label`]
|
||||
if (label) return String(label)
|
||||
const val = row[field]
|
||||
return val ? String(val).slice(0, 8) + '...' : ''
|
||||
}
|
||||
|
||||
// tags 渲染:字符串按分隔符拆分
|
||||
function renderTags(value: unknown, separator = ','): string[] {
|
||||
if (!value) return []
|
||||
return String(value).split(separator).map((s) => s.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
// uuid 渲染:前 8 位 + ...
|
||||
function renderUuid(value: unknown): string {
|
||||
return value ? String(value).slice(0, 8) + '...' : ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="crud-table">
|
||||
<!-- 搜索栏 -->
|
||||
<el-card v-if="searchable" shadow="never" class="search-bar">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="关键字搜索"
|
||||
clearable
|
||||
style="width: 240px"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>搜索
|
||||
</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-card>
|
||||
|
||||
<!-- 表格 + 操作栏 -->
|
||||
<el-card shadow="never" class="table-card">
|
||||
<template #header>
|
||||
<div class="table-header">
|
||||
<span class="table-title">{{ title || table }}</span>
|
||||
<div class="table-actions">
|
||||
<el-button type="primary" @click="handleCreate">
|
||||
<el-icon><Plus /></el-icon>新增
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="selectable"
|
||||
type="danger"
|
||||
:disabled="selectedRows.length === 0"
|
||||
@click="handleBatchDelete"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>批量删除{{ selectedRows.length ? `(${selectedRows.length})` : '' }}
|
||||
</el-button>
|
||||
<el-button @click="fetchData">
|
||||
<el-icon><Refresh /></el-icon>刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<vxe-table
|
||||
ref="xTableRef"
|
||||
:data="dataList"
|
||||
:loading="loading"
|
||||
border
|
||||
stripe
|
||||
round
|
||||
height="auto"
|
||||
:row-config="{ isHover: true }"
|
||||
:column-config="{ resizable: true }"
|
||||
:checkbox-config="{ range: true }"
|
||||
@row-click="(params: { row: T }) => handleRowClick(params.row)"
|
||||
@checkbox-change="onCheckboxChange"
|
||||
@checkbox-all="onCheckboxChange"
|
||||
>
|
||||
<!-- 复选列 -->
|
||||
<vxe-column v-if="selectable" type="checkbox" width="50" />
|
||||
<vxe-column type="seq" width="60" title="#" />
|
||||
|
||||
<!-- 数据列:根据 type/statusMap/entityPicker 分支渲染 -->
|
||||
<vxe-column
|
||||
v-for="col in tableColumns"
|
||||
:key="col.field"
|
||||
:field="col.field"
|
||||
:title="col.title"
|
||||
:width="col.width"
|
||||
:min-width="col.minWidth"
|
||||
:fixed="col.fixed"
|
||||
:sortable="col.sortable"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<!-- entityPicker:显示回显标签 -->
|
||||
<span v-if="col.type === 'entityPicker'">
|
||||
{{ entityLabel(row as Record<string, unknown>, col.field) }}
|
||||
</span>
|
||||
<!-- status:el-tag 渲染 -->
|
||||
<el-tag
|
||||
v-else-if="col.type === 'status' && col.statusMap"
|
||||
:type="col.statusMap[String((row as Record<string, unknown>)[col.field])]?.type || 'info'"
|
||||
:color="col.statusMap[String((row as Record<string, unknown>)[col.field])]?.color"
|
||||
size="small"
|
||||
>
|
||||
{{ col.statusMap[String((row as Record<string, unknown>)[col.field])]?.text || (row as Record<string, unknown>)[col.field] }}
|
||||
</el-tag>
|
||||
<!-- tags:标签组渲染 -->
|
||||
<template v-else-if="col.type === 'tags'">
|
||||
<el-tag
|
||||
v-for="tag in renderTags((row as Record<string, unknown>)[col.field], col.tagsSeparator)"
|
||||
:key="tag"
|
||||
size="small"
|
||||
style="margin-right: 4px"
|
||||
>
|
||||
{{ tag }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<!-- uuid:前 8 位 + ... -->
|
||||
<span v-else-if="col.type === 'uuid'">{{ renderUuid((row as Record<string, unknown>)[col.field]) }}</span>
|
||||
<!-- 默认:原值 -->
|
||||
<span v-else>{{ (row as Record<string, unknown>)[col.field] }}</span>
|
||||
</template>
|
||||
</vxe-column>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<vxe-column title="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="onView" link type="primary" @click="handleView(row as T)">查看</el-button>
|
||||
<el-button link type="primary" @click="handleEdit(row as T)">编辑</el-button>
|
||||
<el-button link type="danger" @click="handleDelete(row as T)">删除</el-button>
|
||||
</template>
|
||||
</vxe-column>
|
||||
</vxe-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="pageable" class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
background
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px" destroy-on-close>
|
||||
<el-form :model="editingRow" label-width="120px" label-position="right">
|
||||
<el-form-item
|
||||
v-for="col in formColumns"
|
||||
:key="col.field"
|
||||
:label="col.title"
|
||||
:required="col.required"
|
||||
>
|
||||
<!-- entityPicker:EntityPicker 组件 -->
|
||||
<EntityPicker
|
||||
v-if="col.type === 'entityPicker' && col.entityPicker"
|
||||
v-model="editingRow[col.field]"
|
||||
:config="col.entityPicker"
|
||||
:existing-label="editingRow[`${col.field}_label`] as string"
|
||||
:placeholder="col.placeholder"
|
||||
:disabled="col.disabled"
|
||||
@update:label="(label: string) => (editingRow[`${col.field}_label`] = label)"
|
||||
/>
|
||||
<el-input
|
||||
v-else-if="!col.type || col.type === 'text' || col.type === 'string'"
|
||||
v-model="editingRow[col.field]"
|
||||
:placeholder="col.placeholder"
|
||||
:maxlength="col.maxLength"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<el-input-number
|
||||
v-else-if="col.type === 'number'"
|
||||
v-model="editingRow[col.field]"
|
||||
:min="col.min"
|
||||
:max="col.max"
|
||||
:step="col.step"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<el-input
|
||||
v-else-if="col.type === 'textarea'"
|
||||
v-model="editingRow[col.field]"
|
||||
type="textarea"
|
||||
:rows="col.rows || 3"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<el-input
|
||||
v-else-if="col.type === 'password'"
|
||||
v-model="editingRow[col.field]"
|
||||
type="password"
|
||||
show-password
|
||||
:placeholder="col.placeholder"
|
||||
/>
|
||||
<el-select
|
||||
v-else-if="col.type === 'select'"
|
||||
v-model="editingRow[col.field]"
|
||||
:placeholder="col.placeholder"
|
||||
:disabled="col.disabled"
|
||||
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]"
|
||||
:disabled="col.disabled"
|
||||
>
|
||||
<el-radio v-for="opt in col.options" :key="String(opt.value)" :value="opt.value">{{ opt.label }}</el-radio>
|
||||
</el-radio-group>
|
||||
<el-checkbox-group
|
||||
v-else-if="col.type === 'checkbox'"
|
||||
v-model="editingRow[col.field]"
|
||||
:disabled="col.disabled"
|
||||
>
|
||||
<el-checkbox v-for="opt in col.options" :key="String(opt.value)" :value="opt.value">{{ opt.label }}</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
<el-date-picker
|
||||
v-else-if="col.type === 'date' || col.type === 'datetime'"
|
||||
v-model="editingRow[col.field]"
|
||||
:type="col.type as 'date' | 'datetime'"
|
||||
:placeholder="col.placeholder"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<el-time-picker
|
||||
v-else-if="col.type === 'time'"
|
||||
v-model="editingRow[col.field]"
|
||||
:placeholder="col.placeholder"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<el-switch
|
||||
v-else-if="col.type === 'switch' || col.type === 'boolean'"
|
||||
v-model="editingRow[col.field]"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<el-input
|
||||
v-else-if="col.type === 'readonly'"
|
||||
:model-value="String(editingRow[col.field] ?? '')"
|
||||
readonly
|
||||
/>
|
||||
<!-- hidden/divider/html 不渲染表单项 -->
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.crud-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
:deep(.el-card__body) {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.table-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* DetailPage 详情页布局容器
|
||||
*
|
||||
* 对齐原生 Company 模块详情页结构:顶部管理员横幅 + 页头(返回/标题/操作)+ 主内容区(信息卡 + 子表)。
|
||||
* - v-loading 覆盖整个详情页,加载态由父组件控制
|
||||
* - 返回按钮先 emit('back') 再 router.back(),父组件可在 back 事件中做清理
|
||||
* - actions 具名插槽放右上角操作按钮,default 默认插槽放信息卡与子表
|
||||
*/
|
||||
import { useRouter } from 'vue-router'
|
||||
import AdminBadge from './AdminBadge.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 页面标题 */
|
||||
title?: string
|
||||
/** 加载态,默认 false */
|
||||
loading?: boolean
|
||||
/** 是否显示返回按钮,默认 true */
|
||||
showBack?: boolean
|
||||
}>(),
|
||||
{
|
||||
loading: false,
|
||||
showBack: true,
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 返回按钮点击事件 */
|
||||
back: []
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
/** 处理返回:先通知父组件(可做清理/埋点),再调用路由回退 */
|
||||
function handleBack(): void {
|
||||
emit('back')
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="detail-page" v-loading="loading">
|
||||
<!-- 顶部管理员特权模式横幅(仅在特权模式下显示) -->
|
||||
<AdminBadge />
|
||||
|
||||
<!-- 页头:返回按钮 + 标题 + 操作按钮区 -->
|
||||
<div class="detail-header">
|
||||
<el-button v-if="props.showBack" link @click="handleBack">
|
||||
<el-icon><ArrowLeft /></el-icon>返回
|
||||
</el-button>
|
||||
<h2 v-if="props.title" class="detail-title">{{ props.title }}</h2>
|
||||
<div class="detail-actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区:信息卡 + 子表 -->
|
||||
<div class="detail-content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// 详情页整体内边距
|
||||
.detail-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
// 页头:横向排列,居中对齐
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
// 标题:占据中间剩余空间
|
||||
.detail-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
// 操作按钮区:推到最右侧
|
||||
.detail-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
// 主内容区:纵向排列,块间留白
|
||||
.detail-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,220 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* EntityPicker 实体选择器
|
||||
*
|
||||
* 基于 el-select 的远程搜索选择器,替代原生 entityPicker 控件。
|
||||
* 配合 entityPickerPresets 预设或自定义 EntityPickerConfig 使用。
|
||||
*
|
||||
* 用法:
|
||||
* <EntityPicker v-model="row.UserUUID" :config="entityPickerPresets.User" :existing-label="row.UserUUID_label" />
|
||||
*/
|
||||
import { ref, watch, computed, onBeforeUnmount } from 'vue'
|
||||
import type { EntityPickerConfig } from '@/types/api'
|
||||
import { get, post } from '@/api/request'
|
||||
|
||||
/** 下拉选项 */
|
||||
interface EntityOption {
|
||||
/** 选项值(UUID) */
|
||||
value: string
|
||||
/** 主文本(labelField) */
|
||||
label: string
|
||||
/** 副文本(secondaryField,可选) */
|
||||
secondary?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** 选中值 UUID(v-model) */
|
||||
modelValue?: string
|
||||
/** 搜索配置 */
|
||||
config: EntityPickerConfig
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean
|
||||
/** 占位符(覆盖 config.placeholder) */
|
||||
placeholder?: string
|
||||
/** 已有值回显标签(对应原生 row[field+'_label']) */
|
||||
existingLabel?: string
|
||||
/** 是否可清空 */
|
||||
clearable?: boolean
|
||||
}>(), {
|
||||
modelValue: '',
|
||||
disabled: false,
|
||||
placeholder: '',
|
||||
existingLabel: '',
|
||||
clearable: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', uuid: string): void
|
||||
(e: 'update:label', label: string): void
|
||||
}>()
|
||||
|
||||
/** 下拉选项列表 */
|
||||
const options = ref<EntityOption[]>([])
|
||||
/** 搜索加载态 */
|
||||
const loading = ref(false)
|
||||
/** 是否已执行过搜索(区分初始回显与空结果) */
|
||||
const searched = ref(false)
|
||||
/** 回显标签 */
|
||||
const displayLabel = ref('')
|
||||
|
||||
/** 占位符:props.placeholder > config.placeholder > 默认 */
|
||||
const placeholderText = computed(() => props.placeholder || props.config.placeholder || '请选择')
|
||||
|
||||
/** 防抖定时器句柄 */
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/**
|
||||
* 防抖触发远程搜索(300ms)
|
||||
* 项目未引入 lodash-es,使用 setTimeout 自定义防抖
|
||||
*/
|
||||
function debounceSearch(keyword: string): void {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
void doSearch(keyword)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
/** 执行远程搜索 */
|
||||
async function doSearch(keyword: string): Promise<void> {
|
||||
const cfg = props.config
|
||||
// 搜索参数名默认 keyword
|
||||
const searchParam = cfg.searchParam || 'keyword'
|
||||
// HTTP 方法默认 GET
|
||||
const method = cfg.method || 'GET'
|
||||
loading.value = true
|
||||
try {
|
||||
let res: unknown
|
||||
if (method === 'POST') {
|
||||
res = await post<unknown>(cfg.api, { [searchParam]: keyword })
|
||||
} else {
|
||||
res = await get<unknown>(cfg.api, { [searchParam]: keyword })
|
||||
}
|
||||
options.value = parseOptions(res, cfg)
|
||||
} catch {
|
||||
options.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析响应为选项数组
|
||||
* 响应拦截器已解包 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,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** el-select remote-method 回调 */
|
||||
function remoteSearch(keyword: string): void {
|
||||
// 关键字为空:重置为回显状态,不发起搜索
|
||||
if (!keyword) {
|
||||
options.value = []
|
||||
searched.value = false
|
||||
return
|
||||
}
|
||||
searched.value = true
|
||||
debounceSearch(keyword)
|
||||
}
|
||||
|
||||
/** 选中变化处理 */
|
||||
function handleChange(value: string): void {
|
||||
emit('update:modelValue', value)
|
||||
// 清空时同步清空 label 并重置搜索态
|
||||
if (!value) {
|
||||
emit('update:label', '')
|
||||
options.value = []
|
||||
searched.value = false
|
||||
return
|
||||
}
|
||||
const opt = options.value.find((o) => o.value === value)
|
||||
emit('update:label', opt ? opt.label : '')
|
||||
}
|
||||
|
||||
/** 同步回显标签 */
|
||||
function syncDisplayLabel(): void {
|
||||
if (props.existingLabel) {
|
||||
displayLabel.value = props.existingLabel
|
||||
} else if (props.modelValue) {
|
||||
// 对齐原生 TableEngine._formatCellValue 第 720-723 行:UUID 前 8 位 + '...'
|
||||
displayLabel.value = props.modelValue.slice(0, 8) + '...'
|
||||
} else {
|
||||
displayLabel.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// existingLabel / modelValue 变化时刷新回显标签
|
||||
watch(() => props.existingLabel, syncDisplayLabel, { immediate: true })
|
||||
watch(() => props.modelValue, syncDisplayLabel)
|
||||
|
||||
// 组件卸载前清理防抖定时器
|
||||
onBeforeUnmount(() => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-select
|
||||
:model-value="modelValue"
|
||||
:placeholder="placeholderText"
|
||||
:disabled="disabled"
|
||||
:clearable="clearable"
|
||||
:loading="loading"
|
||||
filterable
|
||||
remote
|
||||
:remote-method="remoteSearch"
|
||||
@change="handleChange"
|
||||
>
|
||||
<!-- 回显选项:有值且未搜索时,显示回显标签 -->
|
||||
<el-option
|
||||
v-if="modelValue && !searched"
|
||||
:value="modelValue"
|
||||
:label="displayLabel"
|
||||
/>
|
||||
<!-- 空结果提示:已搜索但无匹配 -->
|
||||
<el-option
|
||||
v-else-if="searched && !loading && options.length === 0"
|
||||
disabled
|
||||
label="无匹配"
|
||||
value="__no_match__"
|
||||
/>
|
||||
<!-- 正常选项:主文本 + 副文本 -->
|
||||
<el-option
|
||||
v-for="opt in options"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
:label="opt.label"
|
||||
>
|
||||
<span>{{ opt.label }}</span>
|
||||
<span v-if="opt.secondary" class="entity-picker-secondary">{{ opt.secondary }}</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 副文本:浅色小字,右对齐 */
|
||||
.entity-picker-secondary {
|
||||
float: right;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
max-width: 50%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -1,95 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import viteLogo from '../assets/vite.svg'
|
||||
import heroImg from '../assets/hero.png'
|
||||
import vueLogo from '../assets/vue.svg'
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="center">
|
||||
<div class="hero">
|
||||
<img :src="heroImg" class="base" width="170" height="179" alt="" />
|
||||
<img :src="vueLogo" class="framework" alt="Vue logo" />
|
||||
<img :src="viteLogo" class="vite" alt="Vite logo" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
|
||||
</div>
|
||||
<button type="button" class="counter" @click="count++">
|
||||
Count is {{ count }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#documentation-icon"></use>
|
||||
</svg>
|
||||
<h2>Documentation</h2>
|
||||
<p>Your questions, answered</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vite.dev/" target="_blank">
|
||||
<img class="logo" :src="viteLogo" alt="" />
|
||||
Explore Vite
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://vuejs.org/" target="_blank">
|
||||
<img class="button-icon" :src="vueLogo" alt="" />
|
||||
Learn more
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="social">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#social-icon"></use>
|
||||
</svg>
|
||||
<h2>Connect with us</h2>
|
||||
<p>Join the Vite community</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#github-icon"></use>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vite.dev/" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#discord-icon"></use>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/vite_js" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#x-icon"></use>
|
||||
</svg>
|
||||
X.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#bluesky-icon"></use>
|
||||
</svg>
|
||||
Bluesky
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
<section id="spacer"></section>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* InfoGrid 信息网格组件
|
||||
*
|
||||
* 用于详情页以键值对形式展示实体信息,对齐原生 Company 模块详情页的信息卡布局。
|
||||
* 基于 Element Plus 的 el-descriptions 实现,通过默认插槽接收 el-descriptions-item。
|
||||
*
|
||||
* 用法:
|
||||
* <InfoGrid title="基本信息" :columns="2">
|
||||
* <el-descriptions-item label="名称">{{ data.Name }}</el-descriptions-item>
|
||||
* </InfoGrid>
|
||||
*/
|
||||
defineProps<{
|
||||
/** 每行列数,默认 2 */
|
||||
columns?: number
|
||||
/** 卡片标题(可选) */
|
||||
title?: string
|
||||
/** 是否显示边框,默认 true */
|
||||
border?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 信息网格:columns 控制列数,title 控制标题,border 控制边框 -->
|
||||
<el-descriptions
|
||||
:column="columns || 2"
|
||||
:title="title"
|
||||
:border="border ?? true"
|
||||
class="info-grid"
|
||||
>
|
||||
<slot />
|
||||
</el-descriptions>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// 信息网格底部留白,与后续内容分隔
|
||||
.info-grid {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,307 @@
|
||||
import { ref, reactive, onMounted, type Ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { formApi, type TableName, type QueryParams } from '@/api/form'
|
||||
import type { ColumnSchema, PagedData } from '@/types/api'
|
||||
|
||||
/** CRUD 表格 composable 选项 */
|
||||
export interface UseCrudTableOptions<T> {
|
||||
/** 表名(对应后端实体) */
|
||||
table: TableName
|
||||
/** 查询参数 */
|
||||
query?: QueryParams
|
||||
/** 主键字段名(不传则从 columns 推导 isPrimary/primaryKey,默认 Id) */
|
||||
pkField?: string
|
||||
/** 是否显示复选列(行选择) */
|
||||
selectable?: boolean
|
||||
/** 新增前钩子:返回 false 取消 */
|
||||
beforeCreate?: (row: Partial<T>) => boolean | Promise<boolean>
|
||||
/** 编辑前钩子:返回 false 取消 */
|
||||
beforeEdit?: (row: T) => boolean | Promise<boolean>
|
||||
/** 删除前确认 */
|
||||
beforeDelete?: (row: T) => boolean | Promise<boolean>
|
||||
/** 批量删除前确认 */
|
||||
beforeBatchDelete?: (rows: T[]) => boolean | Promise<boolean>
|
||||
/** 行点击回调 */
|
||||
onRowClick?: (row: T) => void
|
||||
/** 查看按钮回调(详情页跳转) */
|
||||
onView?: (row: T) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* CRUD 表格通用逻辑
|
||||
*
|
||||
* 对齐原生 TableEngine 的公共能力:
|
||||
* - 列表/分页/排序/搜索(LoadData/GoToPage/HandleSort)
|
||||
* - 新增/编辑弹窗(ShowAddDialog/ShowEditDialog/HandleFormSubmit)
|
||||
* - 删除/批量删除(HandleDelete/HandleBatchDelete)
|
||||
* - 行选择(GetSelectedRows/ClearSelection/ToggleSelectAll)
|
||||
* - 外部数据注入(SetData/SetLoading)
|
||||
* - 行点击/查看(onRowClick/onView)
|
||||
*/
|
||||
export function useCrudTable<T extends Record<string, unknown> = Record<string, unknown>>(
|
||||
options: UseCrudTableOptions<T>,
|
||||
) {
|
||||
const { table, query = {} } = options
|
||||
|
||||
// === 状态 ===
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const columns = ref<ColumnSchema[]>([]) as Ref<ColumnSchema[]>
|
||||
const dataList = ref<T[]>([]) as Ref<T[]>
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const sortField = ref('')
|
||||
const sortOrder = ref<'asc' | 'desc'>('asc')
|
||||
const searchKeyword = ref('')
|
||||
|
||||
// === 行选择 ===
|
||||
const selectedRows = ref<T[]>([]) as Ref<T[]>
|
||||
|
||||
// === 弹窗 ===
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('新增')
|
||||
// 使用 Record<string, any> 兼容 v-model 在模板上的类型推断
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const editingRow = reactive<Record<string, any>>({})
|
||||
const isEdit = ref(false)
|
||||
|
||||
// === 主键字段推导 ===
|
||||
function getPkField(): string {
|
||||
if (options.pkField) return options.pkField
|
||||
const pkCol = columns.value.find((c) => c.isPrimary || c.primaryKey)
|
||||
return pkCol?.field || 'Id'
|
||||
}
|
||||
|
||||
// === 加载列定义 ===
|
||||
async function fetchColumns(): Promise<void> {
|
||||
try {
|
||||
columns.value = await formApi.getColumns(table)
|
||||
} catch {
|
||||
// 错误已在拦截器统一处理
|
||||
}
|
||||
}
|
||||
|
||||
// === 加载数据 ===
|
||||
async function fetchData(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: QueryParams = {
|
||||
page: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
sortField: sortField.value || undefined,
|
||||
sortOrder: sortOrder.value,
|
||||
keyword: searchKeyword.value || undefined,
|
||||
...query,
|
||||
}
|
||||
const result = await formApi.list<T>(table, params)
|
||||
// 兼容两种返回结构:PagedData 或裸数组
|
||||
if (Array.isArray(result)) {
|
||||
dataList.value = result
|
||||
total.value = result.length
|
||||
} else {
|
||||
const paged = result as unknown as PagedData<T>
|
||||
dataList.value = paged.list || []
|
||||
total.value = paged.total ?? 0
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// === 新增 ===
|
||||
function handleCreate(): void {
|
||||
isEdit.value = false
|
||||
dialogTitle.value = '新增'
|
||||
// 用列定义初始化默认值
|
||||
const row: Record<string, unknown> = {}
|
||||
for (const col of columns.value) {
|
||||
if (col.defaultValue !== undefined) row[col.field] = col.defaultValue
|
||||
}
|
||||
Object.keys(editingRow).forEach((k) => delete editingRow[k])
|
||||
Object.assign(editingRow, row)
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// === 编辑 ===
|
||||
async function handleEdit(row: T): Promise<void> {
|
||||
if (options.beforeEdit && !(await options.beforeEdit(row))) return
|
||||
isEdit.value = true
|
||||
dialogTitle.value = '编辑'
|
||||
Object.keys(editingRow).forEach((k) => delete editingRow[k])
|
||||
Object.assign(editingRow, row)
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// === 查看(跳详情) ===
|
||||
function handleView(row: T): void {
|
||||
if (options.onView) {
|
||||
options.onView(row)
|
||||
}
|
||||
}
|
||||
|
||||
// === 行点击 ===
|
||||
function handleRowClick(row: T): void {
|
||||
if (options.onRowClick) options.onRowClick(row)
|
||||
}
|
||||
|
||||
// === 提交(新增/编辑统一入口) ===
|
||||
async function handleSubmit(): Promise<void> {
|
||||
if (options.beforeCreate && !isEdit.value) {
|
||||
if (!(await options.beforeCreate(editingRow as Partial<T>))) return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
const pk = getPkField()
|
||||
const id = editingRow[pk]
|
||||
await formApi.update<T>(table, id as string | number, editingRow as Partial<T>)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await formApi.create<T>(table, editingRow as Partial<T>)
|
||||
ElMessage.success('新增成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await fetchData()
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// === 删除 ===
|
||||
async function handleDelete(row: T): Promise<void> {
|
||||
if (options.beforeDelete && !(await options.beforeDelete(row))) return
|
||||
try {
|
||||
await ElMessageBox.confirm('确认删除该记录?删除后不可恢复', '提示', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确认删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return // 用户取消
|
||||
}
|
||||
const pk = getPkField()
|
||||
const id = (row as Record<string, unknown>)[pk]
|
||||
await formApi.remove(table, id as string | number)
|
||||
ElMessage.success('删除成功')
|
||||
await fetchData()
|
||||
}
|
||||
|
||||
// === 批量删除 ===
|
||||
async function handleBatchDelete(): Promise<void> {
|
||||
if (selectedRows.value.length === 0) {
|
||||
ElMessage.warning('请先选择要删除的记录')
|
||||
return
|
||||
}
|
||||
if (options.beforeBatchDelete && !(await options.beforeBatchDelete(selectedRows.value))) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认删除选中的 ${selectedRows.value.length} 条记录?删除后不可恢复`,
|
||||
'提示',
|
||||
{ type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const pk = getPkField()
|
||||
// 串行删除(后端无批量接口时);失败中断
|
||||
for (const row of selectedRows.value) {
|
||||
const id = (row as Record<string, unknown>)[pk]
|
||||
await formApi.remove(table, id as string | number)
|
||||
}
|
||||
ElMessage.success('批量删除成功')
|
||||
selectedRows.value = []
|
||||
await fetchData()
|
||||
}
|
||||
|
||||
// === 行选择 ===
|
||||
function handleSelectionChange(rows: T[]): void {
|
||||
selectedRows.value = rows
|
||||
}
|
||||
|
||||
/** 获取选中行(对齐原生 GetSelectedRows) */
|
||||
function getSelectedRows(): T[] {
|
||||
return selectedRows.value
|
||||
}
|
||||
|
||||
/** 清空选择(对齐原生 ClearSelection) */
|
||||
function clearSelection(): void {
|
||||
selectedRows.value = []
|
||||
}
|
||||
|
||||
// === 搜索 ===
|
||||
function handleSearch(): void {
|
||||
currentPage.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// === 重置搜索 ===
|
||||
function handleReset(): void {
|
||||
searchKeyword.value = ''
|
||||
currentPage.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// === 分页变化 ===
|
||||
function handlePageChange(page: number): void {
|
||||
currentPage.value = page
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function handleSizeChange(size: number): void {
|
||||
pageSize.value = size
|
||||
currentPage.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// === 外部数据注入(对齐原生 SetData) ===
|
||||
function setData(data: T[], totalCount?: number): void {
|
||||
dataList.value = data
|
||||
total.value = totalCount ?? data.length
|
||||
}
|
||||
|
||||
// === 初始化 ===
|
||||
onMounted(async () => {
|
||||
await fetchColumns()
|
||||
await fetchData()
|
||||
})
|
||||
|
||||
return {
|
||||
// 状态
|
||||
loading,
|
||||
submitting,
|
||||
columns,
|
||||
dataList,
|
||||
total,
|
||||
currentPage,
|
||||
pageSize,
|
||||
sortField,
|
||||
sortOrder,
|
||||
searchKeyword,
|
||||
selectedRows,
|
||||
dialogVisible,
|
||||
dialogTitle,
|
||||
editingRow,
|
||||
isEdit,
|
||||
// 主键
|
||||
getPkField,
|
||||
// 动作
|
||||
fetchColumns,
|
||||
fetchData,
|
||||
handleCreate,
|
||||
handleEdit,
|
||||
handleView,
|
||||
handleRowClick,
|
||||
handleSubmit,
|
||||
handleDelete,
|
||||
handleBatchDelete,
|
||||
handleSelectionChange,
|
||||
getSelectedRows,
|
||||
clearSelection,
|
||||
handleSearch,
|
||||
handleReset,
|
||||
handlePageChange,
|
||||
handleSizeChange,
|
||||
setData,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { ref } from 'vue'
|
||||
import { formApi, type TableName } from '@/api/form'
|
||||
|
||||
/**
|
||||
* UUID → 名称 批量解析(带缓存)
|
||||
*
|
||||
* 对齐原生 CompanyUtils.resolveUUIDs:按 pkField 过滤查询单条,回填名称。
|
||||
* 缓存键为表名,避免重复请求。
|
||||
*/
|
||||
|
||||
/** 实体名 → 显示名字段映射(对齐原生 nameFieldMap) */
|
||||
const nameFieldMap: Record<string, string> = {
|
||||
Company: 'CompanyName',
|
||||
Department: 'DeptName',
|
||||
Workgroup: 'WorkgroupName',
|
||||
UserRole: 'RoleName',
|
||||
Role: 'RoleName',
|
||||
User: 'UserName',
|
||||
}
|
||||
|
||||
/** 实体名 → 主键字段映射(对齐原生 pkFieldMap) */
|
||||
const pkFieldMap: Record<string, string> = {
|
||||
Company: 'CompanyUUID',
|
||||
Department: 'DeptUUID',
|
||||
Workgroup: 'WorkgroupUUID',
|
||||
UserRole: 'UserRoleUUID',
|
||||
Role: 'RoleUUID',
|
||||
User: 'UserUUID',
|
||||
}
|
||||
|
||||
/** UUID 缓存:table -> uuid -> name */
|
||||
const uuidCache = new Map<string, Map<string, string>>()
|
||||
|
||||
/** UUID → 名称 解析输入 */
|
||||
export interface ResolveUUIDInput {
|
||||
/** 表名 */
|
||||
table: TableName
|
||||
/** 待解析的 UUID 列表 */
|
||||
uuids: string[]
|
||||
}
|
||||
|
||||
/** 截断显示(对齐原生:UUID 前 8 位 + ...) */
|
||||
function shortUuid(uuid: string): string {
|
||||
return (uuid || '').slice(0, 8) + '...'
|
||||
}
|
||||
|
||||
export function useResolveUUIDs() {
|
||||
const loading = ref(false)
|
||||
|
||||
/**
|
||||
* 批量解析 UUID 为名称
|
||||
* @param uuidMap { key: { table, uuids } }
|
||||
* @returns { key: { uuid: name } }
|
||||
*/
|
||||
async function resolveUUIDs(
|
||||
uuidMap: Record<string, ResolveUUIDInput>,
|
||||
): Promise<Record<string, Record<string, string>>> {
|
||||
loading.value = true
|
||||
try {
|
||||
const result: Record<string, Record<string, string>> = {}
|
||||
for (const [key, { table, uuids }] of Object.entries(uuidMap)) {
|
||||
result[key] = {}
|
||||
const nameField = nameFieldMap[table] || `${table}Name`
|
||||
const pkField = pkFieldMap[table] || `${table}UUID`
|
||||
if (!uuidCache.has(table)) uuidCache.set(table, new Map())
|
||||
const cache = uuidCache.get(table)!
|
||||
|
||||
// 仅查询未缓存的
|
||||
const pending = uuids.filter((u) => !cache.has(u))
|
||||
for (const uuid of pending) {
|
||||
try {
|
||||
const res = await formApi.list(table, { [pkField]: uuid, pageSize: 1 })
|
||||
const list = Array.isArray(res) ? res : (res as { list?: unknown[] }).list || []
|
||||
const name = (list[0] as Record<string, unknown>)?.[nameField]
|
||||
cache.set(uuid, name ? String(name) : shortUuid(uuid))
|
||||
} catch {
|
||||
cache.set(uuid, shortUuid(uuid))
|
||||
}
|
||||
}
|
||||
|
||||
for (const uuid of uuids) {
|
||||
result[key][uuid] = cache.get(uuid) || shortUuid(uuid)
|
||||
}
|
||||
}
|
||||
return result
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取已缓存的名称(同步,未缓存返回 undefined) */
|
||||
function getCachedLabel(table: string, uuid: string): string | undefined {
|
||||
return uuidCache.get(table)?.get(uuid)
|
||||
}
|
||||
|
||||
return { loading, resolveUUIDs, getCachedLabel }
|
||||
}
|
||||
@@ -39,6 +39,14 @@ function handleToggleSidebar(): void {
|
||||
function handleToggleTheme(): void {
|
||||
appStore.toggleTheme()
|
||||
}
|
||||
|
||||
/** 判断图标是否为 FontAwesome 类名(如 'fas fa-chart-line'),与 Element Plus 组件名区分 */
|
||||
const isFaIcon = (icon: string | undefined | null): boolean => {
|
||||
if (!icon) return false
|
||||
const trimmed = icon.trim()
|
||||
// FontAwesome 类名特征:含空格('fas fa-xxx')或以 fa- / fas / far / fab / fal 开头
|
||||
return trimmed.includes(' ') || trimmed.startsWith('fa-') || /^fa[brsl]?\b/.test(trimmed)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -74,7 +82,7 @@ function handleToggleTheme(): void {
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-dropdown trigger="click" @command="(cmd) => cmd === 'logout' && handleLogout()">
|
||||
<el-dropdown trigger="click" @command="(cmd: string) => cmd === 'logout' && handleLogout()">
|
||||
<div class="gerp-user-info">
|
||||
<img :src="avatarUrl" alt="头像" class="gerp-user-avatar" />
|
||||
<span class="gerp-user-name">{{ userStore.displayName }}</span>
|
||||
@@ -109,7 +117,8 @@ function handleToggleTheme(): void {
|
||||
<!-- 含子菜单 -->
|
||||
<el-sub-menu v-if="menu.Children?.length" :index="menu.Path || menu.Title">
|
||||
<template #title>
|
||||
<el-icon v-if="menu.Icon"><component :is="menu.Icon" /></el-icon>
|
||||
<i v-if="menu.Icon && isFaIcon(menu.Icon)" :class="menu.Icon" class="gerp-menu-icon" />
|
||||
<el-icon v-else-if="menu.Icon"><component :is="menu.Icon" /></el-icon>
|
||||
<span>{{ menu.Title }}</span>
|
||||
</template>
|
||||
<el-menu-item
|
||||
@@ -117,14 +126,16 @@ function handleToggleTheme(): void {
|
||||
:key="child.Path || child.Title"
|
||||
:index="child.Path || ''"
|
||||
>
|
||||
<el-icon v-if="child.Icon"><component :is="child.Icon" /></el-icon>
|
||||
<i v-if="child.Icon && isFaIcon(child.Icon)" :class="child.Icon" class="gerp-menu-icon" />
|
||||
<el-icon v-else-if="child.Icon"><component :is="child.Icon" /></el-icon>
|
||||
<template #title>{{ child.Title }}</template>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<!-- 无子菜单 -->
|
||||
<el-menu-item v-else :index="menu.Path || ''">
|
||||
<el-icon v-if="menu.Icon"><component :is="menu.Icon" /></el-icon>
|
||||
<i v-if="menu.Icon && isFaIcon(menu.Icon)" :class="menu.Icon" class="gerp-menu-icon" />
|
||||
<el-icon v-else-if="menu.Icon"><component :is="menu.Icon" /></el-icon>
|
||||
<template #title>{{ menu.Title }}</template>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
@@ -248,6 +259,16 @@ function handleToggleTheme(): void {
|
||||
:deep(.el-menu) {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
// FontAwesome 图标对齐 el-icon 表现(菜单 Icon 字段可能存 FA 类名)
|
||||
:deep(.gerp-menu-icon) {
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
margin-right: 5px;
|
||||
font-size: 18px;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.gerp-content {
|
||||
|
||||
@@ -26,6 +26,212 @@ export const staticRoutes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/Profile.vue'),
|
||||
meta: { title: '个人中心', icon: 'User', requiresAuth: true },
|
||||
},
|
||||
// === 阶段 1:CrudTable 落地验证 ===
|
||||
{
|
||||
path: 'workorder/admin',
|
||||
name: 'WorkorderAdmin',
|
||||
component: () => import('@/views/workorder/Admin.vue'),
|
||||
meta: { title: '工单管理', icon: 'Tickets', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'workorder/my',
|
||||
name: 'WorkorderMy',
|
||||
component: () => import('@/views/workorder/My.vue'),
|
||||
meta: { title: '我的工单', icon: 'Document', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'system/roles',
|
||||
name: 'SystemRoles',
|
||||
component: () => import('@/views/system/Roles.vue'),
|
||||
meta: { title: '角色管理', icon: 'UserFilled', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'system/users',
|
||||
name: 'SystemUsers',
|
||||
component: () => import('@/views/system/Users.vue'),
|
||||
meta: { title: '用户管理', icon: 'User', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'inventory/products',
|
||||
name: 'InventoryProducts',
|
||||
component: () => import('@/views/inventory/Products.vue'),
|
||||
meta: { title: '产品管理', icon: 'Goods', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'sales/orders',
|
||||
name: 'SalesOrders',
|
||||
component: () => import('@/views/sales/Orders.vue'),
|
||||
meta: { title: '销售订单', icon: 'ShoppingCart', requiresAuth: true },
|
||||
},
|
||||
// === 阶段 2:Company 列表页 ===
|
||||
{
|
||||
path: 'company/tenant',
|
||||
name: 'CompanyTenant',
|
||||
component: () => import('@/views/company/Tenant.vue'),
|
||||
meta: { title: '租户管理', icon: 'OfficeBuilding', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'company/company',
|
||||
name: 'CompanyCompany',
|
||||
component: () => import('@/views/company/Company.vue'),
|
||||
meta: { title: '公司管理', icon: 'Management', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'company/department',
|
||||
name: 'CompanyDepartment',
|
||||
component: () => import('@/views/company/Department.vue'),
|
||||
meta: { title: '部门管理', icon: 'Share', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'company/workgroup',
|
||||
name: 'CompanyWorkgroup',
|
||||
component: () => import('@/views/company/Workgroup.vue'),
|
||||
meta: { title: '工作组管理', icon: 'UserFilled', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'company/role',
|
||||
name: 'CompanyRole',
|
||||
component: () => import('@/views/company/Role.vue'),
|
||||
meta: { title: '公司角色', icon: 'Avatar', requiresAuth: true },
|
||||
},
|
||||
// === 阶段 3:详情页 ===
|
||||
{
|
||||
path: 'system/user-detail',
|
||||
name: 'SystemUserDetail',
|
||||
component: () => import('@/views/system/UserDetail.vue'),
|
||||
meta: { title: '用户详情', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'system/role-detail',
|
||||
name: 'SystemRoleDetail',
|
||||
component: () => import('@/views/system/RoleDetail.vue'),
|
||||
meta: { title: '角色详情', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'system/role-permissions',
|
||||
name: 'SystemRolePermissions',
|
||||
component: () => import('@/views/system/RolePermissions.vue'),
|
||||
meta: { title: '权限分配', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'company/tenant-detail',
|
||||
name: 'CompanyTenantDetail',
|
||||
component: () => import('@/views/company/TenantDetail.vue'),
|
||||
meta: { title: '租户详情', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'company/workgroup-detail',
|
||||
name: 'CompanyWorkgroupDetail',
|
||||
component: () => import('@/views/company/WorkgroupDetail.vue'),
|
||||
meta: { title: '工作组详情', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'company/department-detail',
|
||||
name: 'CompanyDepartmentDetail',
|
||||
component: () => import('@/views/company/DepartmentDetail.vue'),
|
||||
meta: { title: '部门详情', requiresAuth: true },
|
||||
},
|
||||
// === 阶段 4:复杂详情页 ===
|
||||
{
|
||||
path: 'company/company-detail',
|
||||
name: 'CompanyCompanyDetail',
|
||||
component: () => import('@/views/company/CompanyDetail.vue'),
|
||||
meta: { title: '公司详情', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'company/role-detail',
|
||||
name: 'CompanyRoleDetail',
|
||||
component: () => import('@/views/company/RoleDetail.vue'),
|
||||
meta: { title: '公司角色详情', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'company/employee-detail',
|
||||
name: 'CompanyEmployeeDetail',
|
||||
component: () => import('@/views/company/EmployeeDetail.vue'),
|
||||
meta: { title: '员工详情', requiresAuth: true },
|
||||
},
|
||||
// === 阶段 5:业务功能页 ===
|
||||
{
|
||||
path: 'system/message',
|
||||
name: 'SystemMessage',
|
||||
component: () => import('@/views/system/Message.vue'),
|
||||
meta: { title: '站内信', icon: 'ChatDotRound', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'system/tenant-create',
|
||||
name: 'SystemTenantCreate',
|
||||
component: () => import('@/views/system/TenantCreate.vue'),
|
||||
meta: { title: '租户入驻', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'system/tenant-review',
|
||||
name: 'SystemTenantReview',
|
||||
component: () => import('@/views/system/TenantReview.vue'),
|
||||
meta: { title: '租户审核', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'system/payment-config',
|
||||
name: 'SystemPaymentConfig',
|
||||
component: () => import('@/views/system/PaymentConfig.vue'),
|
||||
meta: { title: '支付配置', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'company/epcreate',
|
||||
name: 'CompanyEpCreate',
|
||||
component: () => import('@/views/company/EpCreate.vue'),
|
||||
meta: { title: '企业创建向导', requiresAuth: true },
|
||||
},
|
||||
// === 阶段 6:独立工具 ===
|
||||
{
|
||||
path: 'doc/editor',
|
||||
name: 'DocEditor',
|
||||
component: () => import('@/views/doc/Editor.vue'),
|
||||
meta: { title: '文档编辑器', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'assets/manager',
|
||||
name: 'AssetsManager',
|
||||
component: () => import('@/views/assets/Manager.vue'),
|
||||
meta: { title: '资产管理', requiresAuth: true },
|
||||
},
|
||||
// === 阶段 7:管理员工具 ===
|
||||
{
|
||||
path: 'system/audit-log',
|
||||
name: 'SystemAuditLog',
|
||||
component: () => import('@/views/system/AuditLog.vue'),
|
||||
meta: { title: '审计日志', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'system/settings',
|
||||
name: 'SystemSettings',
|
||||
component: () => import('@/views/system/Settings.vue'),
|
||||
meta: { title: '系统设置', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'system/menu',
|
||||
name: 'SystemMenu',
|
||||
component: () => import('@/views/system/Menu.vue'),
|
||||
meta: { title: '菜单管理', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'system/database',
|
||||
name: 'SystemDatabase',
|
||||
component: () => import('@/views/system/Database.vue'),
|
||||
meta: { title: '数据库管理', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'system/dbtable',
|
||||
name: 'SystemDbTable',
|
||||
component: () => import('@/views/system/DbTable.vue'),
|
||||
meta: { title: '表结构管理', requiresAuth: true },
|
||||
},
|
||||
// === 阶段 8:SKU(移动端 C 端,待决策是否拆分独立工程) ===
|
||||
{
|
||||
path: 'sku',
|
||||
name: 'Sku',
|
||||
component: () => import('@/views/SKU/Sku.vue'),
|
||||
meta: { title: '商品详情', requiresAuth: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
+133
-110
@@ -1,110 +1,133 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { UserInfo, MenuItem } from '@/types/api'
|
||||
import { getCurrentUser, getUserRoles, login as apiLogin, logout as apiLogout, type LoginParams } from '@/api/auth'
|
||||
import { getMenuList } from '@/api/menu'
|
||||
|
||||
/** 用户状态管理:当前登录用户、角色、菜单、权限 */
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
// === 状态 ===
|
||||
const userInfo = ref<UserInfo | null>(null)
|
||||
const roles = ref<string[]>([])
|
||||
const menus = ref<MenuItem[]>([])
|
||||
const token = ref<string>('')
|
||||
const loading = ref(false)
|
||||
|
||||
// === 计算属性 ===
|
||||
const isLoggedIn = computed(() => !!userInfo.value)
|
||||
const isAdmin = computed(() => 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 || '未登录')
|
||||
|
||||
/** 扁平化后的菜单(用于面包屑、菜单搜索) */
|
||||
const flatMenus = computed<MenuItem[]>(() => {
|
||||
const result: MenuItem[] = []
|
||||
const walk = (items: MenuItem[]) => {
|
||||
for (const item of items) {
|
||||
result.push(item)
|
||||
if (item.Children?.length) walk(item.Children)
|
||||
}
|
||||
}
|
||||
walk(menus.value)
|
||||
return result
|
||||
})
|
||||
|
||||
// === 动作 ===
|
||||
/** 登录 */
|
||||
async function login(params: LoginParams): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
await apiLogin(params)
|
||||
await fetchUserInfo()
|
||||
await fetchMenus()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 注销 */
|
||||
async function logout(): Promise<void> {
|
||||
try {
|
||||
await apiLogout()
|
||||
} finally {
|
||||
reset()
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取当前用户信息 */
|
||||
async function fetchUserInfo(): Promise<UserInfo> {
|
||||
const info = await getCurrentUser()
|
||||
userInfo.value = info
|
||||
return info
|
||||
}
|
||||
|
||||
/** 获取角色列表 */
|
||||
async function fetchRoles(): Promise<string[]> {
|
||||
const list = await getUserRoles()
|
||||
roles.value = list
|
||||
return list
|
||||
}
|
||||
|
||||
/** 获取菜单 */
|
||||
async function fetchMenus(): Promise<MenuItem[]> {
|
||||
const list = await getMenuList()
|
||||
menus.value = list
|
||||
return list
|
||||
}
|
||||
|
||||
/** 重置状态(登出或登录失效时调用) */
|
||||
function reset(): void {
|
||||
userInfo.value = null
|
||||
roles.value = []
|
||||
menus.value = []
|
||||
token.value = ''
|
||||
localStorage.removeItem('gerp-user')
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
userInfo,
|
||||
roles,
|
||||
menus,
|
||||
token,
|
||||
loading,
|
||||
// 计算属性
|
||||
isLoggedIn,
|
||||
isAdmin,
|
||||
currentRole,
|
||||
avatar,
|
||||
displayName,
|
||||
flatMenus,
|
||||
// 动作
|
||||
login,
|
||||
logout,
|
||||
fetchUserInfo,
|
||||
fetchRoles,
|
||||
fetchMenus,
|
||||
reset,
|
||||
}
|
||||
})
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { UserInfo, MenuItem } from '@/types/api'
|
||||
import { getCurrentUser, getUserRoles, login as apiLogin, logout as apiLogout, type LoginParams } from '@/api/auth'
|
||||
import { getMenuList } from '@/api/menu'
|
||||
|
||||
/** 用户状态管理:当前登录用户、角色、菜单、权限 */
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
// === 状态 ===
|
||||
const userInfo = ref<UserInfo | null>(null)
|
||||
const roles = ref<string[]>([])
|
||||
const menus = ref<MenuItem[]>([])
|
||||
const token = ref<string>('')
|
||||
const loading = ref(false)
|
||||
|
||||
// === 计算属性 ===
|
||||
const isLoggedIn = computed(() => !!userInfo.value)
|
||||
const isAdmin = computed(() => 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 || '未登录')
|
||||
|
||||
/** 扁平化后的菜单(用于面包屑、菜单搜索) */
|
||||
const flatMenus = computed<MenuItem[]>(() => {
|
||||
const result: MenuItem[] = []
|
||||
const walk = (items: MenuItem[]) => {
|
||||
for (const item of items) {
|
||||
result.push(item)
|
||||
if (item.Children?.length) walk(item.Children)
|
||||
}
|
||||
}
|
||||
walk(menus.value)
|
||||
return result
|
||||
})
|
||||
|
||||
// === 动作 ===
|
||||
/** 登录 */
|
||||
async function login(params: LoginParams): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
await apiLogin(params)
|
||||
await fetchUserInfo()
|
||||
await fetchMenus()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 注销 */
|
||||
async function logout(): Promise<void> {
|
||||
try {
|
||||
await apiLogout()
|
||||
} finally {
|
||||
reset()
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取当前用户信息 */
|
||||
async function fetchUserInfo(): Promise<UserInfo> {
|
||||
const info = await getCurrentUser()
|
||||
userInfo.value = info
|
||||
return info
|
||||
}
|
||||
|
||||
/** 获取角色列表 */
|
||||
async function fetchRoles(): Promise<string[]> {
|
||||
const list = await getUserRoles()
|
||||
roles.value = list
|
||||
return list
|
||||
}
|
||||
|
||||
/** 将后端 Page(如 pages/workorder/admin.html)规范化为 Vue 路由(/workorder/admin) */
|
||||
function normalizeMenuPath(page: string | undefined): string {
|
||||
if (!page) return ''
|
||||
let p = page.trim()
|
||||
if (!p) return ''
|
||||
if (/^https?:\/\//i.test(p)) return p
|
||||
if (p.startsWith('#')) p = p.slice(1)
|
||||
p = p.replace(/^\/+/, '')
|
||||
if (p.startsWith('pages/')) p = p.slice(6)
|
||||
p = p.replace(/\.html?$/i, '')
|
||||
return p ? '/' + p : ''
|
||||
}
|
||||
|
||||
/** 递归规范化菜单树:Page → Path */
|
||||
function normalizeMenuTree(items: MenuItem[]): MenuItem[] {
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
Path: normalizeMenuPath(item.Page),
|
||||
Children: item.Children?.length ? normalizeMenuTree(item.Children) : [],
|
||||
}))
|
||||
}
|
||||
|
||||
/** 获取菜单 */
|
||||
async function fetchMenus(): Promise<MenuItem[]> {
|
||||
const list = await getMenuList()
|
||||
const normalized = normalizeMenuTree(list)
|
||||
menus.value = normalized
|
||||
return normalized
|
||||
}
|
||||
|
||||
/** 重置状态(登出或登录失效时调用) */
|
||||
function reset(): void {
|
||||
userInfo.value = null
|
||||
roles.value = []
|
||||
menus.value = []
|
||||
token.value = ''
|
||||
localStorage.removeItem('gerp-user')
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
userInfo,
|
||||
roles,
|
||||
menus,
|
||||
token,
|
||||
loading,
|
||||
// 计算属性
|
||||
isLoggedIn,
|
||||
isAdmin,
|
||||
currentRole,
|
||||
avatar,
|
||||
displayName,
|
||||
flatMenus,
|
||||
// 动作
|
||||
login,
|
||||
logout,
|
||||
fetchUserInfo,
|
||||
fetchRoles,
|
||||
fetchMenus,
|
||||
reset,
|
||||
}
|
||||
})
|
||||
-296
@@ -1,296 +0,0 @@
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
+267
-105
@@ -1,105 +1,267 @@
|
||||
/**
|
||||
* GERP 后端统一响应格式
|
||||
* 所有 API(包括兼容的 PascalCase 路径与现代 DRF 路径)都遵循该格式
|
||||
*/
|
||||
export interface ApiResult<T = unknown> {
|
||||
/** 业务状态码:0 表示成功,非 0 表示失败 */
|
||||
code: number
|
||||
/** 提示消息 */
|
||||
msg: string
|
||||
/** 业务数据 */
|
||||
data: T
|
||||
}
|
||||
|
||||
/** 分页响应数据 */
|
||||
export interface PagedData<T> {
|
||||
/** 当前页数据列表 */
|
||||
list: T[]
|
||||
/** 总条数 */
|
||||
total: number
|
||||
/** 当前页码(从 1 开始) */
|
||||
page: number
|
||||
/** 每页大小 */
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 后端 FormAPI Columns 返回的列定义
|
||||
* 兼容旧 GERP TableEngine 的 column schema,前端用 vxe-table / FormKit 渲染
|
||||
*/
|
||||
export interface ColumnSchema {
|
||||
/** 字段名(对应数据库列) */
|
||||
field: string
|
||||
/** 显示标题 */
|
||||
title: string
|
||||
/** 控件类型:text/number/select/date/datetime/switch/textarea 等 */
|
||||
type?: string
|
||||
/** 是否可编辑 */
|
||||
editable?: boolean
|
||||
/** 是否必填 */
|
||||
required?: boolean
|
||||
/** 是否在表格中显示 */
|
||||
visible?: boolean
|
||||
/** 是否主键 */
|
||||
isPrimary?: boolean
|
||||
/** 选项列表(type 为 select 时使用) */
|
||||
options?: Array<{ label: string; value: string | number }>
|
||||
/** 默认值 */
|
||||
defaultValue?: unknown
|
||||
/** 最小值 */
|
||||
min?: number
|
||||
/** 最大值 */
|
||||
max?: number
|
||||
/** 最大长度 */
|
||||
maxLength?: number
|
||||
/** 占位符 */
|
||||
placeholder?: string
|
||||
/** 是否支持搜索 */
|
||||
searchable?: boolean
|
||||
/** 是否可排序 */
|
||||
sortable?: boolean
|
||||
/** 自定义渲染(前端字段,后端不返回) */
|
||||
_render?: string
|
||||
}
|
||||
|
||||
/** 菜单项 */
|
||||
export interface MenuItem {
|
||||
/** 菜单 ID */
|
||||
Id?: number
|
||||
/** 菜单名/显示文本 */
|
||||
Title: string
|
||||
/** 菜单图标(Font Awesome 或 Element Plus Icon 名) */
|
||||
Icon?: string
|
||||
/** 跳转路径(前端路由) */
|
||||
Path?: string
|
||||
/** 子菜单 */
|
||||
Children?: MenuItem[]
|
||||
/** 排序 */
|
||||
Sort?: number
|
||||
/** 菜单类型:directory/menu/button */
|
||||
Type?: 'directory' | 'menu' | 'button'
|
||||
/** 权限标识 */
|
||||
Permission?: string
|
||||
}
|
||||
|
||||
/** 当前登录用户信息 */
|
||||
export interface UserInfo {
|
||||
/** 用户 UUID */
|
||||
UserUUID: string
|
||||
/** 用户名 */
|
||||
UserName: string
|
||||
/** 显示名 */
|
||||
DisplayName?: string
|
||||
/** 邮箱 */
|
||||
Email?: string
|
||||
/** 头像 URL */
|
||||
Avatar?: string
|
||||
/** 当前角色 */
|
||||
CurrentRole?: string
|
||||
/** 角色列表 */
|
||||
Roles?: string[]
|
||||
/** 是否管理员 */
|
||||
IsAdmin?: boolean
|
||||
/** 个人配置 */
|
||||
Config?: Record<string, unknown>
|
||||
}
|
||||
/**
|
||||
* GERP 后端统一响应格式
|
||||
* 所有 API(包括兼容的 PascalCase 路径与现代 DRF 路径)都遵循该格式
|
||||
*/
|
||||
export interface ApiResult<T = unknown> {
|
||||
/** 业务状态码:0 表示成功,非 0 表示失败 */
|
||||
code: number
|
||||
/** 提示消息 */
|
||||
msg: string
|
||||
/** 业务数据 */
|
||||
data: T
|
||||
}
|
||||
|
||||
/** 分页响应数据 */
|
||||
export interface PagedData<T> {
|
||||
/** 当前页数据列表 */
|
||||
list: T[]
|
||||
/** 总条数 */
|
||||
total: number
|
||||
/** 当前页码(从 1 开始) */
|
||||
page: number
|
||||
/** 每页大小 */
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
/** statusMap 单个状态的渲染配置 */
|
||||
export interface StatusMapEntry {
|
||||
/** 显示文本 */
|
||||
text: string
|
||||
/** Element Plus tag 类型:primary/success/warning/danger/info */
|
||||
type?: 'primary' | 'success' | 'warning' | 'danger' | 'info'
|
||||
/** 自定义颜色(覆盖 type) */
|
||||
color?: string
|
||||
}
|
||||
|
||||
/** codeTable 码表条目 */
|
||||
export interface CodeTableEntry {
|
||||
/** 码值 */
|
||||
code: string | number
|
||||
/** 显示文本 */
|
||||
label: string
|
||||
}
|
||||
|
||||
/** EntityPicker(实体选择器)配置,替代原生 CompanyUtils.epColumn */
|
||||
export interface EntityPickerConfig {
|
||||
/** 搜索接口 URL,如 /API/FormAPI/UserSearch */
|
||||
api: string
|
||||
/** HTTP 方法,默认 GET */
|
||||
method?: 'GET' | 'POST'
|
||||
/** 显示字段名,如 UserName */
|
||||
labelField: string
|
||||
/** 值字段名(主键),如 UserUUID */
|
||||
valueField: string
|
||||
/** 副文本字段名(可选,如 UserEmail) */
|
||||
secondaryField?: string
|
||||
/** 占位符 */
|
||||
placeholder?: string
|
||||
/** 搜索参数名,默认 keyword */
|
||||
searchParam?: string
|
||||
/** 响应数据路径,默认 data */
|
||||
resultPath?: string
|
||||
/** 关联码表名(可选,用于回显翻译) */
|
||||
codeTable?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 后端 FormAPI Columns 返回的列定义
|
||||
* 兼容旧 GERP TableEngine 的 column schema,前端用 vxe-table / FormKit 渲染
|
||||
*
|
||||
* 字段命名遵循原生 TableEngine 约定(camelCase),与后端 Columns 接口返回一致
|
||||
*/
|
||||
export interface ColumnSchema {
|
||||
// === 基础 ===
|
||||
/** 字段名(对应数据库列) */
|
||||
field: string
|
||||
/** 显示标题 */
|
||||
title: string
|
||||
/** 英文标题(可选回退) */
|
||||
titleEn?: string
|
||||
/** 别名(可选) */
|
||||
label?: string
|
||||
|
||||
/**
|
||||
* 控件/渲染类型
|
||||
* 渲染 + 编辑共用:text/string/number/textarea/select/date/datetime/time/
|
||||
* boolean/switch/checkbox/radio/tags/password/email/tel/phone/url/color/
|
||||
* file/image/hidden/entityPicker/readonly/divider/html/status/uuid
|
||||
*/
|
||||
type?: string
|
||||
|
||||
// === 主键 ===
|
||||
/** 是否主键(单主键) */
|
||||
isPrimary?: boolean
|
||||
/** 原生 primaryKey 别名(与 isPrimary 等价,对齐 TableEngine) */
|
||||
primaryKey?: boolean
|
||||
/** 复合主键字段名数组(原生 PrimaryKeys) */
|
||||
primaryKeys?: string[]
|
||||
|
||||
// === 表格展示 ===
|
||||
/** 是否在表格中显示 */
|
||||
visible?: boolean
|
||||
/** 是否可排序 */
|
||||
sortable?: boolean
|
||||
/** 列宽可拖拽调整(默认 true) */
|
||||
resizable?: boolean
|
||||
/** 固定列:left/right */
|
||||
fixed?: 'left' | 'right'
|
||||
/** 列宽 */
|
||||
width?: number | string
|
||||
/** 最小列宽 */
|
||||
minWidth?: number
|
||||
/** 弹性增长(custom 模式) */
|
||||
flex?: number
|
||||
/** 超长截断(默认跟随 vxe-table show-overflow) */
|
||||
ellipsis?: boolean
|
||||
/** 最大长度(同时用于截断与表单校验) */
|
||||
maxLength?: number
|
||||
|
||||
/** 自定义渲染函数名(前端字段,后端不返回;对应原生 formatter/render) */
|
||||
_render?: string
|
||||
/** 原生 formatter 函数引用(前端运行时注入,非后端字段) */
|
||||
formatter?: (value: unknown, row: Record<string, unknown>, column: ColumnSchema) => string
|
||||
/** 码表名,配合 /API/FormAPI/CodeDisplay 翻译 */
|
||||
codeTable?: string
|
||||
/** type=status 时的状态映射 */
|
||||
statusMap?: Record<string, StatusMapEntry>
|
||||
/** type=tags 时的分隔符(默认逗号) */
|
||||
tagsSeparator?: string
|
||||
|
||||
// === 表单 ===
|
||||
/** 是否可编辑(默认 true) */
|
||||
editable?: boolean
|
||||
/** 表单中隐藏 */
|
||||
hideInForm?: boolean
|
||||
/** 是否必填 */
|
||||
required?: boolean
|
||||
/** 必填但允许提交空值(原生特有,跳过 required 校验) */
|
||||
passEmpty?: boolean
|
||||
/** 禁用 */
|
||||
disabled?: boolean
|
||||
/** 默认值 */
|
||||
defaultValue?: unknown
|
||||
/** 占位符 */
|
||||
placeholder?: string
|
||||
|
||||
// === 数值约束 ===
|
||||
/** 最小值 */
|
||||
min?: number
|
||||
/** 最大值 */
|
||||
max?: number
|
||||
/** 步长 */
|
||||
step?: number
|
||||
|
||||
// === 文本校验 ===
|
||||
/** 最小长度 */
|
||||
minLength?: number
|
||||
/** 精确长度 */
|
||||
length?: number
|
||||
/** 正则校验 */
|
||||
pattern?: string
|
||||
|
||||
// === 选项 ===
|
||||
/** 选项列表(type 为 select/radio/checkbox 时使用) */
|
||||
options?: Array<{ label: string; value: string | number; disabled?: boolean }>
|
||||
|
||||
// === 特定控件 ===
|
||||
/** textarea 行数(默认 3) */
|
||||
rows?: number
|
||||
/** checkbox 文本 */
|
||||
checkboxLabel?: string
|
||||
/** file/image 接受的文件类型 */
|
||||
accept?: string
|
||||
/** file 多选 */
|
||||
multiple?: boolean
|
||||
/** 表单项下方说明文字 */
|
||||
description?: string
|
||||
|
||||
// === 实体选择器 ===
|
||||
/** type=entityPicker 时的配置 */
|
||||
entityPicker?: EntityPickerConfig
|
||||
|
||||
// === 分隔/HTML ===
|
||||
/** type=divider 时的分隔文本 */
|
||||
text?: string
|
||||
/** type=html 时的自定义 HTML */
|
||||
html?: string
|
||||
}
|
||||
|
||||
/** 菜单项 */
|
||||
export interface MenuItem {
|
||||
/** 菜单 ID */
|
||||
Id?: number
|
||||
/** 菜单名/显示文本 */
|
||||
Title: string
|
||||
/** 菜单图标(Font Awesome 或 Element Plus Icon 名) */
|
||||
Icon?: string
|
||||
/** 后端原始页面路径(如 pages/workorder/admin.html),转换后存 Path */
|
||||
Page?: string
|
||||
/** 跳转路径(前端路由,由 Page 规范化而来) */
|
||||
Path?: string
|
||||
/** 子菜单 */
|
||||
Children?: MenuItem[]
|
||||
/** 排序 */
|
||||
Sort?: number
|
||||
/** 菜单类型:directory/menu/button */
|
||||
Type?: 'directory' | 'menu' | 'button'
|
||||
/** 权限标识 */
|
||||
Permission?: string
|
||||
}
|
||||
|
||||
/** 当前登录用户信息 */
|
||||
export interface UserInfo {
|
||||
/** 用户 UUID */
|
||||
UserUUID: string
|
||||
/** 用户名 */
|
||||
UserName: string
|
||||
/** 显示名 */
|
||||
DisplayName?: string
|
||||
/** 邮箱 */
|
||||
Email?: string
|
||||
/** 头像 URL */
|
||||
Avatar?: string
|
||||
/** 当前角色 */
|
||||
CurrentRole?: string
|
||||
/** 角色列表 */
|
||||
Roles?: string[]
|
||||
/** 是否管理员 */
|
||||
IsAdmin?: boolean
|
||||
/** 个人配置 */
|
||||
Config?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户作用域(role_scope)
|
||||
*
|
||||
* 对齐原生 GERP.CheckAdmin().role_scope,用于用户管理页按角色差异化显示与隔离
|
||||
* - admin:管理员,全公司可见,可跨公司特权
|
||||
* - dept_manager:部门负责人,仅本部门
|
||||
* - workgroup_lead:工作组组长,仅本工作组
|
||||
* - member:普通组员,仅自己
|
||||
*/
|
||||
export interface RoleScope {
|
||||
/** 作用域类型 */
|
||||
Type: 'admin' | 'dept_manager' | 'workgroup_lead' | 'member'
|
||||
/** 类型显示文本 */
|
||||
TypeLabel?: string
|
||||
/** 是否管理员 */
|
||||
IsAdmin?: boolean
|
||||
/** 是否部门负责人 */
|
||||
IsDeptManager?: boolean
|
||||
/** 是否工作组组长 */
|
||||
IsWorkgroupLead?: boolean
|
||||
/** 是否普通组员 */
|
||||
IsMember?: boolean
|
||||
/** 所属部门 UUID */
|
||||
DeptUUID?: string
|
||||
/** 所属工作组 UUID */
|
||||
WorkgroupUUID?: string
|
||||
}
|
||||
|
||||
/** CheckAdmin 接口返回结果 */
|
||||
export interface AdminCheckResult {
|
||||
/** 是否管理员 */
|
||||
is_admin?: boolean
|
||||
/** 用户作用域 */
|
||||
role_scope?: RoleScope | null
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 服务器作用域工具(纯函数,可在拦截器/组件外使用)
|
||||
*
|
||||
* 对齐原生 CompanyCommon.getServer / hasAdminVerifyArg / setPageTitle / apiUrl 的参数注入逻辑:
|
||||
* - server 参数:URL query 含 server 且非 gvsds-master 时注入
|
||||
* - admin_verify 参数:URL query 含 admin_verify=1 时注入
|
||||
*
|
||||
* 在 axios 请求拦截器统一注入,替代原生每次拼接 buildApiUrl
|
||||
*/
|
||||
|
||||
/** 默认服务器标识(主实例) */
|
||||
export const DEFAULT_SERVER = 'gvsds-master'
|
||||
|
||||
/** 从 URL query 读取 server,默认 gvsds-master */
|
||||
export function getServer(): string {
|
||||
if (typeof window === 'undefined') return DEFAULT_SERVER
|
||||
return new URLSearchParams(window.location.search).get('server') || DEFAULT_SERVER
|
||||
}
|
||||
|
||||
/** 是否为默认服务器(主实例) */
|
||||
export function isDefaultServer(): boolean {
|
||||
return getServer() === DEFAULT_SERVER
|
||||
}
|
||||
|
||||
/** URL query 是否含 admin_verify=1(管理员特权模式标记) */
|
||||
export function hasAdminVerifyArg(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
return new URLSearchParams(window.location.search).get('admin_verify') === '1'
|
||||
}
|
||||
|
||||
/** 需要注入到请求的 server 作用域参数 */
|
||||
export interface ServerScopeParams {
|
||||
/** 非主实例时的服务器标识 */
|
||||
server?: string
|
||||
/** 管理员特权模式标记 */
|
||||
admin_verify?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集当前请求需要注入的 server 作用域参数
|
||||
* - 主实例不注入 server
|
||||
* - 无 admin_verify 标记不注入
|
||||
*/
|
||||
export function getServerScopeParams(): ServerScopeParams {
|
||||
const params: ServerScopeParams = {}
|
||||
if (!isDefaultServer()) params.server = getServer()
|
||||
if (hasAdminVerifyArg()) params.admin_verify = '1'
|
||||
return params
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置页面标题:主实例直接用 title,非主实例加 "server - " 前缀
|
||||
* 对齐原生 CompanyUtils.setPageTitle
|
||||
*/
|
||||
export function setPageTitle(title: string): void {
|
||||
if (typeof document === 'undefined') return
|
||||
document.title = isDefaultServer() ? title : `${getServer()} - ${title}`
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get } from '@/api/request'
|
||||
|
||||
interface DashboardStats {
|
||||
UserCount: number
|
||||
OrderCount: number
|
||||
TodayRevenue: number
|
||||
ActiveTenants: number
|
||||
}
|
||||
|
||||
const stats = ref<DashboardStats>({
|
||||
UserCount: 0,
|
||||
OrderCount: 0,
|
||||
TodayRevenue: 0,
|
||||
ActiveTenants: 0,
|
||||
})
|
||||
const loading = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await get<DashboardStats>('/API/Dashboard/Stats')
|
||||
stats.value = { ...stats.value, ...data }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" class="dashboard">
|
||||
<el-row :gutter="16">
|
||||
<el-col :xs="24" :sm="12" :lg="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon user"><el-icon :size="32"><User /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.UserCount }}</div>
|
||||
<div class="stat-label">用户总数</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="24" :sm="12" :lg="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon order"><el-icon :size="32"><ShoppingCart /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.OrderCount }}</div>
|
||||
<div class="stat-label">订单总数</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="24" :sm="12" :lg="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon revenue"><el-icon :size="32"><Money /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">¥{{ (Number(stats.TodayRevenue) || 0).toFixed(2) }}</div>
|
||||
<div class="stat-label">今日收入</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="24" :sm="12" :lg="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon tenant"><el-icon :size="32"><OfficeBuilding /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.ActiveTenants }}</div>
|
||||
<div class="stat-label">活跃租户</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card class="welcome-card" shadow="never">
|
||||
<template #header>
|
||||
<span>欢迎使用 GERP 管理系统</span>
|
||||
</template>
|
||||
<p>当前技术栈:Vue 3 + TypeScript + Vite + Element Plus + vxe-table + FormKit</p>
|
||||
<p>后端:Django + DRF(已就绪,无需修改)</p>
|
||||
<p>路由模式:SPA(已移除 iframe)</p>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dashboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
|
||||
&.user {
|
||||
background: linear-gradient(135deg, #409eff, #66b1ff);
|
||||
}
|
||||
&.order {
|
||||
background: linear-gradient(135deg, #67c23a, #85ce61);
|
||||
}
|
||||
&.revenue {
|
||||
background: linear-gradient(135deg, #e6a23c, #f0b78a);
|
||||
}
|
||||
&.tenant {
|
||||
background: linear-gradient(135deg, #f56c6c, #f89898);
|
||||
}
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.welcome-card {
|
||||
p {
|
||||
margin: 8px 0;
|
||||
color: #606266;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import type { LoginParams } from '@/api/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const form = reactive<LoginParams>({
|
||||
Username: '',
|
||||
Password: '',
|
||||
RememberMe: false,
|
||||
})
|
||||
|
||||
const rules = {
|
||||
Username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
Password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
async function handleLogin(): Promise<void> {
|
||||
if (!form.Username || !form.Password) {
|
||||
ElMessage.warning('请输入用户名和密码')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await userStore.login(form)
|
||||
const redirect = (route.query.redirect as string) || '/dashboard'
|
||||
router.push(redirect)
|
||||
} catch {
|
||||
// 错误提示已在 axios 拦截器统一处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<img src="/favicon.svg" alt="logo" class="login-logo" />
|
||||
<h1 class="login-title">GERP 管理系统</h1>
|
||||
<p class="login-subtitle">登录到后台管理</p>
|
||||
</div>
|
||||
|
||||
<el-form :model="form" :rules="rules" label-position="top" size="large" @submit.prevent="handleLogin">
|
||||
<el-form-item label="用户名" prop="Username">
|
||||
<el-input v-model="form.Username" placeholder="请输入用户名" :prefix-icon="'User'" clearable />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="密码" prop="Password">
|
||||
<el-input
|
||||
v-model="form.Password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
:prefix-icon="'Lock'"
|
||||
show-password
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-checkbox v-model="form.RememberMe">记住我</el-checkbox>
|
||||
</el-form-item>
|
||||
|
||||
<el-button type="primary" size="large" class="login-btn" :loading="loading" @click="handleLogin">
|
||||
登 录
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.login-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 400px;
|
||||
max-width: 90vw;
|
||||
padding: 40px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
const router = useRouter()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="not-found">
|
||||
<el-result icon="warning" title="404" sub-title="页面不存在或已被移动">
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="router.push('/dashboard')">返回首页</el-button>
|
||||
</template>
|
||||
</el-result>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.not-found {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 80vh;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { getAvatarUrl } from '@/api/auth'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const avatarUrl = getAvatarUrl()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<span>个人中心</span>
|
||||
</template>
|
||||
|
||||
<div class="profile">
|
||||
<img :src="avatarUrl" alt="头像" class="avatar" />
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="用户名">{{ userStore.userInfo?.UserName || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="显示名">{{ userStore.userInfo?.DisplayName || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="邮箱">{{ userStore.userInfo?.Email || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="当前角色">{{ userStore.currentRole || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="是否管理员">
|
||||
<el-tag :type="userStore.isAdmin ? 'danger' : 'info'">{{ userStore.isAdmin ? '是' : '否' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profile {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 公司管理
|
||||
*
|
||||
* 迁移自 TempLate/GERP/pages/Company/company.html
|
||||
* 原生实现:CompanyTablePage 基类 + tableName='Company' + pkField='CompanyUUID'
|
||||
* + adminBadge:true(特权模式徽章)+ defaultColumns fallback + onView 跳详情
|
||||
*
|
||||
* 原生 defaultColumns(仅作 fallback 参考,Vue 版走后端 Columns 接口):
|
||||
* CompanyUUID(主键,隐藏表单) / CompanyName(公司全称) / CompanyShort(公司简称)
|
||||
* / CompanyStatus(0禁用/1启用) / ParentCompanyUUID(上级公司,entityPicker,只读)
|
||||
* / CreateTime(创建时间,只读)
|
||||
*
|
||||
* 特殊业务逻辑:
|
||||
* - ParentCompanyUUID 为自引用外键(公司可挂上级公司),原生用 epColumn 渲染为
|
||||
* entityPicker(CompanySearch 搜索),Vue 版由后端 Columns 返回 entityPicker 配置
|
||||
* - 强公司隔离:CompanyUUID 由后端自动注入,前端隐藏(CrudTable 透传后端列配置)
|
||||
*
|
||||
* Vue 版(阶段 1 简化):
|
||||
* - server / admin_verify 由 request.ts 拦截器统一注入,页面不拼接 buildApiUrl
|
||||
* - 列定义优先走后端 /API/FormAPI/Columns/Company(CrudTable 自动处理)
|
||||
* 非主实例的 getDefaultColumns fallback 暂不实现(TODO)
|
||||
* - AdminBadge 自动根据 server/admin_verify 显示特权横幅(对齐原生 adminBadge:true)
|
||||
* - role_scope 细粒度判断暂用 userStore.isAdmin 二分(TODO 待 checkAdmin 接入)
|
||||
* - onView 跳 Vue 路由 /company/company-detail?CompanyUUID=xxx(替代 GERP.Navigate)
|
||||
*/
|
||||
import { useRouter } from 'vue-router'
|
||||
import CrudTable from '@/components/CrudTable.vue'
|
||||
import AdminBadge from '@/components/AdminBadge.vue'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
|
||||
setPageTitle('公司管理')
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 公司记录类型(仅声明本页用到的字段)
|
||||
interface CompanyRow {
|
||||
CompanyUUID?: string
|
||||
CompanyName?: string
|
||||
ParentCompanyUUID?: string
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
// 跳转公司详情页
|
||||
function handleView(row: CompanyRow): void {
|
||||
router.push({
|
||||
path: '/company/company-detail',
|
||||
query: { CompanyUUID: row.CompanyUUID || '' },
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="company-page">
|
||||
<!-- 管理员特权横幅(仅特权模式显示) -->
|
||||
<AdminBadge />
|
||||
<CrudTable
|
||||
table="Company"
|
||||
title="公司管理"
|
||||
pk-field="CompanyUUID"
|
||||
searchable
|
||||
pageable
|
||||
selectable
|
||||
:on-view="handleView"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.company-page {
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,365 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* CompanyDetail 公司详情页(组织架构枢纽页)
|
||||
*
|
||||
* 迁移自原生页面:pages/Company/company-detail.html
|
||||
*
|
||||
* 业务说明:
|
||||
* - 公司详情是组织架构的枢纽页,顶部展示公司基本信息,下方挂载 3 个子表。
|
||||
* - 子表 1:Department(部门),按 CompanyUUID 过滤,点击行跳转部门详情。
|
||||
* - 子表 2:Workgroup(工作组),按 CompanyUUID 过滤,点击行跳转工作组详情。
|
||||
* - 子表 3:UserRole(员工关联,实际表名 UserRole),按 CompanyUUID 过滤,
|
||||
* 点击行跳转员工详情(employee-detail)。
|
||||
* - 管理员特权模式:URL 携带 admin_verify=1 时跨公司隔离关闭,由 DetailPage 内置
|
||||
* AdminBadge 横幅统一提示,无需本页处理。
|
||||
* - server / admin_verify 参数由 axios 请求拦截器统一注入,子表 CrudTable 与主表
|
||||
* 请求均无需手动拼接 buildApiUrl。
|
||||
*
|
||||
* 关键实现点:
|
||||
* - 主表数据通过 formApi.list('Company', { CompanyUUID, pageSize:1 }) 拉取,响应
|
||||
* 拦截器已解包 {code,msg,data},直接拿到 PagedData;再从 list 中定位精确记录。
|
||||
* - ParentCompanyUUID / TenantUUID 通过 useResolveUUIDs 批量解析为名称回显。
|
||||
* - 编辑按钮复用 DetailPage 的 actions 插槽,弹窗内根据 formApi.getColumns 动态
|
||||
* 渲染表单,保存调用 formApi.update(对齐已迁移详情页的 Modify 全量更新约定)。
|
||||
*/
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import DetailPage from '@/components/DetailPage.vue'
|
||||
import InfoGrid from '@/components/InfoGrid.vue'
|
||||
import CrudTable from '@/components/CrudTable.vue'
|
||||
import { formApi } from '@/api/form'
|
||||
import { useResolveUUIDs } from '@/composables/useResolveUUIDs'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
import type { ColumnSchema } from '@/types/api'
|
||||
|
||||
/** 公司记录类型(仅声明详情页用到的字段) */
|
||||
interface CompanyRecord {
|
||||
CompanyUUID: string
|
||||
CompanyName: string
|
||||
CompanyShort: string
|
||||
CompanyStatus: number
|
||||
ParentCompanyUUID: string
|
||||
TenantUUID: string
|
||||
CompanyCreateTime: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 通用行记录类型 */
|
||||
type RowData = Record<string, unknown>
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { resolveUUIDs } = useResolveUUIDs()
|
||||
|
||||
/** 加载态 */
|
||||
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 */
|
||||
const companyUUID = (route.query.CompanyUUID as string) || ''
|
||||
|
||||
/** 公司主键字段名 */
|
||||
const PK_FIELD = 'CompanyUUID'
|
||||
|
||||
/** 公司状态映射:0=禁用,1=启用 */
|
||||
const companyStatusMap: Record<string, { text: string; type: 'success' | 'danger' }> = {
|
||||
'0': { text: '禁用', type: 'danger' },
|
||||
'1': { text: '启用', type: 'success' },
|
||||
}
|
||||
|
||||
/** 安全读取记录字段为字符串 */
|
||||
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())}`
|
||||
}
|
||||
|
||||
/** 提取分页响应中的列表(兼容数组和 PagedData 两种返回) */
|
||||
function pickList<T = RowData>(res: unknown): T[] {
|
||||
if (Array.isArray(res)) return res as T[]
|
||||
const obj = res as { list?: T[] }
|
||||
return (obj?.list as T[]) || []
|
||||
}
|
||||
|
||||
/** 拉取公司主数据 + 解析关联 UUID */
|
||||
async function loadCompany(): Promise<void> {
|
||||
if (!companyUUID) {
|
||||
errorMsg.value = '缺少 CompanyUUID 参数,无法加载公司详情。'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const res = await formApi.list<CompanyRecord>('Company', {
|
||||
CompanyUUID: companyUUID,
|
||||
pageSize: 1,
|
||||
})
|
||||
const list = pickList<CompanyRecord>(res)
|
||||
const data = list.find((c) => c.CompanyUUID === companyUUID) || list[0]
|
||||
if (!data) {
|
||||
errorMsg.value = '未找到公司数据'
|
||||
return
|
||||
}
|
||||
companyData.value = data
|
||||
setPageTitle(asStr(data.CompanyName) || '公司详情')
|
||||
|
||||
// 解析 ParentCompanyUUID(Company 表)与 TenantUUID(Tenant 表)
|
||||
const uuidMap: Record<string, { table: string; uuids: string[] }> = {}
|
||||
if (data.ParentCompanyUUID) {
|
||||
uuidMap.parentCompany = { table: 'Company', uuids: [data.ParentCompanyUUID] }
|
||||
}
|
||||
if (data.TenantUUID) {
|
||||
uuidMap.tenant = { table: 'Tenant', uuids: [data.TenantUUID] }
|
||||
}
|
||||
if (Object.keys(uuidMap).length) {
|
||||
resolvedNames.value = await resolveUUIDs(uuidMap)
|
||||
}
|
||||
} catch (e) {
|
||||
errorMsg.value = `加载失败: ${e instanceof Error ? e.message : String(e)}`
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 子表行跳转(onView)=====
|
||||
/** 跳转部门详情 */
|
||||
function goDeptDetail(row: RowData): void {
|
||||
router.push({ path: '/company/department-detail', query: { DeptUUID: asStr(row.DeptUUID) } })
|
||||
}
|
||||
/** 跳转工作组详情 */
|
||||
function goWorkgroupDetail(row: RowData): void {
|
||||
router.push({ path: '/company/workgroup-detail', query: { WorkgroupUUID: asStr(row.WorkgroupUUID) } })
|
||||
}
|
||||
/** 跳转员工详情(UserRole 行携带 UserUUID) */
|
||||
function goEmployeeDetail(row: RowData): void {
|
||||
router.push({ path: '/company/employee-detail', query: { UserUUID: asStr(row.UserUUID) } })
|
||||
}
|
||||
|
||||
// ===== 主表编辑弹窗(对齐原生 showEditDialog)=====
|
||||
const editVisible = ref(false)
|
||||
const editSubmitting = ref(false)
|
||||
const editColumns = ref<ColumnSchema[]>([])
|
||||
const editForm = reactive<Record<string, any>>({})
|
||||
|
||||
/** 弹窗内可见的表单字段(排除主键 / 隐藏 / 不可编辑 / 分隔与 HTML) */
|
||||
const editFormColumns = computed<ColumnSchema[]>(() =>
|
||||
editColumns.value.filter(
|
||||
(c) =>
|
||||
c.editable !== false &&
|
||||
!c.hideInForm &&
|
||||
c.type !== 'hidden' &&
|
||||
c.type !== 'divider' &&
|
||||
c.type !== 'html' &&
|
||||
c.field !== PK_FIELD,
|
||||
),
|
||||
)
|
||||
|
||||
/** 打开编辑弹窗:加载列定义并预填当前数据 */
|
||||
async function openEdit(): Promise<void> {
|
||||
if (!companyData.value) return
|
||||
try {
|
||||
if (!editColumns.value.length) {
|
||||
editColumns.value = await formApi.getColumns('Company')
|
||||
}
|
||||
} catch {
|
||||
editColumns.value = []
|
||||
}
|
||||
Object.keys(editForm).forEach((k) => delete editForm[k])
|
||||
Object.assign(editForm, companyData.value)
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
/** 保存编辑(调用 ModifyItem 整行提交) */
|
||||
async function saveEdit(): Promise<void> {
|
||||
if (!companyData.value) return
|
||||
editSubmitting.value = true
|
||||
try {
|
||||
await formApi.update('Company', companyData.value[PK_FIELD] as string, { ...editForm })
|
||||
ElMessage.success('保存成功')
|
||||
editVisible.value = false
|
||||
await loadCompany()
|
||||
} catch {
|
||||
// 错误提示已由响应拦截器统一弹出
|
||||
} finally {
|
||||
editSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadCompany)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DetailPage :title="companyData?.CompanyName || '公司详情'" :loading="loading">
|
||||
<!-- 右上角操作按钮 -->
|
||||
<template #actions>
|
||||
<el-button type="primary" :disabled="!companyData" @click="openEdit">
|
||||
<el-icon><Edit /></el-icon>编辑
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<!-- 错误提示(缺参 / 加载失败) -->
|
||||
<el-alert v-if="errorMsg" type="error" :title="errorMsg" show-icon :closable="false" />
|
||||
|
||||
<!-- 主表基本信息(InfoGrid 直接置于 DetailPage,不包 el-card) -->
|
||||
<InfoGrid v-if="companyData" title="基本信息" :columns="2">
|
||||
<el-descriptions-item label="公司名称">{{ asStr(companyData.CompanyName) || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="公司简称">{{ asStr(companyData.CompanyShort) || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="公司状态">
|
||||
<el-tag :type="companyStatusMap[String(companyData.CompanyStatus)]?.type || 'info'" size="small">
|
||||
{{ companyStatusMap[String(companyData.CompanyStatus)]?.text ?? companyData.CompanyStatus }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="上级公司">
|
||||
{{ resolvedNames.parentCompany?.[companyData.ParentCompanyUUID] || asStr(companyData.ParentCompanyUUID) || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="租户">
|
||||
{{ resolvedNames.tenant?.[companyData.TenantUUID] || asStr(companyData.TenantUUID) || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTime(companyData.CompanyCreateTime) }}</el-descriptions-item>
|
||||
</InfoGrid>
|
||||
|
||||
<!-- 子表 1:部门 -->
|
||||
<el-card v-if="companyData" shadow="never">
|
||||
<CrudTable
|
||||
table="Department"
|
||||
title="部门"
|
||||
:query="{ CompanyUUID: companyUUID }"
|
||||
:selectable="true"
|
||||
:pageable="true"
|
||||
pk-field="DeptUUID"
|
||||
:on-view="goDeptDetail"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 子表 2:工作组 -->
|
||||
<el-card v-if="companyData" shadow="never">
|
||||
<CrudTable
|
||||
table="Workgroup"
|
||||
title="工作组"
|
||||
:query="{ CompanyUUID: companyUUID }"
|
||||
:selectable="true"
|
||||
:pageable="true"
|
||||
pk-field="WorkgroupUUID"
|
||||
:on-view="goWorkgroupDetail"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 子表 3:员工(UserRole 关联表) -->
|
||||
<el-card v-if="companyData" shadow="never">
|
||||
<CrudTable
|
||||
table="UserRole"
|
||||
title="员工"
|
||||
:query="{ CompanyUUID: companyUUID }"
|
||||
:selectable="true"
|
||||
:pageable="true"
|
||||
pk-field="UserRoleUUID"
|
||||
:on-view="goEmployeeDetail"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 主表编辑弹窗 -->
|
||||
<el-dialog v-model="editVisible" title="编辑公司" width="600px" destroy-on-close>
|
||||
<el-form :model="editForm" label-width="120px" label-position="right">
|
||||
<el-form-item
|
||||
v-for="col in editFormColumns"
|
||||
:key="col.field"
|
||||
:label="col.title"
|
||||
:required="col.required"
|
||||
>
|
||||
<!-- 文本类 -->
|
||||
<el-input
|
||||
v-if="!col.type || col.type === 'text' || col.type === 'string'"
|
||||
v-model="editForm[col.field]"
|
||||
:placeholder="col.placeholder"
|
||||
:maxlength="col.maxLength"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<!-- 数值 -->
|
||||
<el-input-number
|
||||
v-else-if="col.type === 'number'"
|
||||
v-model="editForm[col.field]"
|
||||
:min="col.min"
|
||||
:max="col.max"
|
||||
:step="col.step"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<!-- 多行文本 -->
|
||||
<el-input
|
||||
v-else-if="col.type === 'textarea'"
|
||||
v-model="editForm[col.field]"
|
||||
type="textarea"
|
||||
:rows="col.rows || 3"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<!-- 密码 -->
|
||||
<el-input
|
||||
v-else-if="col.type === 'password'"
|
||||
v-model="editForm[col.field]"
|
||||
type="password"
|
||||
show-password
|
||||
:placeholder="col.placeholder"
|
||||
/>
|
||||
<!-- 下拉选择 -->
|
||||
<el-select
|
||||
v-else-if="col.type === 'select'"
|
||||
v-model="editForm[col.field]"
|
||||
:placeholder="col.placeholder"
|
||||
:disabled="col.disabled"
|
||||
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]"
|
||||
:type="col.type as 'date' | 'datetime'"
|
||||
:placeholder="col.placeholder"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<!-- 开关 -->
|
||||
<el-switch
|
||||
v-else-if="col.type === 'switch' || col.type === 'boolean'"
|
||||
v-model="editForm[col.field]"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<!-- 只读 -->
|
||||
<el-input
|
||||
v-else-if="col.type === 'readonly'"
|
||||
:model-value="String(editForm[col.field] ?? '')"
|
||||
readonly
|
||||
/>
|
||||
<!-- 其他类型(entityPicker 等)降级为文本输入 -->
|
||||
<el-input v-else v-model="editForm[col.field]" :placeholder="col.placeholder" :disabled="col.disabled" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="editSubmitting" @click="saveEdit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</DetailPage>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// 子表卡片之间通过 DetailPage 的 detail-content gap 控制间距,此处无需额外样式
|
||||
</style>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 部门管理
|
||||
*
|
||||
* 迁移自 TempLate/GERP/pages/Company/department.html
|
||||
* 原生实现:CompanyTablePage 基类 + tableName='Department' + pkField='DeptUUID'
|
||||
* + defaultColumns fallback + onView 跳详情(无 adminBadge)
|
||||
*
|
||||
* 原生 defaultColumns(仅作 fallback 参考,Vue 版走后端 Columns 接口):
|
||||
* DeptUUID(主键,隐藏表单) / DeptName(部门名称) / DeptSort(排序,number)
|
||||
* / DeptStatus(0禁用/1启用) / CompanyUUID(所属公司,entityPicker,隐藏表单)
|
||||
* / ParentDeptUUID(上级部门,entityPicker 自引用 Department)
|
||||
* / CreateTime(创建时间,只读)
|
||||
*
|
||||
* 特殊业务逻辑:
|
||||
* - ParentDeptUUID 为自引用外键,形成树形结构(部门可挂上级部门)
|
||||
* 原生用普通表格展示,Vue 版暂用普通 CrudTable(TODO:树形展示待接入
|
||||
* vxe-table tree-config + 后端返回 ParentDeptUUID 关联)
|
||||
* - CompanyUUID 强公司隔离:后端自动注入,前端隐藏(hideInForm)
|
||||
*
|
||||
* Vue 版(阶段 1 简化):
|
||||
* - server / admin_verify 由 request.ts 拦截器统一注入,页面不拼接 buildApiUrl
|
||||
* - 列定义优先走后端 /API/FormAPI/Columns/Department(CrudTable 自动处理)
|
||||
* 非主实例的 getDefaultColumns fallback 暂不实现(TODO)
|
||||
* - 无 adminBadge(原生未启用特权徽章)
|
||||
* - role_scope 细粒度判断暂用 userStore.isAdmin 二分(TODO 待 checkAdmin 接入)
|
||||
* - 树形展示暂未实现(TODO:待 vxe-table tree-config 接入)
|
||||
* - onView 跳 Vue 路由 /company/department-detail?DeptUUID=xxx(替代 GERP.Navigate)
|
||||
*/
|
||||
import { useRouter } from 'vue-router'
|
||||
import CrudTable from '@/components/CrudTable.vue'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
|
||||
setPageTitle('部门管理')
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 部门记录类型(仅声明本页用到的字段)
|
||||
interface DepartmentRow {
|
||||
DeptUUID?: string
|
||||
DeptName?: string
|
||||
ParentDeptUUID?: string
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
// 跳转部门详情页
|
||||
function handleView(row: DepartmentRow): void {
|
||||
router.push({
|
||||
path: '/company/department-detail',
|
||||
query: { DeptUUID: row.DeptUUID || '' },
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="department-page">
|
||||
<CrudTable
|
||||
table="Department"
|
||||
title="部门管理"
|
||||
pk-field="DeptUUID"
|
||||
searchable
|
||||
pageable
|
||||
selectable
|
||||
:on-view="handleView"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.department-page {
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,287 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 部门详情页 DepartmentDetail
|
||||
*
|
||||
* 迁移自 TempLate/GERP/pages/Company/department-detail.html
|
||||
*
|
||||
* 业务结构:
|
||||
* 1. 基本信息(InfoGrid):部门名称 / 部门状态 / 上级部门 / 排序 / 创建时间
|
||||
* 2. 部门员工子表(CrudTable: UserRole,按 DeptUUID 过滤)
|
||||
* - 列:UserUUID(entityPicker User,不可编辑) / RoleUUID(entityPicker Role) / RoleType(0员工/1负责人) / CreateTime
|
||||
* 3. actions:编辑按钮(动态编辑弹窗)
|
||||
*
|
||||
* 迁移要点:
|
||||
* - server / admin_verify 由 request.ts 拦截器统一注入
|
||||
* - 主表单条加载:formApi.list('Department', { DeptUUID, pageSize: 1 })
|
||||
* - UUID 解析:上级部门 ParentDeptUUID → DeptName,使用 useResolveUUIDs composable(带缓存)
|
||||
* - 子表列定义由后端 Columns 接口返回(含 RoleType 选项 0员工/1负责人),CrudTable 自动渲染
|
||||
* - onView 跳用户详情:/system/user-detail?UserUUID=xxx(对齐工程统一用户详情入口)
|
||||
*/
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import DetailPage from '@/components/DetailPage.vue'
|
||||
import InfoGrid from '@/components/InfoGrid.vue'
|
||||
import CrudTable from '@/components/CrudTable.vue'
|
||||
import EntityPicker from '@/components/EntityPicker.vue'
|
||||
import { formApi } from '@/api/form'
|
||||
import { useResolveUUIDs } from '@/composables/useResolveUUIDs'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
import type { ColumnSchema } from '@/types/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { resolveUUIDs } = useResolveUUIDs()
|
||||
|
||||
// 路由参数:部门 UUID
|
||||
const deptUUID = (route.query.DeptUUID as string) || ''
|
||||
|
||||
// === 状态 ===
|
||||
const loading = ref(false)
|
||||
const data = ref<Record<string, any> | null>(null)
|
||||
// 上级部门名称(UUID 解析结果)
|
||||
const parentDeptName = ref('')
|
||||
|
||||
// 编辑弹窗状态
|
||||
const editDialogVisible = ref(false)
|
||||
const editSubmitting = ref(false)
|
||||
const columns = ref<ColumnSchema[]>([])
|
||||
const editForm = reactive<Record<string, any>>({})
|
||||
|
||||
// 主键字段
|
||||
const PK_FIELD = 'DeptUUID'
|
||||
|
||||
// 页面标题
|
||||
const title = computed(() => data.value?.DeptName || '部门详情')
|
||||
|
||||
// === 显示格式化 helper ===
|
||||
|
||||
/** 部门状态:1→启用,0→禁用 */
|
||||
function deptStatusTag(v: unknown): { text: string; type: 'success' | 'danger' | 'info' } {
|
||||
if (v === 1 || v === '1') return { text: '启用', type: 'success' }
|
||||
if (v === 0 || v === '0') return { text: '禁用', type: 'danger' }
|
||||
// TODO: 对齐原生 CompanyUtils.statusTag 完整码表
|
||||
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) {
|
||||
ElMessage.warning('缺少 DeptUUID 参数,无法加载部门详情')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await formApi.list('Department', { [PK_FIELD]: deptUUID, pageSize: 1 })
|
||||
const list: any[] = Array.isArray(result) ? result : (result as any)?.list || []
|
||||
const row = list.find((d) => d[PK_FIELD] === deptUUID) || list[0]
|
||||
if (!row) {
|
||||
ElMessage.error('未找到部门数据')
|
||||
return
|
||||
}
|
||||
data.value = row
|
||||
setPageTitle(row.DeptName || '部门详情')
|
||||
// 解析上级部门 UUID → 名称
|
||||
await resolveParentDept(row.ParentDeptUUID)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析 ParentDeptUUID → DeptName */
|
||||
async function resolveParentDept(parentUUID?: string): Promise<void> {
|
||||
if (!parentUUID) {
|
||||
parentDeptName.value = ''
|
||||
return
|
||||
}
|
||||
try {
|
||||
const resolved = await resolveUUIDs({ parentDept: { table: 'Department', uuids: [parentUUID] } })
|
||||
parentDeptName.value = resolved.parentDept?.[parentUUID] || parentUUID
|
||||
} catch {
|
||||
parentDeptName.value = parentUUID
|
||||
}
|
||||
}
|
||||
|
||||
async function loadColumns(): Promise<void> {
|
||||
try {
|
||||
columns.value = await formApi.getColumns('Department')
|
||||
} catch {
|
||||
// 列定义加载失败不阻塞展示
|
||||
}
|
||||
}
|
||||
|
||||
// === 编辑弹窗 ===
|
||||
|
||||
const formColumns = computed(() =>
|
||||
columns.value.filter(
|
||||
(c) =>
|
||||
c.editable !== false &&
|
||||
!c.hideInForm &&
|
||||
c.type !== 'hidden' &&
|
||||
c.type !== 'divider' &&
|
||||
c.type !== 'html' &&
|
||||
c.field !== PK_FIELD,
|
||||
),
|
||||
)
|
||||
|
||||
function handleEdit(): void {
|
||||
if (!data.value) return
|
||||
Object.keys(editForm).forEach((k) => delete editForm[k])
|
||||
Object.assign(editForm, data.value)
|
||||
editDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
editSubmitting.value = true
|
||||
try {
|
||||
await formApi.update('Department', editForm[PK_FIELD], editForm)
|
||||
ElMessage.success('保存成功')
|
||||
editDialogVisible.value = false
|
||||
await loadData()
|
||||
} finally {
|
||||
editSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// === 子表跳转 ===
|
||||
|
||||
/** UserRole 子表查看 → 用户详情 */
|
||||
function goUserDetail(row: Record<string, any>): void {
|
||||
router.push({ path: '/system/user-detail', query: { UserUUID: row.UserUUID || '' } })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadData(), loadColumns()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DetailPage :title="title" :loading="loading">
|
||||
<template #actions>
|
||||
<el-button type="primary" @click="handleEdit">
|
||||
<el-icon><Edit /></el-icon>编辑
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<!-- 基本信息 -->
|
||||
<InfoGrid title="基本信息" :columns="2">
|
||||
<el-descriptions-item label="部门名称">{{ data?.DeptName || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="部门状态">
|
||||
<el-tag :type="deptStatusTag(data?.DeptStatus).type" size="small">
|
||||
{{ deptStatusTag(data?.DeptStatus).text }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="上级部门">
|
||||
{{ 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>
|
||||
</InfoGrid>
|
||||
|
||||
<!-- 部门员工子表 -->
|
||||
<el-card shadow="never">
|
||||
<template #header>部门员工</template>
|
||||
<CrudTable
|
||||
v-if="deptUUID"
|
||||
table="UserRole"
|
||||
title="部门员工"
|
||||
:query="{ DeptUUID: deptUUID }"
|
||||
:pk-field="'UserRoleUUID'"
|
||||
selectable
|
||||
pageable
|
||||
:on-view="goUserDetail"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<el-dialog v-model="editDialogVisible" title="编辑部门" width="600px" destroy-on-close>
|
||||
<el-form :model="editForm" label-width="120px" label-position="right">
|
||||
<el-form-item
|
||||
v-for="col in formColumns"
|
||||
:key="col.field"
|
||||
:label="col.title"
|
||||
:required="col.required"
|
||||
>
|
||||
<EntityPicker
|
||||
v-if="col.type === 'entityPicker' && col.entityPicker"
|
||||
v-model="editForm[col.field]"
|
||||
:config="col.entityPicker"
|
||||
:existing-label="editForm[col.field + '_label']"
|
||||
:placeholder="col.placeholder"
|
||||
:disabled="col.disabled"
|
||||
@update:label="(label: string) => (editForm[col.field + '_label'] = label)"
|
||||
/>
|
||||
<el-input
|
||||
v-else-if="!col.type || col.type === 'text' || col.type === 'string'"
|
||||
v-model="editForm[col.field]"
|
||||
:placeholder="col.placeholder"
|
||||
:maxlength="col.maxLength"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<el-input
|
||||
v-else-if="col.type === 'textarea'"
|
||||
v-model="editForm[col.field]"
|
||||
type="textarea"
|
||||
:rows="col.rows || 3"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<el-input-number
|
||||
v-else-if="col.type === 'number'"
|
||||
v-model="editForm[col.field]"
|
||||
:min="col.min"
|
||||
:max="col.max"
|
||||
:step="col.step"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<el-select
|
||||
v-else-if="col.type === 'select'"
|
||||
v-model="editForm[col.field]"
|
||||
:placeholder="col.placeholder"
|
||||
:disabled="col.disabled"
|
||||
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]"
|
||||
:type="col.type as 'date' | 'datetime'"
|
||||
:placeholder="col.placeholder"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<el-switch
|
||||
v-else-if="col.type === 'switch' || col.type === 'boolean'"
|
||||
v-model="editForm[col.field]"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<el-input
|
||||
v-else
|
||||
:model-value="String(editForm[col.field] ?? '')"
|
||||
readonly
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="editSubmitting" @click="handleSubmit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</DetailPage>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,398 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* EmployeeDetail 员工详情页
|
||||
*
|
||||
* 迁移自原生页面:pages/Company/employee-detail.html
|
||||
*
|
||||
* 业务说明:
|
||||
* - 顶部展示用户基本信息(含头像)+ 安全信息(2FA 状态)。
|
||||
* - 头像:通过 /Query/GetELTUserAvatar 接口获取(el-avatar 直接以 img 形式请求,
|
||||
* 不走 axios 拦截器,故需手动拼接 server / admin_verify 作用域参数)。
|
||||
* - 2FA:调用非标准接口 /API/SiteUserManagement/GetUser2FAInfo 查询双因子状态,
|
||||
* 查询失败不阻塞页面(降级为“未配置”)。
|
||||
* - 子表 1:UserRole(角色与组织),按 UserUUID 过滤。
|
||||
* - 子表 2:UserDept(部门关联),按 UserUUID 过滤,点击行跳转部门详情。
|
||||
* - server / admin_verify 由 axios 请求拦截器统一注入(2FA 接口亦同)。
|
||||
*
|
||||
* 关键实现点:
|
||||
* - 主表数据通过 formApi.list('User', { UserUUID, pageSize:1 }) 拉取并定位记录。
|
||||
* - 2FA 通过 get('/API/SiteUserManagement/GetUser2FAInfo', { UserUUID }) 调用,
|
||||
* 响应拦截器已解包为 data;外层 try/catch 保证失败不阻塞。
|
||||
* - 头像 URL 手动附加 getServerScopeParams() 返回的 server / admin_verify。
|
||||
* - 编辑弹窗动态渲染列定义,保存调用 formApi.update(对齐已迁移详情页约定)。
|
||||
*/
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import DetailPage from '@/components/DetailPage.vue'
|
||||
import InfoGrid from '@/components/InfoGrid.vue'
|
||||
import CrudTable from '@/components/CrudTable.vue'
|
||||
import { formApi } from '@/api/form'
|
||||
import { get } from '@/api/request'
|
||||
import { setPageTitle, getServerScopeParams } from '@/utils/serverScope'
|
||||
import type { ColumnSchema } from '@/types/api'
|
||||
|
||||
/** 用户记录类型(仅声明详情页用到的字段) */
|
||||
interface UserRecord {
|
||||
UserUUID: string
|
||||
UserName: string
|
||||
UserRealName: string
|
||||
UserEmail: string
|
||||
UserTele: string
|
||||
UserAccountLicense: number
|
||||
IsStaff: number | boolean
|
||||
UserCreateTime: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 2FA 信息类型 */
|
||||
interface TwoFaInfo {
|
||||
IsEnabled: number
|
||||
Verified: number
|
||||
LastUsedAt: string
|
||||
CreateTime: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 通用行记录类型 */
|
||||
type RowData = Record<string, unknown>
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
/** 加载态 */
|
||||
const loading = ref(false)
|
||||
/** 用户主数据 */
|
||||
const userData = ref<UserRecord | null>(null)
|
||||
/** 2FA 信息(null 表示未获取或未配置) */
|
||||
const twoFaData = ref<TwoFaInfo | null>(null)
|
||||
/** 错误信息 */
|
||||
const errorMsg = ref('')
|
||||
|
||||
/** 从路由 query 读取用户 UUID */
|
||||
const userUUID = (route.query.UserUUID as string) || ''
|
||||
|
||||
/** 用户主键字段名 */
|
||||
const PK_FIELD = 'UserUUID'
|
||||
|
||||
/** 账号状态映射:0=禁用,1=启用 */
|
||||
const accountStatusMap: Record<string, { text: string; type: 'success' | 'danger' }> = {
|
||||
'0': { text: '禁用', type: 'danger' },
|
||||
'1': { text: '启用', type: 'success' },
|
||||
}
|
||||
|
||||
/** 头像 URL:手动附加 server / admin_verify 作用域参数(el-avatar 不走 axios 拦截器) */
|
||||
const avatarUrl = computed(() => {
|
||||
const params = new URLSearchParams()
|
||||
params.set('UserUUID', userUUID)
|
||||
const scope = getServerScopeParams()
|
||||
if (scope.server) params.set('server', scope.server)
|
||||
if (scope.admin_verify) params.set('admin_verify', scope.admin_verify)
|
||||
return `/Query/GetELTUserAvatar?${params.toString()}`
|
||||
})
|
||||
|
||||
/** 安全读取记录字段为字符串 */
|
||||
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[]
|
||||
const obj = res as { list?: T[] }
|
||||
return (obj?.list as T[]) || []
|
||||
}
|
||||
|
||||
/** IsStaff 转布尔(兼容 0/1 与 true/false) */
|
||||
function isTruthy(val: unknown): boolean {
|
||||
return val === 1 || val === true || val === '1' || val === 'true'
|
||||
}
|
||||
|
||||
/** 拉取用户主数据 + 2FA 信息(2FA 失败不阻塞) */
|
||||
async function loadUser(): Promise<void> {
|
||||
if (!userUUID) {
|
||||
errorMsg.value = '缺少 UserUUID 参数,无法加载员工详情。'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
twoFaData.value = null
|
||||
try {
|
||||
const res = await formApi.list<UserRecord>('User', { UserUUID: userUUID, pageSize: 1 })
|
||||
const list = pickList<UserRecord>(res)
|
||||
const data = list.find((u) => u.UserUUID === userUUID) || list[0]
|
||||
if (!data) {
|
||||
errorMsg.value = '未找到用户数据'
|
||||
return
|
||||
}
|
||||
userData.value = data
|
||||
setPageTitle(asStr(data.UserName) || '员工详情')
|
||||
|
||||
// 并行获取 2FA 信息(失败不阻塞页面)
|
||||
try {
|
||||
twoFaData.value = await get<TwoFaInfo>('/API/SiteUserManagement/GetUser2FAInfo', {
|
||||
UserUUID: userUUID,
|
||||
})
|
||||
} catch {
|
||||
twoFaData.value = null
|
||||
}
|
||||
} catch (e) {
|
||||
errorMsg.value = `加载失败: ${e instanceof Error ? e.message : String(e)}`
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 子表行跳转(onView)=====
|
||||
/** 跳转部门详情(UserDept 行携带 DeptUUID) */
|
||||
function goDeptDetail(row: RowData): void {
|
||||
router.push({ path: '/company/department-detail', query: { DeptUUID: asStr(row.DeptUUID) } })
|
||||
}
|
||||
|
||||
// ===== 主表编辑弹窗(对齐原生 showEditDialog)=====
|
||||
const editVisible = ref(false)
|
||||
const editSubmitting = ref(false)
|
||||
const editColumns = ref<ColumnSchema[]>([])
|
||||
const editForm = reactive<Record<string, any>>({})
|
||||
|
||||
/** 弹窗内可见的表单字段 */
|
||||
const editFormColumns = computed<ColumnSchema[]>(() =>
|
||||
editColumns.value.filter(
|
||||
(c) =>
|
||||
c.editable !== false &&
|
||||
!c.hideInForm &&
|
||||
c.type !== 'hidden' &&
|
||||
c.type !== 'divider' &&
|
||||
c.type !== 'html' &&
|
||||
c.field !== PK_FIELD,
|
||||
),
|
||||
)
|
||||
|
||||
/** 打开编辑弹窗 */
|
||||
async function openEdit(): Promise<void> {
|
||||
if (!userData.value) return
|
||||
try {
|
||||
if (!editColumns.value.length) {
|
||||
editColumns.value = await formApi.getColumns('User')
|
||||
}
|
||||
} catch {
|
||||
editColumns.value = []
|
||||
}
|
||||
Object.keys(editForm).forEach((k) => delete editForm[k])
|
||||
Object.assign(editForm, userData.value)
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
/** 保存编辑 */
|
||||
async function saveEdit(): Promise<void> {
|
||||
if (!userData.value) return
|
||||
editSubmitting.value = true
|
||||
try {
|
||||
await formApi.update('User', userData.value[PK_FIELD] as string, { ...editForm })
|
||||
ElMessage.success('保存成功')
|
||||
editVisible.value = false
|
||||
await loadUser()
|
||||
} catch {
|
||||
// 错误提示已由响应拦截器统一弹出
|
||||
} finally {
|
||||
editSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadUser)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DetailPage :title="userData?.UserName || '员工详情'" :loading="loading">
|
||||
<!-- 右上角操作按钮 -->
|
||||
<template #actions>
|
||||
<el-button type="primary" :disabled="!userData" @click="openEdit">
|
||||
<el-icon><Edit /></el-icon>编辑
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<el-alert v-if="errorMsg" type="error" :title="errorMsg" show-icon :closable="false" />
|
||||
|
||||
<!-- 基本信息(头像 + 信息网格横向排列,直接置于 DetailPage,不包 el-card) -->
|
||||
<div v-if="userData" class="profile-row">
|
||||
<!-- 头像:失败时显示默认图标 -->
|
||||
<el-avatar :size="96" :src="avatarUrl" class="profile-avatar">
|
||||
<el-icon :size="32"><User /></el-icon>
|
||||
</el-avatar>
|
||||
<div class="profile-info">
|
||||
<el-descriptions title="基本信息" :column="2" border>
|
||||
<el-descriptions-item label="用户名">{{ asStr(userData.UserName) || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="真实姓名">{{ asStr(userData.UserRealName) || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="邮箱">{{ asStr(userData.UserEmail) || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="电话">{{ asStr(userData.UserTele) || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="账号状态">
|
||||
<el-tag :type="accountStatusMap[String(userData.UserAccountLicense)]?.type || 'info'" size="small">
|
||||
{{ accountStatusMap[String(userData.UserAccountLicense)]?.text ?? userData.UserAccountLicense }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="是否员工">
|
||||
<span :style="{ color: isTruthy(userData.IsStaff) ? '#166534' : '#991b1b' }">
|
||||
{{ isTruthy(userData.IsStaff) ? '是' : '否' }}
|
||||
</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTime(userData.UserCreateTime) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 安全信息(2FA,InfoGrid 直接置于 DetailPage,不包 el-card) -->
|
||||
<InfoGrid v-if="userData" title="安全信息" :columns="2">
|
||||
<el-descriptions-item label="2FA状态">
|
||||
<span v-if="twoFaData" :style="{ color: twoFaData.IsEnabled === 1 ? '#166534' : '#991b1b' }">
|
||||
{{ twoFaData.IsEnabled === 1 ? '已启用' : '未启用' }}
|
||||
</span>
|
||||
<span v-else style="color: #6b7280">未配置</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="验证状态">
|
||||
<span v-if="twoFaData" :style="{ color: twoFaData.Verified === 1 ? '#166534' : '#991b1b' }">
|
||||
{{ twoFaData.Verified === 1 ? '已验证' : '未验证' }}
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="最后使用">
|
||||
{{ twoFaData && twoFaData.LastUsedAt ? formatTime(twoFaData.LastUsedAt) : '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="2FA创建时间">
|
||||
{{ twoFaData && twoFaData.CreateTime ? formatTime(twoFaData.CreateTime) : '-' }}
|
||||
</el-descriptions-item>
|
||||
</InfoGrid>
|
||||
|
||||
<!-- 子表 1:角色与组织(UserRole) -->
|
||||
<el-card v-if="userData" shadow="never">
|
||||
<CrudTable
|
||||
table="UserRole"
|
||||
title="角色与组织"
|
||||
:query="{ UserUUID: userUUID }"
|
||||
:selectable="true"
|
||||
:pageable="true"
|
||||
pk-field="UserRoleUUID"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 子表 2:部门关联(UserDept) -->
|
||||
<el-card v-if="userData" shadow="never">
|
||||
<CrudTable
|
||||
table="UserDept"
|
||||
title="部门关联"
|
||||
:query="{ UserUUID: userUUID }"
|
||||
:selectable="true"
|
||||
:pageable="true"
|
||||
pk-field="UserDeptUUID"
|
||||
:on-view="goDeptDetail"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 主表编辑弹窗 -->
|
||||
<el-dialog v-model="editVisible" title="编辑员工" width="600px" destroy-on-close>
|
||||
<el-form :model="editForm" label-width="120px" label-position="right">
|
||||
<el-form-item
|
||||
v-for="col in editFormColumns"
|
||||
:key="col.field"
|
||||
:label="col.title"
|
||||
:required="col.required"
|
||||
>
|
||||
<el-input
|
||||
v-if="!col.type || col.type === 'text' || col.type === 'string'"
|
||||
v-model="editForm[col.field]"
|
||||
:placeholder="col.placeholder"
|
||||
:maxlength="col.maxLength"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<el-input-number
|
||||
v-else-if="col.type === 'number'"
|
||||
v-model="editForm[col.field]"
|
||||
:min="col.min"
|
||||
:max="col.max"
|
||||
:step="col.step"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<el-input
|
||||
v-else-if="col.type === 'textarea'"
|
||||
v-model="editForm[col.field]"
|
||||
type="textarea"
|
||||
:rows="col.rows || 3"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<el-input
|
||||
v-else-if="col.type === 'password'"
|
||||
v-model="editForm[col.field]"
|
||||
type="password"
|
||||
show-password
|
||||
:placeholder="col.placeholder"
|
||||
/>
|
||||
<el-select
|
||||
v-else-if="col.type === 'select'"
|
||||
v-model="editForm[col.field]"
|
||||
:placeholder="col.placeholder"
|
||||
:disabled="col.disabled"
|
||||
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]"
|
||||
:type="col.type as 'date' | 'datetime'"
|
||||
:placeholder="col.placeholder"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<el-switch
|
||||
v-else-if="col.type === 'switch' || col.type === 'boolean'"
|
||||
v-model="editForm[col.field]"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<el-input
|
||||
v-else-if="col.type === 'readonly'"
|
||||
:model-value="String(editForm[col.field] ?? '')"
|
||||
readonly
|
||||
/>
|
||||
<el-input v-else v-model="editForm[col.field]" :placeholder="col.placeholder" :disabled="col.disabled" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="editSubmitting" @click="saveEdit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</DetailPage>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// 头像与信息网格横向排列
|
||||
.profile-row {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
flex-shrink: 0;
|
||||
border: 2px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.profile-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 角色管理(Company 模块)
|
||||
*
|
||||
* 迁移自 TempLate/GERP/pages/Company/role.html
|
||||
* 原生实现:CompanyTablePage 基类 + tableName='Role' + pkField='RoleUUID'
|
||||
* + adminBadge:true(特权模式徽章)+ defaultColumns fallback + onView 跳详情
|
||||
*
|
||||
* 原生 defaultColumns(仅作 fallback 参考,Vue 版走后端 Columns 接口):
|
||||
* RoleUUID(主键,隐藏表单) / RoleName(角色名称)
|
||||
* / RoleType(0组员/1组长/2其他) / RoleStatus(0禁用/1启用)
|
||||
* / AssignScope(0全租户/1公司/2部门) / RoleDesc(角色描述)
|
||||
* / CreateTime(创建时间,只读)
|
||||
*
|
||||
* 特殊业务逻辑:
|
||||
* - AssignScope 控制角色分配范围(全租户/公司/部门),影响权限边界
|
||||
* - RoleType 区分组员/组长/其他,原生用 select 渲染
|
||||
*
|
||||
* Vue 版(阶段 1 简化):
|
||||
* - server / admin_verify 由 request.ts 拦截器统一注入,页面不拼接 buildApiUrl
|
||||
* - 列定义优先走后端 /API/FormAPI/Columns/Role(CrudTable 自动处理)
|
||||
* 非主实例的 getDefaultColumns fallback 暂不实现(TODO)
|
||||
* - AdminBadge 自动根据 server/admin_verify 显示特权横幅(对齐原生 adminBadge:true)
|
||||
* - role_scope 细粒度判断暂用 userStore.isAdmin 二分(TODO 待 checkAdmin 接入)
|
||||
* - onView 跳 Vue 路由 /company/role-detail?RoleUUID=xxx(替代 GERP.Navigate)
|
||||
* - 注意:与 system/Roles.vue(system 模块角色管理)区分,两者迁移来源与路由不同
|
||||
*/
|
||||
import { useRouter } from 'vue-router'
|
||||
import CrudTable from '@/components/CrudTable.vue'
|
||||
import AdminBadge from '@/components/AdminBadge.vue'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
|
||||
setPageTitle('角色管理')
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 角色记录类型(仅声明本页用到的字段)
|
||||
interface RoleRow {
|
||||
RoleUUID?: string
|
||||
RoleName?: string
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
// 跳转角色详情页
|
||||
function handleView(row: RoleRow): void {
|
||||
router.push({
|
||||
path: '/company/role-detail',
|
||||
query: { RoleUUID: row.RoleUUID || '' },
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="role-page">
|
||||
<!-- 管理员特权横幅(仅特权模式显示) -->
|
||||
<AdminBadge />
|
||||
<CrudTable
|
||||
table="Role"
|
||||
title="角色管理"
|
||||
pk-field="RoleUUID"
|
||||
searchable
|
||||
pageable
|
||||
selectable
|
||||
:on-view="handleView"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.role-page {
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,332 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* RoleDetail 角色详情页
|
||||
*
|
||||
* 迁移自原生页面:pages/Company/role-detail.html
|
||||
*
|
||||
* 业务说明:
|
||||
* - 顶部展示角色基本信息(角色名/类型/状态/分配范围/描述/创建时间)。
|
||||
* - 角色类型 RoleType:0=组员,1=组长,2=其他。
|
||||
* - 分配范围 AssignScope:0=全租户,1=公司,2=部门。
|
||||
* - 范围提示横幅:特权模式下提示“显示所有公司关联记录(无公司隔离)”,
|
||||
* 普通模式下提示“仅显示当前公司数据,查看全部需管理员特权”。
|
||||
* DetailPage 已内置 AdminBadge 顶部横幅,本页在基本信息下方再补充一条作用域
|
||||
* 说明(scope-banner),对齐原生两态提示。
|
||||
* - 子表 1:RolePermission(权限列表),按 RoleUUID 过滤。
|
||||
* - 子表 2:UserRole(关联用户),按 RoleUUID 过滤。
|
||||
* - server / admin_verify 由请求拦截器统一注入。
|
||||
*
|
||||
* 关键实现点:
|
||||
* - 主表数据通过 formApi.list('Role', { RoleUUID, pageSize:1 }) 拉取并定位记录。
|
||||
* - 是否特权模式由 isDefaultServer()/hasAdminVerifyArg() 综合判断(与 AdminBadge 一致)。
|
||||
* - 编辑弹窗动态渲染列定义,保存调用 formApi.update(对齐已迁移详情页约定)。
|
||||
*/
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import DetailPage from '@/components/DetailPage.vue'
|
||||
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 type { ColumnSchema } from '@/types/api'
|
||||
|
||||
/** 角色记录类型(仅声明详情页用到的字段) */
|
||||
interface RoleRecord {
|
||||
RoleUUID: string
|
||||
RoleName: string
|
||||
RoleType: number
|
||||
RoleStatus: number
|
||||
AssignScope: number
|
||||
RoleDesc: string
|
||||
CreateTime: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 通用行记录类型 */
|
||||
type RowData = Record<string, unknown>
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
/** 加载态 */
|
||||
const loading = ref(false)
|
||||
/** 角色主数据 */
|
||||
const roleData = ref<RoleRecord | null>(null)
|
||||
/** 错误信息 */
|
||||
const errorMsg = ref('')
|
||||
|
||||
/** 从路由 query 读取角色 UUID */
|
||||
const roleUUID = (route.query.RoleUUID as string) || ''
|
||||
|
||||
/** 角色主键字段名 */
|
||||
const PK_FIELD = 'RoleUUID'
|
||||
|
||||
/** 角色类型映射:0=组员,1=组长,2=其他 */
|
||||
const roleTypeMap: Record<string, string> = { '0': '组员', '1': '组长', '2': '其他' }
|
||||
|
||||
/** 分配范围映射:0=全租户,1=公司,2=部门 */
|
||||
const assignScopeMap: Record<string, string> = { '0': '全租户', '1': '公司', '2': '部门' }
|
||||
|
||||
/** 角色状态映射:0=禁用,1=启用 */
|
||||
const roleStatusMap: Record<string, { text: string; type: 'success' | 'danger' }> = {
|
||||
'0': { text: '禁用', type: 'danger' },
|
||||
'1': { text: '启用', type: 'success' },
|
||||
}
|
||||
|
||||
/** 是否特权模式(与 AdminBadge 判定逻辑一致) */
|
||||
const isAdminMode = computed(() => !isDefaultServer() || hasAdminVerifyArg())
|
||||
|
||||
/** 作用域提示文案:特权模式 vs 普通模式 */
|
||||
const scopeHint = computed(() =>
|
||||
isAdminMode.value
|
||||
? '特权模式:显示该角色在所有公司的关联记录(无公司隔离)'
|
||||
: '仅显示当前公司数据。如需查看所有公司记录,需管理员特权。',
|
||||
)
|
||||
|
||||
/** 安全读取记录字段为字符串 */
|
||||
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[]
|
||||
const obj = res as { list?: T[] }
|
||||
return (obj?.list as T[]) || []
|
||||
}
|
||||
|
||||
/** 拉取角色主数据 */
|
||||
async function loadRole(): Promise<void> {
|
||||
if (!roleUUID) {
|
||||
errorMsg.value = '缺少 RoleUUID 参数,无法加载角色详情。'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const res = await formApi.list<RoleRecord>('Role', { RoleUUID: roleUUID, pageSize: 1 })
|
||||
const list = pickList<RoleRecord>(res)
|
||||
const data = list.find((r) => r.RoleUUID === roleUUID) || 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)}`
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 主表编辑弹窗(对齐原生 showEditDialog)=====
|
||||
const editVisible = ref(false)
|
||||
const editSubmitting = ref(false)
|
||||
const editColumns = ref<ColumnSchema[]>([])
|
||||
const editForm = reactive<Record<string, any>>({})
|
||||
|
||||
/** 弹窗内可见的表单字段 */
|
||||
const editFormColumns = computed<ColumnSchema[]>(() =>
|
||||
editColumns.value.filter(
|
||||
(c) =>
|
||||
c.editable !== false &&
|
||||
!c.hideInForm &&
|
||||
c.type !== 'hidden' &&
|
||||
c.type !== 'divider' &&
|
||||
c.type !== 'html' &&
|
||||
c.field !== PK_FIELD,
|
||||
),
|
||||
)
|
||||
|
||||
/** 打开编辑弹窗 */
|
||||
async function openEdit(): Promise<void> {
|
||||
if (!roleData.value) return
|
||||
try {
|
||||
if (!editColumns.value.length) {
|
||||
editColumns.value = await formApi.getColumns('Role')
|
||||
}
|
||||
} catch {
|
||||
editColumns.value = []
|
||||
}
|
||||
Object.keys(editForm).forEach((k) => delete editForm[k])
|
||||
Object.assign(editForm, roleData.value)
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
/** 保存编辑 */
|
||||
async function saveEdit(): Promise<void> {
|
||||
if (!roleData.value) return
|
||||
editSubmitting.value = true
|
||||
try {
|
||||
await formApi.update('Role', roleData.value[PK_FIELD] as string, { ...editForm })
|
||||
ElMessage.success('保存成功')
|
||||
editVisible.value = false
|
||||
await loadRole()
|
||||
} catch {
|
||||
// 错误提示已由响应拦截器统一弹出
|
||||
} finally {
|
||||
editSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadRole)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DetailPage :title="roleData?.RoleName || '角色详情'" :loading="loading">
|
||||
<!-- 右上角操作按钮 -->
|
||||
<template #actions>
|
||||
<el-button type="primary" :disabled="!roleData" @click="openEdit">
|
||||
<el-icon><Edit /></el-icon>编辑
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<el-alert v-if="errorMsg" type="error" :title="errorMsg" show-icon :closable="false" />
|
||||
|
||||
<!-- 主表基本信息(InfoGrid 直接置于 DetailPage,不包 el-card) -->
|
||||
<InfoGrid v-if="roleData" 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="状态">
|
||||
<el-tag :type="roleStatusMap[String(roleData.RoleStatus)]?.type || 'info'" size="small">
|
||||
{{ roleStatusMap[String(roleData.RoleStatus)]?.text ?? roleData.RoleStatus }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="分配范围">{{ assignScopeMap[String(roleData.AssignScope)] ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="角色描述">{{ asStr(roleData.RoleDesc) || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTime(roleData.CreateTime) }}</el-descriptions-item>
|
||||
</InfoGrid>
|
||||
|
||||
<!-- 作用域范围提示横幅(scope-banner) -->
|
||||
<el-alert
|
||||
v-if="roleData"
|
||||
:type="isAdminMode ? 'warning' : 'info'"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="scope-banner"
|
||||
>
|
||||
{{ scopeHint }}
|
||||
</el-alert>
|
||||
|
||||
<!-- 子表 1:权限列表 -->
|
||||
<el-card v-if="roleData" shadow="never">
|
||||
<CrudTable
|
||||
table="RolePermission"
|
||||
title="权限列表"
|
||||
:query="{ RoleUUID: roleUUID }"
|
||||
:selectable="true"
|
||||
:pageable="true"
|
||||
pk-field="RolePermUUID"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 子表 2:关联用户 -->
|
||||
<el-card v-if="roleData" shadow="never">
|
||||
<CrudTable
|
||||
table="UserRole"
|
||||
title="关联用户"
|
||||
:query="{ RoleUUID: roleUUID }"
|
||||
:selectable="true"
|
||||
:pageable="true"
|
||||
pk-field="UserRoleUUID"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 主表编辑弹窗 -->
|
||||
<el-dialog v-model="editVisible" title="编辑角色" width="600px" destroy-on-close>
|
||||
<el-form :model="editForm" label-width="120px" label-position="right">
|
||||
<el-form-item
|
||||
v-for="col in editFormColumns"
|
||||
:key="col.field"
|
||||
:label="col.title"
|
||||
:required="col.required"
|
||||
>
|
||||
<el-input
|
||||
v-if="!col.type || col.type === 'text' || col.type === 'string'"
|
||||
v-model="editForm[col.field]"
|
||||
:placeholder="col.placeholder"
|
||||
:maxlength="col.maxLength"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<el-input-number
|
||||
v-else-if="col.type === 'number'"
|
||||
v-model="editForm[col.field]"
|
||||
:min="col.min"
|
||||
:max="col.max"
|
||||
:step="col.step"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<el-input
|
||||
v-else-if="col.type === 'textarea'"
|
||||
v-model="editForm[col.field]"
|
||||
type="textarea"
|
||||
:rows="col.rows || 3"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<el-input
|
||||
v-else-if="col.type === 'password'"
|
||||
v-model="editForm[col.field]"
|
||||
type="password"
|
||||
show-password
|
||||
:placeholder="col.placeholder"
|
||||
/>
|
||||
<el-select
|
||||
v-else-if="col.type === 'select'"
|
||||
v-model="editForm[col.field]"
|
||||
:placeholder="col.placeholder"
|
||||
:disabled="col.disabled"
|
||||
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]"
|
||||
:type="col.type as 'date' | 'datetime'"
|
||||
:placeholder="col.placeholder"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<el-switch
|
||||
v-else-if="col.type === 'switch' || col.type === 'boolean'"
|
||||
v-model="editForm[col.field]"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<el-input
|
||||
v-else-if="col.type === 'readonly'"
|
||||
:model-value="String(editForm[col.field] ?? '')"
|
||||
readonly
|
||||
/>
|
||||
<el-input v-else v-model="editForm[col.field]" :placeholder="col.placeholder" :disabled="col.disabled" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="editSubmitting" @click="saveEdit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</DetailPage>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// 作用域提示横幅底部留白
|
||||
.scope-banner {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 租户管理
|
||||
*
|
||||
* 迁移自 TempLate/GERP/pages/Company/tenant.html
|
||||
* 原生实现:CompanyTablePage 基类 + tableName='Tenant' + pkField='TenantUUID'
|
||||
* + adminBadge:true(特权模式徽章)+ defaultColumns fallback + onView 跳详情
|
||||
*
|
||||
* 原生 defaultColumns(仅作 fallback 参考,Vue 版走后端 Columns 接口):
|
||||
* TenantUUID(主键,隐藏表单) / TenantName(文本) / TenantStatus(0禁用/1启用)
|
||||
* / ReviewStatus(0待审核/1已通过/2已拒绝) / TenantExpireTime(日期时间)
|
||||
* / ReviewRemark(文本) / CreateTime(创建时间,只读)
|
||||
*
|
||||
* Vue 版(阶段 1 简化):
|
||||
* - server / admin_verify 由 request.ts 拦截器统一注入,页面不拼接 buildApiUrl
|
||||
* - 列定义优先走后端 /API/FormAPI/Columns/Tenant(CrudTable 自动处理)
|
||||
* 非主实例的 getDefaultColumns fallback 暂不实现(TODO)
|
||||
* - AdminBadge 自动根据 server/admin_verify 显示特权横幅(对齐原生 adminBadge:true)
|
||||
* - role_scope 细粒度判断暂用 userStore.isAdmin 二分(TODO 待 checkAdmin 接入)
|
||||
* - onView 跳 Vue 路由 /company/tenant-detail?TenantUUID=xxx(替代 GERP.Navigate)
|
||||
* - 页面标题随 server 变化由 setPageTitle 统一处理
|
||||
*/
|
||||
import { useRouter } from 'vue-router'
|
||||
import CrudTable from '@/components/CrudTable.vue'
|
||||
import AdminBadge from '@/components/AdminBadge.vue'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
|
||||
setPageTitle('租户管理')
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 租户记录类型(仅声明本页用到的字段)
|
||||
interface TenantRow {
|
||||
TenantUUID?: string
|
||||
TenantName?: string
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
// 跳转租户详情页
|
||||
function handleView(row: TenantRow): void {
|
||||
router.push({
|
||||
path: '/company/tenant-detail',
|
||||
query: { TenantUUID: row.TenantUUID || '' },
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tenant-page">
|
||||
<!-- 管理员特权横幅(仅特权模式显示) -->
|
||||
<AdminBadge />
|
||||
<CrudTable
|
||||
table="Tenant"
|
||||
title="租户管理"
|
||||
pk-field="TenantUUID"
|
||||
searchable
|
||||
pageable
|
||||
selectable
|
||||
:on-view="handleView"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tenant-page {
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,283 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 租户详情页 TenantDetail
|
||||
*
|
||||
* 迁移自 TempLate/GERP/pages/Company/tenant-detail.html
|
||||
*
|
||||
* 业务结构:
|
||||
* 1. 基本信息(InfoGrid):租户名称 / 租户状态 / 到期时间 / 创建时间
|
||||
* 2. 审核信息(InfoGrid):审核状态 / 审核时间 / 审核备注
|
||||
* 3. 关联公司子表(CrudTable: Company,按 TenantUUID 过滤)
|
||||
* 4. actions:编辑按钮(动态编辑弹窗)
|
||||
*
|
||||
* 迁移要点:
|
||||
* - server / admin_verify 由 request.ts 拦截器统一注入
|
||||
* - 主表单条加载:formApi.list('Tenant', { TenantUUID, pageSize: 1 })
|
||||
* - 审核状态码:0→待审核,1→已通过,2→已拒绝
|
||||
* - 租户状态码:0→禁用,1→启用
|
||||
* - 原生 _isAdminMode(hasAdminVerifyArg)由拦截器统一处理,无需手动注入
|
||||
*/
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import DetailPage from '@/components/DetailPage.vue'
|
||||
import InfoGrid from '@/components/InfoGrid.vue'
|
||||
import CrudTable from '@/components/CrudTable.vue'
|
||||
import EntityPicker from '@/components/EntityPicker.vue'
|
||||
import { formApi } from '@/api/form'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
import type { ColumnSchema } from '@/types/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 路由参数:租户 UUID
|
||||
const tenantUUID = (route.query.TenantUUID as string) || ''
|
||||
|
||||
// === 状态 ===
|
||||
const loading = ref(false)
|
||||
const data = ref<Record<string, any> | null>(null)
|
||||
|
||||
// 编辑弹窗状态
|
||||
const editDialogVisible = ref(false)
|
||||
const editSubmitting = ref(false)
|
||||
const columns = ref<ColumnSchema[]>([])
|
||||
const editForm = reactive<Record<string, any>>({})
|
||||
|
||||
// 主键字段
|
||||
const PK_FIELD = 'TenantUUID'
|
||||
|
||||
// 页面标题
|
||||
const title = computed(() => data.value?.TenantName || '租户详情')
|
||||
|
||||
// === 显示格式化 helper ===
|
||||
|
||||
/** 租户状态:0→禁用,1→启用 */
|
||||
function tenantStatusTag(v: unknown): { text: string; type: 'success' | 'danger' | 'info' } {
|
||||
if (v === 1 || v === '1') return { text: '启用', type: 'success' }
|
||||
if (v === 0 || v === '0') return { text: '禁用', type: 'danger' }
|
||||
return { text: String(v ?? '-'), type: 'info' }
|
||||
}
|
||||
|
||||
/** 审核状态:0→待审核,1→已通过,2→已拒绝 */
|
||||
function reviewStatusTag(v: unknown): { text: string; type: 'success' | 'danger' | 'warning' | 'info' } {
|
||||
if (v === 0 || v === '0') return { text: '待审核', type: 'warning' }
|
||||
if (v === 1 || v === '1') return { text: '已通过', type: 'success' }
|
||||
if (v === 2 || v === '2') return { text: '已拒绝', type: '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(
|
||||
(c) =>
|
||||
c.editable !== false &&
|
||||
!c.hideInForm &&
|
||||
c.type !== 'hidden' &&
|
||||
c.type !== 'divider' &&
|
||||
c.type !== 'html' &&
|
||||
c.field !== PK_FIELD,
|
||||
),
|
||||
)
|
||||
|
||||
// === 数据加载 ===
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
if (!tenantUUID) {
|
||||
ElMessage.warning('缺少 TenantUUID 参数,无法加载租户详情')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await formApi.list('Tenant', { [PK_FIELD]: tenantUUID, pageSize: 1 })
|
||||
const list: any[] = Array.isArray(result) ? result : (result as any)?.list || []
|
||||
const row = list.find((t) => t[PK_FIELD] === tenantUUID) || list[0]
|
||||
if (!row) {
|
||||
ElMessage.error('未找到租户数据')
|
||||
return
|
||||
}
|
||||
data.value = row
|
||||
setPageTitle(row.TenantName || '租户详情')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadColumns(): Promise<void> {
|
||||
try {
|
||||
columns.value = await formApi.getColumns('Tenant')
|
||||
} catch {
|
||||
// 列定义加载失败不阻塞展示
|
||||
}
|
||||
}
|
||||
|
||||
// === 编辑弹窗 ===
|
||||
|
||||
function handleEdit(): void {
|
||||
if (!data.value) return
|
||||
Object.keys(editForm).forEach((k) => delete editForm[k])
|
||||
Object.assign(editForm, data.value)
|
||||
editDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
editSubmitting.value = true
|
||||
try {
|
||||
await formApi.update('Tenant', editForm[PK_FIELD], editForm)
|
||||
ElMessage.success('保存成功')
|
||||
editDialogVisible.value = false
|
||||
await loadData()
|
||||
} finally {
|
||||
editSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// === 子表跳转 ===
|
||||
|
||||
/** Company 子表查看 → 公司详情 */
|
||||
function goCompanyDetail(row: Record<string, any>): void {
|
||||
router.push({ path: '/company/company-detail', query: { CompanyUUID: row.CompanyUUID || '' } })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadData(), loadColumns()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DetailPage :title="title" :loading="loading">
|
||||
<template #actions>
|
||||
<el-button type="primary" @click="handleEdit">
|
||||
<el-icon><Edit /></el-icon>编辑
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<!-- 基本信息 -->
|
||||
<InfoGrid title="基本信息" :columns="2">
|
||||
<el-descriptions-item label="租户名称">{{ data?.TenantName || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="租户状态">
|
||||
<el-tag :type="tenantStatusTag(data?.TenantStatus).type" size="small">
|
||||
{{ 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>
|
||||
</InfoGrid>
|
||||
|
||||
<!-- 审核信息 -->
|
||||
<InfoGrid title="审核信息" :columns="2">
|
||||
<el-descriptions-item label="审核状态">
|
||||
<el-tag :type="reviewStatusTag(data?.ReviewStatus).type" size="small">
|
||||
{{ reviewStatusTag(data?.ReviewStatus).text }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="审核时间">{{ formatTime(data?.ReviewTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="审核备注">{{ data?.ReviewRemark || '-' }}</el-descriptions-item>
|
||||
</InfoGrid>
|
||||
|
||||
<!-- 关联公司子表 -->
|
||||
<el-card shadow="never">
|
||||
<template #header>关联公司</template>
|
||||
<CrudTable
|
||||
v-if="tenantUUID"
|
||||
table="Company"
|
||||
title="租户公司"
|
||||
:query="{ TenantUUID: tenantUUID }"
|
||||
:pk-field="'CompanyUUID'"
|
||||
selectable
|
||||
pageable
|
||||
:on-view="goCompanyDetail"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<el-dialog v-model="editDialogVisible" title="编辑租户" width="600px" destroy-on-close>
|
||||
<el-form :model="editForm" label-width="120px" label-position="right">
|
||||
<el-form-item
|
||||
v-for="col in formColumns"
|
||||
:key="col.field"
|
||||
:label="col.title"
|
||||
:required="col.required"
|
||||
>
|
||||
<EntityPicker
|
||||
v-if="col.type === 'entityPicker' && col.entityPicker"
|
||||
v-model="editForm[col.field]"
|
||||
:config="col.entityPicker"
|
||||
:existing-label="editForm[col.field + '_label']"
|
||||
:placeholder="col.placeholder"
|
||||
:disabled="col.disabled"
|
||||
@update:label="(label: string) => (editForm[col.field + '_label'] = label)"
|
||||
/>
|
||||
<el-input
|
||||
v-else-if="!col.type || col.type === 'text' || col.type === 'string'"
|
||||
v-model="editForm[col.field]"
|
||||
:placeholder="col.placeholder"
|
||||
:maxlength="col.maxLength"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<el-input
|
||||
v-else-if="col.type === 'textarea'"
|
||||
v-model="editForm[col.field]"
|
||||
type="textarea"
|
||||
:rows="col.rows || 3"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<el-input-number
|
||||
v-else-if="col.type === 'number'"
|
||||
v-model="editForm[col.field]"
|
||||
:min="col.min"
|
||||
:max="col.max"
|
||||
:step="col.step"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<el-select
|
||||
v-else-if="col.type === 'select'"
|
||||
v-model="editForm[col.field]"
|
||||
:placeholder="col.placeholder"
|
||||
:disabled="col.disabled"
|
||||
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]"
|
||||
:type="col.type as 'date' | 'datetime'"
|
||||
:placeholder="col.placeholder"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<el-switch
|
||||
v-else-if="col.type === 'switch' || col.type === 'boolean'"
|
||||
v-model="editForm[col.field]"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<el-input
|
||||
v-else
|
||||
:model-value="String(editForm[col.field] ?? '')"
|
||||
readonly
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="editSubmitting" @click="handleSubmit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</DetailPage>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 工作组管理
|
||||
*
|
||||
* 迁移自 TempLate/GERP/pages/Company/workgroup.html
|
||||
* 原生实现:CompanyTablePage 基类 + tableName='Workgroup'
|
||||
* + pkField 默认='WorkgroupUUID' + defaultColumns fallback + onView 跳详情
|
||||
* (无 adminBadge,无显式 pkField,由基类默认推导)
|
||||
*
|
||||
* 原生 defaultColumns(仅作 fallback 参考,Vue 版走后端 Columns 接口):
|
||||
* WorkgroupUUID(主键,隐藏表单) / WorkgroupName(工作组名称)
|
||||
* / WorkgroupEnglishName(英文名称) / WorkgroupIntroduction(工作组介绍,textarea)
|
||||
* / CompanyUUID(所属公司,entityPicker)
|
||||
*
|
||||
* 特殊业务逻辑:
|
||||
* - 无状态字段、无创建时间字段(列定义较精简)
|
||||
* - CompanyUUID 为 entityPicker(CompanySearch 搜索),强公司隔离由后端注入
|
||||
*
|
||||
* Vue 版(阶段 1 简化):
|
||||
* - server / admin_verify 由 request.ts 拦截器统一注入,页面不拼接 buildApiUrl
|
||||
* - 列定义优先走后端 /API/FormAPI/Columns/Workgroup(CrudTable 自动处理)
|
||||
* 非主实例的 getDefaultColumns fallback 暂不实现(TODO)
|
||||
* - 无 adminBadge(原生未启用特权徽章)
|
||||
* - role_scope 细粒度判断暂用 userStore.isAdmin 二分(TODO 待 checkAdmin 接入)
|
||||
* - onView 跳 Vue 路由 /company/workgroup-detail?WorkgroupUUID=xxx(替代 GERP.Navigate)
|
||||
*/
|
||||
import { useRouter } from 'vue-router'
|
||||
import CrudTable from '@/components/CrudTable.vue'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
|
||||
setPageTitle('工作组管理')
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 工作组记录类型(仅声明本页用到的字段)
|
||||
interface WorkgroupRow {
|
||||
WorkgroupUUID?: string
|
||||
WorkgroupName?: string
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
// 跳转工作组详情页
|
||||
function handleView(row: WorkgroupRow): void {
|
||||
router.push({
|
||||
path: '/company/workgroup-detail',
|
||||
query: { WorkgroupUUID: row.WorkgroupUUID || '' },
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="workgroup-page">
|
||||
<CrudTable
|
||||
table="Workgroup"
|
||||
title="工作组管理"
|
||||
pk-field="WorkgroupUUID"
|
||||
searchable
|
||||
pageable
|
||||
selectable
|
||||
:on-view="handleView"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.workgroup-page {
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,262 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 工作组详情页 WorkgroupDetail
|
||||
*
|
||||
* 迁移自 TempLate/GERP/pages/Company/workgroup-detail.html
|
||||
*
|
||||
* 业务结构:
|
||||
* 1. 基本信息(InfoGrid):工作组名称 / 英文名称 / 所属公司 / 工作组介绍
|
||||
* 2. 工作组角色子表(CrudTable: WorkgroupRole,按 WorkgroupUUID 过滤)
|
||||
* - 列:RoleUUID(entityPicker Role) / RoleType(0组员/1组长) / CreateTime
|
||||
* 3. actions:编辑按钮(动态编辑弹窗)
|
||||
*
|
||||
* 迁移要点:
|
||||
* - server / admin_verify 由 request.ts 拦截器统一注入
|
||||
* - 主表单条加载:formApi.list('Workgroup', { WorkgroupUUID, pageSize: 1 })
|
||||
* - UUID 解析:所属公司 CompanyUUID → CompanyName,使用 useResolveUUIDs composable(带缓存)
|
||||
* - 原生 buildApiUrl 无 admin_verify 分支(本页未启用特权模式),拦截器按 URL 自动处理
|
||||
*/
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import DetailPage from '@/components/DetailPage.vue'
|
||||
import InfoGrid from '@/components/InfoGrid.vue'
|
||||
import CrudTable from '@/components/CrudTable.vue'
|
||||
import EntityPicker from '@/components/EntityPicker.vue'
|
||||
import { formApi } from '@/api/form'
|
||||
import { useResolveUUIDs } from '@/composables/useResolveUUIDs'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
import type { ColumnSchema } from '@/types/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { resolveUUIDs } = useResolveUUIDs()
|
||||
|
||||
// 路由参数:工作组 UUID
|
||||
const workgroupUUID = (route.query.WorkgroupUUID as string) || ''
|
||||
|
||||
// === 状态 ===
|
||||
const loading = ref(false)
|
||||
const data = ref<Record<string, any> | null>(null)
|
||||
// 所属公司名称(UUID 解析结果)
|
||||
const companyName = ref('')
|
||||
|
||||
// 编辑弹窗状态
|
||||
const editDialogVisible = ref(false)
|
||||
const editSubmitting = ref(false)
|
||||
const columns = ref<ColumnSchema[]>([])
|
||||
const editForm = reactive<Record<string, any>>({})
|
||||
|
||||
// 主键字段
|
||||
const PK_FIELD = 'WorkgroupUUID'
|
||||
|
||||
// 页面标题
|
||||
const title = computed(() => data.value?.WorkgroupName || '工作组详情')
|
||||
|
||||
// === 数据加载 ===
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
if (!workgroupUUID) {
|
||||
ElMessage.warning('缺少 WorkgroupUUID 参数,无法加载工作组详情')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await formApi.list('Workgroup', { [PK_FIELD]: workgroupUUID, pageSize: 1 })
|
||||
const list: any[] = Array.isArray(result) ? result : (result as any)?.list || []
|
||||
const row = list.find((w) => w[PK_FIELD] === workgroupUUID) || list[0]
|
||||
if (!row) {
|
||||
ElMessage.error('未找到工作组数据')
|
||||
return
|
||||
}
|
||||
data.value = row
|
||||
setPageTitle(row.WorkgroupName || '工作组详情')
|
||||
// 解析所属公司 UUID → 名称
|
||||
await resolveCompany(row.CompanyUUID)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析 CompanyUUID → CompanyName */
|
||||
async function resolveCompany(companyUUID?: string): Promise<void> {
|
||||
if (!companyUUID) {
|
||||
companyName.value = ''
|
||||
return
|
||||
}
|
||||
try {
|
||||
const resolved = await resolveUUIDs({ company: { table: 'Company', uuids: [companyUUID] } })
|
||||
companyName.value = resolved.company?.[companyUUID] || companyUUID
|
||||
} catch {
|
||||
companyName.value = companyUUID
|
||||
}
|
||||
}
|
||||
|
||||
async function loadColumns(): Promise<void> {
|
||||
try {
|
||||
columns.value = await formApi.getColumns('Workgroup')
|
||||
} catch {
|
||||
// 列定义加载失败不阻塞展示
|
||||
}
|
||||
}
|
||||
|
||||
// === 编辑弹窗 ===
|
||||
|
||||
const formColumns = computed(() =>
|
||||
columns.value.filter(
|
||||
(c) =>
|
||||
c.editable !== false &&
|
||||
!c.hideInForm &&
|
||||
c.type !== 'hidden' &&
|
||||
c.type !== 'divider' &&
|
||||
c.type !== 'html' &&
|
||||
c.field !== PK_FIELD,
|
||||
),
|
||||
)
|
||||
|
||||
function handleEdit(): void {
|
||||
if (!data.value) return
|
||||
Object.keys(editForm).forEach((k) => delete editForm[k])
|
||||
Object.assign(editForm, data.value)
|
||||
editDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
editSubmitting.value = true
|
||||
try {
|
||||
await formApi.update('Workgroup', editForm[PK_FIELD], editForm)
|
||||
ElMessage.success('保存成功')
|
||||
editDialogVisible.value = false
|
||||
await loadData()
|
||||
} finally {
|
||||
editSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// === 子表跳转 ===
|
||||
|
||||
/** WorkgroupRole 子表查看 → 角色详情 */
|
||||
function goRoleDetail(row: Record<string, any>): void {
|
||||
router.push({ path: '/system/role-detail', query: { RoleUUID: row.RoleUUID || '' } })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadData(), loadColumns()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DetailPage :title="title" :loading="loading">
|
||||
<template #actions>
|
||||
<el-button type="primary" @click="handleEdit">
|
||||
<el-icon><Edit /></el-icon>编辑
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<!-- 基本信息 -->
|
||||
<InfoGrid title="基本信息" :columns="2">
|
||||
<el-descriptions-item label="工作组名称">{{ data?.WorkgroupName || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="英文名称">{{ data?.WorkgroupEnglishName || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="所属公司">
|
||||
{{ companyName || data?.CompanyUUID || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="工作组介绍">{{ data?.WorkgroupIntroduction || '-' }}</el-descriptions-item>
|
||||
</InfoGrid>
|
||||
|
||||
<!-- 工作组角色子表 -->
|
||||
<el-card shadow="never">
|
||||
<template #header>工作组角色</template>
|
||||
<CrudTable
|
||||
v-if="workgroupUUID"
|
||||
table="WorkgroupRole"
|
||||
title="工作组角色"
|
||||
:query="{ WorkgroupUUID: workgroupUUID }"
|
||||
:pk-field="'WorkgroupRoleUUID'"
|
||||
selectable
|
||||
pageable
|
||||
:on-view="goRoleDetail"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<el-dialog v-model="editDialogVisible" title="编辑工作组" width="600px" destroy-on-close>
|
||||
<el-form :model="editForm" label-width="120px" label-position="right">
|
||||
<el-form-item
|
||||
v-for="col in formColumns"
|
||||
:key="col.field"
|
||||
:label="col.title"
|
||||
:required="col.required"
|
||||
>
|
||||
<EntityPicker
|
||||
v-if="col.type === 'entityPicker' && col.entityPicker"
|
||||
v-model="editForm[col.field]"
|
||||
:config="col.entityPicker"
|
||||
:existing-label="editForm[col.field + '_label']"
|
||||
:placeholder="col.placeholder"
|
||||
:disabled="col.disabled"
|
||||
@update:label="(label: string) => (editForm[col.field + '_label'] = label)"
|
||||
/>
|
||||
<el-input
|
||||
v-else-if="!col.type || col.type === 'text' || col.type === 'string'"
|
||||
v-model="editForm[col.field]"
|
||||
:placeholder="col.placeholder"
|
||||
:maxlength="col.maxLength"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<el-input
|
||||
v-else-if="col.type === 'textarea'"
|
||||
v-model="editForm[col.field]"
|
||||
type="textarea"
|
||||
:rows="col.rows || 3"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<el-input-number
|
||||
v-else-if="col.type === 'number'"
|
||||
v-model="editForm[col.field]"
|
||||
:min="col.min"
|
||||
:max="col.max"
|
||||
:step="col.step"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<el-select
|
||||
v-else-if="col.type === 'select'"
|
||||
v-model="editForm[col.field]"
|
||||
:placeholder="col.placeholder"
|
||||
:disabled="col.disabled"
|
||||
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]"
|
||||
:type="col.type as 'date' | 'datetime'"
|
||||
:placeholder="col.placeholder"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<el-switch
|
||||
v-else-if="col.type === 'switch' || col.type === 'boolean'"
|
||||
v-model="editForm[col.field]"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<el-input
|
||||
v-else
|
||||
:model-value="String(editForm[col.field] ?? '')"
|
||||
readonly
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="editSubmitting" @click="handleSubmit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</DetailPage>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,695 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Editor 文档编辑器
|
||||
*
|
||||
* 迁移自 TempLate/GERP/pages/doc/editor.html(约 417 行)
|
||||
*
|
||||
* 原生实现要点:
|
||||
* - 左侧文档列表 + 右侧 TinyMCE 5.10.9 富文本编辑器(CDN 加载 + 中文语言包)
|
||||
* - 顶部工具栏四个操作:新建 / 保存 / 导出 .docx / 删除
|
||||
* - API 路径:POST /API/doc/doc/<action>,全部 JSON 请求
|
||||
* · GetDocs { keyword? } → 文档列表
|
||||
* · GetDoc { DocUUID } → 文档详情(标题 + 内容)
|
||||
* · AddDoc { DocTitle, DocContent } → 新建,返回 { data: { DocUUID } }
|
||||
* · ModifyDocSettings { DocUUID, DocTitle, DocContent } → 保存
|
||||
* · DeleteDoc { DocUUID } → 删除
|
||||
* · ExportDocx { DocUUID } → 二进制 .docx 流(Content-Disposition 携带文件名)
|
||||
* - 切换文档/新建时若有未保存改动,二次确认丢弃
|
||||
* - 搜索框 300ms 防抖触发列表刷新
|
||||
* - Ctrl+S / Cmd+S 快捷键保存
|
||||
* - 保存状态文本与颜色联动(未保存/保存中/已保存/失败)
|
||||
*
|
||||
* Vue 版关键改造:
|
||||
* - TinyMCE 改用 @tinymce/tinymce-vue 官方 Vue 组件
|
||||
* TODO:需执行 `npm install @tinymce/tinymce-vue tinymce` 后取消下方注释启用真组件
|
||||
* - 原生 alert/confirm/prompt → ElMessage / ElMessageBox
|
||||
* - 原生 <input> → el-input
|
||||
* - server / admin_verify 由 @/api/request 拦截器统一注入,无需页面关心
|
||||
* - 导出 .docx 走底层 axios 实例 + responseType: 'blob' 拿二进制流
|
||||
* (响应拦截器对非业务响应直接放行,故返回完整 AxiosResponse,需取 .data)
|
||||
* - 复用 AdminBadge 顶部特权模式横幅
|
||||
* - 复用 setPageTitle 设置浏览器标题(含 server 前缀)
|
||||
*/
|
||||
import { ref, computed, onMounted, onBeforeUnmount, shallowRef } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { post } from '@/api/request'
|
||||
import request from '@/api/request'
|
||||
import AdminBadge from '@/components/AdminBadge.vue'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
|
||||
// TODO: 安装 @tinymce/tinymce-vue + tinymce 后启用
|
||||
// import Editor from '@tinymce/tinymce-vue'
|
||||
// import 'tinymce/tinymce'
|
||||
// import 'tinymce/themes/silver'
|
||||
// import 'tinymce/icons/default/icons'
|
||||
// import 'tinymce/plugins/advlist' // 其它插件依此类推
|
||||
|
||||
setPageTitle('文档编辑器')
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
// ============================================================================
|
||||
|
||||
/** 文档列表项(GetDocs 接口返回) */
|
||||
interface DocListItem {
|
||||
/** 文档 UUID(主键) */
|
||||
DocUUID: string
|
||||
/** 文档标题 */
|
||||
DocTitle: string
|
||||
/** 创建时间(后端字符串) */
|
||||
DocCreateTime?: string
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
/** 文档详情(GetDoc 接口返回) */
|
||||
interface DocDetail {
|
||||
DocTitle: string
|
||||
DocContent: string
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
/** 新建文档响应(AddDoc 接口的 data 字段) */
|
||||
interface AddDocResult {
|
||||
DocUUID: string
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
/** 保存状态文本(驱动颜色映射) */
|
||||
type SaveStatus =
|
||||
| ''
|
||||
| '未保存'
|
||||
| '保存中...'
|
||||
| '已保存'
|
||||
| '保存失败'
|
||||
| '导出中...'
|
||||
| '导出成功'
|
||||
| '导出失败'
|
||||
|
||||
// ============================================================================
|
||||
// 响应式状态
|
||||
// ============================================================================
|
||||
|
||||
/** 文档列表 */
|
||||
const DocList = ref<DocListItem[]>([])
|
||||
/** 列表加载中标志 */
|
||||
const ListLoading = ref(false)
|
||||
/** 列表加载错误消息(为空表示无错误) */
|
||||
const ListError = ref('')
|
||||
/** 搜索关键字(与输入框双向绑定) */
|
||||
const SearchKeyword = ref('')
|
||||
/** 搜索防抖定时器句柄 */
|
||||
let SearchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/** 当前选中的文档 UUID(为 null 表示未选择任何文档) */
|
||||
const CurrentDocUUID = ref<string | null>(null)
|
||||
/** 当前文档标题(绑定标题输入框) */
|
||||
const CurrentDocTitle = ref('')
|
||||
/** 当前文档内容(绑定 TinyMCE 编辑器) */
|
||||
const CurrentDocContent = ref('')
|
||||
/** 文档是否被修改(脏标记,切换/新建/删除前用于二次确认) */
|
||||
const Dirty = ref(false)
|
||||
/** 保存状态文本 */
|
||||
const SaveStatusText = ref<SaveStatus>('')
|
||||
|
||||
/** 保存状态对应的颜色(与原生 SaveStatus.style.color 对齐) */
|
||||
const SaveStatusColor = computed<string>(() => {
|
||||
switch (SaveStatusText.value) {
|
||||
case '已保存':
|
||||
case '导出成功':
|
||||
return '#10b981'
|
||||
case '未保存':
|
||||
return '#f59e0b'
|
||||
case '保存失败':
|
||||
case '导出失败':
|
||||
return '#ef4444'
|
||||
default:
|
||||
return '#6b7280'
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* TinyMCE 编辑器实例
|
||||
* 使用 shallowRef 避免对第三方实例做深层响应式代理(性能 + 兼容性)
|
||||
*/
|
||||
const EditorInstance = shallowRef<{ getContent: () => string; remove: () => void } | null>(null)
|
||||
|
||||
// ============================================================================
|
||||
// 计算属性:工具栏按钮 disabled 状态
|
||||
// ============================================================================
|
||||
|
||||
/** 保存按钮可用:已选文档 且 有未保存改动 */
|
||||
const CanSave = computed(() => !!CurrentDocUUID.value && Dirty.value)
|
||||
/** 导出按钮可用:已选文档 */
|
||||
const CanExport = computed(() => !!CurrentDocUUID.value)
|
||||
/** 删除按钮可用:已选文档 */
|
||||
const CanDelete = computed(() => !!CurrentDocUUID.value)
|
||||
|
||||
// ============================================================================
|
||||
// API 调用
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 调用文档 API
|
||||
* 对齐原生 CallDocAPI:POST /API/doc/doc/<action>
|
||||
* request.ts 响应拦截器会自动剥离外层 {code,msg,data},故这里直接拿到 data
|
||||
*/
|
||||
async function CallDocAPI<T = unknown>(
|
||||
action: string,
|
||||
data: Record<string, unknown> = {},
|
||||
): Promise<T> {
|
||||
return post<T>(`/API/doc/doc/${action}`, data)
|
||||
}
|
||||
|
||||
/** 加载文档列表 */
|
||||
async function LoadDocList(keyword = ''): Promise<void> {
|
||||
ListLoading.value = true
|
||||
ListError.value = ''
|
||||
try {
|
||||
const result = await CallDocAPI<DocListItem[]>('GetDocs', keyword ? { keyword } : {})
|
||||
DocList.value = Array.isArray(result) ? result : []
|
||||
} catch (e) {
|
||||
ListError.value = (e as Error)?.message || '加载失败'
|
||||
DocList.value = []
|
||||
} finally {
|
||||
ListLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 选择文档:切换前若有未保存改动,二次确认丢弃 */
|
||||
async function SelectDoc(docUUID: string): Promise<void> {
|
||||
if (Dirty.value && CurrentDocUUID.value) {
|
||||
try {
|
||||
await ElMessageBox.confirm('当前文档未保存,是否丢弃改动?', '提示', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '丢弃',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return // 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
CurrentDocUUID.value = docUUID
|
||||
Dirty.value = false
|
||||
|
||||
try {
|
||||
const result = await CallDocAPI<DocDetail>('GetDoc', { DocUUID: docUUID })
|
||||
CurrentDocTitle.value = result.DocTitle || ''
|
||||
CurrentDocContent.value = result.DocContent || ''
|
||||
SaveStatusText.value = ''
|
||||
} catch (e) {
|
||||
ElMessage.error('加载文档失败: ' + ((e as Error)?.message || ''))
|
||||
}
|
||||
}
|
||||
|
||||
/** 新建文档:提示输入标题,新建后自动选中文档 */
|
||||
async function NewDoc(): Promise<void> {
|
||||
if (Dirty.value && CurrentDocUUID.value) {
|
||||
try {
|
||||
await ElMessageBox.confirm('当前文档未保存,是否丢弃改动?', '提示', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '丢弃',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let title: string
|
||||
try {
|
||||
const result = await ElMessageBox.prompt('请输入文档标题:', '新建文档', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
inputValue: '未命名文档',
|
||||
inputValidator: (v) => (v && v.trim() ? true : '标题不能为空'),
|
||||
})
|
||||
title = result.value.trim()
|
||||
} catch {
|
||||
return // 用户取消
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await CallDocAPI<AddDocResult>('AddDoc', {
|
||||
DocTitle: title,
|
||||
DocContent: '',
|
||||
})
|
||||
await LoadDocList(SearchKeyword.value)
|
||||
if (result?.DocUUID) {
|
||||
await SelectDoc(result.DocUUID)
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('新建失败: ' + ((e as Error)?.message || ''))
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存文档:从 TinyMCE 取最新内容并提交 ModifyDocSettings */
|
||||
async function SaveDoc(): Promise<void> {
|
||||
if (!CurrentDocUUID.value || !EditorInstance.value) return
|
||||
|
||||
const title = CurrentDocTitle.value.trim()
|
||||
if (!title) {
|
||||
ElMessage.warning('请输入文档标题')
|
||||
return
|
||||
}
|
||||
|
||||
const content = EditorInstance.value.getContent()
|
||||
SaveStatusText.value = '保存中...'
|
||||
|
||||
try {
|
||||
await CallDocAPI('ModifyDocSettings', {
|
||||
DocUUID: CurrentDocUUID.value,
|
||||
DocTitle: title,
|
||||
DocContent: content,
|
||||
})
|
||||
Dirty.value = false
|
||||
SaveStatusText.value = '已保存'
|
||||
await LoadDocList(SearchKeyword.value)
|
||||
} catch (e) {
|
||||
SaveStatusText.value = '保存失败'
|
||||
ElMessage.error('保存失败: ' + ((e as Error)?.message || ''))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出 .docx
|
||||
* 二进制响应需要走底层 axios 实例 + responseType: 'blob'
|
||||
* 响应拦截器对非业务响应(无 code 字段)会原样放行,故返回完整 AxiosResponse
|
||||
*/
|
||||
async function ExportDocx(): Promise<void> {
|
||||
if (!CurrentDocUUID.value) return
|
||||
|
||||
SaveStatusText.value = '导出中...'
|
||||
|
||||
try {
|
||||
const response = await request.post('/API/doc/doc/ExportDocx', {
|
||||
DocUUID: CurrentDocUUID.value,
|
||||
}, { responseType: 'blob' })
|
||||
const blob = response.data as Blob
|
||||
|
||||
// 从 Content-Disposition 头解析文件名(后端按 RFC 5987 编码)
|
||||
const disposition = (response.headers['content-disposition'] as string) || ''
|
||||
let filename = 'document.docx'
|
||||
const match = disposition.match(/filename="?([^"]+)"?/)
|
||||
if (match) filename = decodeURIComponent(match[1])
|
||||
|
||||
// 触发浏览器下载
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
SaveStatusText.value = '导出成功'
|
||||
} catch (e) {
|
||||
SaveStatusText.value = '导出失败'
|
||||
ElMessage.error('导出失败: ' + ((e as Error)?.message || ''))
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除文档:二次确认后调用 DeleteDoc,并清空当前文档状态 */
|
||||
async function DeleteDoc(): Promise<void> {
|
||||
if (!CurrentDocUUID.value) return
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除此文档?此操作不可撤销。', '危险操作', {
|
||||
type: 'error',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
confirmButtonClass: 'el-button--danger',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await CallDocAPI('DeleteDoc', { DocUUID: CurrentDocUUID.value })
|
||||
// 清空当前文档状态
|
||||
CurrentDocUUID.value = null
|
||||
CurrentDocTitle.value = ''
|
||||
CurrentDocContent.value = ''
|
||||
Dirty.value = false
|
||||
SaveStatusText.value = ''
|
||||
await LoadDocList(SearchKeyword.value)
|
||||
} catch (e) {
|
||||
ElMessage.error('删除失败: ' + ((e as Error)?.message || ''))
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 编辑器 / 表单事件
|
||||
// ============================================================================
|
||||
|
||||
/** 标记文档已修改 */
|
||||
function MarkDirty(): void {
|
||||
Dirty.value = true
|
||||
SaveStatusText.value = '未保存'
|
||||
}
|
||||
|
||||
/** 搜索输入事件:300ms 防抖后刷新列表 */
|
||||
function HandleSearchInput(): void {
|
||||
if (SearchTimer) clearTimeout(SearchTimer)
|
||||
SearchTimer = setTimeout(() => {
|
||||
LoadDocList(SearchKeyword.value.trim())
|
||||
}, 300)
|
||||
}
|
||||
|
||||
/** 刷新列表按钮 */
|
||||
function HandleRefresh(): void {
|
||||
LoadDocList(SearchKeyword.value.trim())
|
||||
}
|
||||
|
||||
/** 全局 Ctrl+S / Cmd+S 快捷键保存 */
|
||||
function HandleKeydown(e: KeyboardEvent): void {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault()
|
||||
if (CanSave.value) SaveDoc()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 生命周期
|
||||
// ============================================================================
|
||||
|
||||
onMounted(() => {
|
||||
LoadDocList()
|
||||
document.addEventListener('keydown', HandleKeydown)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', HandleKeydown)
|
||||
if (SearchTimer) clearTimeout(SearchTimer)
|
||||
// 主动销毁 TinyMCE 实例,避免泄漏
|
||||
if (EditorInstance.value) {
|
||||
EditorInstance.value.remove()
|
||||
EditorInstance.value = null
|
||||
}
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 工具函数
|
||||
// ============================================================================
|
||||
|
||||
/** 格式化文档时间(zh-CN 本地化) */
|
||||
function FormatTime(time?: string): string {
|
||||
if (!time) return ''
|
||||
return new Date(time).toLocaleString('zh-CN')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="gerp-page">
|
||||
<!-- 顶部管理员特权模式横幅(仅在特权模式下显示) -->
|
||||
<AdminBadge />
|
||||
|
||||
<div class="doc-page">
|
||||
<!-- 顶部工具栏 -->
|
||||
<div class="doc-header">
|
||||
<h1 class="doc-title">
|
||||
<el-icon><Document /></el-icon>
|
||||
文档编辑器
|
||||
</h1>
|
||||
<div class="spacer" />
|
||||
<div class="doc-toolbar">
|
||||
<el-button type="primary" @click="NewDoc">
|
||||
<el-icon><Plus /></el-icon> 新建
|
||||
</el-button>
|
||||
<el-button :disabled="!CanSave" @click="SaveDoc">
|
||||
<el-icon><Save /></el-icon> 保存
|
||||
</el-button>
|
||||
<el-button :disabled="!CanExport" @click="ExportDocx">
|
||||
<el-icon><Download /></el-icon> 导出 .docx
|
||||
</el-button>
|
||||
<el-button type="danger" :disabled="!CanDelete" @click="DeleteDoc">
|
||||
<el-icon><Delete /></el-icon> 删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主体:左侧文档列表 + 右侧编辑器 -->
|
||||
<div class="doc-body">
|
||||
<!-- 左侧文档列表 -->
|
||||
<div class="doc-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<el-input
|
||||
v-model="SearchKeyword"
|
||||
placeholder="搜索文档..."
|
||||
clearable
|
||||
@input="HandleSearchInput"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button title="刷新列表" @click="HandleRefresh">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-loading="ListLoading" class="doc-list">
|
||||
<div v-if="ListError" class="doc-empty">{{ ListError }}</div>
|
||||
<div v-else-if="!DocList.length && !ListLoading" class="doc-empty">
|
||||
暂无文档,点击"新建"创建
|
||||
</div>
|
||||
<div
|
||||
v-for="doc in DocList"
|
||||
:key="doc.DocUUID"
|
||||
class="doc-item"
|
||||
:class="{ active: doc.DocUUID === CurrentDocUUID }"
|
||||
@click="SelectDoc(doc.DocUUID)"
|
||||
>
|
||||
<div class="doc-item-title">{{ doc.DocTitle || '未命名' }}</div>
|
||||
<div class="doc-item-time">{{ FormatTime(doc.DocCreateTime) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧编辑器 -->
|
||||
<div class="doc-editor">
|
||||
<div class="doc-title-bar">
|
||||
<el-input
|
||||
v-model="CurrentDocTitle"
|
||||
placeholder="请选择或新建文档"
|
||||
:disabled="!CurrentDocUUID"
|
||||
@input="MarkDirty"
|
||||
/>
|
||||
<span class="doc-save-status" :style="{ color: SaveStatusColor }">
|
||||
{{ SaveStatusText }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="editor-container">
|
||||
<!-- 当前未选择文档时的占位 -->
|
||||
<div v-if="!CurrentDocUUID" class="doc-loading">
|
||||
请从左侧选择或新建文档
|
||||
</div>
|
||||
|
||||
<!--
|
||||
TODO: 安装依赖后启用 TinyMCE 真组件
|
||||
npm install @tinymce/tinymce-vue tinymce
|
||||
并在文件顶部 import Editor from '@tinymce/tinymce-vue'
|
||||
以及按需 import 'tinymce/plugins/*' 子模块
|
||||
<Editor
|
||||
v-else
|
||||
:key="CurrentDocUUID"
|
||||
v-model="CurrentDocContent"
|
||||
:init="TinyMceConfig"
|
||||
@init="HandleEditorInit"
|
||||
@change="HandleEditorChange"
|
||||
@input="HandleEditorChange"
|
||||
/>
|
||||
-->
|
||||
|
||||
<!-- 临时占位:未集成 TinyMCE 时用 textarea 维持基础可读可写 -->
|
||||
<el-input
|
||||
v-else
|
||||
v-model="CurrentDocContent"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 20 }"
|
||||
placeholder="TinyMCE 待 npm install @tinymce/tinymce-vue tinymce 后集成(已标 TODO)"
|
||||
@input="MarkDirty"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// 页面根容器:撑满父级,纵向排布
|
||||
.gerp-page {
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// 编辑器主体:纵向占满
|
||||
.doc-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// 顶部工具栏:横向排列,左标题右操作
|
||||
.doc-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 20px;
|
||||
background: var(--GERP-Bg-Card, #fff);
|
||||
border-bottom: 1px solid var(--GERP-Border, #e5e7eb);
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// 标题:左对齐,含图标
|
||||
.doc-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
// 占位间隔:把工具栏推到最右侧
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
// 工具栏按钮组
|
||||
.doc-toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
// 主体区:横向左列表 + 右编辑器
|
||||
.doc-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// 左侧边栏:固定宽度 260px
|
||||
.doc-sidebar {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--GERP-Border, #e5e7eb);
|
||||
background: var(--GERP-Bg-Card, #fff);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
// 侧边栏头部:搜索框 + 刷新按钮
|
||||
.sidebar-header {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--GERP-Border, #e5e7eb);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// 文档列表:可滚动
|
||||
.doc-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
// 文档项:悬停高亮 + 选中态左竖条
|
||||
.doc-item {
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--GERP-Border-Light, #f3f4f6);
|
||||
transition: background 0.1s;
|
||||
|
||||
&:hover {
|
||||
background: var(--GERP-Bg-Hover, #f9fafb);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba(30, 64, 175, 0.08);
|
||||
border-left: 3px solid #1e40af;
|
||||
padding-left: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
// 文档项标题:单行省略
|
||||
.doc-item-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--GERP-Text, #1f2937);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// 文档项时间:弱化文字
|
||||
.doc-item-time {
|
||||
font-size: 11px;
|
||||
color: var(--GERP-Text-Muted, #9ca3af);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
// 列表空态 / 错误态
|
||||
.doc-empty {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: var(--GERP-Text-Muted, #9ca3af);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
// 右侧编辑器:纵向占满
|
||||
.doc-editor {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--GERP-Bg, #f9fafb);
|
||||
}
|
||||
|
||||
// 标题栏:标题输入 + 保存状态
|
||||
.doc-title-bar {
|
||||
padding: 10px 16px;
|
||||
background: var(--GERP-Bg-Card, #fff);
|
||||
border-bottom: 1px solid var(--GERP-Border, #e5e7eb);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
// 保存状态文本
|
||||
.doc-save-status {
|
||||
font-size: 12px;
|
||||
color: var(--GERP-Text-Muted, #9ca3af);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// 编辑器容器:撑满剩余空间
|
||||
.editor-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
// TinyMCE 容器去掉边框与圆角,撑满编辑区
|
||||
:deep(.tox-tinymce) {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 占位 / 加载态
|
||||
.doc-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--GERP-Text-Muted, #9ca3af);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 产品管理(订单模板)
|
||||
*
|
||||
* 迁移自 TempLate/GERP/pages/inventory/products.html(154 行)
|
||||
* 原生实现:tableName=OrderTemplate(非 Product),server 作用域 + admin_verify 特权 +
|
||||
* role_scope 横幅(admin/normal 二色)+ 非主实例列 fallback + onView 跳详情
|
||||
*
|
||||
* Vue 版(阶段 1 简化):
|
||||
* - server / admin_verify 由 request.ts 拦截器统一注入
|
||||
* - 作用域判断暂用 userStore.isAdmin 二分(admin / normal)
|
||||
* - 细粒度 role_scope(dept_manager / workgroup_lead)待 checkAdmin 接入(TODO)
|
||||
* - 管理员开启行选择;普通组员只读(editable 待 CrudTable 暴露 editable prop 后接入,TODO)
|
||||
* - onView 跳 /inventory/product-detail?TemplateUUID=xxx
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import CrudTable from '@/components/CrudTable.vue'
|
||||
import AdminBadge from '@/components/AdminBadge.vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
setPageTitle('产品管理')
|
||||
|
||||
const isAdmin = computed(() => userStore.isAdmin)
|
||||
const bannerClass = computed(() => `scope-banner ${isAdmin.value ? 'admin' : 'normal'}`)
|
||||
const scopeHint = computed(() =>
|
||||
isAdmin.value
|
||||
? '管理员视图:可查看和管理全公司产品模板,含跨公司特权模式。'
|
||||
: '普通组员视图:可查看可用产品列表。',
|
||||
)
|
||||
|
||||
interface TemplateRow {
|
||||
TemplateUUID?: string
|
||||
TemplateName?: string
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
function handleView(row: TemplateRow): void {
|
||||
router.push({
|
||||
path: '/inventory/product-detail',
|
||||
query: { TemplateUUID: row.TemplateUUID || '' },
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="product-page">
|
||||
<AdminBadge />
|
||||
<div :class="bannerClass">
|
||||
<i class="fas" :class="isAdmin ? 'fa-shield-alt' : 'fa-info-circle'" />
|
||||
<span>{{ scopeHint }}</span>
|
||||
</div>
|
||||
<CrudTable
|
||||
table="OrderTemplate"
|
||||
title="产品管理"
|
||||
pk-field="TemplateUUID"
|
||||
searchable
|
||||
pageable
|
||||
:selectable="isAdmin"
|
||||
:on-view="handleView"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.product-page {
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.scope-banner {
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
|
||||
i { font-size: 16px; }
|
||||
|
||||
&.admin {
|
||||
background: rgba(30, 64, 175, 0.08);
|
||||
color: #1e40af;
|
||||
border: 1px solid rgba(30, 64, 175, 0.2);
|
||||
}
|
||||
|
||||
&.normal {
|
||||
background: rgba(100, 116, 139, 0.08);
|
||||
color: #475569;
|
||||
border: 1px solid rgba(100, 116, 139, 0.2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 销售订单
|
||||
*
|
||||
* 迁移自 TempLate/GERP/pages/sales/orders.html(156 行)
|
||||
* 原生实现:tableName=Order,server 作用域 + admin_verify + role_scope 横幅(admin/normal)+
|
||||
* 非主实例列 fallback + 总是开启行选择 + onView 跳详情
|
||||
*
|
||||
* Vue 版(阶段 1 简化):
|
||||
* - server / admin_verify 由 request.ts 拦截器统一注入
|
||||
* - 作用域判断暂用 userStore.isAdmin 二分
|
||||
* - 行选择始终开启(对齐原生 showCheckbox: true)
|
||||
* - editable 待 CrudTable 暴露 editable prop 后按 isAdmin 控制(TODO)
|
||||
* - onView 跳 /sales/order-detail?OrderUUID=xxx
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import CrudTable from '@/components/CrudTable.vue'
|
||||
import AdminBadge from '@/components/AdminBadge.vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
setPageTitle('销售订单')
|
||||
|
||||
const isAdmin = computed(() => userStore.isAdmin)
|
||||
const bannerClass = computed(() => `scope-banner ${isAdmin.value ? 'admin' : 'normal'}`)
|
||||
const scopeHint = computed(() =>
|
||||
isAdmin.value
|
||||
? '管理员视图:可查看全公司订单,含跨公司特权模式。'
|
||||
: '普通组员视图:仅可查看自己的订单。',
|
||||
)
|
||||
|
||||
interface OrderRow {
|
||||
OrderUUID?: string
|
||||
OrderNO?: string
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
function handleView(row: OrderRow): void {
|
||||
router.push({
|
||||
path: '/sales/order-detail',
|
||||
query: { OrderUUID: row.OrderUUID || '' },
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="order-page">
|
||||
<AdminBadge />
|
||||
<div :class="bannerClass">
|
||||
<i class="fas" :class="isAdmin ? 'fa-shield-alt' : 'fa-info-circle'" />
|
||||
<span>{{ scopeHint }}</span>
|
||||
</div>
|
||||
<CrudTable
|
||||
table="Order"
|
||||
title="销售订单"
|
||||
pk-field="OrderUUID"
|
||||
searchable
|
||||
pageable
|
||||
selectable
|
||||
:on-view="handleView"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.order-page {
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.scope-banner {
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
|
||||
i { font-size: 16px; }
|
||||
|
||||
&.admin {
|
||||
background: rgba(30, 64, 175, 0.08);
|
||||
color: #1e40af;
|
||||
border: 1px solid rgba(30, 64, 175, 0.2);
|
||||
}
|
||||
|
||||
&.normal {
|
||||
background: rgba(100, 116, 139, 0.08);
|
||||
color: #475569;
|
||||
border: 1px solid rgba(100, 116, 139, 0.2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,453 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 操作审计日志(AuditLog)
|
||||
*
|
||||
* 迁移自 TempLate/GERP/pages/system/audit-log.html(287 行)
|
||||
*
|
||||
* 原生实现要点:
|
||||
* - 仅管理员可访问(GERP.CheckAdmin),非管理员显示无权访问
|
||||
* - 筛选条件:表名 / 操作类型 / 时间范围(CreateTime__gte / CreateTime__lte)
|
||||
* - 分页查询接口 GET /API/FormAPI/Get/AuditLog,参数 page / page_size / ordering=-CreateTime / admin_verify=1
|
||||
* - 表格列:时间、表名、操作(add/modify/delete 色彩标签)、目标UUID、IP地址、操作人、详情
|
||||
* - 详情弹窗:基本信息 + OldData / NewData JSON 展示
|
||||
*
|
||||
* Vue 版改动:
|
||||
* - server / admin_verify 由 request.ts 拦截器统一注入(不再手动拼接 buildApiUrl)
|
||||
* - 权限校验改用 userStore.isAdmin 前端预拦截(后端仍会校验);非管理员显示无权访问占位
|
||||
* - 表格 / 分页 / 弹窗改用 Element Plus(el-table / el-pagination / el-dialog)
|
||||
* - 日期筛选改用 el-date-picker daterange,提交时拆分为 CreateTime__gte / CreateTime__lte
|
||||
* - 响应格式兼容:后端可能返回数组或 { records, total },统一适配
|
||||
*/
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { get } from '@/api/request'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import AdminBadge from '@/components/AdminBadge.vue'
|
||||
|
||||
setPageTitle('操作审计日志')
|
||||
|
||||
const userStore = useUserStore()
|
||||
/** 是否管理员(前端预拦截,后端仍校验) */
|
||||
const isAdmin = computed(() => userStore.isAdmin)
|
||||
/** 无权访问标记:非管理员进入时置 true */
|
||||
const denied = ref(false)
|
||||
|
||||
/** 审计日志记录(对齐后端字段,PascalCase) */
|
||||
interface AuditLogRow {
|
||||
AuditLogUUID?: string
|
||||
CreateTime?: string
|
||||
TableName?: string
|
||||
Action?: string
|
||||
TargetUUID?: string
|
||||
IPAddress?: string
|
||||
UserUUID?: string
|
||||
UserUUID_label?: string
|
||||
OldData?: string | Record<string, unknown> | null
|
||||
NewData?: string | Record<string, unknown> | null
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
/** 表名筛选选项(对齐原生固定列表) */
|
||||
const tableOptions = ['User', 'Role', 'Permission', 'Tenant', 'Company']
|
||||
/** 操作类型筛选选项 */
|
||||
const actionOptions = [
|
||||
{ value: 'add', label: '新增' },
|
||||
{ value: 'modify', label: '修改' },
|
||||
{ value: 'delete', label: '删除' },
|
||||
]
|
||||
|
||||
/** 筛选条件 */
|
||||
const filterTable = ref('')
|
||||
const filterAction = ref('')
|
||||
/** 日期范围 [开始, 结束],el-date-picker daterange 绑定值 */
|
||||
const filterDateRange = ref<[string, string] | null>(null)
|
||||
|
||||
/** 分页状态 */
|
||||
const currentPage = ref(1)
|
||||
const pageSize = 20
|
||||
const total = ref(0)
|
||||
|
||||
/** 表格数据与加载态 */
|
||||
const logList = ref<AuditLogRow[]>([])
|
||||
const loading = ref(false)
|
||||
/** 错误信息(非空时展示错误占位) */
|
||||
const errorMsg = ref('')
|
||||
|
||||
/** 详情弹窗 */
|
||||
const detailVisible = ref(false)
|
||||
const detailRow = ref<AuditLogRow | null>(null)
|
||||
|
||||
/** 操作类型 → 显示文本 */
|
||||
function actionLabel(action?: string): string {
|
||||
const map: Record<string, string> = { add: '新增', modify: '修改', delete: '删除' }
|
||||
return (action && map[action]) || action || '-'
|
||||
}
|
||||
|
||||
/** 操作类型 → el-tag 类型 */
|
||||
function actionTagType(action?: string): 'success' | 'warning' | 'danger' | 'info' {
|
||||
const map: Record<string, 'success' | 'warning' | 'danger'> = {
|
||||
add: 'success',
|
||||
modify: 'warning',
|
||||
delete: 'danger',
|
||||
}
|
||||
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
|
||||
try {
|
||||
return JSON.parse(str)
|
||||
} catch {
|
||||
return str
|
||||
}
|
||||
}
|
||||
|
||||
/** 将 OldData / NewData 规范化为可序列化对象 */
|
||||
function normalizeData(data: unknown): Record<string, unknown> | null {
|
||||
if (!data) return null
|
||||
if (typeof data === 'string') {
|
||||
const parsed = safeJsonParse(data)
|
||||
return typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : null
|
||||
}
|
||||
return typeof data === 'object' ? (data as Record<string, unknown>) : null
|
||||
}
|
||||
|
||||
/** 详情弹窗:变更前数据 */
|
||||
const detailOldData = computed(() => normalizeData(detailRow.value?.OldData))
|
||||
/** 详情弹窗:变更后数据 */
|
||||
const detailNewData = computed(() => normalizeData(detailRow.value?.NewData))
|
||||
|
||||
/** 构造查询参数(对齐原生 buildFilters) */
|
||||
function buildQuery(): Record<string, unknown> {
|
||||
const params: Record<string, unknown> = {
|
||||
page: currentPage.value,
|
||||
page_size: pageSize,
|
||||
ordering: '-CreateTime',
|
||||
}
|
||||
if (filterTable.value) params.TableName = filterTable.value
|
||||
if (filterAction.value) params.Action = filterAction.value
|
||||
if (filterDateRange.value && filterDateRange.value.length === 2) {
|
||||
params.CreateTime__gte = filterDateRange.value[0] + ' 00:00:00'
|
||||
params.CreateTime__lte = filterDateRange.value[1] + ' 23:59:59'
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载审计日志
|
||||
* 注:admin_verify 由 request.ts 拦截器按 URL query 自动注入,此处不再手动拼接
|
||||
* 响应拦截器返回 result.data,兼容数组或 { records, total } 两种结构
|
||||
*/
|
||||
async function loadAuditLogs(): Promise<void> {
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
// 拦截器返回 result.data;total 可能在 data 内或顶层(此处通过 as any 兼容读取)
|
||||
const data = await get<AuditLogRow[] | { records: AuditLogRow[]; total?: number } | null>(
|
||||
'/API/FormAPI/Get/AuditLog',
|
||||
buildQuery(),
|
||||
)
|
||||
// 兼容数组 / { records, total } 两种结构
|
||||
if (Array.isArray(data)) {
|
||||
logList.value = data
|
||||
total.value = data.length
|
||||
} else if (data && Array.isArray((data as { records?: AuditLogRow[] }).records)) {
|
||||
logList.value = (data as { records: AuditLogRow[] }).records
|
||||
total.value = (data as { total?: number }).total ?? logList.value.length
|
||||
} else {
|
||||
logList.value = []
|
||||
total.value = 0
|
||||
}
|
||||
} catch (err) {
|
||||
// 业务错误拦截器已弹提示,此处仅展示占位
|
||||
errorMsg.value = '加载审计日志失败:' + (err instanceof Error ? err.message : String(err))
|
||||
logList.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 点击查询:重置到第一页 */
|
||||
function onFilter(): void {
|
||||
currentPage.value = 1
|
||||
loadAuditLogs()
|
||||
}
|
||||
|
||||
/** 重置筛选条件 */
|
||||
function onResetFilter(): void {
|
||||
filterTable.value = ''
|
||||
filterAction.value = ''
|
||||
filterDateRange.value = null
|
||||
currentPage.value = 1
|
||||
loadAuditLogs()
|
||||
}
|
||||
|
||||
/** 分页变化 */
|
||||
function onPageChange(page: number): void {
|
||||
currentPage.value = page
|
||||
loadAuditLogs()
|
||||
}
|
||||
|
||||
/** 打开详情弹窗 */
|
||||
function showDetail(row: AuditLogRow): void {
|
||||
detailRow.value = row
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
/** 详情 JSON 文本(美化输出) */
|
||||
function prettyJson(data: unknown): string {
|
||||
if (!data) return ''
|
||||
try {
|
||||
return JSON.stringify(data, null, 2)
|
||||
} catch {
|
||||
return String(data)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 非管理员直接拦截,展示无权访问
|
||||
if (!isAdmin.value) {
|
||||
denied.value = true
|
||||
return
|
||||
}
|
||||
loadAuditLogs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="audit-log-page">
|
||||
<!-- 管理员特权横幅(自动判断 server / admin_verify) -->
|
||||
<AdminBadge />
|
||||
|
||||
<!-- 无权访问占位 -->
|
||||
<el-empty
|
||||
v-if="denied"
|
||||
description="无权访问:该页面仅管理员可查看,请切换至管理员角色或联系管理员"
|
||||
>
|
||||
<template #image>
|
||||
<i class="fas fa-lock" style="font-size: 48px; color: var(--el-color-danger)" />
|
||||
</template>
|
||||
</el-empty>
|
||||
|
||||
<template v-else>
|
||||
<!-- 页头 -->
|
||||
<div class="audit-log-header">
|
||||
<h1 class="page-title">
|
||||
<i class="fas fa-history icon-primary" />
|
||||
<span>操作审计日志</span>
|
||||
</h1>
|
||||
<el-button :icon="'Refresh'" @click="loadAuditLogs">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 筛选条件 -->
|
||||
<div class="audit-log-filters">
|
||||
<span class="filter-label">表名:</span>
|
||||
<el-select v-model="filterTable" placeholder="全部" clearable style="width: 140px">
|
||||
<el-option v-for="t in tableOptions" :key="t" :label="t" :value="t" />
|
||||
</el-select>
|
||||
<span class="filter-label">操作:</span>
|
||||
<el-select v-model="filterAction" placeholder="全部" clearable style="width: 120px">
|
||||
<el-option v-for="a in actionOptions" :key="a.value" :label="a.label" :value="a.value" />
|
||||
</el-select>
|
||||
<span class="filter-label">时间范围:</span>
|
||||
<el-date-picker
|
||||
v-model="filterDateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 280px"
|
||||
/>
|
||||
<el-button type="primary" :icon="'Search'" @click="onFilter">查询</el-button>
|
||||
<el-button :icon="'RefreshLeft'" @click="onResetFilter">重置</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="logList"
|
||||
border
|
||||
stripe
|
||||
style="width: 100%"
|
||||
class="audit-log-table"
|
||||
>
|
||||
<el-table-column label="时间" width="170">
|
||||
<template #default="{ row }">{{ formatTime(row.CreateTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="TableName" label="表名" width="120" />
|
||||
<el-table-column label="操作" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="actionTagType(row.Action)" size="small">{{ actionLabel(row.Action) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="目标UUID" width="190">
|
||||
<template #default="{ row }">
|
||||
<code class="mono">{{ row.TargetUUID || '-' }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="IP地址" width="150">
|
||||
<template #default="{ row }">
|
||||
<code class="mono">{{ row.IPAddress || '-' }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作人" min-width="140">
|
||||
<template #default="{ row }">{{ row.UserUUID_label || row.UserUUID || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="详情" width="90" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" link type="primary" @click="showDetail(row)">
|
||||
<i class="fas fa-eye" /> 详情
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 空数据 / 错误占位 -->
|
||||
<template #empty>
|
||||
<div v-if="errorMsg" style="color: var(--el-color-danger); padding: 20px">
|
||||
<i class="fas fa-exclamation-circle" /> {{ errorMsg }}
|
||||
</div>
|
||||
<el-empty v-else description="暂无审计日志" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="audit-log-pagination">
|
||||
<el-pagination
|
||||
background
|
||||
layout="total, prev, pager, next"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
:current-page="currentPage"
|
||||
@current-change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<el-dialog v-model="detailVisible" title="审计日志详情" width="800px" top="6vh">
|
||||
<template v-if="detailRow">
|
||||
<div class="detail-section">
|
||||
<h4>基本信息</h4>
|
||||
<div class="detail-base">
|
||||
<div><strong>时间:</strong> {{ formatTime(detailRow.CreateTime) }}</div>
|
||||
<div><strong>表名:</strong> {{ detailRow.TableName || '' }}</div>
|
||||
<div>
|
||||
<strong>操作:</strong>
|
||||
<el-tag :type="actionTagType(detailRow.Action)" size="small">{{ actionLabel(detailRow.Action) }}</el-tag>
|
||||
</div>
|
||||
<div><strong>目标UUID:</strong> <code class="mono">{{ detailRow.TargetUUID || '-' }}</code></div>
|
||||
<div><strong>IP地址:</strong> <code class="mono">{{ detailRow.IPAddress || '-' }}</code></div>
|
||||
<div><strong>操作人:</strong> {{ detailRow.UserUUID_label || detailRow.UserUUID || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="detailOldData" class="detail-section">
|
||||
<h4>变更前数据 (OldData)</h4>
|
||||
<pre class="detail-json">{{ prettyJson(detailOldData) }}</pre>
|
||||
</div>
|
||||
<div v-if="detailNewData" class="detail-section">
|
||||
<h4>变更后数据 (NewData)</h4>
|
||||
<pre class="detail-json">{{ prettyJson(detailNewData) }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.audit-log-page {
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.audit-log-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.icon-primary {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.audit-log-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.filter-label {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.audit-log-table {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
.mono {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.audit-log-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: 16px;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-base {
|
||||
font-size: 13px;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
.detail-json {
|
||||
background: var(--el-fill-color-light);
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,263 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 数据库管理(Database)
|
||||
*
|
||||
* 迁移自 TempLate/GERP/pages/system/database.html(166 行)
|
||||
*
|
||||
* 原生实现要点:
|
||||
* - 仅管理员可访问(GERP.CheckAdmin),非管理员显示无权访问
|
||||
* - 主实例(默认服务器):GET /API/FormAPI/Tables 返回全部表,按 type=static/dynamic 分两组展示
|
||||
* - 子服务器:GET /api/gerp/msync/servers/{server}/form/?Action=tables 返回该服务器表列表
|
||||
* - 表项字段:name / title / type(static|dynamic) / count(记录数)
|
||||
* - 点击"查看"跳转 dbtable.html?server&table&title
|
||||
*
|
||||
* Vue 版改动:
|
||||
* - server / admin_verify 由 request.ts 拦截器统一注入(主实例 FormAPI 路径自动注入)
|
||||
* - 权限校验改用 userStore.isAdmin 前端预拦截;非管理员显示无权访问占位
|
||||
* - 子服务器表列表接口路径已含 serverName,admin_verify 仍由拦截器按 URL query 注入
|
||||
* - 跳转改用 vue-router:/system/dbtable?table=xxx&title=xxx(子服务器追加 server)
|
||||
* - 返回服务器列表改用 router.push(替代 GERP.Navigate)
|
||||
*/
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { get } from '@/api/request'
|
||||
import { setPageTitle, getServer, isDefaultServer } from '@/utils/serverScope'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import AdminBadge from '@/components/AdminBadge.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const isAdmin = computed(() => userStore.isAdmin)
|
||||
const denied = ref(false)
|
||||
|
||||
/** 服务器标识(主实例为 gvsds-master) */
|
||||
const serverName = getServer()
|
||||
/** 是否主实例 */
|
||||
const isMain = isDefaultServer()
|
||||
|
||||
/** 表项(对齐后端字段) */
|
||||
interface TableItem {
|
||||
name: string
|
||||
title?: string
|
||||
type?: 'static' | 'dynamic'
|
||||
count?: number
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
/** 静态表列表(仅主实例有分组) */
|
||||
const staticTables = ref<TableItem[]>([])
|
||||
/** 系统表 / 子服务器数据表列表 */
|
||||
const dynamicTables = ref<TableItem[]>([])
|
||||
|
||||
/** 页面标题 */
|
||||
const pageTitle = computed(() => (isMain ? '数据库管理' : `${serverName} — 数据库管理`))
|
||||
|
||||
setPageTitle(pageTitle.value)
|
||||
|
||||
/** 加载主实例表列表:GET /API/FormAPI/Tables */
|
||||
async function loadMainTables(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await get<TableItem[]>('/API/FormAPI/Tables')
|
||||
const tables = Array.isArray(data) ? data : []
|
||||
staticTables.value = tables.filter((t) => t.type === 'static')
|
||||
dynamicTables.value = tables.filter((t) => t.type === 'dynamic')
|
||||
} catch {
|
||||
/* 拦截器已弹错误 */
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载子服务器表列表:GET /api/gerp/msync/servers/{server}/form/?Action=tables */
|
||||
async function loadSubTables(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await get<TableItem[]>(`/api/gerp/msync/servers/${encodeURIComponent(serverName)}/form/`, {
|
||||
Action: 'tables',
|
||||
})
|
||||
dynamicTables.value = Array.isArray(data) ? data : []
|
||||
} catch {
|
||||
dynamicTables.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 查看表:跳转 DbTable 页面 */
|
||||
function viewTable(table: TableItem): void {
|
||||
const query: Record<string, string> = {
|
||||
table: table.name,
|
||||
title: table.title || table.name,
|
||||
}
|
||||
if (!isMain) query.server = serverName
|
||||
router.push({ path: '/system/dbtable', query })
|
||||
}
|
||||
|
||||
/** 返回服务器列表 */
|
||||
function goBack(): void {
|
||||
router.push({ path: '/system/msync-servers' })
|
||||
}
|
||||
|
||||
/** 表项展示文本:标题与名称 */
|
||||
function tableLabel(table: TableItem): string {
|
||||
return table.title && table.title !== table.name ? `${table.title}(${table.name})` : table.name
|
||||
}
|
||||
|
||||
/** 表项记录数 / 类型描述 */
|
||||
function tableMeta(table: TableItem): string {
|
||||
if (typeof table.count === 'number' && table.count >= 0) return `${table.count} 条记录`
|
||||
return table.type === 'dynamic' ? '系统表' : '自定义表'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!isAdmin.value) {
|
||||
denied.value = true
|
||||
return
|
||||
}
|
||||
if (isMain) {
|
||||
loadMainTables()
|
||||
} else {
|
||||
loadSubTables()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="db-page">
|
||||
<AdminBadge />
|
||||
|
||||
<el-empty
|
||||
v-if="denied"
|
||||
description="无权访问:该页面仅管理员可查看,请切换至管理员角色或联系管理员"
|
||||
>
|
||||
<template #image>
|
||||
<i class="fas fa-lock" style="font-size: 48px; color: var(--el-color-danger)" />
|
||||
</template>
|
||||
</el-empty>
|
||||
|
||||
<template v-else>
|
||||
<!-- 子服务器:返回服务器列表 -->
|
||||
<div v-if="!isMain" class="db-back" @click="goBack">
|
||||
<i class="fas fa-arrow-left" /> <span>返回服务器列表</span>
|
||||
</div>
|
||||
|
||||
<!-- 页头 -->
|
||||
<div class="db-header">
|
||||
<h1 class="page-title">
|
||||
<i class="fas fa-database icon-primary" />
|
||||
<span>{{ pageTitle }}</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- 静态表分组(仅主实例) -->
|
||||
<div v-if="isMain" class="db-section">
|
||||
<h3>静态表</h3>
|
||||
<div v-loading="loading" class="db-list">
|
||||
<div v-for="t in staticTables" :key="t.name" class="db-table-item">
|
||||
<span class="db-table-name">{{ tableLabel(t) }}</span>
|
||||
<span class="db-table-meta">{{ tableMeta(t) }}</span>
|
||||
<el-button size="small" type="primary" @click="viewTable(t)">
|
||||
<i class="fas fa-table" /> 查看
|
||||
</el-button>
|
||||
</div>
|
||||
<el-empty v-if="!loading && staticTables.length === 0" description="暂无数据" :image-size="60" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 系统表 / 子服务器数据表分组 -->
|
||||
<div class="db-section">
|
||||
<h3>{{ isMain ? '系统表' : '数据表' }}</h3>
|
||||
<div v-loading="loading" class="db-list">
|
||||
<div v-for="t in dynamicTables" :key="t.name" class="db-table-item">
|
||||
<span class="db-table-name">{{ tableLabel(t) }}</span>
|
||||
<span class="db-table-meta">{{ tableMeta(t) }}</span>
|
||||
<el-button size="small" type="primary" @click="viewTable(t)">
|
||||
<i class="fas fa-table" /> 查看
|
||||
</el-button>
|
||||
</div>
|
||||
<el-empty v-if="!loading && dynamicTables.length === 0" description="暂无数据表" :image-size="60" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.db-page {
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.db-back {
|
||||
cursor: pointer;
|
||||
color: var(--el-color-primary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 14px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.db-header {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.icon-primary {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.db-section {
|
||||
margin-bottom: 30px;
|
||||
|
||||
h3 {
|
||||
margin-bottom: 10px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.db-list {
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.db-table-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.db-table-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.db-table-meta {
|
||||
margin-right: 16px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,201 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 表结构管理(DbTable)
|
||||
*
|
||||
* 迁移自 TempLate/GERP/pages/system/dbtable.html(100 行)
|
||||
*
|
||||
* 原生实现要点:
|
||||
* - 仅管理员可访问(GERP.CheckAdmin),非管理员显示无权访问
|
||||
* - 从 URL query 读取 server / table / title 参数
|
||||
* - 核心依赖原生全局对象 DbTableHelper.init(),支持两种 API 模式:
|
||||
* - formapi(主实例):直接走 /API/FormAPI/... 通用表接口
|
||||
* - subproxy(子服务器):走 /api/gerp/msync/servers/{server}/form/ 代理接口
|
||||
* - 返回按钮跳回 database.html?server=xxx
|
||||
*
|
||||
* Vue 版改动(先搭骨架,标 TODO):
|
||||
* - server / admin_verify 由 request.ts 拦截器统一注入
|
||||
* - 权限校验改用 userStore.isAdmin 前端预拦截;非管理员显示无权访问占位
|
||||
* - 参数改从 vue-router useRoute 获取(替代 URLSearchParams)
|
||||
* - 主实例暂用 CrudTable 提供基本表数据浏览/编辑能力(table=tableName)
|
||||
* - 子服务器 subproxy 模式 CrudTable 暂不支持,标 TODO 待 DbTableHelper 迁移
|
||||
* - TODO: 完整迁移 DbTableHelper(列定义动态渲染、子服务器代理、表结构编辑等高级能力)
|
||||
*/
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { setPageTitle, getServer, isDefaultServer } from '@/utils/serverScope'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import AdminBadge from '@/components/AdminBadge.vue'
|
||||
import CrudTable from '@/components/CrudTable.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const isAdmin = computed(() => userStore.isAdmin)
|
||||
const denied = ref(false)
|
||||
|
||||
/** 服务器标识(主实例为 gvsds-master) */
|
||||
const serverName = getServer()
|
||||
/** 是否主实例 */
|
||||
const isMain = isDefaultServer()
|
||||
/** 表名(路由 query) */
|
||||
const tableName = (route.query.table as string) || ''
|
||||
/** 表标题(路由 query,缺省回退表名) */
|
||||
const tableTitle = (route.query.title as string) || tableName
|
||||
|
||||
/** 页面标题 */
|
||||
const pageTitle = computed(() => (isMain ? tableTitle : `${serverName} — ${tableTitle}`))
|
||||
setPageTitle(pageTitle.value)
|
||||
|
||||
/** 返回数据库管理页 */
|
||||
function goBack(): void {
|
||||
const query: Record<string, string> = {}
|
||||
if (!isMain) query.server = serverName
|
||||
router.push({ path: '/system/database', query })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 非管理员直接拦截,展示无权访问
|
||||
if (!isAdmin.value) {
|
||||
denied.value = true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dbtable-page">
|
||||
<AdminBadge />
|
||||
|
||||
<el-empty
|
||||
v-if="denied"
|
||||
description="无权访问:该页面仅管理员可查看,请切换至管理员角色或联系管理员"
|
||||
>
|
||||
<template #image>
|
||||
<i class="fas fa-lock" style="font-size: 48px; color: var(--el-color-danger)" />
|
||||
</template>
|
||||
</el-empty>
|
||||
|
||||
<el-empty
|
||||
v-else-if="!tableName"
|
||||
description="未指定表名"
|
||||
>
|
||||
<template #image>
|
||||
<i class="fas fa-table" style="font-size: 48px; opacity: 0.3" />
|
||||
</template>
|
||||
</el-empty>
|
||||
|
||||
<template v-else>
|
||||
<!-- 页头 -->
|
||||
<div class="dbtable-header">
|
||||
<div class="dbtable-back" @click="goBack">
|
||||
<i class="fas fa-arrow-left" /> 返回数据库
|
||||
</div>
|
||||
<h1 class="page-title">
|
||||
<i class="fas fa-table icon-primary" />
|
||||
<span>{{ pageTitle }}</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- 表信息 -->
|
||||
<div class="dbtable-info">
|
||||
<span><i class="fas fa-server" /> 服务器: {{ isMain ? '主实例' : serverName }}</span>
|
||||
<span><i class="fas fa-table" /> 表名: {{ tableName }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 表数据区域 -->
|
||||
<div class="dbtable-container">
|
||||
<!--
|
||||
主实例:暂用 CrudTable 提供基本表数据浏览/编辑能力
|
||||
TODO: 完整迁移 DbTableHelper 高级能力(动态列定义、表结构编辑、数据导入导出等)
|
||||
-->
|
||||
<CrudTable
|
||||
v-if="isMain"
|
||||
:table="tableName"
|
||||
:title="tableTitle"
|
||||
searchable
|
||||
pageable
|
||||
selectable
|
||||
/>
|
||||
|
||||
<!--
|
||||
子服务器 subproxy 模式:CrudTable 暂不支持代理路径
|
||||
TODO: 待迁移 DbTableHelper subproxy 模式(/api/gerp/msync/servers/{server}/form/)
|
||||
-->
|
||||
<el-alert
|
||||
v-else
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="子服务器表浏览待迁移"
|
||||
description="子服务器 subproxy 代理模式(/api/gerp/msync/servers/{server}/form/)暂未迁移,待 DbTableHelper 完整迁移后接入。"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dbtable-page {
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dbtable-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.dbtable-back {
|
||||
cursor: pointer;
|
||||
color: var(--el-color-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.icon-primary {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dbtable-info {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.dbtable-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,794 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 站内信
|
||||
*
|
||||
* 迁移自 TempLate/GERP/pages/system/message.html(高频页面)
|
||||
*
|
||||
* 原生业务逻辑:
|
||||
* - 顶部 Tab 筛选:全部 / 未读(带角标)/ 系统通知(0) / 审核通知(1) / 工单通知(2) / 个人消息(3)
|
||||
* - 工具栏:发送消息(弹窗,含收件人搜索)、全部已读、刷新
|
||||
* - 列表:每条消息卡片(类型图标、标题、内容预览、发送人、类型、时间、未读高亮、悬停删除)
|
||||
* - 分页:PageSize=20
|
||||
* - 详情弹窗:标题、发送人、类型、时间、内容;个人消息且发送人非系统时支持回复;底部删除
|
||||
* - 发送弹窗:收件人搜索(用户/角色,防抖 300ms)、标题、内容;MsgType 固定 3(个人消息)
|
||||
* - URL query id=xxx 时自动打开该消息详情
|
||||
*
|
||||
* 接口协议(非 FormAPI,使用 /API/Message 自定义接口):
|
||||
* - GET /API/Message?action=list&PageIndex=&PageSize=&IsRead=&MsgType= → { code, data:[...], total }
|
||||
* - GET /API/Message?action=detail&MessageUUID= → { code, data: msg }
|
||||
* - GET /API/Message?action=unread_count → { code, UnreadCount } ⚠ 顶层字段
|
||||
* - GET /API/Message?action=search_recipients&keyword=&limit= → { code, data:[{type,uuid,name,label}] }
|
||||
* - DELETE /API/Message body:{MessageUUID} → { code }
|
||||
* - PATCH /API/Message body:action=read_all (form-urlencoded) → { code }
|
||||
* - POST /API/Message body:ReceiverUUID/ReceiverType/Title/Content/MsgType (form-urlencoded) → { code }
|
||||
*
|
||||
* 关键迁移取舍:
|
||||
* - Message 接口为自定义协议(非 FormAPI),且核心功能(Tab 筛选/未读角标/收件人搜索/发送/回复)
|
||||
* 均非 CrudTable 能覆盖,故不套用 CrudTable,自建列表 + el-dialog 详情/发送,完整保留原生能力。
|
||||
* - server / admin_verify 由 request.ts 请求拦截器统一注入,本文件不手动拼接。
|
||||
* - ⚠ 全局响应拦截器在 code===0 时仅返回 result.data,会剥离 result.total / result.UnreadCount
|
||||
* 顶层字段。因此 list / unread_count / detail / search_recipients 改用 rawGet(responseType:'text'
|
||||
* 绕过剥离,自行 JSON.parse 取完整响应体)。写操作(DELETE/PATCH/POST)不需要顶层字段,仍用标准封装。
|
||||
* - 消息类型图标沿用原生 FontAwesome(项目已全局引入 fa)。
|
||||
*/
|
||||
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import request, { del, patch, post } from '@/api/request'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
import type { AxiosResponse } from 'axios'
|
||||
|
||||
// === 类型定义 ===
|
||||
|
||||
/** 消息类型:0 系统通知 / 1 审核通知 / 2 工单通知 / 3 个人消息 */
|
||||
type MsgType = 0 | 1 | 2 | 3
|
||||
|
||||
/** 消息记录 */
|
||||
interface MessageItem {
|
||||
MessageUUID: string
|
||||
Title: string
|
||||
Content: string
|
||||
SenderName: string
|
||||
SenderUUID: string
|
||||
MsgType: MsgType
|
||||
IsRead: 0 | 1
|
||||
CreateTime: string
|
||||
}
|
||||
|
||||
/** 收件人搜索结果项 */
|
||||
interface Recipient {
|
||||
type: 'user' | 'role'
|
||||
uuid: string
|
||||
name: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/** 后端原始响应体(含顶层 total / UnreadCount) */
|
||||
interface RawResult<T = unknown> {
|
||||
code: number
|
||||
msg?: string
|
||||
data?: T
|
||||
total?: number
|
||||
UnreadCount?: number
|
||||
}
|
||||
|
||||
// === 常量 ===
|
||||
|
||||
/** 消息类型 → FontAwesome 图标 */
|
||||
const MSG_TYPE_ICONS: Record<MsgType, string> = {
|
||||
0: 'fa-info-circle',
|
||||
1: 'fa-clipboard-check',
|
||||
2: 'fa-tools',
|
||||
3: 'fa-envelope',
|
||||
}
|
||||
|
||||
/** 消息类型 → 中文名 */
|
||||
const MSG_TYPE_NAMES: Record<MsgType, string> = {
|
||||
0: '系统通知',
|
||||
1: '审核通知',
|
||||
2: '工单通知',
|
||||
3: '个人消息',
|
||||
}
|
||||
|
||||
/** 消息类型 → 图标背景/前景色(对齐原生 type-0..3 配色) */
|
||||
const MSG_TYPE_COLORS: Record<MsgType, { bg: string; color: string }> = {
|
||||
0: { bg: 'rgba(37,99,235,0.1)', color: '#2563eb' },
|
||||
1: { bg: 'rgba(34,197,94,0.1)', color: '#16a34a' },
|
||||
2: { bg: 'rgba(234,179,8,0.1)', color: '#ca8a04' },
|
||||
3: { bg: 'rgba(139,92,246,0.1)', color: '#8b5cf6' },
|
||||
}
|
||||
|
||||
/** Tab 筛选项配置 */
|
||||
const TABS: Array<{ name: string; label: string }> = [
|
||||
{ name: 'all', label: '全部' },
|
||||
{ name: 'unread', label: '未读' },
|
||||
{ name: '0', label: '系统通知' },
|
||||
{ name: '1', label: '审核通知' },
|
||||
{ name: '2', label: '工单通知' },
|
||||
{ name: '3', label: '个人消息' },
|
||||
]
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
// === 状态 ===
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
/** 当前筛选 tab(all / unread / 0 / 1 / 2 / 3) */
|
||||
const currentFilter = ref<string>('all')
|
||||
/** 当前页码(从 1 开始) */
|
||||
const currentPage = ref(1)
|
||||
/** 消息列表 */
|
||||
const list = ref<MessageItem[]>([])
|
||||
/** 总条数(来自后端 total 顶层字段) */
|
||||
const total = ref(0)
|
||||
/** 未读数(来自后端 UnreadCount 顶层字段,驱动未读 tab 角标) */
|
||||
const unreadCount = ref(0)
|
||||
/** 列表加载态 */
|
||||
const loading = ref(false)
|
||||
|
||||
/** 详情弹窗可见 */
|
||||
const detailVisible = ref(false)
|
||||
/** 详情数据 */
|
||||
const detailData = ref<MessageItem | null>(null)
|
||||
|
||||
/** 发送弹窗可见 */
|
||||
const composeVisible = ref(false)
|
||||
/** 发送表单 */
|
||||
const composeForm = reactive<{ title: string; content: string }>({ title: '', content: '' })
|
||||
/** 已选收件人 */
|
||||
const selectedRecipient = ref<Recipient | null>(null)
|
||||
/** 收件人搜索关键字 */
|
||||
const recipientKeyword = ref('')
|
||||
/** 收件人搜索下拉候选 */
|
||||
const recipientDropdown = ref<Recipient[]>([])
|
||||
/** 收件人下拉是否可见 */
|
||||
const recipientDropdownVisible = ref(false)
|
||||
/** 发送提交中 */
|
||||
const sending = ref(false)
|
||||
|
||||
/** 收件人搜索防抖计时器 */
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// === rawGet:绕过全局响应拦截器的剥离,取完整响应体 ===
|
||||
// 说明:拦截器对含 code 字段的响应在 code===0 时仅返回 result.data,会丢失 result.total /
|
||||
// result.UnreadCount 等顶层字段。此处用 responseType:'text' 让拦截器按"非业务响应"放行,
|
||||
// 再自行 JSON.parse 得到完整 { code, data, total, UnreadCount }。
|
||||
async function rawGet<T = unknown>(url: string, params?: Record<string, unknown>): Promise<RawResult<T>> {
|
||||
const resp = (await request.get(url, { params, responseType: 'text' })) as unknown as AxiosResponse<string>
|
||||
return JSON.parse(resp.data) as RawResult<T>
|
||||
}
|
||||
|
||||
// === 数据加载 ===
|
||||
|
||||
/** 加载消息列表(按 currentFilter / currentPage 拼参) */
|
||||
async function loadList(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, unknown> = {
|
||||
action: 'list',
|
||||
PageIndex: currentPage.value,
|
||||
PageSize: PAGE_SIZE,
|
||||
}
|
||||
if (currentFilter.value === 'unread') {
|
||||
params.IsRead = 0
|
||||
} else if (currentFilter.value !== 'all') {
|
||||
params.MsgType = currentFilter.value
|
||||
}
|
||||
const result = await rawGet<MessageItem[]>('/API/Message', params)
|
||||
if (result.code === 0) {
|
||||
list.value = result.data || []
|
||||
total.value = result.total || 0
|
||||
} else {
|
||||
list.value = []
|
||||
total.value = 0
|
||||
}
|
||||
} catch {
|
||||
list.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载未读数(驱动未读 tab 角标) */
|
||||
async function loadUnread(): Promise<void> {
|
||||
try {
|
||||
const result = await rawGet('/API/Message', { action: 'unread_count' })
|
||||
if (result.code === 0) {
|
||||
unreadCount.value = result.UnreadCount || 0
|
||||
}
|
||||
} catch {
|
||||
// 未读数加载失败静默处理,不阻断主流程
|
||||
}
|
||||
}
|
||||
|
||||
// === Tab / 分页 ===
|
||||
|
||||
/** Tab 切换:重置页码并重新加载 */
|
||||
function handleTabChange(name: string | number): void {
|
||||
currentFilter.value = String(name)
|
||||
currentPage.value = 1
|
||||
loadList()
|
||||
}
|
||||
|
||||
/** 分页变化 */
|
||||
function handlePageChange(page: number): void {
|
||||
currentPage.value = page
|
||||
loadList()
|
||||
}
|
||||
|
||||
// === 详情 ===
|
||||
|
||||
/** 打开消息详情(同时触发后端标记已读,再刷新列表与未读数) */
|
||||
async function showDetail(uuid: string): Promise<void> {
|
||||
try {
|
||||
const result = await rawGet<MessageItem>('/API/Message', {
|
||||
action: 'detail',
|
||||
MessageUUID: uuid,
|
||||
})
|
||||
if (result.code === 0 && result.data) {
|
||||
detailData.value = result.data
|
||||
detailVisible.value = true
|
||||
// 详情接口会标记已读,刷新列表与未读角标
|
||||
loadList()
|
||||
loadUnread()
|
||||
}
|
||||
} catch {
|
||||
// 详情加载失败静默处理
|
||||
}
|
||||
}
|
||||
|
||||
/** 关闭详情弹窗 */
|
||||
function closeDetail(): void {
|
||||
detailVisible.value = false
|
||||
detailData.value = null
|
||||
}
|
||||
|
||||
// === 删除 ===
|
||||
|
||||
/** 删除消息(带二次确认;fromDetail=true 时同步关闭详情弹窗) */
|
||||
async function deleteMsg(uuid: string, fromDetail = false): Promise<void> {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除这条消息吗?此操作不可撤销。', '删除消息', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return // 用户取消
|
||||
}
|
||||
try {
|
||||
// DELETE + JSON body:通过 config.data 传递 MessageUUID
|
||||
await del('/API/Message', undefined, { data: { MessageUUID: uuid } })
|
||||
ElMessage.success('已删除')
|
||||
if (fromDetail) closeDetail()
|
||||
loadList()
|
||||
loadUnread()
|
||||
} catch {
|
||||
// 业务错误已由拦截器统一提示
|
||||
}
|
||||
}
|
||||
|
||||
// === 全部已读 ===
|
||||
|
||||
/** 全部标记已读(PATCH + form-urlencoded body: action=read_all) */
|
||||
async function markAllRead(): Promise<void> {
|
||||
try {
|
||||
await patch('/API/Message', 'action=read_all', {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
ElMessage.success('已全部标记为已读')
|
||||
loadList()
|
||||
loadUnread()
|
||||
} catch {
|
||||
// 业务错误已由拦截器统一提示
|
||||
}
|
||||
}
|
||||
|
||||
// === 发送消息 ===
|
||||
|
||||
/** 打开发送弹窗(清空表单与收件人) */
|
||||
function openCompose(): void {
|
||||
selectedRecipient.value = null
|
||||
recipientKeyword.value = ''
|
||||
composeForm.title = ''
|
||||
composeForm.content = ''
|
||||
recipientDropdown.value = []
|
||||
recipientDropdownVisible.value = false
|
||||
composeVisible.value = true
|
||||
}
|
||||
|
||||
/** 从详情回复:预填收件人(仅个人消息且发送人非系统时调用) */
|
||||
function openComposeTo(uuid: string, name: string): void {
|
||||
openCompose()
|
||||
selectedRecipient.value = { type: 'user', uuid, name, label: name }
|
||||
recipientKeyword.value = name
|
||||
}
|
||||
|
||||
/** 收件人输入:300ms 防抖后搜索 */
|
||||
function onRecipientInput(val: string): void {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
if (!val) {
|
||||
recipientDropdown.value = []
|
||||
recipientDropdownVisible.value = false
|
||||
return
|
||||
}
|
||||
searchTimer = setTimeout(() => {
|
||||
void searchRecipients(val)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
/** 搜索收件人(用户/角色) */
|
||||
async function searchRecipients(keyword: string): Promise<void> {
|
||||
try {
|
||||
const result = await rawGet<Recipient[]>('/API/Message', {
|
||||
action: 'search_recipients',
|
||||
keyword,
|
||||
limit: 10,
|
||||
})
|
||||
if (result.code === 0 && result.data) {
|
||||
recipientDropdown.value = result.data
|
||||
recipientDropdownVisible.value = result.data.length > 0
|
||||
} else {
|
||||
recipientDropdown.value = []
|
||||
recipientDropdownVisible.value = false
|
||||
}
|
||||
} catch {
|
||||
recipientDropdown.value = []
|
||||
recipientDropdownVisible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 选中收件人 */
|
||||
function selectRecipient(item: Recipient): void {
|
||||
selectedRecipient.value = item
|
||||
recipientKeyword.value = item.name
|
||||
recipientDropdownVisible.value = false
|
||||
}
|
||||
|
||||
/** 移除已选收件人 */
|
||||
function removeRecipient(): void {
|
||||
selectedRecipient.value = null
|
||||
recipientKeyword.value = ''
|
||||
}
|
||||
|
||||
/** 发送消息(form-urlencoded;MsgType 固定 3=个人消息) */
|
||||
async function sendMessage(): Promise<void> {
|
||||
if (!selectedRecipient.value) {
|
||||
ElMessage.warning('请选择收件人')
|
||||
return
|
||||
}
|
||||
if (!composeForm.title.trim()) {
|
||||
ElMessage.warning('请输入标题')
|
||||
return
|
||||
}
|
||||
const payload = {
|
||||
ReceiverUUID: selectedRecipient.value.uuid,
|
||||
ReceiverType: selectedRecipient.value.type,
|
||||
Title: composeForm.title.trim(),
|
||||
Content: composeForm.content.trim(),
|
||||
MsgType: '3',
|
||||
}
|
||||
sending.value = true
|
||||
try {
|
||||
await post('/API/Message', new URLSearchParams(payload).toString(), {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
ElMessage.success('发送成功')
|
||||
composeVisible.value = false
|
||||
// 个人消息 tab 或全部 tab 下刷新列表
|
||||
if (currentFilter.value === '3' || currentFilter.value === 'all') {
|
||||
loadList()
|
||||
}
|
||||
} catch {
|
||||
// 业务错误已由拦截器统一提示
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// === 生命周期 ===
|
||||
|
||||
onMounted(() => {
|
||||
setPageTitle('站内信')
|
||||
loadList()
|
||||
loadUnread()
|
||||
// URL query id=xxx 时自动打开该消息详情
|
||||
const id = route.query.id as string | undefined
|
||||
if (id) showDetail(id)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="message-page" v-loading="loading">
|
||||
<h1 class="page-title">
|
||||
<i class="fas fa-envelope icon-primary" />站内信
|
||||
</h1>
|
||||
|
||||
<!-- 筛选 Tab -->
|
||||
<el-tabs v-model="currentFilter" class="msg-tabs" @tab-change="handleTabChange">
|
||||
<el-tab-pane v-for="tab in TABS" :key="tab.name" :name="tab.name">
|
||||
<template #label>
|
||||
<span v-if="tab.name === 'unread'">
|
||||
{{ tab.label }}
|
||||
<el-badge v-if="unreadCount > 0" :value="unreadCount" class="unread-badge" />
|
||||
</span>
|
||||
<span v-else>{{ tab.label }}</span>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<div class="msg-toolbar">
|
||||
<el-button type="primary" size="small" @click="openCompose">
|
||||
<i class="fas fa-paper-plane" /> 发送消息
|
||||
</el-button>
|
||||
<el-button size="small" @click="markAllRead">
|
||||
<i class="fas fa-check-double" /> 全部已读
|
||||
</el-button>
|
||||
<el-button size="small" @click="loadList">
|
||||
<i class="fas fa-sync-alt" /> 刷新
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<div class="msg-list">
|
||||
<el-empty v-if="!loading && list.length === 0" description="暂无消息" :image-size="60" />
|
||||
<div
|
||||
v-for="msg in list"
|
||||
:key="msg.MessageUUID"
|
||||
class="msg-item"
|
||||
:class="{ unread: msg.IsRead === 0 }"
|
||||
@click="showDetail(msg.MessageUUID)"
|
||||
>
|
||||
<!-- 类型图标 -->
|
||||
<div
|
||||
class="msg-item-icon"
|
||||
:style="{ background: MSG_TYPE_COLORS[msg.MsgType].bg, color: MSG_TYPE_COLORS[msg.MsgType].color }"
|
||||
>
|
||||
<i class="fas" :class="MSG_TYPE_ICONS[msg.MsgType]" />
|
||||
</div>
|
||||
<!-- 主体 -->
|
||||
<div class="msg-item-body">
|
||||
<div class="msg-item-title">{{ msg.Title || '(无标题)' }}</div>
|
||||
<div class="msg-item-content">{{ msg.Content || '' }}</div>
|
||||
<div class="msg-item-meta">
|
||||
<span><i class="fas fa-user" /> {{ msg.SenderName || '系统' }}</span>
|
||||
<span>{{ MSG_TYPE_NAMES[msg.MsgType] || '消息' }}</span>
|
||||
<span>{{ msg.CreateTime || '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 悬停删除 -->
|
||||
<div class="msg-item-actions">
|
||||
<el-button
|
||||
circle
|
||||
size="small"
|
||||
type="danger"
|
||||
title="删除"
|
||||
@click.stop="deleteMsg(msg.MessageUUID)"
|
||||
>
|
||||
<i class="fas fa-trash-alt" />
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="total > PAGE_SIZE" class="msg-pagination">
|
||||
<el-pagination
|
||||
:current-page="currentPage"
|
||||
:page-size="PAGE_SIZE"
|
||||
:total="total"
|
||||
layout="prev, pager, next"
|
||||
background
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<el-dialog v-model="detailVisible" width="600px" :show-close="true" @close="closeDetail">
|
||||
<template #header>
|
||||
<div class="detail-title">
|
||||
<i class="fas" :class="MSG_TYPE_ICONS[detailData?.MsgType ?? 3]" />
|
||||
<span>{{ detailData?.Title || '(无标题)' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="detailData" class="detail-body">
|
||||
<div class="detail-meta">
|
||||
<span><i class="fas fa-user" /> {{ detailData.SenderName || '系统' }}</span>
|
||||
<span><i class="fas fa-tag" /> {{ MSG_TYPE_NAMES[detailData.MsgType] || '消息' }}</span>
|
||||
<span><i class="fas fa-clock" /> {{ detailData.CreateTime || '' }}</span>
|
||||
</div>
|
||||
<div class="detail-content">{{ detailData.Content || '(无内容)' }}</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="detail-footer">
|
||||
<!-- 个人消息且发送人非系统:回复按钮 -->
|
||||
<el-button
|
||||
v-if="detailData && detailData.MsgType === 3 && detailData.SenderName && detailData.SenderName !== '系统'"
|
||||
type="primary"
|
||||
@click="closeDetail(); openComposeTo(detailData.SenderUUID || '', detailData.SenderName || '')"
|
||||
>
|
||||
<i class="fas fa-reply" /> 回复
|
||||
</el-button>
|
||||
<el-button type="danger" @click="detailData && deleteMsg(detailData.MessageUUID, true)">
|
||||
<i class="fas fa-trash-alt" /> 删除
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 发送弹窗 -->
|
||||
<el-dialog v-model="composeVisible" title="发送消息" width="560px">
|
||||
<el-form label-position="top">
|
||||
<!-- 收件人搜索 -->
|
||||
<el-form-item label="收件人" required>
|
||||
<div class="recipient-search">
|
||||
<el-input
|
||||
v-model="recipientKeyword"
|
||||
placeholder="输入用户名或角色名搜索..."
|
||||
autocomplete="off"
|
||||
@input="onRecipientInput"
|
||||
@focus="recipientDropdown.length && (recipientDropdownVisible = true)"
|
||||
/>
|
||||
<div v-if="recipientDropdownVisible" class="recipient-dropdown">
|
||||
<div
|
||||
v-for="item in recipientDropdown"
|
||||
:key="item.uuid"
|
||||
class="recipient-item"
|
||||
@click="selectRecipient(item)"
|
||||
>
|
||||
<el-tag size="small" :type="item.type === 'role' ? 'success' : 'primary'">
|
||||
{{ item.type === 'role' ? '角色' : '用户' }}
|
||||
</el-tag>
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 已选收件人 -->
|
||||
<div v-if="selectedRecipient" class="selected-recipient">
|
||||
<el-tag size="small" :type="selectedRecipient.type === 'role' ? 'success' : 'primary'">
|
||||
{{ selectedRecipient.type === 'role' ? '角色' : '用户' }}
|
||||
</el-tag>
|
||||
<span>{{ selectedRecipient.name }}</span>
|
||||
<i class="fas fa-times remove" @click="removeRecipient" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="标题" required>
|
||||
<el-input v-model="composeForm.title" placeholder="消息标题" />
|
||||
</el-form-item>
|
||||
<el-form-item label="内容">
|
||||
<el-input v-model="composeForm.content" type="textarea" :rows="5" placeholder="消息内容..." />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="composeVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="sending" @click="sendMessage">发送</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.message-page {
|
||||
padding: 20px;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.icon-primary {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.msg-tabs {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
// 未读 tab 角标位置微调
|
||||
.unread-badge {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.msg-toolbar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.msg-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.msg-item {
|
||||
background: var(--el-bg-color, #fff);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--el-border-color, #e2e8f0);
|
||||
padding: 16px 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
|
||||
.msg-item-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 未读:左侧主色边
|
||||
&.unread {
|
||||
border-left: 3px solid var(--el-color-primary);
|
||||
|
||||
.msg-item-title {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.msg-item-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.msg-item-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.msg-item-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.msg-item-content {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.msg-item-meta {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.msg-item-actions {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 12px;
|
||||
transform: translateY(-50%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.msg-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
// 详情弹窗
|
||||
.detail-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
i {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.detail-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
// 发送弹窗 - 收件人搜索
|
||||
.recipient-search {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.recipient-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
background: var(--el-bg-color, #fff);
|
||||
border: 1px solid var(--el-border-color, #e2e8f0);
|
||||
border-radius: 8px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.recipient-item {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
&:hover {
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
}
|
||||
|
||||
.selected-recipient {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
margin-top: 8px;
|
||||
|
||||
.remove {
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,510 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 支付网关配置
|
||||
*
|
||||
* 迁移自 TempLate/GERP/pages/system/payment-config.html
|
||||
*
|
||||
* 原生业务逻辑:
|
||||
* - 仅管理员可访问(原生 GERP.CheckAdmin()),非管理员显示"无权访问"错误
|
||||
* - 网关列表:GET /API/Payment/Configs → 卡片网格展示
|
||||
* 每张卡片:平台、默认标记、启用状态、AppID、商户号、APIKey/PrivateKey/PublicKey 设置状态(已设置 *** / 未设置)、回调
|
||||
* - 新增/编辑弹窗:平台、AppID、MchID、APIKey、PrivateKey、PublicKey、NotifyURL、ReturnURL、CertPath、CertKeyPath、设为默认、启用
|
||||
* - 保存:POST /API/Payment/SaveConfig (JSON)
|
||||
* - 删除:POST /API/Payment/DeleteConfig/{ConfigUUID}(二次确认)
|
||||
*
|
||||
* 敏感字段(APIKey / PrivateKey / PublicKey)处理:
|
||||
* - 原生:编辑时显示 ***,提示"*** 保留原值,清空将删除";提交时直接传 input 值(*** 或空)
|
||||
* - TODO: 本文件暂用 el-input type="password" placeholder="留空保留原值" 代替原计划的 SecretInput
|
||||
* 自定义类型。当前实现遵循"空值=保留原值"语义:
|
||||
* · 编辑模式:输入留空 → 提交 "***"(保留原值标记,对齐原生后端协议);输入新值 → 提交新值
|
||||
* · 新增模式:输入留空 → 提交空字符串(无原值可保留)
|
||||
* 后续接入 SecretInput 自定义类型后,应统一为组件内封装的"空值=保留原值"行为。
|
||||
*
|
||||
* 迁移取舍:
|
||||
* - server / admin_verify 由 request.ts 请求拦截器统一注入
|
||||
* - 权限校验改用 userStore.isAdmin 前端预拦截(后端仍会校验);非管理员显示无权访问占位
|
||||
* - 业务错误(code!==0)由拦截器统一 ElMessage.error 并 reject,本文件 catch 后恢复按钮状态
|
||||
* - AdminBadge 自动根据 server/admin_verify 显示特权横幅
|
||||
*/
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { get, post } from '@/api/request'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
import AdminBadge from '@/components/AdminBadge.vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// === 类型定义 ===
|
||||
|
||||
/** 支付网关配置记录(列表展示用,敏感字段仅返回 Set 标记) */
|
||||
interface PaymentConfigItem {
|
||||
ConfigUUID: string
|
||||
Platform: string
|
||||
AppID: string
|
||||
MchID: string
|
||||
/** APIKey 是否已设置(后端不回传明文) */
|
||||
APIKeySet: boolean
|
||||
PrivateKeySet: boolean
|
||||
PublicKeySet: boolean
|
||||
NotifyURL: string
|
||||
ReturnURL: string
|
||||
CertPath: string
|
||||
CertKeyPath: string
|
||||
ConfigDefault: boolean | 0 | 1
|
||||
ConfigStatus: 0 | 1
|
||||
}
|
||||
|
||||
/** 编辑表单数据 */
|
||||
interface PaymentForm {
|
||||
Platform: string
|
||||
AppID: string
|
||||
MchID: string
|
||||
APIKey: string
|
||||
PrivateKey: string
|
||||
PublicKey: string
|
||||
NotifyURL: string
|
||||
ReturnURL: string
|
||||
CertPath: string
|
||||
CertKeyPath: string
|
||||
ConfigDefault: boolean
|
||||
ConfigStatus: boolean
|
||||
}
|
||||
|
||||
// === 常量 ===
|
||||
|
||||
/** 平台选项 */
|
||||
const PLATFORM_OPTIONS = [
|
||||
{ value: 'wechat', label: '微信支付' },
|
||||
{ value: 'alipay', label: '支付宝' },
|
||||
{ value: 'bank', label: '银行直连' },
|
||||
{ value: 'other', label: '其他' },
|
||||
]
|
||||
|
||||
/** 平台 value → 中文 label 映射 */
|
||||
const PLATFORM_LABELS: Record<string, string> = {
|
||||
wechat: '微信支付',
|
||||
alipay: '支付宝',
|
||||
bank: '银行直连',
|
||||
other: '其他',
|
||||
}
|
||||
|
||||
/** 空表单模板 */
|
||||
function createEmptyForm(): PaymentForm {
|
||||
return {
|
||||
Platform: 'wechat',
|
||||
AppID: '',
|
||||
MchID: '',
|
||||
APIKey: '',
|
||||
PrivateKey: '',
|
||||
PublicKey: '',
|
||||
NotifyURL: '',
|
||||
ReturnURL: '',
|
||||
CertPath: '',
|
||||
CertKeyPath: '',
|
||||
ConfigDefault: false,
|
||||
ConfigStatus: true,
|
||||
}
|
||||
}
|
||||
|
||||
// === 状态 ===
|
||||
|
||||
/** 网关配置列表 */
|
||||
const configs = ref<PaymentConfigItem[]>([])
|
||||
/** 加载态 */
|
||||
const loading = ref(false)
|
||||
/** 错误状态文案 */
|
||||
const errorState = ref('')
|
||||
|
||||
/** 编辑弹窗可见 */
|
||||
const editVisible = ref(false)
|
||||
/** 当前编辑的 ConfigUUID(null 表示新增) */
|
||||
const editingUuid = ref<string | null>(null)
|
||||
/** 是否编辑模式 */
|
||||
const isEdit = computed(() => editingUuid.value !== null)
|
||||
/** 编辑表单 */
|
||||
const form = reactive<PaymentForm>(createEmptyForm())
|
||||
/** 保存中 */
|
||||
const saving = ref(false)
|
||||
|
||||
/** 是否管理员(前端预拦截) */
|
||||
const isAdmin = computed(() => userStore.isAdmin)
|
||||
|
||||
// === 数据加载 ===
|
||||
|
||||
/** 加载支付网关配置列表 */
|
||||
async function loadConfigs(): Promise<void> {
|
||||
// 前端权限预拦截
|
||||
if (!isAdmin.value) {
|
||||
errorState.value = '无权访问:仅管理员可查看支付网关配置'
|
||||
configs.value = []
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
errorState.value = ''
|
||||
try {
|
||||
const data = await get<PaymentConfigItem[]>('/API/Payment/Configs')
|
||||
configs.value = Array.isArray(data) ? data : []
|
||||
} catch (err) {
|
||||
configs.value = []
|
||||
errorState.value = err instanceof Error ? err.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// === 新增 / 编辑 ===
|
||||
|
||||
/** 打开新增弹窗 */
|
||||
function openCreate(): void {
|
||||
editingUuid.value = null
|
||||
Object.assign(form, createEmptyForm())
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
/** 打开编辑弹窗(敏感字段不回填,留空保留原值) */
|
||||
function openEdit(config: PaymentConfigItem): void {
|
||||
editingUuid.value = config.ConfigUUID
|
||||
Object.assign(form, {
|
||||
Platform: config.Platform,
|
||||
AppID: config.AppID || '',
|
||||
MchID: config.MchID || '',
|
||||
// TODO: SecretInput 自定义类型未实现,编辑时敏感字段留空(placeholder 提示"留空保留原值")
|
||||
APIKey: '',
|
||||
PrivateKey: '',
|
||||
PublicKey: '',
|
||||
NotifyURL: config.NotifyURL || '',
|
||||
ReturnURL: config.ReturnURL || '',
|
||||
CertPath: config.CertPath || '',
|
||||
CertKeyPath: config.CertKeyPath || '',
|
||||
ConfigDefault: !!config.ConfigDefault,
|
||||
ConfigStatus: config.ConfigStatus === 1,
|
||||
})
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造敏感字段提交值:
|
||||
* - 编辑模式 + 输入为空 → 传 "***"(保留原值标记,对齐原生后端协议)
|
||||
* - 新增模式 + 输入为空 → 传空字符串(无原值可保留)
|
||||
* - 输入非空 → 传实际值
|
||||
*/
|
||||
function resolveSecret(value: string): string {
|
||||
if (value) return value
|
||||
return isEdit.value ? '***' : ''
|
||||
}
|
||||
|
||||
/** 保存配置 */
|
||||
async function saveConfig(): Promise<void> {
|
||||
const payload = {
|
||||
ConfigUUID: editingUuid.value || null,
|
||||
Platform: form.Platform,
|
||||
AppID: form.AppID,
|
||||
MchID: form.MchID,
|
||||
APIKey: resolveSecret(form.APIKey),
|
||||
PrivateKey: resolveSecret(form.PrivateKey),
|
||||
PublicKey: resolveSecret(form.PublicKey),
|
||||
NotifyURL: form.NotifyURL,
|
||||
ReturnURL: form.ReturnURL,
|
||||
CertPath: form.CertPath,
|
||||
CertKeyPath: form.CertKeyPath,
|
||||
ConfigDefault: form.ConfigDefault,
|
||||
ConfigStatus: form.ConfigStatus ? 1 : 0,
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await post('/API/Payment/SaveConfig', payload)
|
||||
ElMessage.success('保存成功')
|
||||
editVisible.value = false
|
||||
loadConfigs()
|
||||
} catch {
|
||||
// 业务错误已由拦截器统一提示
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// === 删除 ===
|
||||
|
||||
/** 删除配置(二次确认) */
|
||||
async function deleteConfig(uuid: string, platform: string): Promise<void> {
|
||||
const label = PLATFORM_LABELS[platform] || platform
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除 ${label} 网关配置?`, '提示', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确认删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return // 用户取消
|
||||
}
|
||||
try {
|
||||
await post(`/API/Payment/DeleteConfig/${uuid}`)
|
||||
ElMessage.success('已删除')
|
||||
loadConfigs()
|
||||
} catch {
|
||||
// 业务错误已由拦截器统一提示
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setPageTitle('支付网关配置')
|
||||
loadConfigs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="payment-config-page">
|
||||
<!-- 管理员特权横幅 -->
|
||||
<AdminBadge />
|
||||
|
||||
<!-- 页头 -->
|
||||
<div class="pc-header">
|
||||
<h1 class="page-title">
|
||||
<i class="fas fa-credit-card icon-primary" />
|
||||
<span>支付网关配置</span>
|
||||
</h1>
|
||||
<el-button type="primary" @click="openCreate">
|
||||
<i class="fas fa-plus" /> 新增配置
|
||||
</el-button>
|
||||
<el-button @click="loadConfigs">
|
||||
<i class="fas fa-sync" /> 刷新
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 配置卡片网格 -->
|
||||
<div class="pc-grid" v-loading="loading">
|
||||
<!-- 错误状态 -->
|
||||
<div v-if="errorState" class="pc-error">
|
||||
<i class="fas fa-exclamation-triangle" />
|
||||
<div>{{ errorState }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<el-empty
|
||||
v-else-if="!loading && configs.length === 0"
|
||||
description="暂无支付网关配置"
|
||||
:image-size="60"
|
||||
/>
|
||||
|
||||
<!-- 网关卡片 -->
|
||||
<div v-for="c in configs" :key="c.ConfigUUID" class="pc-card">
|
||||
<div class="pc-card-header">
|
||||
<span class="platform">{{ PLATFORM_LABELS[c.Platform] || c.Platform }}</span>
|
||||
<el-tag v-if="c.ConfigDefault" type="primary" size="small">默认</el-tag>
|
||||
<el-tag :type="c.ConfigStatus === 1 ? 'success' : 'danger'" size="small">
|
||||
{{ c.ConfigStatus === 1 ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="pc-card-body">
|
||||
<div class="row"><span class="label">AppID</span><span class="value">{{ c.AppID || '-' }}</span></div>
|
||||
<div class="row"><span class="label">商户号</span><span class="value">{{ c.MchID || '-' }}</span></div>
|
||||
<div class="row">
|
||||
<span class="label">APIKey</span>
|
||||
<span class="value" :class="c.APIKeySet ? 'secret-set' : 'secret-unset'">
|
||||
{{ c.APIKeySet ? '已设置 ***' : '未设置' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">PrivateKey</span>
|
||||
<span class="value" :class="c.PrivateKeySet ? 'secret-set' : 'secret-unset'">
|
||||
{{ c.PrivateKeySet ? '已设置 ***' : '未设置' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">PublicKey</span>
|
||||
<span class="value" :class="c.PublicKeySet ? 'secret-set' : 'secret-unset'">
|
||||
{{ c.PublicKeySet ? '已设置 ***' : '未设置' }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="c.NotifyURL" class="row"><span class="label">回调</span><span class="value">{{ c.NotifyURL }}</span></div>
|
||||
</div>
|
||||
<div class="pc-card-actions">
|
||||
<el-button size="small" @click="openEdit(c)">
|
||||
<i class="fas fa-edit" /> 编辑
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteConfig(c.ConfigUUID, c.Platform)">
|
||||
<i class="fas fa-trash" /> 删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<el-dialog
|
||||
v-model="editVisible"
|
||||
:title="isEdit ? `编辑 - ${PLATFORM_LABELS[form.Platform] || form.Platform}` : '新增支付网关配置'"
|
||||
width="600px"
|
||||
>
|
||||
<el-form :model="form" label-position="top">
|
||||
<el-form-item label="平台 *">
|
||||
<el-select v-model="form.Platform" style="width: 100%">
|
||||
<el-option v-for="opt in PLATFORM_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="AppID">
|
||||
<el-input v-model="form.AppID" placeholder="应用ID" />
|
||||
</el-form-item>
|
||||
<el-form-item label="商户号 MchID">
|
||||
<el-input v-model="form.MchID" placeholder="商户号" />
|
||||
</el-form-item>
|
||||
<!-- TODO: SecretInput 自定义类型未实现,暂用 el-input type=password 代替 -->
|
||||
<el-form-item label="APIKey">
|
||||
<el-input v-model="form.APIKey" type="password" show-password placeholder="留空保留原值" />
|
||||
<div v-if="isEdit" class="secret-hint">已设置时留空表示保留原值</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="PrivateKey">
|
||||
<el-input v-model="form.PrivateKey" type="textarea" :rows="3" placeholder="留空保留原值" />
|
||||
<div v-if="isEdit" class="secret-hint">已设置时留空表示保留原值</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="PublicKey">
|
||||
<el-input v-model="form.PublicKey" type="textarea" :rows="3" placeholder="留空保留原值" />
|
||||
<div v-if="isEdit" class="secret-hint">已设置时留空表示保留原值</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="NotifyURL">
|
||||
<el-input v-model="form.NotifyURL" placeholder="支付回调通知地址" />
|
||||
</el-form-item>
|
||||
<el-form-item label="ReturnURL">
|
||||
<el-input v-model="form.ReturnURL" placeholder="支付完成跳转地址" />
|
||||
</el-form-item>
|
||||
<el-form-item label="CertPath">
|
||||
<el-input v-model="form.CertPath" placeholder="证书路径(可选)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="CertKeyPath">
|
||||
<el-input v-model="form.CertKeyPath" placeholder="证书密钥路径(可选)" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-checkbox v-model="form.ConfigDefault">设为默认网关</el-checkbox>
|
||||
<el-checkbox v-model="form.ConfigStatus">启用</el-checkbox>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="saveConfig">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.payment-config-page {
|
||||
padding: 24px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.pc-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.icon-primary {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pc-grid {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
|
||||
gap: 16px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.pc-card {
|
||||
background: var(--el-bg-color, #fff);
|
||||
border: 1px solid var(--el-border-color, #e2e8f0);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pc-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.platform {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.pc-card-body {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
.label {
|
||||
min-width: 80px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--el-text-color-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.secret-set {
|
||||
color: var(--el-color-success);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.secret-unset {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pc-card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--el-border-color, #e2e8f0);
|
||||
}
|
||||
|
||||
.pc-error {
|
||||
color: var(--el-color-danger);
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
grid-column: 1 / -1;
|
||||
|
||||
i {
|
||||
font-size: 40px;
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.secret-hint {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,351 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 角色详情页 RoleDetail
|
||||
*
|
||||
* 迁移自 TempLate/GERP/pages/system/role-detail.html
|
||||
*
|
||||
* 业务结构:
|
||||
* 1. 基本信息(InfoGrid):角色名称 / 角色类型 / 角色状态 / 分配范围 / 角色描述 / 创建时间
|
||||
* 2. 关联用户子表(CrudTable: UserRole,按 RoleUUID 过滤)
|
||||
* 3. 角色权限子表(CrudTable: RolePermission,按 RoleUUID 过滤)
|
||||
* - 备注:添加 admin:super 权限可使该角色成为超级管理员
|
||||
* 4. 隔离提示横幅:管理员跨公司 vs 仅当前公司
|
||||
* 5. actions:编辑按钮(动态编辑弹窗)+ 权限树分配按钮(跳 RolePermissions)
|
||||
*
|
||||
* 迁移要点:
|
||||
* - server / admin_verify 由 request.ts 拦截器统一注入
|
||||
* - 主表单条加载:formApi.list('Role', { RoleUUID, pageSize: 1 })
|
||||
* - 权限树分配:跳 /system/role-permissions?RoleUUID=xxx(RolePermissions 页用 el-tree 实现)
|
||||
* - role_scope 细粒度判断暂用 userStore.isAdmin 二分(TODO)
|
||||
*/
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import DetailPage from '@/components/DetailPage.vue'
|
||||
import InfoGrid from '@/components/InfoGrid.vue'
|
||||
import CrudTable from '@/components/CrudTable.vue'
|
||||
import EntityPicker from '@/components/EntityPicker.vue'
|
||||
import { formApi } from '@/api/form'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
import type { ColumnSchema } from '@/types/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 路由参数:角色 UUID
|
||||
const roleUUID = (route.query.RoleUUID as string) || ''
|
||||
|
||||
// === 状态 ===
|
||||
const loading = ref(false)
|
||||
const data = ref<Record<string, any> | null>(null)
|
||||
|
||||
// 编辑弹窗状态
|
||||
const editDialogVisible = ref(false)
|
||||
const editSubmitting = ref(false)
|
||||
const columns = ref<ColumnSchema[]>([])
|
||||
const editForm = reactive<Record<string, any>>({})
|
||||
|
||||
// 主键字段
|
||||
const PK_FIELD = 'RoleUUID'
|
||||
|
||||
// 页面标题
|
||||
const title = computed(() => data.value?.RoleName || '角色详情')
|
||||
|
||||
// 隔离提示
|
||||
// TODO: 接入 checkAdmin → role_scope
|
||||
const isolationHint = computed(() =>
|
||||
userStore.isAdmin
|
||||
? '特权模式:显示该角色在所有公司的关联记录(无公司隔离)'
|
||||
: '仅显示当前公司数据。如需查看所有公司记录,需管理员特权。',
|
||||
)
|
||||
|
||||
// === 显示格式化 helper ===
|
||||
|
||||
/** 角色类型:0→组员,1→组长 */
|
||||
function roleTypeText(t: unknown): string {
|
||||
if (t === 0 || t === '0') return '组员'
|
||||
if (t === 1 || t === '1') return '组长'
|
||||
return '-'
|
||||
}
|
||||
|
||||
/** 角色状态标签 */
|
||||
function roleStatusTag(v: unknown): { text: string; type: 'success' | 'danger' | 'warning' | 'info' } {
|
||||
if (v === 1 || v === '1') return { text: '启用', type: 'success' }
|
||||
if (v === 0 || v === '0') return { text: '禁用', type: 'danger' }
|
||||
// TODO: 对齐原生 CompanyUtils.statusTag 完整码表
|
||||
return { text: String(v ?? '-'), type: 'warning' }
|
||||
}
|
||||
|
||||
/** 分配范围:0→全租户可分发,1→指定公司,2→指定部门 */
|
||||
function assignScopeText(s: unknown): { text: string; type: 'success' | 'primary' | 'warning' | 'info' } {
|
||||
if (s === 0 || s === '0') return { text: '全租户可分发', type: 'success' }
|
||||
if (s === 1 || s === '1') return { text: '指定公司可分发', type: 'primary' }
|
||||
if (s === 2 || s === '2') return { text: '指定部门可分发', type: 'warning' }
|
||||
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(
|
||||
(c) =>
|
||||
c.editable !== false &&
|
||||
!c.hideInForm &&
|
||||
c.type !== 'hidden' &&
|
||||
c.type !== 'divider' &&
|
||||
c.type !== 'html' &&
|
||||
c.field !== PK_FIELD,
|
||||
),
|
||||
)
|
||||
|
||||
// === 数据加载 ===
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
if (!roleUUID) {
|
||||
ElMessage.warning('缺少 RoleUUID 参数,无法加载角色详情')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await formApi.list('Role', { [PK_FIELD]: roleUUID, pageSize: 1 })
|
||||
const list: any[] = Array.isArray(result) ? result : (result as any)?.list || []
|
||||
const row = list.find((r) => r[PK_FIELD] === roleUUID) || list[0]
|
||||
if (!row) {
|
||||
ElMessage.error('未找到角色数据')
|
||||
return
|
||||
}
|
||||
data.value = row
|
||||
setPageTitle(row.RoleName || '角色详情')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadColumns(): Promise<void> {
|
||||
try {
|
||||
columns.value = await formApi.getColumns('Role')
|
||||
} catch {
|
||||
// 列定义加载失败不阻塞展示
|
||||
}
|
||||
}
|
||||
|
||||
// === 编辑弹窗 ===
|
||||
|
||||
function handleEdit(): void {
|
||||
if (!data.value) return
|
||||
Object.keys(editForm).forEach((k) => delete editForm[k])
|
||||
Object.assign(editForm, data.value)
|
||||
editDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
editSubmitting.value = true
|
||||
try {
|
||||
await formApi.update('Role', editForm[PK_FIELD], editForm)
|
||||
ElMessage.success('保存成功')
|
||||
editDialogVisible.value = false
|
||||
await loadData()
|
||||
} finally {
|
||||
editSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// === 权限树分配 ===
|
||||
|
||||
/** 跳转权限树分配页(RolePermissions) */
|
||||
function goPermissionTree(): void {
|
||||
if (!roleUUID) {
|
||||
ElMessage.warning('缺少 RoleUUID')
|
||||
return
|
||||
}
|
||||
router.push({ path: '/system/role-permissions', query: { RoleUUID: roleUUID } })
|
||||
}
|
||||
|
||||
// === 子表跳转 ===
|
||||
|
||||
/** UserRole 子表查看 → 用户详情 */
|
||||
function goUserDetail(row: Record<string, any>): void {
|
||||
router.push({ path: '/system/user-detail', query: { UserUUID: row.UserUUID || '' } })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadData(), loadColumns()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DetailPage :title="title" :loading="loading">
|
||||
<template #actions>
|
||||
<el-button type="primary" @click="handleEdit">
|
||||
<el-icon><Edit /></el-icon>编辑
|
||||
</el-button>
|
||||
<el-button @click="goPermissionTree" title="按权限组勾选分配权限">
|
||||
<el-icon><Connection /></el-icon>权限树分配
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<!-- 隔离提示横幅 -->
|
||||
<el-alert :type="userStore.isAdmin ? 'warning' : 'info'" :closable="false" show-icon>
|
||||
{{ isolationHint }}
|
||||
</el-alert>
|
||||
|
||||
<!-- 基本信息 -->
|
||||
<InfoGrid title="基本信息" :columns="2">
|
||||
<el-descriptions-item label="角色名称">{{ data?.RoleName || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="角色类型">{{ roleTypeText(data?.RoleType) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="角色状态">
|
||||
<el-tag :type="roleStatusTag(data?.RoleStatus).type" size="small">
|
||||
{{ roleStatusTag(data?.RoleStatus).text }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="分配范围">
|
||||
<el-tag :type="assignScopeText(data?.AssignScope).type" size="small">
|
||||
{{ assignScopeText(data?.AssignScope).text }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="角色描述">{{ data?.RoleDesc || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTime(data?.CreateTime) }}</el-descriptions-item>
|
||||
</InfoGrid>
|
||||
|
||||
<!-- 关联用户子表 -->
|
||||
<el-card shadow="never">
|
||||
<template #header>关联用户</template>
|
||||
<CrudTable
|
||||
v-if="roleUUID"
|
||||
table="UserRole"
|
||||
title="角色用户"
|
||||
:query="{ RoleUUID: roleUUID }"
|
||||
:pk-field="'UserRoleUUID'"
|
||||
selectable
|
||||
pageable
|
||||
:on-view="goUserDetail"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 角色权限子表 -->
|
||||
<el-card shadow="never">
|
||||
<template #header>角色权限</template>
|
||||
<CrudTable
|
||||
v-if="roleUUID"
|
||||
table="RolePermission"
|
||||
title="角色权限"
|
||||
:query="{ RoleUUID: roleUUID }"
|
||||
:pk-field="'RolePermUUID'"
|
||||
selectable
|
||||
pageable
|
||||
/>
|
||||
<div class="perm-hint">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
添加 <code>admin:super</code> 权限可使该角色成为超级管理员
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<el-dialog v-model="editDialogVisible" title="编辑角色" width="600px" destroy-on-close>
|
||||
<el-form :model="editForm" label-width="120px" label-position="right">
|
||||
<el-form-item
|
||||
v-for="col in formColumns"
|
||||
:key="col.field"
|
||||
:label="col.title"
|
||||
:required="col.required"
|
||||
>
|
||||
<EntityPicker
|
||||
v-if="col.type === 'entityPicker' && col.entityPicker"
|
||||
v-model="editForm[col.field]"
|
||||
:config="col.entityPicker"
|
||||
:existing-label="editForm[col.field + '_label']"
|
||||
:placeholder="col.placeholder"
|
||||
:disabled="col.disabled"
|
||||
@update:label="(label: string) => (editForm[col.field + '_label'] = label)"
|
||||
/>
|
||||
<el-input
|
||||
v-else-if="!col.type || col.type === 'text' || col.type === 'string'"
|
||||
v-model="editForm[col.field]"
|
||||
:placeholder="col.placeholder"
|
||||
:maxlength="col.maxLength"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<el-input
|
||||
v-else-if="col.type === 'textarea'"
|
||||
v-model="editForm[col.field]"
|
||||
type="textarea"
|
||||
:rows="col.rows || 3"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<el-input-number
|
||||
v-else-if="col.type === 'number'"
|
||||
v-model="editForm[col.field]"
|
||||
:min="col.min"
|
||||
:max="col.max"
|
||||
:step="col.step"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<el-select
|
||||
v-else-if="col.type === 'select'"
|
||||
v-model="editForm[col.field]"
|
||||
:placeholder="col.placeholder"
|
||||
:disabled="col.disabled"
|
||||
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]"
|
||||
:type="col.type as 'date' | 'datetime'"
|
||||
:placeholder="col.placeholder"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<el-switch
|
||||
v-else-if="col.type === 'switch' || col.type === 'boolean'"
|
||||
v-model="editForm[col.field]"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<el-input
|
||||
v-else
|
||||
:model-value="String(editForm[col.field] ?? '')"
|
||||
readonly
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="editSubmitting" @click="handleSubmit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</DetailPage>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// 权限子表下方提示
|
||||
.perm-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
code {
|
||||
padding: 1px 6px;
|
||||
background: #f4f4f5;
|
||||
border-radius: 3px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,475 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 角色权限分配页 RolePermissions
|
||||
*
|
||||
* 迁移自 TempLate/GERP/pages/system/role-permissions.html
|
||||
*
|
||||
* 业务结构(非标准详情页,是权限树勾选页):
|
||||
* 1. 工具栏:角色下拉 + 公司下拉 + 统计 + 全部展开/折叠 + 全选/清空
|
||||
* 2. 权限树:按 Category 分组,叶子为权限项(PermName / PermCode / PermDesc),可勾选
|
||||
* 3. 底部:保存权限按钮(POST 勾选的 PermUUIDs)
|
||||
* 4. 非管理员访问时显示"无权访问"拒绝态
|
||||
*
|
||||
* 迁移要点(对齐工程迁移规则):
|
||||
* - server / admin_verify 由 request.ts 拦截器统一注入
|
||||
* - 权限树用 el-tree 实现(TODO:待 PermissionTreePicker 组件封装后替换)
|
||||
* - role_scope 细粒度判断暂用 userStore.isAdmin 二分(TODO)
|
||||
* - 仅管理员可访问:非管理员显示拒绝页
|
||||
* - 离开页面前提示未保存(beforeunload + 路由守卫)
|
||||
*
|
||||
* 接口:
|
||||
* - 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 { useRoute, onBeforeRouteLeave } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import DetailPage from '@/components/DetailPage.vue'
|
||||
import { formApi } from '@/api/form'
|
||||
import { get, post } from '@/api/request'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 路由初始参数
|
||||
const initRoleUUID = (route.query.RoleUUID as string) || ''
|
||||
const initCompanyUUID = (route.query.CompanyUUID as string) || ''
|
||||
|
||||
// 选中值
|
||||
const selectedRoleUUID = ref(initRoleUUID)
|
||||
const selectedCompanyUUID = ref(initCompanyUUID)
|
||||
|
||||
// === 状态 ===
|
||||
const roles = ref<Array<Record<string, any>>>([])
|
||||
const companies = ref<Array<Record<string, any>>>([])
|
||||
const permissionTree = ref<PermGroup[]>([])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const denied = ref(false)
|
||||
const hasChanges = ref(false)
|
||||
|
||||
// 原始勾选集合(用于变更检测)
|
||||
const originalCheckedSet = ref<Set<string>>(new Set())
|
||||
// 当前勾选的 PermUUID 列表(驱动 default-checked-keys)
|
||||
const checkedKeys = ref<string[]>([])
|
||||
// 展开/折叠控制(通过重建 tree 应用)
|
||||
const expandAllFlag = ref(false)
|
||||
const treeVersion = ref(0)
|
||||
|
||||
// el-tree 实例引用
|
||||
const treeRef = ref<any>(null)
|
||||
|
||||
// 权限组 / 权限项类型
|
||||
interface PermItem {
|
||||
PermUUID: string
|
||||
PermName: string
|
||||
PermCode: string
|
||||
PermDesc?: string
|
||||
Checked: boolean
|
||||
}
|
||||
interface PermGroup {
|
||||
Category: string
|
||||
Permissions: PermItem[]
|
||||
}
|
||||
|
||||
// 仅管理员可访问
|
||||
// TODO: 接入 checkAdmin → role_scope,细化 dept_manager / workgroup_lead 分支
|
||||
const isAdmin = computed(() => userStore.isAdmin)
|
||||
|
||||
// el-tree 数据:组 → 权限,统一 _nodeKey 作为 node-key
|
||||
const treeData = computed(() =>
|
||||
permissionTree.value.map((g, gi) => ({
|
||||
_nodeKey: `group_${gi}`,
|
||||
label: g.Category,
|
||||
children: g.Permissions.map((p) => ({
|
||||
_nodeKey: p.PermUUID,
|
||||
label: p.PermName,
|
||||
permCode: p.PermCode,
|
||||
permDesc: p.PermDesc || '',
|
||||
isLeaf: true,
|
||||
})),
|
||||
})),
|
||||
)
|
||||
|
||||
// 统计:总权限数
|
||||
const totalCount = computed(() =>
|
||||
permissionTree.value.reduce((s, g) => s + g.Permissions.length, 0),
|
||||
)
|
||||
// 统计:已勾选数
|
||||
const checkedCount = computed(() => checkedKeys.value.length)
|
||||
|
||||
// 保存按钮可用:选中角色+公司 且 无保存中
|
||||
const canSave = computed(
|
||||
() => !!selectedRoleUUID.value && !!selectedCompanyUUID.value && !saving.value,
|
||||
)
|
||||
|
||||
// === 数据加载 ===
|
||||
|
||||
async function loadRoles(): Promise<void> {
|
||||
try {
|
||||
const result = await formApi.list('Role', { pageSize: 500 })
|
||||
roles.value = Array.isArray(result) ? result : (result as any)?.list || []
|
||||
} catch {
|
||||
// 拦截器已统一提示
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCompanies(): Promise<void> {
|
||||
try {
|
||||
const result = await formApi.list('Company', { pageSize: 500 })
|
||||
companies.value = Array.isArray(result) ? result : (result as any)?.list || []
|
||||
} catch {
|
||||
// 拦截器已统一提示
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载权限树 */
|
||||
async function loadTree(): Promise<void> {
|
||||
const roleUUID = selectedRoleUUID.value
|
||||
const companyUUID = selectedCompanyUUID.value
|
||||
if (!roleUUID || !companyUUID) {
|
||||
permissionTree.value = []
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
hasChanges.value = false
|
||||
try {
|
||||
const result = await get<{ Groups?: PermGroup[] }>('/API/Role/PermissionsTree', {
|
||||
RoleUUID: roleUUID,
|
||||
CompanyUUID: companyUUID,
|
||||
})
|
||||
permissionTree.value = result?.Groups || []
|
||||
const checked = permissionTree.value.flatMap((g) =>
|
||||
g.Permissions.filter((p) => p.Checked).map((p) => p.PermUUID),
|
||||
)
|
||||
originalCheckedSet.value = new Set(checked)
|
||||
checkedKeys.value = [...checked]
|
||||
// 重建 tree 以应用 default-checked-keys / default-expand-all
|
||||
treeVersion.value++
|
||||
await nextTick()
|
||||
updateHasChanges()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// === 交互 ===
|
||||
|
||||
function onRoleChange(): void {
|
||||
if (selectedCompanyUUID.value) void loadTree()
|
||||
}
|
||||
function onCompanyChange(): void {
|
||||
if (selectedRoleUUID.value) void loadTree()
|
||||
}
|
||||
|
||||
/** 勾选变化:同步 checkedKeys 与 hasChanges */
|
||||
function onCheckChange(): void {
|
||||
const keys = treeRef.value?.getCheckedKeys?.(true) as string[] | undefined
|
||||
checkedKeys.value = keys || []
|
||||
updateHasChanges()
|
||||
}
|
||||
|
||||
function updateHasChanges(): void {
|
||||
const currentSet = new Set(checkedKeys.value)
|
||||
hasChanges.value = !(
|
||||
currentSet.size === originalCheckedSet.value.size &&
|
||||
[...currentSet].every((x) => originalCheckedSet.value.has(x))
|
||||
)
|
||||
}
|
||||
|
||||
/** 全部展开:重建 tree 并默认展开 */
|
||||
function expandAll(): void {
|
||||
expandAllFlag.value = true
|
||||
treeVersion.value++
|
||||
void nextTick(() => onCheckChange())
|
||||
}
|
||||
/** 全部折叠:重建 tree 并默认折叠 */
|
||||
function collapseAll(): void {
|
||||
expandAllFlag.value = false
|
||||
treeVersion.value++
|
||||
void nextTick(() => onCheckChange())
|
||||
}
|
||||
|
||||
/** 全选所有权限 */
|
||||
function checkAll(): void {
|
||||
const all = permissionTree.value.flatMap((g) => g.Permissions.map((p) => p.PermUUID))
|
||||
treeRef.value?.setCheckedKeys?.(all)
|
||||
checkedKeys.value = all
|
||||
updateHasChanges()
|
||||
}
|
||||
/** 清空所有勾选 */
|
||||
function uncheckAll(): void {
|
||||
treeRef.value?.setCheckedKeys?.([])
|
||||
checkedKeys.value = []
|
||||
updateHasChanges()
|
||||
}
|
||||
|
||||
/** 保存权限 */
|
||||
async function savePermissions(): Promise<void> {
|
||||
const roleUUID = selectedRoleUUID.value
|
||||
const companyUUID = selectedCompanyUUID.value
|
||||
if (!roleUUID || !companyUUID) {
|
||||
ElMessage.warning('请先选择角色和公司')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const result = await post<{ Added?: number; Removed?: number; Kept?: number }>(
|
||||
'/API/Role/SavePermissions',
|
||||
{ RoleUUID: roleUUID, CompanyUUID: companyUUID, PermUUIDs: checkedKeys.value },
|
||||
)
|
||||
const d = result || {}
|
||||
ElMessage.success(`保存成功(新增 ${d.Added || 0},移除 ${d.Removed || 0},保留 ${d.Kept || 0})`)
|
||||
originalCheckedSet.value = new Set(checkedKeys.value)
|
||||
hasChanges.value = false
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// === 离开提示 ===
|
||||
|
||||
function beforeUnloadHandler(e: BeforeUnloadEvent): void {
|
||||
if (hasChanges.value) {
|
||||
e.preventDefault()
|
||||
e.returnValue = ''
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeRouteLeave((_to, _from, next) => {
|
||||
if (!hasChanges.value) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
// 未保存提示:用户确认后放行
|
||||
if (window.confirm('有未保存的权限变更,确定离开?')) {
|
||||
next()
|
||||
} else {
|
||||
next(false)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
setPageTitle('角色权限分配')
|
||||
// 入口校验:仅管理员可访问
|
||||
if (!isAdmin.value) {
|
||||
denied.value = true
|
||||
return
|
||||
}
|
||||
await Promise.all([loadRoles(), loadCompanies()])
|
||||
// 自动选中初始角色 / 公司
|
||||
if (initRoleUUID) selectedRoleUUID.value = initRoleUUID
|
||||
if (initCompanyUUID) selectedCompanyUUID.value = initCompanyUUID
|
||||
if (initRoleUUID && initCompanyUUID) {
|
||||
await loadTree()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('beforeunload', beforeUnloadHandler)
|
||||
})
|
||||
window.addEventListener('beforeunload', beforeUnloadHandler)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DetailPage title="角色权限分配" :loading="loading">
|
||||
<!-- 非管理员:无权访问 -->
|
||||
<div v-if="denied" class="denied-state">
|
||||
<el-icon class="denied-icon"><Lock /></el-icon>
|
||||
<div class="denied-title">无权访问</div>
|
||||
<div class="denied-desc">该页面仅管理员可查看,请切换至管理员角色或联系管理员。</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 工具栏:角色 / 公司选择 + 统计 + 批量操作 -->
|
||||
<el-card shadow="never" class="toolbar">
|
||||
<div class="toolbar-row">
|
||||
<span class="toolbar-label">角色:</span>
|
||||
<el-select
|
||||
v-model="selectedRoleUUID"
|
||||
placeholder="请选择角色"
|
||||
filterable
|
||||
style="min-width: 200px"
|
||||
@change="onRoleChange"
|
||||
>
|
||||
<el-option label="请选择角色" value="" />
|
||||
<el-option
|
||||
v-for="r in roles"
|
||||
:key="r.RoleUUID"
|
||||
:label="r.RoleName"
|
||||
:value="r.RoleUUID"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<span class="toolbar-label">公司:</span>
|
||||
<el-select
|
||||
v-model="selectedCompanyUUID"
|
||||
placeholder="请选择公司"
|
||||
filterable
|
||||
style="min-width: 200px"
|
||||
@change="onCompanyChange"
|
||||
>
|
||||
<el-option label="请选择公司" value="" />
|
||||
<el-option
|
||||
v-for="c in companies"
|
||||
:key="c.CompanyUUID"
|
||||
:label="c.CompanyName"
|
||||
:value="c.CompanyUUID"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<div class="toolbar-stat">
|
||||
共 <strong>{{ totalCount }}</strong> 项权限,已勾选 <strong>{{ checkedCount }}</strong> 项
|
||||
</div>
|
||||
|
||||
<div class="toolbar-actions">
|
||||
<el-button @click="expandAll">全部展开</el-button>
|
||||
<el-button @click="collapseAll">全部折叠</el-button>
|
||||
<el-button @click="checkAll">全选</el-button>
|
||||
<el-button @click="uncheckAll">清空</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 权限树 -->
|
||||
<el-card shadow="never" class="tree-wrap">
|
||||
<div v-if="!selectedRoleUUID || !selectedCompanyUUID" class="empty-state">
|
||||
<el-icon class="empty-icon"><Lock /></el-icon>
|
||||
<div>请先选择角色和公司</div>
|
||||
</div>
|
||||
<el-empty v-else-if="treeData.length === 0 && !loading" description="暂无权限定义" />
|
||||
|
||||
<!-- TODO: 待 PermissionTreePicker 组件封装后替换此处 el-tree -->
|
||||
<el-tree
|
||||
v-else
|
||||
ref="treeRef"
|
||||
:key="treeVersion"
|
||||
:data="treeData"
|
||||
node-key="_nodeKey"
|
||||
show-checkbox
|
||||
:default-checked-keys="checkedKeys"
|
||||
:default-expand-all="expandAllFlag"
|
||||
:props="{ label: 'label', children: 'children' }"
|
||||
@check="onCheckChange"
|
||||
>
|
||||
<template #default="{ data: node }">
|
||||
<div class="perm-row">
|
||||
<span class="perm-name">{{ node.label }}</span>
|
||||
<el-tag v-if="node.permCode" size="small" type="info" class="perm-code">
|
||||
{{ node.permCode }}
|
||||
</el-tag>
|
||||
<span v-if="node.permDesc" class="perm-desc">{{ node.permDesc }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-tree>
|
||||
</el-card>
|
||||
|
||||
<!-- 底部保存 -->
|
||||
<el-card shadow="never" class="footer">
|
||||
<el-button type="primary" :loading="saving" :disabled="!canSave" @click="savePermissions">
|
||||
<el-icon><Select /></el-icon>保存权限
|
||||
</el-button>
|
||||
</el-card>
|
||||
</template>
|
||||
</DetailPage>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.toolbar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-label {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.toolbar-stat {
|
||||
margin-left: auto;
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
|
||||
strong {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tree-wrap {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.perm-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.perm-name {
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.perm-code {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
.perm-desc {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
display: block;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.denied-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.denied-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
display: block;
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.denied-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.denied-desc {
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 角色管理
|
||||
*
|
||||
* 迁移自 TempLate/GERP/pages/system/roles.html(108 行)
|
||||
* 原生实现:server 作用域 + admin_verify 特权 + 列 fallback(getDefaultColumns)+ onView 跳详情
|
||||
*
|
||||
* Vue 版:
|
||||
* - server / admin_verify 由 request.ts 拦截器统一注入
|
||||
* - 列定义优先走后端 /API/FormAPI/Columns/Role;非主实例后端不可用时由 useCrudTable fallback 处理(TODO)
|
||||
* - onView 跳 Vue 路由 /system/role-detail?RoleUUID=xxx(替代 GERP.Navigate)
|
||||
* - 页面标题随 server 变化由 setPageTitle 统一处理
|
||||
*/
|
||||
import { useRouter } from 'vue-router'
|
||||
import CrudTable from '@/components/CrudTable.vue'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
|
||||
setPageTitle('角色管理')
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 角色记录类型(仅声明本页用到的字段)
|
||||
interface RoleRow {
|
||||
RoleUUID?: string
|
||||
RoleName?: string
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
// 跳转角色详情页
|
||||
function handleView(row: RoleRow): void {
|
||||
router.push({
|
||||
path: '/system/role-detail',
|
||||
query: { RoleUUID: row.RoleUUID || '' },
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="role-page">
|
||||
<CrudTable
|
||||
table="Role"
|
||||
title="角色管理"
|
||||
pk-field="RoleUUID"
|
||||
searchable
|
||||
pageable
|
||||
selectable
|
||||
:on-view="handleView"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.role-page {
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,493 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 系统设置(Settings)
|
||||
*
|
||||
* 迁移自 TempLate/GERP/pages/system/settings.html(528 行)
|
||||
*
|
||||
* 原生实现要点:
|
||||
* - 设置项:主题模式、侧边栏默认展开、最大标签页数、允许重复标签页、关闭确认、
|
||||
* 桌面通知、通知声音、动画效果、预加载页面
|
||||
* - 主题选项:default / light / dark / green / purple / red
|
||||
* - 存储:优先后端 /API/Settings(GET 读取 / POST 保存),失败回退 localStorage(key: GERPSettings)
|
||||
* - 主题管理经 window.GERPApp.ThemeManager + window.GERPThemeSync 跨 iframe 同步
|
||||
* - 通过 postMessage 与父页面通信:SETTINGS_UPDATE(下发设置)、GET_THEME(请求主题)、
|
||||
* 监听 THEME_CHANGE(接收父页面主题色变量)
|
||||
* - 即时保存:所有输入变更 debounce 200~300ms 静默提交
|
||||
* - 危险操作:清除缓存、重置所有设置
|
||||
*
|
||||
* Vue 版改动(重点重构):
|
||||
* - 【废弃 GERPThemeSync】:不再调用 window.GERPThemeSync.init(),主题改由 Pinia appStore 统一管理
|
||||
* - 【移除 postMessage】:删除 SETTINGS_UPDATE / GET_THEME / THEME_CHANGE 全部跨窗口通信,
|
||||
* Vue 单页应用内主题与设置直接通过 store / 响应式数据驱动,无需 iframe 消息桥
|
||||
* - 主题管理用 useAppStore:appStore 当前仅支持 light / dark,选择这两项时调用 appStore.setTheme;
|
||||
* 其余主题(default / green / purple / red)仅本地记录,TODO 待 appStore 扩展多主题支持
|
||||
* - 侧边栏折叠改用 appStore.sidebarCollapsed / toggleSidebar(替代 GERPUI.SetSidebarCollapsed)
|
||||
* - 动画开关通过 watch 设置 CSS 变量 --TransitionSpeed / --AnimationDuration(替代原生 DOM 操作)
|
||||
* - server / admin_verify 由 request.ts 拦截器统一注入
|
||||
* - 确认弹窗改用 ElMessageBox.confirm(替代 GERPUI.Confirm)
|
||||
*/
|
||||
import { ref, reactive, watch, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { get, post } from '@/api/request'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
setPageTitle('系统设置')
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
/** 本地存储键(兜底用,对齐原生 GERPSettings) */
|
||||
const STORAGE_KEY = 'GERPSettings'
|
||||
/** 设置接口地址 */
|
||||
const API_URL = '/API/Settings'
|
||||
|
||||
/** 设置数据结构 */
|
||||
interface Settings {
|
||||
/** 主题模式:default / light / dark / green / purple / red */
|
||||
themeMode: string
|
||||
/** 侧边栏默认展开 */
|
||||
sidebarExpanded: boolean
|
||||
/** 最大标签页数量 */
|
||||
maxTabs: number
|
||||
/** 允许重复标签页 */
|
||||
allowDuplicate: boolean
|
||||
/** 关闭标签页时确认 */
|
||||
confirmClose: boolean
|
||||
/** 启用桌面通知 */
|
||||
desktopNotify: boolean
|
||||
/** 通知声音 */
|
||||
notifySound: boolean
|
||||
/** 动画效果 */
|
||||
animations: boolean
|
||||
/** 预加载页面 */
|
||||
preload: boolean
|
||||
}
|
||||
|
||||
/** 默认设置(对齐原生 defaultSettings) */
|
||||
const defaultSettings: Settings = {
|
||||
themeMode: 'default',
|
||||
sidebarExpanded: true,
|
||||
maxTabs: 20,
|
||||
allowDuplicate: false,
|
||||
confirmClose: false,
|
||||
desktopNotify: true,
|
||||
notifySound: true,
|
||||
animations: true,
|
||||
preload: true,
|
||||
}
|
||||
|
||||
/** 主题选项 */
|
||||
const themeOptions = [
|
||||
{ value: 'default', label: '默认主题' },
|
||||
{ value: 'light', label: '浅色主题' },
|
||||
{ value: 'dark', label: '深色主题' },
|
||||
{ value: 'green', label: '绿色主题' },
|
||||
{ value: 'purple', label: '紫色主题' },
|
||||
{ value: 'red', label: '红色主题' },
|
||||
]
|
||||
|
||||
/** 当前设置(响应式) */
|
||||
const settings = reactive<Settings>({ ...defaultSettings })
|
||||
/** 是否已加载完成(避免初始化期间触发即时保存) */
|
||||
const initialized = ref(false)
|
||||
|
||||
/**
|
||||
* 读取设置:优先后端 /API/Settings,失败回退 localStorage
|
||||
* 响应拦截器返回 result.data(设置对象)
|
||||
*/
|
||||
async function loadSettings(): Promise<Settings> {
|
||||
const merged: Settings = { ...defaultSettings }
|
||||
try {
|
||||
const data = await get<Partial<Settings> | null>(API_URL)
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
Object.assign(merged, data)
|
||||
}
|
||||
} catch {
|
||||
// 后端读取失败:回退 localStorage
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (saved) {
|
||||
try {
|
||||
Object.assign(merged, JSON.parse(saved))
|
||||
} catch {
|
||||
/* 忽略损坏的本地数据 */
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/** 静默保存:写入 localStorage + 后端,不弹成功提示(用于即时保存) */
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function debounceSave(delay = 200): void {
|
||||
if (!initialized.value) return
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(() => saveSettings(true), delay)
|
||||
}
|
||||
|
||||
/** 保存设置到 localStorage 与后端 */
|
||||
async function saveSettings(silent = false): Promise<void> {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings))
|
||||
try {
|
||||
await post(API_URL, settings)
|
||||
if (!silent) ElMessage.success('已保存')
|
||||
} catch {
|
||||
if (!silent) ElMessage.warning('保存到服务器失败')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将设置应用到运行时(主题 / 侧边栏 / 动画)
|
||||
* 替代原生 applySettingsToDOM + applySettingsToParent
|
||||
*/
|
||||
function applySettingsToRuntime(s: Settings): void {
|
||||
// 动画:设置 CSS 变量(对齐原生 --TransitionSpeed / --AnimationDuration)
|
||||
const root = document.documentElement
|
||||
root.style.setProperty('--TransitionSpeed', s.animations ? '0.2s' : '0s')
|
||||
root.style.setProperty('--AnimationDuration', s.animations ? '0.3s' : '0s')
|
||||
root.classList.toggle('GERPNoAnimations', !s.animations)
|
||||
|
||||
// 侧边栏:同步到 appStore(sidebarExpanded 取反即 sidebarCollapsed)
|
||||
if (appStore.sidebarCollapsed === s.sidebarExpanded) {
|
||||
// 状态不一致时翻转一次;一致则不动
|
||||
appStore.sidebarCollapsed = !s.sidebarExpanded
|
||||
}
|
||||
|
||||
// 主题:appStore 仅支持 light / dark,其余主题 TODO
|
||||
applyTheme(s.themeMode)
|
||||
}
|
||||
|
||||
/** 应用主题:light/dark 走 appStore,其余主题 TODO 待 appStore 扩展 */
|
||||
function applyTheme(mode: string): void {
|
||||
if (mode === 'light' || mode === 'dark') {
|
||||
appStore.setTheme(mode)
|
||||
return
|
||||
}
|
||||
// TODO: appStore 当前仅支持 light/dark,default/green/purple/red 等主题待扩展后接入
|
||||
// default 暂按 light 处理,避免无主题
|
||||
if (mode === 'default') {
|
||||
appStore.setTheme('light')
|
||||
}
|
||||
}
|
||||
|
||||
/** 主题切换处理:立即应用并即时保存 */
|
||||
function onThemeChange(value: string): void {
|
||||
applyTheme(value)
|
||||
debounceSave(300)
|
||||
}
|
||||
|
||||
/** 侧边栏切换处理:立即生效并即时保存 */
|
||||
function onSidebarChange(value: string | number | boolean): void {
|
||||
appStore.sidebarCollapsed = !value
|
||||
debounceSave(200)
|
||||
}
|
||||
|
||||
/** 动画切换处理:立即生效并即时保存 */
|
||||
function onAnimationsChange(value: string | number | boolean): void {
|
||||
const root = document.documentElement
|
||||
root.style.setProperty('--TransitionSpeed', value ? '0.2s' : '0s')
|
||||
root.style.setProperty('--AnimationDuration', value ? '0.3s' : '0s')
|
||||
root.classList.toggle('GERPNoAnimations', !value)
|
||||
debounceSave(200)
|
||||
}
|
||||
|
||||
/** 清除缓存:清空本地设置并重新加载 */
|
||||
async function onClearCache(): Promise<void> {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要清除所有缓存吗?', '清除缓存', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
try {
|
||||
await post(API_URL, {})
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
const s = await loadSettings()
|
||||
Object.assign(settings, s)
|
||||
applySettingsToRuntime(settings)
|
||||
ElMessage.success('缓存已清除')
|
||||
}
|
||||
|
||||
/** 重置所有设置为默认值 */
|
||||
async function onResetSettings(): Promise<void> {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要重置所有设置为默认值吗?', '重置设置', {
|
||||
type: 'error',
|
||||
confirmButtonText: '确定重置',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
Object.assign(settings, defaultSettings)
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings))
|
||||
try {
|
||||
await post(API_URL, settings)
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
applyTheme('default')
|
||||
applySettingsToRuntime(settings)
|
||||
ElMessage.success('设置已重置')
|
||||
}
|
||||
|
||||
// 即时保存:监听非主题/动画/侧边栏的开关与数值变化(这三个已单独处理立即生效)
|
||||
watch(
|
||||
() => [settings.maxTabs, settings.allowDuplicate, settings.confirmClose, settings.desktopNotify, settings.notifySound, settings.preload] as const,
|
||||
() => debounceSave(200),
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
const s = await loadSettings()
|
||||
Object.assign(settings, s)
|
||||
applySettingsToRuntime(settings)
|
||||
initialized.value = true
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<!-- 页头 -->
|
||||
<div class="settings-header">
|
||||
<h1 class="settings-title">
|
||||
<i class="fas fa-cog icon-primary" />
|
||||
<span>系统设置</span>
|
||||
</h1>
|
||||
<p class="settings-desc">管理系统外观、行为和功能配置</p>
|
||||
</div>
|
||||
|
||||
<!-- 外观设置 -->
|
||||
<div class="settings-section">
|
||||
<h2 class="settings-section-title">外观设置</h2>
|
||||
<div class="settings-item">
|
||||
<div class="settings-item-info">
|
||||
<div class="settings-item-title">主题模式</div>
|
||||
<div class="settings-item-desc">选择系统的主题风格</div>
|
||||
</div>
|
||||
<div class="settings-item-control">
|
||||
<el-select
|
||||
:model-value="settings.themeMode"
|
||||
style="width: 160px"
|
||||
@update:model-value="onThemeChange"
|
||||
>
|
||||
<el-option v-for="t in themeOptions" :key="t.value" :label="t.label" :value="t.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-item">
|
||||
<div class="settings-item-info">
|
||||
<div class="settings-item-title">侧边栏默认展开</div>
|
||||
<div class="settings-item-desc">启动时是否默认展开侧边栏</div>
|
||||
</div>
|
||||
<div class="settings-item-control">
|
||||
<el-switch
|
||||
:model-value="settings.sidebarExpanded"
|
||||
@update:model-value="onSidebarChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签页设置 -->
|
||||
<div class="settings-section">
|
||||
<h2 class="settings-section-title">标签页设置</h2>
|
||||
<div class="settings-item">
|
||||
<div class="settings-item-info">
|
||||
<div class="settings-item-title">最大标签页数量</div>
|
||||
<div class="settings-item-desc">超过数量时自动关闭最早的标签页</div>
|
||||
</div>
|
||||
<div class="settings-item-control">
|
||||
<el-input-number v-model="settings.maxTabs" :min="5" :max="50" :step="1" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-item">
|
||||
<div class="settings-item-info">
|
||||
<div class="settings-item-title">允许重复标签页</div>
|
||||
<div class="settings-item-desc">是否允许打开相同页面的多个标签页</div>
|
||||
</div>
|
||||
<div class="settings-item-control">
|
||||
<el-switch v-model="settings.allowDuplicate" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-item">
|
||||
<div class="settings-item-info">
|
||||
<div class="settings-item-title">关闭标签页时确认</div>
|
||||
<div class="settings-item-desc">关闭标签页前是否弹出确认对话框</div>
|
||||
</div>
|
||||
<div class="settings-item-control">
|
||||
<el-switch v-model="settings.confirmClose" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 通知设置 -->
|
||||
<div class="settings-section">
|
||||
<h2 class="settings-section-title">通知设置</h2>
|
||||
<div class="settings-item">
|
||||
<div class="settings-item-info">
|
||||
<div class="settings-item-title">启用桌面通知</div>
|
||||
<div class="settings-item-desc">接收系统桌面通知推送</div>
|
||||
</div>
|
||||
<div class="settings-item-control">
|
||||
<el-switch v-model="settings.desktopNotify" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-item">
|
||||
<div class="settings-item-info">
|
||||
<div class="settings-item-title">通知声音</div>
|
||||
<div class="settings-item-desc">收到通知时播放提示音</div>
|
||||
</div>
|
||||
<div class="settings-item-control">
|
||||
<el-switch v-model="settings.notifySound" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 性能设置 -->
|
||||
<div class="settings-section">
|
||||
<h2 class="settings-section-title">性能设置</h2>
|
||||
<div class="settings-item">
|
||||
<div class="settings-item-info">
|
||||
<div class="settings-item-title">动画效果</div>
|
||||
<div class="settings-item-desc">启用界面过渡动画效果</div>
|
||||
</div>
|
||||
<div class="settings-item-control">
|
||||
<el-switch
|
||||
:model-value="settings.animations"
|
||||
@update:model-value="onAnimationsChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-item">
|
||||
<div class="settings-item-info">
|
||||
<div class="settings-item-title">预加载页面</div>
|
||||
<div class="settings-item-desc">预加载常用页面以提升响应速度</div>
|
||||
</div>
|
||||
<div class="settings-item-control">
|
||||
<el-switch v-model="settings.preload" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 危险操作 -->
|
||||
<div class="settings-section danger-zone">
|
||||
<h2 class="settings-section-title">危险操作</h2>
|
||||
<div class="settings-item">
|
||||
<div class="settings-item-info">
|
||||
<div class="settings-item-title">清除缓存</div>
|
||||
<div class="settings-item-desc">清除所有本地存储的设置和缓存数据</div>
|
||||
</div>
|
||||
<div class="settings-item-control">
|
||||
<el-button @click="onClearCache">清除缓存</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-item">
|
||||
<div class="settings-item-info">
|
||||
<div class="settings-item-title">重置所有设置</div>
|
||||
<div class="settings-item-desc">将所有设置恢复为默认值</div>
|
||||
</div>
|
||||
<div class="settings-item-control">
|
||||
<el-button type="danger" @click="onResetSettings">重置设置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.settings-page {
|
||||
padding: 24px;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.settings-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.icon-primary {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-desc {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
background: var(--el-bg-color);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
|
||||
.settings-section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--el-border-color);
|
||||
}
|
||||
|
||||
&.danger-zone {
|
||||
border: 1px solid var(--el-color-danger);
|
||||
background: var(--el-color-danger-light-9);
|
||||
|
||||
.settings-section-title {
|
||||
color: var(--el-color-danger);
|
||||
border-bottom-color: var(--el-color-danger-light-5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.settings-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--el-border-color);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.settings-item-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.settings-item-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.settings-item-desc {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.settings-item-control {
|
||||
margin-left: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,230 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 租户入驻申请
|
||||
*
|
||||
* 迁移自 TempLate/GERP/pages/system/tenant-create.html
|
||||
*
|
||||
* 原生业务逻辑:
|
||||
* - 表单:租户名称(必填)、有效期(必填,datetime)、申请说明(可选)
|
||||
* - 提交申请:POST /API/Tenant/Apply (JSON: { TenantName, TenantExpireTime, ReviewRemark })
|
||||
* - 提交成功后切换到成功提示页(申请已提交,等待审核通过后激活)
|
||||
* - 成功页提供"继续申请"(重置表单)与"返回"(router.back)操作
|
||||
*
|
||||
* 迁移取舍:
|
||||
* - server / admin_verify 由 request.ts 请求拦截器统一注入
|
||||
* - 原生有效期用 GERPDateTimePicker 自定义组件,此处改用 el-date-picker type="datetime"
|
||||
* - 表单校验用 el-form rules
|
||||
* - 业务错误(code!==0)由拦截器统一 ElMessage.error,本文件 catch 后恢复按钮状态即可
|
||||
*/
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { post } from '@/api/request'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// === 表单数据 ===
|
||||
const formRef = ref<FormInstance>()
|
||||
const form = reactive({
|
||||
TenantName: '',
|
||||
TenantExpireTime: '',
|
||||
ReviewRemark: '',
|
||||
})
|
||||
|
||||
// === 校验规则 ===
|
||||
const rules: FormRules = {
|
||||
TenantName: [{ required: true, message: '请输入租户名称', trigger: 'blur' }],
|
||||
TenantExpireTime: [{ required: true, message: '请选择有效期', trigger: 'change' }],
|
||||
}
|
||||
|
||||
// === 状态 ===
|
||||
/** 提交中 */
|
||||
const submitting = ref(false)
|
||||
/** 是否已提交成功(切换到成功页) */
|
||||
const submitted = ref(false)
|
||||
|
||||
// === 提交申请 ===
|
||||
async function submitTenant(): Promise<void> {
|
||||
if (!formRef.value) return
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch {
|
||||
return // 校验失败
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await post('/API/Tenant/Apply', {
|
||||
TenantName: form.TenantName.trim(),
|
||||
TenantExpireTime: form.TenantExpireTime,
|
||||
ReviewRemark: form.ReviewRemark.trim(),
|
||||
})
|
||||
// 成功:切换到成功页
|
||||
submitted.value = true
|
||||
ElMessage.success('申请已提交')
|
||||
} catch {
|
||||
// 业务错误已由拦截器统一提示
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// === 成功页操作 ===
|
||||
|
||||
/** 继续申请:重置表单并回到表单页 */
|
||||
function resetForNew(): void {
|
||||
submitted.value = false
|
||||
form.TenantName = ''
|
||||
form.TenantExpireTime = ''
|
||||
form.ReviewRemark = ''
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
/** 返回上一页 */
|
||||
function goBack(): void {
|
||||
router.back()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setPageTitle('租户入驻申请')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tenant-create-page">
|
||||
<div class="tenant-create-card">
|
||||
<!-- 表单区 -->
|
||||
<div v-if="!submitted" class="form-section">
|
||||
<div class="card-title">
|
||||
<i class="fas fa-key icon-primary" />
|
||||
<span>租户入驻申请</span>
|
||||
</div>
|
||||
<div class="card-desc">提交租户入驻申请,等待超管或审核员审核通过后激活。</div>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
class="tenant-form"
|
||||
>
|
||||
<el-form-item label="租户名称" prop="TenantName">
|
||||
<el-input v-model="form.TenantName" placeholder="请输入租户名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="有效期" prop="TenantExpireTime">
|
||||
<el-date-picker
|
||||
v-model="form.TenantExpireTime"
|
||||
type="datetime"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
placeholder="请选择有效期"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="申请说明">
|
||||
<el-input
|
||||
v-model="form.ReviewRemark"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="可选,填写申请理由或备注"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="card-actions">
|
||||
<el-button @click="goBack">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitTenant">提交申请</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 成功页 -->
|
||||
<div v-else class="success-section">
|
||||
<i class="fas fa-check-circle success-icon" />
|
||||
<h3>申请已提交</h3>
|
||||
<p>租户入驻申请已提交,等待审核通过后激活。</p>
|
||||
<div class="success-actions">
|
||||
<el-button type="primary" @click="resetForNew">继续申请</el-button>
|
||||
<el-button @click="goBack">返回</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tenant-create-page {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tenant-create-card {
|
||||
background: var(--el-bg-color, #fff);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--el-border-color, #e2e8f0);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
.icon-primary {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.tenant-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
// 成功页
|
||||
.success-section {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
font-size: 48px;
|
||||
color: var(--el-color-success);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.success-section h3 {
|
||||
font-size: 18px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.success-section p {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
|
||||
.success-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,324 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 租户审核工作台
|
||||
*
|
||||
* 迁移自 TempLate/GERP/pages/system/tenant-review.html
|
||||
*
|
||||
* 原生业务逻辑:
|
||||
* - 加载待审核租户列表:GET /API/Tenant/Pending
|
||||
* - 权限校验:业务 code 403 → 显示"无权访问审核工作台";其他非 0 → 显示错误信息
|
||||
* - 卡片列表:租户名称、有效期、申请时间、申请说明(备注)
|
||||
* - 批准:二次确认后 POST /API/Tenant/Approve/{TenantUUID}
|
||||
* - 拒绝:弹窗输入拒绝理由(必填)→ POST /API/Tenant/Reject/{TenantUUID} (JSON: { ReviewRemark })
|
||||
* - 操作成功后刷新列表
|
||||
*
|
||||
* 迁移取舍:
|
||||
* - server / admin_verify 由 request.ts 请求拦截器统一注入
|
||||
* - 原生用 GERP.SetTitle 设标题,此处用 setPageTitle
|
||||
* - ⚠ 全局响应拦截器在 code!==0 时统一 ElMessage.error 并 reject。原生对 403 显示固定文案
|
||||
* "无权访问审核工作台",新架构下 403 会被拦截器以 result.msg 形式提示并 reject,本文件在
|
||||
* catch 中将 error.message 写入页面 errorState 占位显示(与拦截器 toast 形成双保险)。
|
||||
* 若需还原固定文案,可在 catch 中按 error.message 匹配关键字判断(TODO)。
|
||||
* - 批准/拒绝成功后由拦截器统一提示,本文件额外不重复 toast,仅刷新列表
|
||||
*/
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { get, post } from '@/api/request'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
|
||||
// === 类型定义 ===
|
||||
|
||||
/** 待审核租户记录 */
|
||||
interface PendingTenant {
|
||||
TenantUUID: string
|
||||
TenantName: string
|
||||
TenantExpireTime: string
|
||||
CreateTime: string
|
||||
ReviewRemark: string
|
||||
}
|
||||
|
||||
// === 状态 ===
|
||||
|
||||
/** 待审核列表 */
|
||||
const list = ref<PendingTenant[]>([])
|
||||
/** 加载态 */
|
||||
const loading = ref(false)
|
||||
/** 错误状态文案(非空时替代列表显示) */
|
||||
const errorState = ref('')
|
||||
|
||||
/** 拒绝弹窗可见 */
|
||||
const rejectVisible = ref(false)
|
||||
/** 拒绝目标租户 UUID */
|
||||
const rejectTargetUuid = ref('')
|
||||
/** 拒绝目标租户名称(弹窗展示) */
|
||||
const rejectTargetName = ref('')
|
||||
/** 拒绝理由 */
|
||||
const rejectReason = ref('')
|
||||
/** 拒绝提交中 */
|
||||
const rejecting = ref(false)
|
||||
|
||||
// === 工具函数 ===
|
||||
|
||||
/** 时间格式化:去掉 T 分隔,截取到秒 */
|
||||
function formatTime(t: string): string {
|
||||
if (!t) return '-'
|
||||
return t.replace('T', ' ').substring(0, 19)
|
||||
}
|
||||
|
||||
// === 数据加载 ===
|
||||
|
||||
/** 加载待审核租户列表 */
|
||||
async function loadPending(): Promise<void> {
|
||||
loading.value = true
|
||||
errorState.value = ''
|
||||
try {
|
||||
// 拦截器在 code===0 时返回 result.data(数组)
|
||||
const data = await get<PendingTenant[]>('/API/Tenant/Pending')
|
||||
list.value = Array.isArray(data) ? data : []
|
||||
} catch (err) {
|
||||
// code!==0(含 403 无权访问)由拦截器 reject,此处显示页面内错误占位
|
||||
list.value = []
|
||||
errorState.value = err instanceof Error ? err.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// === 批准 ===
|
||||
|
||||
/** 批准租户入驻(二次确认) */
|
||||
async function approveTenant(uuid: string): Promise<void> {
|
||||
try {
|
||||
await ElMessageBox.confirm('确认批准该租户入驻申请?', '提示', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确认批准',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return // 用户取消
|
||||
}
|
||||
try {
|
||||
await post(`/API/Tenant/Approve/${uuid}`)
|
||||
ElMessage.success('审核通过')
|
||||
loadPending()
|
||||
} catch {
|
||||
// 业务错误已由拦截器统一提示
|
||||
}
|
||||
}
|
||||
|
||||
// === 拒绝 ===
|
||||
|
||||
/** 打开拒绝弹窗 */
|
||||
function openReject(uuid: string, name: string): void {
|
||||
rejectTargetUuid.value = uuid
|
||||
rejectTargetName.value = name
|
||||
rejectReason.value = ''
|
||||
rejectVisible.value = true
|
||||
}
|
||||
|
||||
/** 确认拒绝(理由必填) */
|
||||
async function confirmReject(): Promise<void> {
|
||||
if (!rejectReason.value.trim()) {
|
||||
ElMessage.warning('请输入拒绝理由')
|
||||
return
|
||||
}
|
||||
rejecting.value = true
|
||||
try {
|
||||
await post(`/API/Tenant/Reject/${rejectTargetUuid.value}`, {
|
||||
ReviewRemark: rejectReason.value.trim(),
|
||||
})
|
||||
ElMessage.success('已拒绝申请')
|
||||
rejectVisible.value = false
|
||||
loadPending()
|
||||
} catch {
|
||||
// 业务错误已由拦截器统一提示
|
||||
} finally {
|
||||
rejecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setPageTitle('租户审核工作台')
|
||||
loadPending()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tenant-review-page">
|
||||
<!-- 页头 -->
|
||||
<div class="review-header">
|
||||
<h1 class="page-title">
|
||||
<i class="fas fa-clipboard-check icon-primary" />
|
||||
<span>租户审核工作台</span>
|
||||
</h1>
|
||||
<el-button @click="loadPending">
|
||||
<i class="fas fa-sync" /> 刷新
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 列表区 -->
|
||||
<div class="review-container" v-loading="loading">
|
||||
<!-- 错误状态 -->
|
||||
<div v-if="errorState" class="error-state">
|
||||
<i class="fas fa-exclamation-triangle" />
|
||||
<div>{{ errorState }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<el-empty
|
||||
v-else-if="!loading && list.length === 0"
|
||||
description="暂无待审核的租户申请"
|
||||
:image-size="60"
|
||||
/>
|
||||
|
||||
<!-- 待审核卡片列表 -->
|
||||
<div v-else class="review-list">
|
||||
<div v-for="t in list" :key="t.TenantUUID" class="review-card">
|
||||
<div class="info">
|
||||
<div class="name">{{ t.TenantName }}</div>
|
||||
<div class="meta">
|
||||
<span><i class="fas fa-calendar" /> 有效期: {{ formatTime(t.TenantExpireTime) }}</span>
|
||||
<span><i class="fas fa-clock" /> 申请时间: {{ formatTime(t.CreateTime) }}</span>
|
||||
</div>
|
||||
<div v-if="t.ReviewRemark" class="remark">
|
||||
<i class="fas fa-comment" /> {{ t.ReviewRemark }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button type="primary" @click="approveTenant(t.TenantUUID)">
|
||||
<i class="fas fa-check" /> 批准
|
||||
</el-button>
|
||||
<el-button type="danger" @click="openReject(t.TenantUUID, t.TenantName)">
|
||||
<i class="fas fa-times" /> 拒绝
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 拒绝弹窗 -->
|
||||
<el-dialog v-model="rejectVisible" title="拒绝入驻申请" width="420px">
|
||||
<p class="reject-target">租户:<span>{{ rejectTargetName }}</span></p>
|
||||
<el-input
|
||||
v-model="rejectReason"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="请输入拒绝理由"
|
||||
/>
|
||||
<template #footer>
|
||||
<el-button @click="rejectVisible = false">取消</el-button>
|
||||
<el-button type="danger" :loading="rejecting" @click="confirmReject">确认拒绝</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tenant-review-page {
|
||||
padding: 24px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.review-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.icon-primary {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.review-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.review-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.review-card {
|
||||
background: var(--el-bg-color, #fff);
|
||||
border: 1px solid var(--el-border-color, #e2e8f0);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.remark {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 6px;
|
||||
padding: 8px 12px;
|
||||
background: var(--el-fill-color-light, #f8fafc);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.error-state {
|
||||
color: var(--el-color-danger);
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
|
||||
i {
|
||||
font-size: 40px;
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.reject-target {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin: 0 0 12px;
|
||||
|
||||
span {
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,360 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 用户详情页 UserDetail
|
||||
*
|
||||
* 迁移自 TempLate/GERP/pages/system/user-detail.html
|
||||
*
|
||||
* 业务结构:
|
||||
* 1. 基本信息(InfoGrid):用户名 / 邮箱 / 电话 / 性别 / 账号状态 / 是否员工 / 是否超级用户 / 创建时间 / 最后登录
|
||||
* 2. 2FA 双因素信息卡(TODO:GetUser2FAInfo 接口契约确认后补全字段渲染)
|
||||
* 3. 角色与组织子表(CrudTable: UserRole,按 UserUUID 过滤)
|
||||
* 4. 部门关联子表(CrudTable: UserDept,按 UserUUID 过滤)
|
||||
* 5. 隔离提示横幅:管理员特权(跨公司)vs 仅当前公司
|
||||
* 6. 编辑按钮:基于列定义的动态编辑弹窗(formApi.update)
|
||||
*
|
||||
* 迁移要点(对齐工程迁移规则):
|
||||
* - server / admin_verify 由 request.ts 拦截器统一注入,不再手动拼 buildApiUrl
|
||||
* - 主表单条加载:formApi.list('User', { UserUUID, pageSize: 1 }) 取首条
|
||||
* - 子表 CrudTable 通过 query 透传父级 UUID,无需手动建表
|
||||
* - UUID 解析使用 useResolveUUIDs composable(本页主表暂无 UUID 字段需解析)
|
||||
* - role_scope 细粒度判断暂用 userStore.isAdmin 二分(TODO:接入 checkAdmin 后补 dept_manager / workgroup_lead)
|
||||
* - 2FA 接口:get('/API/SiteUserManagement/GetUser2FAInfo', { UserUUID })(TODO:契约确认)
|
||||
*/
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import DetailPage from '@/components/DetailPage.vue'
|
||||
import InfoGrid from '@/components/InfoGrid.vue'
|
||||
import CrudTable from '@/components/CrudTable.vue'
|
||||
import EntityPicker from '@/components/EntityPicker.vue'
|
||||
import { formApi } from '@/api/form'
|
||||
import { get } from '@/api/request'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
import type { ColumnSchema } from '@/types/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 路由参数:用户 UUID
|
||||
const userUUID = (route.query.UserUUID as string) || ''
|
||||
|
||||
// === 状态 ===
|
||||
const loading = ref(false)
|
||||
const data = ref<Record<string, any> | null>(null)
|
||||
const twoFactorInfo = ref<Record<string, any> | null>(null)
|
||||
|
||||
// 编辑弹窗状态
|
||||
const editDialogVisible = ref(false)
|
||||
const editSubmitting = ref(false)
|
||||
const columns = ref<ColumnSchema[]>([])
|
||||
const editForm = reactive<Record<string, any>>({})
|
||||
|
||||
// 主键字段
|
||||
const PK_FIELD = 'UserUUID'
|
||||
|
||||
// 页面标题:有数据时显示用户名
|
||||
const title = computed(() => data.value?.UserName || '用户详情')
|
||||
|
||||
// 隔离提示:管理员跨公司 vs 仅当前公司
|
||||
// TODO: 接入 checkAdmin → role_scope,补 dept_manager / workgroup_lead 分支
|
||||
const isolationHint = computed(() =>
|
||||
userStore.isAdmin
|
||||
? '特权模式:显示该用户在所有公司、所有部门的关联记录(无公司隔离)'
|
||||
: '仅显示当前公司数据。如需查看所有公司记录,需管理员特权。',
|
||||
)
|
||||
|
||||
// === 显示格式化 helper ===
|
||||
|
||||
/** 性别:1→男,0→女 */
|
||||
function genderText(g: unknown): string {
|
||||
if (g === 1 || g === '1') return '男'
|
||||
if (g === 0 || g === '0') return '女'
|
||||
return '-'
|
||||
}
|
||||
|
||||
/** 是 / 否 文本 */
|
||||
function yesNoText(val: unknown): string {
|
||||
return val ? '是' : '否'
|
||||
}
|
||||
|
||||
/** 账号状态标签:1→启用,0→禁用,其他→待审核 */
|
||||
function accountLicenseTag(v: unknown): { text: string; type: 'success' | 'danger' | 'warning' | 'info' } {
|
||||
if (v === 1 || v === '1') return { text: '启用', type: 'success' }
|
||||
if (v === 0 || v === '0') return { text: '禁用', type: 'danger' }
|
||||
// TODO: 对齐原生 CompanyUtils.statusTag 的完整码表
|
||||
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(
|
||||
(c) =>
|
||||
c.editable !== false &&
|
||||
!c.hideInForm &&
|
||||
c.type !== 'hidden' &&
|
||||
c.type !== 'divider' &&
|
||||
c.type !== 'html' &&
|
||||
c.field !== PK_FIELD,
|
||||
),
|
||||
)
|
||||
|
||||
// === 数据加载 ===
|
||||
|
||||
/** 加载用户主表单条数据 */
|
||||
async function loadData(): Promise<void> {
|
||||
if (!userUUID) {
|
||||
ElMessage.warning('缺少 UserUUID 参数,无法加载用户详情')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await formApi.list('User', { [PK_FIELD]: userUUID, pageSize: 1 })
|
||||
const list: any[] = Array.isArray(result) ? result : (result as any)?.list || []
|
||||
const row = list.find((u) => u[PK_FIELD] === userUUID) || list[0]
|
||||
if (!row) {
|
||||
ElMessage.error('未找到用户数据')
|
||||
return
|
||||
}
|
||||
data.value = row
|
||||
setPageTitle(row.UserName || '用户详情')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载 2FA 双因素信息(TODO:接口契约确认后补全渲染) */
|
||||
async function loadTwoFactor(): Promise<void> {
|
||||
if (!userUUID) return
|
||||
try {
|
||||
// TODO: GetUser2FAInfo 返回字段契约确认(Enabled / BackupCodes / BoundAt 等)
|
||||
twoFactorInfo.value = await get('/API/SiteUserManagement/GetUser2FAInfo', { UserUUID: userUUID })
|
||||
} catch {
|
||||
// 2FA 信息加载失败不阻塞主流程(拦截器已统一提示)
|
||||
twoFactorInfo.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载列定义(用于编辑弹窗动态渲染) */
|
||||
async function loadColumns(): Promise<void> {
|
||||
try {
|
||||
columns.value = await formApi.getColumns('User')
|
||||
} catch {
|
||||
// 列定义加载失败不阻塞展示
|
||||
}
|
||||
}
|
||||
|
||||
// === 编辑弹窗 ===
|
||||
|
||||
/** 打开编辑弹窗:拷贝当前数据到表单 */
|
||||
function handleEdit(): void {
|
||||
if (!data.value) return
|
||||
Object.keys(editForm).forEach((k) => delete editForm[k])
|
||||
Object.assign(editForm, data.value)
|
||||
editDialogVisible.value = true
|
||||
}
|
||||
|
||||
/** 提交编辑:formApi.update 全量更新 */
|
||||
async function handleSubmit(): Promise<void> {
|
||||
editSubmitting.value = true
|
||||
try {
|
||||
await formApi.update('User', editForm[PK_FIELD], editForm)
|
||||
ElMessage.success('保存成功')
|
||||
editDialogVisible.value = false
|
||||
await loadData()
|
||||
} finally {
|
||||
editSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// === 子表跳转 ===
|
||||
|
||||
/** UserRole 子表查看 → 角色详情 */
|
||||
function goRoleDetail(row: Record<string, any>): void {
|
||||
router.push({ path: '/system/role-detail', query: { RoleUUID: row.RoleUUID || '' } })
|
||||
}
|
||||
|
||||
/** UserDept 子表查看 → 部门详情 */
|
||||
function goDeptDetail(row: Record<string, any>): void {
|
||||
router.push({ path: '/company/department-detail', query: { DeptUUID: row.DeptUUID || '' } })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadData(), loadColumns()])
|
||||
void loadTwoFactor()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DetailPage :title="title" :loading="loading">
|
||||
<template #actions>
|
||||
<el-button type="primary" @click="handleEdit">
|
||||
<el-icon><Edit /></el-icon>编辑
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<!-- 隔离提示横幅 -->
|
||||
<el-alert :type="userStore.isAdmin ? 'warning' : 'info'" :closable="false" show-icon>
|
||||
{{ isolationHint }}
|
||||
</el-alert>
|
||||
|
||||
<!-- 基本信息 -->
|
||||
<InfoGrid title="基本信息" :columns="2">
|
||||
<el-descriptions-item label="用户名">{{ data?.UserName || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="邮箱">{{ data?.UserEmail || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="电话">{{ data?.UserTele || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="性别">{{ genderText(data?.UserGender) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="账号状态">
|
||||
<el-tag :type="accountLicenseTag(data?.UserAccountLicense).type" size="small">
|
||||
{{ accountLicenseTag(data?.UserAccountLicense).text }}
|
||||
</el-tag>
|
||||
</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>
|
||||
</InfoGrid>
|
||||
|
||||
<!-- 2FA 双因素信息(TODO:契约确认后补全字段) -->
|
||||
<InfoGrid title="双因素认证 (2FA)" :columns="2">
|
||||
<el-descriptions-item label="2FA 状态">
|
||||
<!-- TODO: GetUser2FAInfo 字段契约确认后替换为真实状态渲染 -->
|
||||
<el-tag v-if="twoFactorInfo" type="success" size="small">已加载</el-tag>
|
||||
<el-tag v-else type="info" size="small">未启用 / 未加载</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="说明">
|
||||
<span style="color: #909399; font-size: 12px">2FA 详情字段待 GetUser2FAInfo 契约确认后接入</span>
|
||||
</el-descriptions-item>
|
||||
</InfoGrid>
|
||||
|
||||
<!-- 角色与组织子表 -->
|
||||
<el-card shadow="never">
|
||||
<template #header>角色与组织</template>
|
||||
<CrudTable
|
||||
v-if="userUUID"
|
||||
table="UserRole"
|
||||
title="用户角色"
|
||||
:query="{ UserUUID: userUUID }"
|
||||
:pk-field="'UserRoleUUID'"
|
||||
selectable
|
||||
pageable
|
||||
:on-view="goRoleDetail"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 部门关联子表 -->
|
||||
<el-card shadow="never">
|
||||
<template #header>部门关联</template>
|
||||
<CrudTable
|
||||
v-if="userUUID"
|
||||
table="UserDept"
|
||||
title="用户部门"
|
||||
:query="{ UserUUID: userUUID }"
|
||||
:pk-field="'UserDeptUUID'"
|
||||
selectable
|
||||
pageable
|
||||
:on-view="goDeptDetail"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 编辑弹窗:基于列定义动态渲染 -->
|
||||
<el-dialog v-model="editDialogVisible" title="编辑用户" width="600px" destroy-on-close>
|
||||
<el-form :model="editForm" label-width="120px" label-position="right">
|
||||
<el-form-item
|
||||
v-for="col in formColumns"
|
||||
:key="col.field"
|
||||
:label="col.title"
|
||||
:required="col.required"
|
||||
>
|
||||
<EntityPicker
|
||||
v-if="col.type === 'entityPicker' && col.entityPicker"
|
||||
v-model="editForm[col.field]"
|
||||
:config="col.entityPicker"
|
||||
:existing-label="editForm[col.field + '_label']"
|
||||
:placeholder="col.placeholder"
|
||||
:disabled="col.disabled"
|
||||
@update:label="(label: string) => (editForm[col.field + '_label'] = label)"
|
||||
/>
|
||||
<el-input
|
||||
v-else-if="!col.type || col.type === 'text' || col.type === 'string'"
|
||||
v-model="editForm[col.field]"
|
||||
:placeholder="col.placeholder"
|
||||
:maxlength="col.maxLength"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<el-input
|
||||
v-else-if="col.type === 'textarea'"
|
||||
v-model="editForm[col.field]"
|
||||
type="textarea"
|
||||
:rows="col.rows || 3"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<el-input-number
|
||||
v-else-if="col.type === 'number'"
|
||||
v-model="editForm[col.field]"
|
||||
:min="col.min"
|
||||
:max="col.max"
|
||||
:step="col.step"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<el-select
|
||||
v-else-if="col.type === 'select'"
|
||||
v-model="editForm[col.field]"
|
||||
:placeholder="col.placeholder"
|
||||
:disabled="col.disabled"
|
||||
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]"
|
||||
:type="col.type as 'date' | 'datetime'"
|
||||
:placeholder="col.placeholder"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<el-switch
|
||||
v-else-if="col.type === 'switch' || col.type === 'boolean'"
|
||||
v-model="editForm[col.field]"
|
||||
:disabled="col.disabled"
|
||||
/>
|
||||
<el-input
|
||||
v-else-if="col.type === 'password'"
|
||||
v-model="editForm[col.field]"
|
||||
type="password"
|
||||
show-password
|
||||
:placeholder="col.placeholder"
|
||||
/>
|
||||
<el-input
|
||||
v-else
|
||||
:model-value="String(editForm[col.field] ?? '')"
|
||||
readonly
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="editSubmitting" @click="handleSubmit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</DetailPage>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// el-card 之间留白由 DetailPage 的 detail-content gap 控制
|
||||
</style>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 用户管理
|
||||
*
|
||||
* 迁移自 TempLate/GERP/pages/system/users.html(151 行)
|
||||
* 原生实现:调 GERP.CheckAdmin() 获取 role_scope → 按作用域差异化标题/横幅/可编辑性/行选择
|
||||
* + onView 跳详情 + 非主实例列 fallback + AdminBadge 特权徽章
|
||||
*
|
||||
* Vue 版(阶段 1 简化):
|
||||
* - server / admin_verify 由 request.ts 拦截器统一注入
|
||||
* - 作用域判断暂用 userStore.isAdmin 二分(管理员 / 普通组员)
|
||||
* - 细粒度 role_scope(dept_manager / workgroup_lead)待 checkAdmin 接口确认后接入(TODO)
|
||||
* - 列 fallback 暂未实现,主实例走后端 Columns 接口(TODO:非主实例 getDefaultColumns)
|
||||
* - AdminBadge 自动根据 server/admin_verify 显示特权横幅
|
||||
* - onView 跳 /system/user-detail?UserUUID=xxx
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import CrudTable from '@/components/CrudTable.vue'
|
||||
import AdminBadge from '@/components/AdminBadge.vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
setPageTitle('用户管理')
|
||||
|
||||
// 阶段 1 简化:仅区分管理员 / 普通组员
|
||||
// TODO: 接入 checkAdmin → role_scope,补 dept_manager / workgroup_lead 分支
|
||||
const scopeType = computed<'admin' | 'member'>(() =>
|
||||
userStore.isAdmin ? 'admin' : 'member',
|
||||
)
|
||||
|
||||
// 作用域提示文本(对齐原生 hintMap)
|
||||
const scopeHint = computed(() => {
|
||||
switch (scopeType.value) {
|
||||
case 'admin':
|
||||
return '管理员视图:可查看和管理全公司用户,含跨公司特权模式。'
|
||||
case 'member':
|
||||
default:
|
||||
return '普通组员视图:仅可查看和编辑自己的资料,无法查看他人数据。'
|
||||
}
|
||||
})
|
||||
|
||||
// 作用域横幅样式类(对齐原生 user-scope-banner 样式分支)
|
||||
const scopeBannerClass = computed(() => `user-scope-banner ${scopeType.value}`)
|
||||
|
||||
// 普通组员:禁用行选择与编辑
|
||||
const isMember = computed(() => scopeType.value === 'member')
|
||||
|
||||
// 用户记录类型
|
||||
interface UserRow {
|
||||
UserUUID?: string
|
||||
UserName?: string
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
// 跳转用户详情页
|
||||
function handleView(row: UserRow): void {
|
||||
router.push({
|
||||
path: '/system/user-detail',
|
||||
query: { UserUUID: row.UserUUID || '' },
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="user-page">
|
||||
<!-- 管理员特权横幅(仅特权模式显示) -->
|
||||
<AdminBadge />
|
||||
|
||||
<!-- 作用域提示横幅 -->
|
||||
<div :class="scopeBannerClass">
|
||||
<i class="fas" :class="isMember ? 'fa-user' : 'fa-shield-alt'" />
|
||||
<span>{{ scopeHint }}</span>
|
||||
</div>
|
||||
|
||||
<CrudTable
|
||||
table="User"
|
||||
title="用户管理"
|
||||
pk-field="UserUUID"
|
||||
searchable
|
||||
pageable
|
||||
:selectable="!isMember"
|
||||
:on-view="handleView"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.user-page {
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// 作用域横幅样式(对齐原生 user-scope-banner)
|
||||
.user-scope-banner {
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
|
||||
i {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
// 管理员:蓝色
|
||||
&.admin {
|
||||
background: rgba(30, 64, 175, 0.08);
|
||||
color: #1e40af;
|
||||
border: 1px solid rgba(30, 64, 175, 0.2);
|
||||
}
|
||||
|
||||
// 普通组员:灰色
|
||||
&.member {
|
||||
background: rgba(100, 116, 139, 0.08);
|
||||
color: #475569;
|
||||
border: 1px solid rgba(100, 116, 139, 0.2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 工单管理(管理员视图)
|
||||
*
|
||||
* 迁移自 TempLate/GERP/pages/workorder/admin.html(55 行)
|
||||
* 原生实现:拉取 /API/FormAPI/Columns/WorkOrder 后实例化 TableEngine,全权委托 CRUD
|
||||
*
|
||||
* Vue 版:直接挂载 CrudTable,表名 WorkOrder,开启分页/搜索/行选择
|
||||
* server / admin_verify 由 request.ts 拦截器统一注入,无需页面关心
|
||||
*/
|
||||
import CrudTable from '@/components/CrudTable.vue'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
|
||||
setPageTitle('工单管理')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wo-admin-page">
|
||||
<CrudTable table="WorkOrder" title="工单管理" searchable pageable selectable />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.wo-admin-page {
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 我的工单(当前用户视图)
|
||||
*
|
||||
* 迁移自 TempLate/GERP/pages/workorder/my.html(72 行)
|
||||
* 原生实现:跨域请求 https://ugm.gvsds.com/Query/GetELTUserInfo 获取 UserUUID,
|
||||
* 再以 ?CreatorUUID=<uuid> 拼接到 Get 接口过滤;含"提交工单"按钮触发 openAddDialog
|
||||
*
|
||||
* Vue 版:
|
||||
* - UserUUID 改由 Pinia userStore 提供(已通过 /Query/GetELTUserInfo 拉取),消除跨域
|
||||
* - 过滤条件走 CrudTable 的 query 参数,由 useCrudTable 注入到 list 请求
|
||||
* - "提交工单"即新增,复用 CrudTable 内置"新增"按钮,不另设
|
||||
* - 不开启行选择(个人工单无需批量删除)
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import CrudTable from '@/components/CrudTable.vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
|
||||
const userStore = useUserStore()
|
||||
setPageTitle('我的工单')
|
||||
|
||||
// 以当前用户 UUID 作为过滤条件;未登录时为空对象(CrudTable 会拉全部,但路由守卫已保证登录)
|
||||
const query = computed(() => {
|
||||
const uuid = userStore.userInfo?.UserUUID
|
||||
return uuid ? { CreatorUUID: uuid } : {}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wo-my-page">
|
||||
<CrudTable table="WorkOrder" title="我的工单" :query="query" searchable pageable :selectable="false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.wo-my-page {
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
+1
-2
@@ -4,9 +4,8 @@
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client", "node"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
|
||||
Reference in new issue
Block a user