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 02251f4..0000000 Binary files a/src/assets/hero.png and /dev/null differ 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 */