From dbf30a9bc025669dd4f62fa9538eb2b9d1c5d29d Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Sat, 1 Aug 2026 18:42:17 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9F=BA=E6=9C=AC=E4=B8=8A=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E4=BA=86=20GERP=20=E7=9A=84=E8=BF=81=E7=A7=BB=EF=BC=8C?= =?UTF-8?q?=E7=9B=AE=E5=89=8D=E6=AD=A3=E5=9C=A8=E8=A7=A3=E5=86=B3=20table?= =?UTF-8?q?=20=E8=99=9A=E6=8B=9F=E6=BB=9A=E5=8A=A8=E6=95=85=E9=9A=9C?= =?UTF-8?q?=E9=97=AE=E9=A2=98=EF=BC=8C=E4=BB=A5=E5=8F=8A=E5=85=B6=E5=AE=83?= =?UTF-8?q?=E7=90=90=E7=A2=8E=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 52 +- package-lock.json | 26 + package.json | 1 + src/api/entityPickerPresets.ts | 42 + src/api/form.ts | 10 +- src/api/index.ts | 5 + src/api/request.ts | 14 +- src/assets/hero.png | Bin 13057 -> 0 bytes src/assets/vite.svg | 1 - src/assets/vue.svg | 1 - src/components/AdminBadge.vue | 44 + src/components/CrudTable.vue | 395 +++++ src/components/DetailPage.vue | 98 ++ src/components/EntityPicker.vue | 220 +++ src/components/HelloWorld.vue | 95 -- src/components/InfoGrid.vue | 40 + src/composables/useCrudTable.ts | 307 ++++ src/composables/useResolveUUIDs.ts | 97 ++ src/layouts/DefaultLayout.vue | 29 +- src/router/index.ts | 206 +++ src/stores/user.ts | 243 +-- src/style.css | 296 ---- src/types/api.ts | 372 +++-- src/utils/serverScope.ts | 58 + src/views/Dashboard.vue | 152 ++ src/views/Login.vue | 124 ++ src/views/NotFound.vue | 23 + src/views/Profile.vue | 42 + src/views/SKU/Sku.vue | 1870 ++++++++++++++++++++++++ src/views/assets/Manager.vue | 1392 ++++++++++++++++++ src/views/company/Company.vue | 77 + src/views/company/CompanyDetail.vue | 365 +++++ src/views/company/Department.vue | 77 + src/views/company/DepartmentDetail.vue | 287 ++++ src/views/company/EmployeeDetail.vue | 398 +++++ src/views/company/EpCreate.vue | 1221 ++++++++++++++++ src/views/company/Role.vue | 77 + src/views/company/RoleDetail.vue | 332 +++++ src/views/company/Tenant.vue | 72 + src/views/company/TenantDetail.vue | 283 ++++ src/views/company/Workgroup.vue | 73 + src/views/company/WorkgroupDetail.vue | 262 ++++ src/views/doc/Editor.vue | 695 +++++++++ src/views/inventory/Products.vue | 101 ++ src/views/sales/Orders.vue | 101 ++ src/views/system/AuditLog.vue | 453 ++++++ src/views/system/Database.vue | 263 ++++ src/views/system/DbTable.vue | 201 +++ src/views/system/Menu.vue | 1200 +++++++++++++++ src/views/system/Message.vue | 794 ++++++++++ src/views/system/PaymentConfig.vue | 510 +++++++ src/views/system/RoleDetail.vue | 351 +++++ src/views/system/RolePermissions.vue | 475 ++++++ src/views/system/Roles.vue | 60 + src/views/system/Settings.vue | 493 +++++++ src/views/system/TenantCreate.vue | 230 +++ src/views/system/TenantReview.vue | 324 ++++ src/views/system/UserDetail.vue | 360 +++++ src/views/system/Users.vue | 128 ++ src/views/workorder/Admin.vue | 31 + src/views/workorder/My.vue | 44 + tsconfig.app.json | 3 +- 62 files changed, 15953 insertions(+), 643 deletions(-) create mode 100644 src/api/entityPickerPresets.ts create mode 100644 src/api/index.ts delete mode 100644 src/assets/hero.png delete mode 100644 src/assets/vite.svg delete mode 100644 src/assets/vue.svg create mode 100644 src/components/AdminBadge.vue create mode 100644 src/components/CrudTable.vue create mode 100644 src/components/DetailPage.vue create mode 100644 src/components/EntityPicker.vue delete mode 100644 src/components/HelloWorld.vue create mode 100644 src/components/InfoGrid.vue create mode 100644 src/composables/useCrudTable.ts create mode 100644 src/composables/useResolveUUIDs.ts delete mode 100644 src/style.css create mode 100644 src/utils/serverScope.ts create mode 100644 src/views/Dashboard.vue create mode 100644 src/views/Login.vue create mode 100644 src/views/NotFound.vue create mode 100644 src/views/Profile.vue create mode 100644 src/views/SKU/Sku.vue create mode 100644 src/views/assets/Manager.vue create mode 100644 src/views/company/Company.vue create mode 100644 src/views/company/CompanyDetail.vue create mode 100644 src/views/company/Department.vue create mode 100644 src/views/company/DepartmentDetail.vue create mode 100644 src/views/company/EmployeeDetail.vue create mode 100644 src/views/company/EpCreate.vue create mode 100644 src/views/company/Role.vue create mode 100644 src/views/company/RoleDetail.vue create mode 100644 src/views/company/Tenant.vue create mode 100644 src/views/company/TenantDetail.vue create mode 100644 src/views/company/Workgroup.vue create mode 100644 src/views/company/WorkgroupDetail.vue create mode 100644 src/views/doc/Editor.vue create mode 100644 src/views/inventory/Products.vue create mode 100644 src/views/sales/Orders.vue create mode 100644 src/views/system/AuditLog.vue create mode 100644 src/views/system/Database.vue create mode 100644 src/views/system/DbTable.vue create mode 100644 src/views/system/Menu.vue create mode 100644 src/views/system/Message.vue create mode 100644 src/views/system/PaymentConfig.vue create mode 100644 src/views/system/RoleDetail.vue create mode 100644 src/views/system/RolePermissions.vue create mode 100644 src/views/system/Roles.vue create mode 100644 src/views/system/Settings.vue create mode 100644 src/views/system/TenantCreate.vue create mode 100644 src/views/system/TenantReview.vue create mode 100644 src/views/system/UserDetail.vue create mode 100644 src/views/system/Users.vue create mode 100644 src/views/workorder/Admin.vue create mode 100644 src/views/workorder/My.vue diff --git a/.gitignore b/.gitignore index a547bf3..aad7074 100644 --- a/.gitignore +++ b/.gitignore @@ -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? diff --git a/package-lock.json b/package-lock.json index 3f5f468..8e3baef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 6230337..54a42b4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/api/entityPickerPresets.ts b/src/api/entityPickerPresets.ts new file mode 100644 index 0000000..020e97e --- /dev/null +++ b/src/api/entityPickerPresets.ts @@ -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 = { + // 用户:副文本展示邮箱 + 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 = {}) { + const config = entityPickerPresets[entityName] + if (!config) throw new Error(`EntityPicker 预设不存在: ${entityName}`) + return { field, title, type: 'entityPicker', entityPicker: config, ...extra } +} diff --git a/src/api/form.ts b/src/api/form.ts index a6d2c88..98a064e 100644 --- a/src/api/form.ts +++ b/src/api/form.ts @@ -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(`/API/FormAPI/Modify/${table}/${id}`, data) }, - /** 部分更新记录(合并) */ - patch: >(table: TableName, id: string | number, data: Partial): Promise => { + /** 整行编辑(POST JSON,对应原生 showEditDialog 的 ModifyItem 提交) */ + modifyItem: >(table: TableName, id: string | number, data: Partial): Promise => { return post(`/API/FormAPI/ModifyItem/${table}/${id}`, data) }, + /** 单字段内联编辑(PATCH,对应原生 TableEngine 单元格 modify) */ + patchItem: >(table: TableName, id: string | number, data: Partial): Promise => { + return patch(`/API/FormAPI/ModifyItem/${table}/${id}`, data) + }, /** 删除记录 */ remove: (table: TableName, id: string | number): Promise => { diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..a0e10b5 --- /dev/null +++ b/src/api/index.ts @@ -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' diff --git a/src/api/request.ts b/src/api/request.ts index ddb6494..42892c3 100644 --- a/src/api/request.ts +++ b/src/api/request.ts @@ -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 = AxiosResponse> @@ -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) } + } return config }, (error) => Promise.reject(error), diff --git a/src/assets/hero.png b/src/assets/hero.png deleted file mode 100644 index 02251f4b956c55af2d76fd0788124d7eee2b45eb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13057 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 P00000NkvXXu0mjft=yBf diff --git a/src/assets/vite.svg b/src/assets/vite.svg deleted file mode 100644 index 5101b67..0000000 --- a/src/assets/vite.svg +++ /dev/null @@ -1 +0,0 @@ -Vite diff --git a/src/assets/vue.svg b/src/assets/vue.svg deleted file mode 100644 index 770e9d3..0000000 --- a/src/assets/vue.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/components/AdminBadge.vue b/src/components/AdminBadge.vue new file mode 100644 index 0000000..a1e625d --- /dev/null +++ b/src/components/AdminBadge.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/src/components/CrudTable.vue b/src/components/CrudTable.vue new file mode 100644 index 0000000..cc4d969 --- /dev/null +++ b/src/components/CrudTable.vue @@ -0,0 +1,395 @@ + + + + + diff --git a/src/components/DetailPage.vue b/src/components/DetailPage.vue new file mode 100644 index 0000000..74b520a --- /dev/null +++ b/src/components/DetailPage.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/src/components/EntityPicker.vue b/src/components/EntityPicker.vue new file mode 100644 index 0000000..bad75d3 --- /dev/null +++ b/src/components/EntityPicker.vue @@ -0,0 +1,220 @@ + + + + + diff --git a/src/components/HelloWorld.vue b/src/components/HelloWorld.vue deleted file mode 100644 index c232865..0000000 --- a/src/components/HelloWorld.vue +++ /dev/null @@ -1,95 +0,0 @@ - - - diff --git a/src/components/InfoGrid.vue b/src/components/InfoGrid.vue new file mode 100644 index 0000000..f286ea0 --- /dev/null +++ b/src/components/InfoGrid.vue @@ -0,0 +1,40 @@ + + + + + diff --git a/src/composables/useCrudTable.ts b/src/composables/useCrudTable.ts new file mode 100644 index 0000000..2ef23fc --- /dev/null +++ b/src/composables/useCrudTable.ts @@ -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 { + /** 表名(对应后端实体) */ + table: TableName + /** 查询参数 */ + query?: QueryParams + /** 主键字段名(不传则从 columns 推导 isPrimary/primaryKey,默认 Id) */ + pkField?: string + /** 是否显示复选列(行选择) */ + selectable?: boolean + /** 新增前钩子:返回 false 取消 */ + beforeCreate?: (row: Partial) => boolean | Promise + /** 编辑前钩子:返回 false 取消 */ + beforeEdit?: (row: T) => boolean | Promise + /** 删除前确认 */ + beforeDelete?: (row: T) => boolean | Promise + /** 批量删除前确认 */ + beforeBatchDelete?: (rows: T[]) => boolean | Promise + /** 行点击回调 */ + 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 = Record>( + options: UseCrudTableOptions, +) { + const { table, query = {} } = options + + // === 状态 === + const loading = ref(false) + const submitting = ref(false) + const columns = ref([]) as Ref + const dataList = ref([]) as Ref + 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([]) as Ref + + // === 弹窗 === + const dialogVisible = ref(false) + const dialogTitle = ref('新增') + // 使用 Record 兼容 v-model 在模板上的类型推断 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const editingRow = reactive>({}) + 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 { + try { + columns.value = await formApi.getColumns(table) + } catch { + // 错误已在拦截器统一处理 + } + } + + // === 加载数据 === + async function fetchData(): Promise { + 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(table, params) + // 兼容两种返回结构:PagedData 或裸数组 + if (Array.isArray(result)) { + dataList.value = result + total.value = result.length + } else { + const paged = result as unknown as PagedData + dataList.value = paged.list || [] + total.value = paged.total ?? 0 + } + } finally { + loading.value = false + } + } + + // === 新增 === + function handleCreate(): void { + isEdit.value = false + dialogTitle.value = '新增' + // 用列定义初始化默认值 + const row: Record = {} + 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 { + 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 { + if (options.beforeCreate && !isEdit.value) { + if (!(await options.beforeCreate(editingRow as Partial))) return + } + submitting.value = true + try { + if (isEdit.value) { + const pk = getPkField() + const id = editingRow[pk] + await formApi.update(table, id as string | number, editingRow as Partial) + ElMessage.success('更新成功') + } else { + await formApi.create(table, editingRow as Partial) + ElMessage.success('新增成功') + } + dialogVisible.value = false + await fetchData() + } finally { + submitting.value = false + } + } + + // === 删除 === + async function handleDelete(row: T): Promise { + 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)[pk] + await formApi.remove(table, id as string | number) + ElMessage.success('删除成功') + await fetchData() + } + + // === 批量删除 === + async function handleBatchDelete(): Promise { + 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)[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, + } +} diff --git a/src/composables/useResolveUUIDs.ts b/src/composables/useResolveUUIDs.ts new file mode 100644 index 0000000..f75f274 --- /dev/null +++ b/src/composables/useResolveUUIDs.ts @@ -0,0 +1,97 @@ +import { ref } from 'vue' +import { formApi, type TableName } from '@/api/form' + +/** + * UUID → 名称 批量解析(带缓存) + * + * 对齐原生 CompanyUtils.resolveUUIDs:按 pkField 过滤查询单条,回填名称。 + * 缓存键为表名,避免重复请求。 + */ + +/** 实体名 → 显示名字段映射(对齐原生 nameFieldMap) */ +const nameFieldMap: Record = { + Company: 'CompanyName', + Department: 'DeptName', + Workgroup: 'WorkgroupName', + UserRole: 'RoleName', + Role: 'RoleName', + User: 'UserName', +} + +/** 实体名 → 主键字段映射(对齐原生 pkFieldMap) */ +const pkFieldMap: Record = { + Company: 'CompanyUUID', + Department: 'DeptUUID', + Workgroup: 'WorkgroupUUID', + UserRole: 'UserRoleUUID', + Role: 'RoleUUID', + User: 'UserUUID', +} + +/** UUID 缓存:table -> uuid -> name */ +const uuidCache = new Map>() + +/** 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, + ): Promise>> { + loading.value = true + try { + const result: Record> = {} + 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)?.[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 } +} diff --git a/src/layouts/DefaultLayout.vue b/src/layouts/DefaultLayout.vue index a093ad1..a4b6bd4 100644 --- a/src/layouts/DefaultLayout.vue +++ b/src/layouts/DefaultLayout.vue @@ -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) +} @@ -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 { diff --git a/src/router/index.ts b/src/router/index.ts index d874fa0..137ed1c 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -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 }, + }, ], }, { diff --git a/src/stores/user.ts b/src/stores/user.ts index ff824d2..ec2dada 100644 --- a/src/stores/user.ts +++ b/src/stores/user.ts @@ -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(null) - const roles = ref([]) - const menus = ref([]) - const token = ref('') - 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(() => { - 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 { - loading.value = true - try { - await apiLogin(params) - await fetchUserInfo() - await fetchMenus() - } finally { - loading.value = false - } - } - - /** 注销 */ - async function logout(): Promise { - try { - await apiLogout() - } finally { - reset() - } - } - - /** 获取当前用户信息 */ - async function fetchUserInfo(): Promise { - const info = await getCurrentUser() - userInfo.value = info - return info - } - - /** 获取角色列表 */ - async function fetchRoles(): Promise { - const list = await getUserRoles() - roles.value = list - return list - } - - /** 获取菜单 */ - async function fetchMenus(): Promise { - 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(null) + const roles = ref([]) + const menus = ref([]) + const token = ref('') + 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(() => { + 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 { + loading.value = true + try { + await apiLogin(params) + await fetchUserInfo() + await fetchMenus() + } finally { + loading.value = false + } + } + + /** 注销 */ + async function logout(): Promise { + try { + await apiLogout() + } finally { + reset() + } + } + + /** 获取当前用户信息 */ + async function fetchUserInfo(): Promise { + const info = await getCurrentUser() + userInfo.value = info + return info + } + + /** 获取角色列表 */ + async function fetchRoles(): Promise { + 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 { + 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, + } +}) diff --git a/src/style.css b/src/style.css deleted file mode 100644 index 527d4fb..0000000 --- a/src/style.css +++ /dev/null @@ -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); - } -} diff --git a/src/types/api.ts b/src/types/api.ts index 3bb472d..a03a849 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -1,105 +1,267 @@ -/** - * GERP 后端统一响应格式 - * 所有 API(包括兼容的 PascalCase 路径与现代 DRF 路径)都遵循该格式 - */ -export interface ApiResult { - /** 业务状态码:0 表示成功,非 0 表示失败 */ - code: number - /** 提示消息 */ - msg: string - /** 业务数据 */ - data: T -} - -/** 分页响应数据 */ -export interface PagedData { - /** 当前页数据列表 */ - 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 -} +/** + * GERP 后端统一响应格式 + * 所有 API(包括兼容的 PascalCase 路径与现代 DRF 路径)都遵循该格式 + */ +export interface ApiResult { + /** 业务状态码:0 表示成功,非 0 表示失败 */ + code: number + /** 提示消息 */ + msg: string + /** 业务数据 */ + data: T +} + +/** 分页响应数据 */ +export interface PagedData { + /** 当前页数据列表 */ + 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, column: ColumnSchema) => string + /** 码表名,配合 /API/FormAPI/CodeDisplay 翻译 */ + codeTable?: string + /** type=status 时的状态映射 */ + statusMap?: Record + /** 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 +} + +/** + * 用户作用域(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 +} diff --git a/src/utils/serverScope.ts b/src/utils/serverScope.ts new file mode 100644 index 0000000..286aa2e --- /dev/null +++ b/src/utils/serverScope.ts @@ -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}` +} diff --git a/src/views/Dashboard.vue b/src/views/Dashboard.vue new file mode 100644 index 0000000..6c385a3 --- /dev/null +++ b/src/views/Dashboard.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/src/views/Login.vue b/src/views/Login.vue new file mode 100644 index 0000000..ec756b8 --- /dev/null +++ b/src/views/Login.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/src/views/NotFound.vue b/src/views/NotFound.vue new file mode 100644 index 0000000..e0211d1 --- /dev/null +++ b/src/views/NotFound.vue @@ -0,0 +1,23 @@ + + + + + diff --git a/src/views/Profile.vue b/src/views/Profile.vue new file mode 100644 index 0000000..6068d69 --- /dev/null +++ b/src/views/Profile.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/src/views/SKU/Sku.vue b/src/views/SKU/Sku.vue new file mode 100644 index 0000000..b3760a5 --- /dev/null +++ b/src/views/SKU/Sku.vue @@ -0,0 +1,1870 @@ + + + + + + diff --git a/src/views/assets/Manager.vue b/src/views/assets/Manager.vue new file mode 100644 index 0000000..6dbfd54 --- /dev/null +++ b/src/views/assets/Manager.vue @@ -0,0 +1,1392 @@ + + + + + diff --git a/src/views/company/Company.vue b/src/views/company/Company.vue new file mode 100644 index 0000000..2128f93 --- /dev/null +++ b/src/views/company/Company.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/src/views/company/CompanyDetail.vue b/src/views/company/CompanyDetail.vue new file mode 100644 index 0000000..72b2022 --- /dev/null +++ b/src/views/company/CompanyDetail.vue @@ -0,0 +1,365 @@ + + + + + diff --git a/src/views/company/Department.vue b/src/views/company/Department.vue new file mode 100644 index 0000000..6b026e8 --- /dev/null +++ b/src/views/company/Department.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/src/views/company/DepartmentDetail.vue b/src/views/company/DepartmentDetail.vue new file mode 100644 index 0000000..b81edac --- /dev/null +++ b/src/views/company/DepartmentDetail.vue @@ -0,0 +1,287 @@ + + + + + diff --git a/src/views/company/EmployeeDetail.vue b/src/views/company/EmployeeDetail.vue new file mode 100644 index 0000000..6614fe8 --- /dev/null +++ b/src/views/company/EmployeeDetail.vue @@ -0,0 +1,398 @@ + + + + + diff --git a/src/views/company/EpCreate.vue b/src/views/company/EpCreate.vue new file mode 100644 index 0000000..1dfbdf6 --- /dev/null +++ b/src/views/company/EpCreate.vue @@ -0,0 +1,1221 @@ + + + + + diff --git a/src/views/company/Role.vue b/src/views/company/Role.vue new file mode 100644 index 0000000..50236a3 --- /dev/null +++ b/src/views/company/Role.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/src/views/company/RoleDetail.vue b/src/views/company/RoleDetail.vue new file mode 100644 index 0000000..b4dca63 --- /dev/null +++ b/src/views/company/RoleDetail.vue @@ -0,0 +1,332 @@ + + + + + diff --git a/src/views/company/Tenant.vue b/src/views/company/Tenant.vue new file mode 100644 index 0000000..ef219a7 --- /dev/null +++ b/src/views/company/Tenant.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/src/views/company/TenantDetail.vue b/src/views/company/TenantDetail.vue new file mode 100644 index 0000000..de4629c --- /dev/null +++ b/src/views/company/TenantDetail.vue @@ -0,0 +1,283 @@ + + + + + diff --git a/src/views/company/Workgroup.vue b/src/views/company/Workgroup.vue new file mode 100644 index 0000000..7163f2f --- /dev/null +++ b/src/views/company/Workgroup.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/src/views/company/WorkgroupDetail.vue b/src/views/company/WorkgroupDetail.vue new file mode 100644 index 0000000..beb2420 --- /dev/null +++ b/src/views/company/WorkgroupDetail.vue @@ -0,0 +1,262 @@ + + + + + diff --git a/src/views/doc/Editor.vue b/src/views/doc/Editor.vue new file mode 100644 index 0000000..0e8c689 --- /dev/null +++ b/src/views/doc/Editor.vue @@ -0,0 +1,695 @@ + + + + + diff --git a/src/views/inventory/Products.vue b/src/views/inventory/Products.vue new file mode 100644 index 0000000..676cc81 --- /dev/null +++ b/src/views/inventory/Products.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/src/views/sales/Orders.vue b/src/views/sales/Orders.vue new file mode 100644 index 0000000..275c80a --- /dev/null +++ b/src/views/sales/Orders.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/src/views/system/AuditLog.vue b/src/views/system/AuditLog.vue new file mode 100644 index 0000000..8a4dadc --- /dev/null +++ b/src/views/system/AuditLog.vue @@ -0,0 +1,453 @@ + + + + + diff --git a/src/views/system/Database.vue b/src/views/system/Database.vue new file mode 100644 index 0000000..0be8277 --- /dev/null +++ b/src/views/system/Database.vue @@ -0,0 +1,263 @@ + + + + + diff --git a/src/views/system/DbTable.vue b/src/views/system/DbTable.vue new file mode 100644 index 0000000..6805b04 --- /dev/null +++ b/src/views/system/DbTable.vue @@ -0,0 +1,201 @@ + + + + + diff --git a/src/views/system/Menu.vue b/src/views/system/Menu.vue new file mode 100644 index 0000000..d2d110b --- /dev/null +++ b/src/views/system/Menu.vue @@ -0,0 +1,1200 @@ + + + + + diff --git a/src/views/system/Message.vue b/src/views/system/Message.vue new file mode 100644 index 0000000..bf598d1 --- /dev/null +++ b/src/views/system/Message.vue @@ -0,0 +1,794 @@ + + + + + diff --git a/src/views/system/PaymentConfig.vue b/src/views/system/PaymentConfig.vue new file mode 100644 index 0000000..7286afc --- /dev/null +++ b/src/views/system/PaymentConfig.vue @@ -0,0 +1,510 @@ + + + + + diff --git a/src/views/system/RoleDetail.vue b/src/views/system/RoleDetail.vue new file mode 100644 index 0000000..6f9affc --- /dev/null +++ b/src/views/system/RoleDetail.vue @@ -0,0 +1,351 @@ + + + + + diff --git a/src/views/system/RolePermissions.vue b/src/views/system/RolePermissions.vue new file mode 100644 index 0000000..88f3752 --- /dev/null +++ b/src/views/system/RolePermissions.vue @@ -0,0 +1,475 @@ + + + + + diff --git a/src/views/system/Roles.vue b/src/views/system/Roles.vue new file mode 100644 index 0000000..847823d --- /dev/null +++ b/src/views/system/Roles.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/src/views/system/Settings.vue b/src/views/system/Settings.vue new file mode 100644 index 0000000..1cd91d6 --- /dev/null +++ b/src/views/system/Settings.vue @@ -0,0 +1,493 @@ + + + + + diff --git a/src/views/system/TenantCreate.vue b/src/views/system/TenantCreate.vue new file mode 100644 index 0000000..3b95fc5 --- /dev/null +++ b/src/views/system/TenantCreate.vue @@ -0,0 +1,230 @@ + + + + + diff --git a/src/views/system/TenantReview.vue b/src/views/system/TenantReview.vue new file mode 100644 index 0000000..b83cf83 --- /dev/null +++ b/src/views/system/TenantReview.vue @@ -0,0 +1,324 @@ + + + + + diff --git a/src/views/system/UserDetail.vue b/src/views/system/UserDetail.vue new file mode 100644 index 0000000..f3ddee2 --- /dev/null +++ b/src/views/system/UserDetail.vue @@ -0,0 +1,360 @@ + + + + + diff --git a/src/views/system/Users.vue b/src/views/system/Users.vue new file mode 100644 index 0000000..c278c96 --- /dev/null +++ b/src/views/system/Users.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/src/views/workorder/Admin.vue b/src/views/workorder/Admin.vue new file mode 100644 index 0000000..5965765 --- /dev/null +++ b/src/views/workorder/Admin.vue @@ -0,0 +1,31 @@ + + + + + diff --git a/src/views/workorder/My.vue b/src/views/workorder/My.vue new file mode 100644 index 0000000..0940cdc --- /dev/null +++ b/src/views/workorder/My.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/tsconfig.app.json b/tsconfig.app.json index 4caf174..bd9ca6e 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -4,9 +4,8 @@ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "types": ["vite/client", "node"], "allowArbitraryExtensions": true, - "baseUrl": ".", "paths": { - "@/*": ["src/*"] + "@/*": ["./src/*"] }, /* Linting */