基本上完成了 GERP 的迁移,目前正在解决 table 虚拟滚动故障问题,以及其它琐碎修改

This commit is contained in:
TermiNexus committed 2026-08-01 18:42:17 +08:00
1 parent 2b7e02108c
commit dbf30a9bc0
62 files changed
+15953 -643

No files matched your search

+28 -24
View File
@@ -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?
+26
View File
@@ -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",
+1
View File
@@ -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",
+42
View File
@@ -0,0 +1,42 @@
/**
* EntityPicker 实体选择器预设配置
*
* 对齐原生 CompanyCommon.EntityPickerConfigs:预置 6 类常用实体的搜索配置,
* 供 EntityPicker 组件及 epColumn 列定义辅助函数使用。
*/
import type { EntityPickerConfig } from '@/types/api'
/**
* 6 类实体的 EntityPicker 预设配置(对齐原生 CompanyCommon.EntityPickerConfigs)
* key 为实体名,value 为对应的搜索配置
*/
export const entityPickerPresets: Record<string, EntityPickerConfig> = {
// 用户:副文本展示邮箱
User: { api: '/API/FormAPI/UserSearch', labelField: 'UserName', valueField: 'UserUUID', secondaryField: 'UserEmail', placeholder: '搜索用户' },
// 角色
Role: { api: '/API/FormAPI/RoleSearch', labelField: 'RoleName', valueField: 'RoleUUID', placeholder: '搜索角色' },
// 公司
Company: { api: '/API/FormAPI/CompanySearch', labelField: 'CompanyName', valueField: 'CompanyUUID', placeholder: '搜索公司' },
// 部门
Department: { api: '/API/FormAPI/DepartmentSearch', labelField: 'DeptName', valueField: 'DeptUUID', placeholder: '搜索部门' },
// 工作组
Workgroup: { api: '/API/FormAPI/WorkgroupSearch', labelField: 'WorkgroupName', valueField: 'WorkgroupUUID', placeholder: '搜索工作组' },
// 权限:副文本展示权限码
Permission: { api: '/API/FormAPI/PermissionSearch', labelField: 'PermName', valueField: 'PermUUID', secondaryField: 'PermCode', placeholder: '搜索权限' },
}
/**
* 生成 entityPicker 类型列定义
* 对齐原生 CompanyUtils.epColumn(field, title, entityName, extra)
*
* @param field 列字段名
* @param title 列标题
* @param entityName 实体名(需存在于 entityPickerPresets)
* @param extra 附加列配置(如 width、required 等)
* @returns ColumnSchema 片段(type 固定为 entityPicker)
*/
export function epColumn(field: string, title: string, entityName: string, extra: Record<string, unknown> = {}) {
const config = entityPickerPresets[entityName]
if (!config) throw new Error(`EntityPicker 预设不存在: ${entityName}`)
return { field, title, type: 'entityPicker', entityPicker: config, ...extra }
}
+7 -3
View File
@@ -1,4 +1,4 @@
import { get, post, put, del } from './request'
import { get, post, put, del, patch } from './request'
import type { ColumnSchema, PagedData } from '@/types/api'
/** 表名(对应后端实体) */
@@ -57,10 +57,14 @@ export const formApi = {
return put<T>(`/API/FormAPI/Modify/${table}/${id}`, data)
},
/** 部分更新记录(合并) */
patch: <T = Record<string, unknown>>(table: TableName, id: string | number, data: Partial<T>): Promise<T> => {
/** 整行编辑(POST JSON,对应原生 showEditDialog 的 ModifyItem 提交) */
modifyItem: <T = Record<string, unknown>>(table: TableName, id: string | number, data: Partial<T>): Promise<T> => {
return post<T>(`/API/FormAPI/ModifyItem/${table}/${id}`, data)
},
/** 单字段内联编辑(PATCH,对应原生 TableEngine 单元格 modify) */
patchItem: <T = Record<string, unknown>>(table: TableName, id: string | number, data: Partial<T>): Promise<T> => {
return patch<T>(`/API/FormAPI/ModifyItem/${table}/${id}`, data)
},
/** 删除记录 */
remove: (table: TableName, id: string | number): Promise<void> => {
+5
View File
@@ -0,0 +1,5 @@
export * from './request'
export * as authApi from './auth'
export * as menuApi from './menu'
export { formApi } from './form'
export type * from '@/types/api'
+12 -2
View File
@@ -6,6 +6,7 @@ import axios, {
} from 'axios'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { ApiResult } from '@/types/api'
import { getServerScopeParams } from '@/utils/serverScope'
/** 后端响应统一格式 */
type BackendResponse<T> = AxiosResponse<ApiResult<T>>
@@ -30,11 +31,20 @@ const request: AxiosInstance = axios.create({
},
})
/** 请求拦截器:注入时区头、可在此追加 Token */
/**
* 请求拦截器:
* - 注入时区头(每次刷新,用户可能跨时区)
* - 注入 server 作用域参数(server/admin_verify),对齐原生 CompanyCommon.buildApiUrl
* 主实例不注入 server;URL 含 admin_verify=1 时注入特权标记
*/
request.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// 每次请求实时刷新时区(用户可能跨时区使用)
config.headers['X-User-Timezone'] = Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Shanghai'
// 合并 server 作用域参数到 query(不覆盖调用方已显式传入的同名参数)
const scopeParams = getServerScopeParams()
if (Object.keys(scopeParams).length) {
config.params = { ...scopeParams, ...(config.params as Record<string, unknown>) }
}
return config
},
(error) => Promise.reject(error),
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="usLine truncated

Before

Width:  |  Height:  |  Size: 8.5 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

Before

Width:  |  Height:  |  Size: 496 B

+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
/**
* AdminBadge 管理员特权模式横幅
*
* 对齐原生 CompanyCommon 的 adminBadge 横幅,在管理员特权模式下显眼提示
* 跨租户/公司隔离已关闭,避免误操作。
*
* 显示条件:
* - visible 不为 false(外部可控)
* - 且(非默认服务器 或 携带 admin_verify=1 标记)
*/
import { computed } from 'vue'
import { isDefaultServer, hasAdminVerifyArg } from '@/utils/serverScope'
const props = defineProps<{
/** 是否显示,默认 true。设为 false 可强制隐藏 */
visible?: boolean
}>()
// 综合判断:外部未隐藏,且满足特权模式触发条件之一
const show = computed(
() => props.visible !== false && (!isDefaultServer() || hasAdminVerifyArg())
)
</script>
<template>
<!-- 警告色横幅,居中展示,不可关闭 -->
<el-alert
v-if="show"
type="warning"
center
:closable="false"
class="admin-badge"
>
管理员特权模式 - 跨租户/公司隔离已关闭
</el-alert>
</template>
<style scoped lang="scss">
// 横幅底部留白,与下方内容分隔
.admin-badge {
margin-bottom: 12px;
}
</style>
+395
View File
@@ -0,0 +1,395 @@
<script setup lang="ts" generic="T extends Record<string, unknown>">
import { ref, computed } from 'vue'
import { useCrudTable, type UseCrudTableOptions } from '@/composables/useCrudTable'
import type { ColumnSchema } from '@/types/api'
import EntityPicker from './EntityPicker.vue'
/**
* 通用 CRUD 表格组件
* 基于 vxe-table + Element Plus,配合后端 FormAPI 协议
*
* 用法:<CrudTable table="User" />
* 带行选择与详情跳转:<CrudTable table="User" selectable :on-view="goDetail" />
*/
const props = defineProps<{
/** 表名(对应后端实体) */
table: string
/** 标题 */
title?: string
/** 额外查询参数 */
query?: Record<string, unknown>
/** 是否显示搜索栏 */
searchable?: boolean
/** 是否显示分页 */
pageable?: boolean
/** 是否显示复选列(行选择 + 批量删除) */
selectable?: boolean
/** 主键字段名(不传则从 columns 推导) */
pkField?: string
/** 查看按钮回调(详情页跳转) */
onView?: (row: T) => void
/** 行点击回调 */
onRowClick?: (row: T) => void
}>()
const options: UseCrudTableOptions<T> = {
table: props.table,
query: props.query,
pkField: props.pkField,
selectable: props.selectable,
onView: props.onView,
onRowClick: props.onRowClick,
}
const {
loading,
submitting,
columns,
dataList,
total,
currentPage,
pageSize,
searchKeyword,
selectedRows,
dialogVisible,
dialogTitle,
editingRow,
fetchData,
handleCreate,
handleEdit,
handleView,
handleRowClick,
handleSubmit,
handleDelete,
handleBatchDelete,
handleSelectionChange,
handleSearch,
handleReset,
handlePageChange,
handleSizeChange,
} = useCrudTable<T>(options)
// vxe-table 实例引用(用于获取选中行)
const xTableRef = ref()
// 可见表格列(排除 hidden)
const tableColumns = computed(() =>
columns.value.filter((c: ColumnSchema) => c.visible !== false && c.type !== 'hidden'),
)
// 弹窗表单可见列(排除隐藏/主键隐藏/不可编辑/divider/html)
const formColumns = computed(() => {
const pkField = columns.value.find((c) => c.isPrimary || c.primaryKey)?.field || 'Id'
return columns.value.filter(
(c: ColumnSchema) =>
c.editable !== false &&
!c.hideInForm &&
c.type !== 'hidden' &&
c.type !== 'divider' &&
c.type !== 'html' &&
c.field !== pkField,
)
})
// vxe-table checkbox 变化:同步到 selectedRows
function onCheckboxChange(): void {
const records = xTableRef.value?.getCheckboxRecords?.() || []
handleSelectionChange(records as T[])
}
// 实体选择器回显标签:优先 row[field+'_label'],否则前 8 位 + ...
function entityLabel(row: Record<string, unknown>, field: string): string {
const label = row[`${field}_label`] ?? row[`${field}__label`]
if (label) return String(label)
const val = row[field]
return val ? String(val).slice(0, 8) + '...' : ''
}
// tags 渲染:字符串按分隔符拆分
function renderTags(value: unknown, separator = ','): string[] {
if (!value) return []
return String(value).split(separator).map((s) => s.trim()).filter(Boolean)
}
// uuid 渲染:前 8 位 + ...
function renderUuid(value: unknown): string {
return value ? String(value).slice(0, 8) + '...' : ''
}
</script>
<template>
<div class="crud-table">
<!-- 搜索栏 -->
<el-card v-if="searchable" shadow="never" class="search-bar">
<el-input
v-model="searchKeyword"
placeholder="关键字搜索"
clearable
style="width: 240px"
@keyup.enter="handleSearch"
/>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-card>
<!-- 表格 + 操作栏 -->
<el-card shadow="never" class="table-card">
<template #header>
<div class="table-header">
<span class="table-title">{{ title || table }}</span>
<div class="table-actions">
<el-button type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon>新增
</el-button>
<el-button
v-if="selectable"
type="danger"
:disabled="selectedRows.length === 0"
@click="handleBatchDelete"
>
<el-icon><Delete /></el-icon>批量删除{{ selectedRows.length ? `(${selectedRows.length})` : '' }}
</el-button>
<el-button @click="fetchData">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
</div>
</template>
<vxe-table
ref="xTableRef"
:data="dataList"
:loading="loading"
border
stripe
round
height="auto"
:row-config="{ isHover: true }"
:column-config="{ resizable: true }"
:checkbox-config="{ range: true }"
@row-click="(params: { row: T }) => handleRowClick(params.row)"
@checkbox-change="onCheckboxChange"
@checkbox-all="onCheckboxChange"
>
<!-- 复选列 -->
<vxe-column v-if="selectable" type="checkbox" width="50" />
<vxe-column type="seq" width="60" title="#" />
<!-- 数据列:根据 type/statusMap/entityPicker 分支渲染 -->
<vxe-column
v-for="col in tableColumns"
:key="col.field"
:field="col.field"
:title="col.title"
:width="col.width"
:min-width="col.minWidth"
:fixed="col.fixed"
:sortable="col.sortable"
>
<template #default="{ row }">
<!-- entityPicker:显示回显标签 -->
<span v-if="col.type === 'entityPicker'">
{{ entityLabel(row as Record<string, unknown>, col.field) }}
</span>
<!-- status:el-tag 渲染 -->
<el-tag
v-else-if="col.type === 'status' && col.statusMap"
:type="col.statusMap[String((row as Record<string, unknown>)[col.field])]?.type || 'info'"
:color="col.statusMap[String((row as Record<string, unknown>)[col.field])]?.color"
size="small"
>
{{ col.statusMap[String((row as Record<string, unknown>)[col.field])]?.text || (row as Record<string, unknown>)[col.field] }}
</el-tag>
<!-- tags:标签组渲染 -->
<template v-else-if="col.type === 'tags'">
<el-tag
v-for="tag in renderTags((row as Record<string, unknown>)[col.field], col.tagsSeparator)"
:key="tag"
size="small"
style="margin-right: 4px"
>
{{ tag }}
</el-tag>
</template>
<!-- uuid:前 8 位 + ... -->
<span v-else-if="col.type === 'uuid'">{{ renderUuid((row as Record<string, unknown>)[col.field]) }}</span>
<!-- 默认:原值 -->
<span v-else>{{ (row as Record<string, unknown>)[col.field] }}</span>
</template>
</vxe-column>
<!-- 操作列 -->
<vxe-column title="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button v-if="onView" link type="primary" @click="handleView(row as T)">查看</el-button>
<el-button link type="primary" @click="handleEdit(row as T)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(row as T)">删除</el-button>
</template>
</vxe-column>
</vxe-table>
<!-- 分页 -->
<div v-if="pageable" class="pagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</el-card>
<!-- 新增/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px" destroy-on-close>
<el-form :model="editingRow" label-width="120px" label-position="right">
<el-form-item
v-for="col in formColumns"
:key="col.field"
:label="col.title"
:required="col.required"
>
<!-- entityPicker:EntityPicker 组件 -->
<EntityPicker
v-if="col.type === 'entityPicker' && col.entityPicker"
v-model="editingRow[col.field]"
:config="col.entityPicker"
:existing-label="editingRow[`${col.field}_label`] as string"
:placeholder="col.placeholder"
:disabled="col.disabled"
@update:label="(label: string) => (editingRow[`${col.field}_label`] = label)"
/>
<el-input
v-else-if="!col.type || col.type === 'text' || col.type === 'string'"
v-model="editingRow[col.field]"
:placeholder="col.placeholder"
:maxlength="col.maxLength"
:disabled="col.disabled"
/>
<el-input-number
v-else-if="col.type === 'number'"
v-model="editingRow[col.field]"
:min="col.min"
:max="col.max"
:step="col.step"
:disabled="col.disabled"
/>
<el-input
v-else-if="col.type === 'textarea'"
v-model="editingRow[col.field]"
type="textarea"
:rows="col.rows || 3"
:disabled="col.disabled"
/>
<el-input
v-else-if="col.type === 'password'"
v-model="editingRow[col.field]"
type="password"
show-password
:placeholder="col.placeholder"
/>
<el-select
v-else-if="col.type === 'select'"
v-model="editingRow[col.field]"
:placeholder="col.placeholder"
:disabled="col.disabled"
style="width: 100%"
>
<el-option
v-for="opt in col.options"
:key="String(opt.value)"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-radio-group
v-else-if="col.type === 'radio'"
v-model="editingRow[col.field]"
:disabled="col.disabled"
>
<el-radio v-for="opt in col.options" :key="String(opt.value)" :value="opt.value">{{ opt.label }}</el-radio>
</el-radio-group>
<el-checkbox-group
v-else-if="col.type === 'checkbox'"
v-model="editingRow[col.field]"
:disabled="col.disabled"
>
<el-checkbox v-for="opt in col.options" :key="String(opt.value)" :value="opt.value">{{ opt.label }}</el-checkbox>
</el-checkbox-group>
<el-date-picker
v-else-if="col.type === 'date' || col.type === 'datetime'"
v-model="editingRow[col.field]"
:type="col.type as 'date' | 'datetime'"
:placeholder="col.placeholder"
style="width: 100%"
/>
<el-time-picker
v-else-if="col.type === 'time'"
v-model="editingRow[col.field]"
:placeholder="col.placeholder"
style="width: 100%"
/>
<el-switch
v-else-if="col.type === 'switch' || col.type === 'boolean'"
v-model="editingRow[col.field]"
:disabled="col.disabled"
/>
<el-input
v-else-if="col.type === 'readonly'"
:model-value="String(editingRow[col.field] ?? '')"
readonly
/>
<!-- hidden/divider/html 不渲染表单项 -->
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped lang="scss">
.crud-table {
display: flex;
flex-direction: column;
gap: 12px;
}
.search-bar {
:deep(.el-card__body) {
display: flex;
gap: 8px;
align-items: center;
}
}
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.table-title {
font-size: 16px;
font-weight: 600;
}
.table-actions {
display: flex;
gap: 8px;
}
.pagination {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
</style>
+98
View File
@@ -0,0 +1,98 @@
<script setup lang="ts">
/**
* DetailPage 详情页布局容器
*
* 对齐原生 Company 模块详情页结构:顶部管理员横幅 + 页头(返回/标题/操作)+ 主内容区(信息卡 + 子表)。
* - v-loading 覆盖整个详情页,加载态由父组件控制
* - 返回按钮先 emit('back') 再 router.back(),父组件可在 back 事件中做清理
* - actions 具名插槽放右上角操作按钮,default 默认插槽放信息卡与子表
*/
import { useRouter } from 'vue-router'
import AdminBadge from './AdminBadge.vue'
const props = withDefaults(
defineProps<{
/** 页面标题 */
title?: string
/** 加载态,默认 false */
loading?: boolean
/** 是否显示返回按钮,默认 true */
showBack?: boolean
}>(),
{
loading: false,
showBack: true,
}
)
const emit = defineEmits<{
/** 返回按钮点击事件 */
back: []
}>()
const router = useRouter()
/** 处理返回:先通知父组件(可做清理/埋点),再调用路由回退 */
function handleBack(): void {
emit('back')
router.back()
}
</script>
<template>
<div class="detail-page" v-loading="loading">
<!-- 顶部管理员特权模式横幅(仅在特权模式下显示) -->
<AdminBadge />
<!-- 页头:返回按钮 + 标题 + 操作按钮区 -->
<div class="detail-header">
<el-button v-if="props.showBack" link @click="handleBack">
<el-icon><ArrowLeft /></el-icon>返回
</el-button>
<h2 v-if="props.title" class="detail-title">{{ props.title }}</h2>
<div class="detail-actions">
<slot name="actions" />
</div>
</div>
<!-- 主内容区:信息卡 + 子表 -->
<div class="detail-content">
<slot />
</div>
</div>
</template>
<style scoped lang="scss">
// 详情页整体内边距
.detail-page {
padding: 16px;
}
// 页头:横向排列,居中对齐
.detail-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
// 标题:占据中间剩余空间
.detail-title {
margin: 0;
font-size: 18px;
font-weight: 600;
flex: 1;
}
// 操作按钮区:推到最右侧
.detail-actions {
margin-left: auto;
}
// 主内容区:纵向排列,块间留白
.detail-content {
display: flex;
flex-direction: column;
gap: 16px;
}
</style>
+220
View File
@@ -0,0 +1,220 @@
<script setup lang="ts">
/**
* EntityPicker 实体选择器
*
* 基于 el-select 的远程搜索选择器,替代原生 entityPicker 控件。
* 配合 entityPickerPresets 预设或自定义 EntityPickerConfig 使用。
*
* 用法:
* <EntityPicker v-model="row.UserUUID" :config="entityPickerPresets.User" :existing-label="row.UserUUID_label" />
*/
import { ref, watch, computed, onBeforeUnmount } from 'vue'
import type { EntityPickerConfig } from '@/types/api'
import { get, post } from '@/api/request'
/** 下拉选项 */
interface EntityOption {
/** 选项值(UUID) */
value: string
/** 主文本(labelField) */
label: string
/** 副文本(secondaryField,可选) */
secondary?: string
}
const props = withDefaults(defineProps<{
/** 选中值 UUID(v-model) */
modelValue?: string
/** 搜索配置 */
config: EntityPickerConfig
/** 是否禁用 */
disabled?: boolean
/** 占位符(覆盖 config.placeholder) */
placeholder?: string
/** 已有值回显标签(对应原生 row[field+'_label']) */
existingLabel?: string
/** 是否可清空 */
clearable?: boolean
}>(), {
modelValue: '',
disabled: false,
placeholder: '',
existingLabel: '',
clearable: false,
})
const emit = defineEmits<{
(e: 'update:modelValue', uuid: string): void
(e: 'update:label', label: string): void
}>()
/** 下拉选项列表 */
const options = ref<EntityOption[]>([])
/** 搜索加载态 */
const loading = ref(false)
/** 是否已执行过搜索(区分初始回显与空结果) */
const searched = ref(false)
/** 回显标签 */
const displayLabel = ref('')
/** 占位符:props.placeholder > config.placeholder > 默认 */
const placeholderText = computed(() => props.placeholder || props.config.placeholder || '请选择')
/** 防抖定时器句柄 */
let debounceTimer: ReturnType<typeof setTimeout> | null = null
/**
* 防抖触发远程搜索(300ms)
* 项目未引入 lodash-es,使用 setTimeout 自定义防抖
*/
function debounceSearch(keyword: string): void {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
void doSearch(keyword)
}, 300)
}
/** 执行远程搜索 */
async function doSearch(keyword: string): Promise<void> {
const cfg = props.config
// 搜索参数名默认 keyword
const searchParam = cfg.searchParam || 'keyword'
// HTTP 方法默认 GET
const method = cfg.method || 'GET'
loading.value = true
try {
let res: unknown
if (method === 'POST') {
res = await post<unknown>(cfg.api, { [searchParam]: keyword })
} else {
res = await get<unknown>(cfg.api, { [searchParam]: keyword })
}
options.value = parseOptions(res, cfg)
} catch {
options.value = []
} finally {
loading.value = false
}
}
/**
* 解析响应为选项数组
* 响应拦截器已解包 data,res 通常直接是数组;
* 兼容配置了 resultPath 或对象包裹的情况
*/
function parseOptions(res: unknown, cfg: EntityPickerConfig): EntityOption[] {
let list: unknown = res
// 非数组时按 resultPath(默认 data)取字段
if (!Array.isArray(res) && res && typeof res === 'object') {
const obj = res as Record<string, unknown>
list = cfg.resultPath ? obj[cfg.resultPath] : obj.data
}
if (!Array.isArray(list)) return []
return list.map((item) => {
const row = item as Record<string, unknown>
return {
value: String(row[cfg.valueField] ?? ''),
label: String(row[cfg.labelField] ?? ''),
secondary: cfg.secondaryField ? String(row[cfg.secondaryField] ?? '') : undefined,
}
})
}
/** el-select remote-method 回调 */
function remoteSearch(keyword: string): void {
// 关键字为空:重置为回显状态,不发起搜索
if (!keyword) {
options.value = []
searched.value = false
return
}
searched.value = true
debounceSearch(keyword)
}
/** 选中变化处理 */
function handleChange(value: string): void {
emit('update:modelValue', value)
// 清空时同步清空 label 并重置搜索态
if (!value) {
emit('update:label', '')
options.value = []
searched.value = false
return
}
const opt = options.value.find((o) => o.value === value)
emit('update:label', opt ? opt.label : '')
}
/** 同步回显标签 */
function syncDisplayLabel(): void {
if (props.existingLabel) {
displayLabel.value = props.existingLabel
} else if (props.modelValue) {
// 对齐原生 TableEngine._formatCellValue 第 720-723 行:UUID 前 8 位 + '...'
displayLabel.value = props.modelValue.slice(0, 8) + '...'
} else {
displayLabel.value = ''
}
}
// existingLabel / modelValue 变化时刷新回显标签
watch(() => props.existingLabel, syncDisplayLabel, { immediate: true })
watch(() => props.modelValue, syncDisplayLabel)
// 组件卸载前清理防抖定时器
onBeforeUnmount(() => {
if (debounceTimer) clearTimeout(debounceTimer)
})
</script>
<template>
<el-select
:model-value="modelValue"
:placeholder="placeholderText"
:disabled="disabled"
:clearable="clearable"
:loading="loading"
filterable
remote
:remote-method="remoteSearch"
@change="handleChange"
>
<!-- 回显选项:有值且未搜索时,显示回显标签 -->
<el-option
v-if="modelValue && !searched"
:value="modelValue"
:label="displayLabel"
/>
<!-- 空结果提示:已搜索但无匹配 -->
<el-option
v-else-if="searched && !loading && options.length === 0"
disabled
label="无匹配"
value="__no_match__"
/>
<!-- 正常选项:主文本 + 副文本 -->
<el-option
v-for="opt in options"
:key="opt.value"
:value="opt.value"
:label="opt.label"
>
<span>{{ opt.label }}</span>
<span v-if="opt.secondary" class="entity-picker-secondary">{{ opt.secondary }}</span>
</el-option>
</el-select>
</template>
<style scoped>
/* 副文本:浅色小字,右对齐 */
.entity-picker-secondary {
float: right;
color: var(--el-text-color-secondary);
font-size: 12px;
max-width: 50%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
-95
View File
@@ -1,95 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import viteLogo from '../assets/vite.svg'
import heroImg from '../assets/hero.png'
import vueLogo from '../assets/vue.svg'
const count = ref(0)
</script>
<template>
<section id="center">
<div class="hero">
<img :src="heroImg" class="base" width="170" height="179" alt="" />
<img :src="vueLogo" class="framework" alt="Vue logo" />
<img :src="viteLogo" class="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
</div>
<button type="button" class="counter" @click="count++">
Count is {{ count }}
</button>
</section>
<div class="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img class="logo" :src="viteLogo" alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://vuejs.org/" target="_blank">
<img class="button-icon" :src="vueLogo" alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div class="ticks"></div>
<section id="spacer"></section>
</template>
+40
View File
@@ -0,0 +1,40 @@
<script setup lang="ts">
/**
* InfoGrid 信息网格组件
*
* 用于详情页以键值对形式展示实体信息,对齐原生 Company 模块详情页的信息卡布局。
* 基于 Element Plus 的 el-descriptions 实现,通过默认插槽接收 el-descriptions-item。
*
* 用法:
* <InfoGrid title="基本信息" :columns="2">
* <el-descriptions-item label="名称">{{ data.Name }}</el-descriptions-item>
* </InfoGrid>
*/
defineProps<{
/** 每行列数,默认 2 */
columns?: number
/** 卡片标题(可选) */
title?: string
/** 是否显示边框,默认 true */
border?: boolean
}>()
</script>
<template>
<!-- 信息网格:columns 控制列数,title 控制标题,border 控制边框 -->
<el-descriptions
:column="columns || 2"
:title="title"
:border="border ?? true"
class="info-grid"
>
<slot />
</el-descriptions>
</template>
<style scoped lang="scss">
// 信息网格底部留白,与后续内容分隔
.info-grid {
margin-bottom: 16px;
}
</style>
+307
View File
@@ -0,0 +1,307 @@
import { ref, reactive, onMounted, type Ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { formApi, type TableName, type QueryParams } from '@/api/form'
import type { ColumnSchema, PagedData } from '@/types/api'
/** CRUD 表格 composable 选项 */
export interface UseCrudTableOptions<T> {
/** 表名(对应后端实体) */
table: TableName
/** 查询参数 */
query?: QueryParams
/** 主键字段名(不传则从 columns 推导 isPrimary/primaryKey,默认 Id) */
pkField?: string
/** 是否显示复选列(行选择) */
selectable?: boolean
/** 新增前钩子:返回 false 取消 */
beforeCreate?: (row: Partial<T>) => boolean | Promise<boolean>
/** 编辑前钩子:返回 false 取消 */
beforeEdit?: (row: T) => boolean | Promise<boolean>
/** 删除前确认 */
beforeDelete?: (row: T) => boolean | Promise<boolean>
/** 批量删除前确认 */
beforeBatchDelete?: (rows: T[]) => boolean | Promise<boolean>
/** 行点击回调 */
onRowClick?: (row: T) => void
/** 查看按钮回调(详情页跳转) */
onView?: (row: T) => void
}
/**
* CRUD 表格通用逻辑
*
* 对齐原生 TableEngine 的公共能力:
* - 列表/分页/排序/搜索(LoadData/GoToPage/HandleSort)
* - 新增/编辑弹窗(ShowAddDialog/ShowEditDialog/HandleFormSubmit)
* - 删除/批量删除(HandleDelete/HandleBatchDelete)
* - 行选择(GetSelectedRows/ClearSelection/ToggleSelectAll)
* - 外部数据注入(SetData/SetLoading)
* - 行点击/查看(onRowClick/onView)
*/
export function useCrudTable<T extends Record<string, unknown> = Record<string, unknown>>(
options: UseCrudTableOptions<T>,
) {
const { table, query = {} } = options
// === 状态 ===
const loading = ref(false)
const submitting = ref(false)
const columns = ref<ColumnSchema[]>([]) as Ref<ColumnSchema[]>
const dataList = ref<T[]>([]) as Ref<T[]>
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(20)
const sortField = ref('')
const sortOrder = ref<'asc' | 'desc'>('asc')
const searchKeyword = ref('')
// === 行选择 ===
const selectedRows = ref<T[]>([]) as Ref<T[]>
// === 弹窗 ===
const dialogVisible = ref(false)
const dialogTitle = ref('新增')
// 使用 Record<string, any> 兼容 v-model 在模板上的类型推断
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const editingRow = reactive<Record<string, any>>({})
const isEdit = ref(false)
// === 主键字段推导 ===
function getPkField(): string {
if (options.pkField) return options.pkField
const pkCol = columns.value.find((c) => c.isPrimary || c.primaryKey)
return pkCol?.field || 'Id'
}
// === 加载列定义 ===
async function fetchColumns(): Promise<void> {
try {
columns.value = await formApi.getColumns(table)
} catch {
// 错误已在拦截器统一处理
}
}
// === 加载数据 ===
async function fetchData(): Promise<void> {
loading.value = true
try {
const params: QueryParams = {
page: currentPage.value,
pageSize: pageSize.value,
sortField: sortField.value || undefined,
sortOrder: sortOrder.value,
keyword: searchKeyword.value || undefined,
...query,
}
const result = await formApi.list<T>(table, params)
// 兼容两种返回结构:PagedData 或裸数组
if (Array.isArray(result)) {
dataList.value = result
total.value = result.length
} else {
const paged = result as unknown as PagedData<T>
dataList.value = paged.list || []
total.value = paged.total ?? 0
}
} finally {
loading.value = false
}
}
// === 新增 ===
function handleCreate(): void {
isEdit.value = false
dialogTitle.value = '新增'
// 用列定义初始化默认值
const row: Record<string, unknown> = {}
for (const col of columns.value) {
if (col.defaultValue !== undefined) row[col.field] = col.defaultValue
}
Object.keys(editingRow).forEach((k) => delete editingRow[k])
Object.assign(editingRow, row)
dialogVisible.value = true
}
// === 编辑 ===
async function handleEdit(row: T): Promise<void> {
if (options.beforeEdit && !(await options.beforeEdit(row))) return
isEdit.value = true
dialogTitle.value = '编辑'
Object.keys(editingRow).forEach((k) => delete editingRow[k])
Object.assign(editingRow, row)
dialogVisible.value = true
}
// === 查看(跳详情) ===
function handleView(row: T): void {
if (options.onView) {
options.onView(row)
}
}
// === 行点击 ===
function handleRowClick(row: T): void {
if (options.onRowClick) options.onRowClick(row)
}
// === 提交(新增/编辑统一入口) ===
async function handleSubmit(): Promise<void> {
if (options.beforeCreate && !isEdit.value) {
if (!(await options.beforeCreate(editingRow as Partial<T>))) return
}
submitting.value = true
try {
if (isEdit.value) {
const pk = getPkField()
const id = editingRow[pk]
await formApi.update<T>(table, id as string | number, editingRow as Partial<T>)
ElMessage.success('更新成功')
} else {
await formApi.create<T>(table, editingRow as Partial<T>)
ElMessage.success('新增成功')
}
dialogVisible.value = false
await fetchData()
} finally {
submitting.value = false
}
}
// === 删除 ===
async function handleDelete(row: T): Promise<void> {
if (options.beforeDelete && !(await options.beforeDelete(row))) return
try {
await ElMessageBox.confirm('确认删除该记录?删除后不可恢复', '提示', {
type: 'warning',
confirmButtonText: '确认删除',
cancelButtonText: '取消',
})
} catch {
return // 用户取消
}
const pk = getPkField()
const id = (row as Record<string, unknown>)[pk]
await formApi.remove(table, id as string | number)
ElMessage.success('删除成功')
await fetchData()
}
// === 批量删除 ===
async function handleBatchDelete(): Promise<void> {
if (selectedRows.value.length === 0) {
ElMessage.warning('请先选择要删除的记录')
return
}
if (options.beforeBatchDelete && !(await options.beforeBatchDelete(selectedRows.value))) return
try {
await ElMessageBox.confirm(
`确认删除选中的 ${selectedRows.value.length} 条记录?删除后不可恢复`,
'提示',
{ type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' },
)
} catch {
return
}
const pk = getPkField()
// 串行删除(后端无批量接口时);失败中断
for (const row of selectedRows.value) {
const id = (row as Record<string, unknown>)[pk]
await formApi.remove(table, id as string | number)
}
ElMessage.success('批量删除成功')
selectedRows.value = []
await fetchData()
}
// === 行选择 ===
function handleSelectionChange(rows: T[]): void {
selectedRows.value = rows
}
/** 获取选中行(对齐原生 GetSelectedRows) */
function getSelectedRows(): T[] {
return selectedRows.value
}
/** 清空选择(对齐原生 ClearSelection) */
function clearSelection(): void {
selectedRows.value = []
}
// === 搜索 ===
function handleSearch(): void {
currentPage.value = 1
fetchData()
}
// === 重置搜索 ===
function handleReset(): void {
searchKeyword.value = ''
currentPage.value = 1
fetchData()
}
// === 分页变化 ===
function handlePageChange(page: number): void {
currentPage.value = page
fetchData()
}
function handleSizeChange(size: number): void {
pageSize.value = size
currentPage.value = 1
fetchData()
}
// === 外部数据注入(对齐原生 SetData) ===
function setData(data: T[], totalCount?: number): void {
dataList.value = data
total.value = totalCount ?? data.length
}
// === 初始化 ===
onMounted(async () => {
await fetchColumns()
await fetchData()
})
return {
// 状态
loading,
submitting,
columns,
dataList,
total,
currentPage,
pageSize,
sortField,
sortOrder,
searchKeyword,
selectedRows,
dialogVisible,
dialogTitle,
editingRow,
isEdit,
// 主键
getPkField,
// 动作
fetchColumns,
fetchData,
handleCreate,
handleEdit,
handleView,
handleRowClick,
handleSubmit,
handleDelete,
handleBatchDelete,
handleSelectionChange,
getSelectedRows,
clearSelection,
handleSearch,
handleReset,
handlePageChange,
handleSizeChange,
setData,
}
}
+97
View File
@@ -0,0 +1,97 @@
import { ref } from 'vue'
import { formApi, type TableName } from '@/api/form'
/**
* UUID → 名称 批量解析(带缓存)
*
* 对齐原生 CompanyUtils.resolveUUIDs:按 pkField 过滤查询单条,回填名称。
* 缓存键为表名,避免重复请求。
*/
/** 实体名 → 显示名字段映射(对齐原生 nameFieldMap) */
const nameFieldMap: Record<string, string> = {
Company: 'CompanyName',
Department: 'DeptName',
Workgroup: 'WorkgroupName',
UserRole: 'RoleName',
Role: 'RoleName',
User: 'UserName',
}
/** 实体名 → 主键字段映射(对齐原生 pkFieldMap) */
const pkFieldMap: Record<string, string> = {
Company: 'CompanyUUID',
Department: 'DeptUUID',
Workgroup: 'WorkgroupUUID',
UserRole: 'UserRoleUUID',
Role: 'RoleUUID',
User: 'UserUUID',
}
/** UUID 缓存:table -> uuid -> name */
const uuidCache = new Map<string, Map<string, string>>()
/** UUID → 名称 解析输入 */
export interface ResolveUUIDInput {
/** 表名 */
table: TableName
/** 待解析的 UUID 列表 */
uuids: string[]
}
/** 截断显示(对齐原生:UUID 前 8 位 + ...) */
function shortUuid(uuid: string): string {
return (uuid || '').slice(0, 8) + '...'
}
export function useResolveUUIDs() {
const loading = ref(false)
/**
* 批量解析 UUID 为名称
* @param uuidMap { key: { table, uuids } }
* @returns { key: { uuid: name } }
*/
async function resolveUUIDs(
uuidMap: Record<string, ResolveUUIDInput>,
): Promise<Record<string, Record<string, string>>> {
loading.value = true
try {
const result: Record<string, Record<string, string>> = {}
for (const [key, { table, uuids }] of Object.entries(uuidMap)) {
result[key] = {}
const nameField = nameFieldMap[table] || `${table}Name`
const pkField = pkFieldMap[table] || `${table}UUID`
if (!uuidCache.has(table)) uuidCache.set(table, new Map())
const cache = uuidCache.get(table)!
// 仅查询未缓存的
const pending = uuids.filter((u) => !cache.has(u))
for (const uuid of pending) {
try {
const res = await formApi.list(table, { [pkField]: uuid, pageSize: 1 })
const list = Array.isArray(res) ? res : (res as { list?: unknown[] }).list || []
const name = (list[0] as Record<string, unknown>)?.[nameField]
cache.set(uuid, name ? String(name) : shortUuid(uuid))
} catch {
cache.set(uuid, shortUuid(uuid))
}
}
for (const uuid of uuids) {
result[key][uuid] = cache.get(uuid) || shortUuid(uuid)
}
}
return result
} finally {
loading.value = false
}
}
/** 读取已缓存的名称(同步,未缓存返回 undefined) */
function getCachedLabel(table: string, uuid: string): string | undefined {
return uuidCache.get(table)?.get(uuid)
}
return { loading, resolveUUIDs, getCachedLabel }
}
+25 -4
View File
@@ -39,6 +39,14 @@ function handleToggleSidebar(): void {
function handleToggleTheme(): void {
appStore.toggleTheme()
}
/** 判断图标是否为 FontAwesome 类名(如 'fas fa-chart-line'),与 Element Plus 组件名区分 */
const isFaIcon = (icon: string | undefined | null): boolean => {
if (!icon) return false
const trimmed = icon.trim()
// FontAwesome 类名特征:含空格('fas fa-xxx')或以 fa- / fas / far / fab / fal 开头
return trimmed.includes(' ') || trimmed.startsWith('fa-') || /^fa[brsl]?\b/.test(trimmed)
}
</script>
<template>
@@ -74,7 +82,7 @@ function handleToggleTheme(): void {
</el-button>
</div>
<el-dropdown trigger="click" @command="(cmd) => cmd === 'logout' && handleLogout()">
<el-dropdown trigger="click" @command="(cmd: string) => cmd === 'logout' && handleLogout()">
<div class="gerp-user-info">
<img :src="avatarUrl" alt="头像" class="gerp-user-avatar" />
<span class="gerp-user-name">{{ userStore.displayName }}</span>
@@ -109,7 +117,8 @@ function handleToggleTheme(): void {
<!-- 含子菜单 -->
<el-sub-menu v-if="menu.Children?.length" :index="menu.Path || menu.Title">
<template #title>
<el-icon v-if="menu.Icon"><component :is="menu.Icon" /></el-icon>
<i v-if="menu.Icon && isFaIcon(menu.Icon)" :class="menu.Icon" class="gerp-menu-icon" />
<el-icon v-else-if="menu.Icon"><component :is="menu.Icon" /></el-icon>
<span>{{ menu.Title }}</span>
</template>
<el-menu-item
@@ -117,14 +126,16 @@ function handleToggleTheme(): void {
:key="child.Path || child.Title"
:index="child.Path || ''"
>
<el-icon v-if="child.Icon"><component :is="child.Icon" /></el-icon>
<i v-if="child.Icon && isFaIcon(child.Icon)" :class="child.Icon" class="gerp-menu-icon" />
<el-icon v-else-if="child.Icon"><component :is="child.Icon" /></el-icon>
<template #title>{{ child.Title }}</template>
</el-menu-item>
</el-sub-menu>
<!-- 无子菜单 -->
<el-menu-item v-else :index="menu.Path || ''">
<el-icon v-if="menu.Icon"><component :is="menu.Icon" /></el-icon>
<i v-if="menu.Icon && isFaIcon(menu.Icon)" :class="menu.Icon" class="gerp-menu-icon" />
<el-icon v-else-if="menu.Icon"><component :is="menu.Icon" /></el-icon>
<template #title>{{ menu.Title }}</template>
</el-menu-item>
</template>
@@ -248,6 +259,16 @@ function handleToggleTheme(): void {
:deep(.el-menu) {
border-right: none;
}
// FontAwesome 图标对齐 el-icon 表现(菜单 Icon 字段可能存 FA 类名)
:deep(.gerp-menu-icon) {
display: inline-block;
width: 1em;
margin-right: 5px;
font-size: 18px;
text-align: center;
vertical-align: middle;
}
}
.gerp-content {
+206
View File
@@ -26,6 +26,212 @@ export const staticRoutes: RouteRecordRaw[] = [
component: () => import('@/views/Profile.vue'),
meta: { title: '个人中心', icon: 'User', requiresAuth: true },
},
// === 阶段 1:CrudTable 落地验证 ===
{
path: 'workorder/admin',
name: 'WorkorderAdmin',
component: () => import('@/views/workorder/Admin.vue'),
meta: { title: '工单管理', icon: 'Tickets', requiresAuth: true },
},
{
path: 'workorder/my',
name: 'WorkorderMy',
component: () => import('@/views/workorder/My.vue'),
meta: { title: '我的工单', icon: 'Document', requiresAuth: true },
},
{
path: 'system/roles',
name: 'SystemRoles',
component: () => import('@/views/system/Roles.vue'),
meta: { title: '角色管理', icon: 'UserFilled', requiresAuth: true },
},
{
path: 'system/users',
name: 'SystemUsers',
component: () => import('@/views/system/Users.vue'),
meta: { title: '用户管理', icon: 'User', requiresAuth: true },
},
{
path: 'inventory/products',
name: 'InventoryProducts',
component: () => import('@/views/inventory/Products.vue'),
meta: { title: '产品管理', icon: 'Goods', requiresAuth: true },
},
{
path: 'sales/orders',
name: 'SalesOrders',
component: () => import('@/views/sales/Orders.vue'),
meta: { title: '销售订单', icon: 'ShoppingCart', requiresAuth: true },
},
// === 阶段 2:Company 列表页 ===
{
path: 'company/tenant',
name: 'CompanyTenant',
component: () => import('@/views/company/Tenant.vue'),
meta: { title: '租户管理', icon: 'OfficeBuilding', requiresAuth: true },
},
{
path: 'company/company',
name: 'CompanyCompany',
component: () => import('@/views/company/Company.vue'),
meta: { title: '公司管理', icon: 'Management', requiresAuth: true },
},
{
path: 'company/department',
name: 'CompanyDepartment',
component: () => import('@/views/company/Department.vue'),
meta: { title: '部门管理', icon: 'Share', requiresAuth: true },
},
{
path: 'company/workgroup',
name: 'CompanyWorkgroup',
component: () => import('@/views/company/Workgroup.vue'),
meta: { title: '工作组管理', icon: 'UserFilled', requiresAuth: true },
},
{
path: 'company/role',
name: 'CompanyRole',
component: () => import('@/views/company/Role.vue'),
meta: { title: '公司角色', icon: 'Avatar', requiresAuth: true },
},
// === 阶段 3:详情页 ===
{
path: 'system/user-detail',
name: 'SystemUserDetail',
component: () => import('@/views/system/UserDetail.vue'),
meta: { title: '用户详情', requiresAuth: true },
},
{
path: 'system/role-detail',
name: 'SystemRoleDetail',
component: () => import('@/views/system/RoleDetail.vue'),
meta: { title: '角色详情', requiresAuth: true },
},
{
path: 'system/role-permissions',
name: 'SystemRolePermissions',
component: () => import('@/views/system/RolePermissions.vue'),
meta: { title: '权限分配', requiresAuth: true },
},
{
path: 'company/tenant-detail',
name: 'CompanyTenantDetail',
component: () => import('@/views/company/TenantDetail.vue'),
meta: { title: '租户详情', requiresAuth: true },
},
{
path: 'company/workgroup-detail',
name: 'CompanyWorkgroupDetail',
component: () => import('@/views/company/WorkgroupDetail.vue'),
meta: { title: '工作组详情', requiresAuth: true },
},
{
path: 'company/department-detail',
name: 'CompanyDepartmentDetail',
component: () => import('@/views/company/DepartmentDetail.vue'),
meta: { title: '部门详情', requiresAuth: true },
},
// === 阶段 4:复杂详情页 ===
{
path: 'company/company-detail',
name: 'CompanyCompanyDetail',
component: () => import('@/views/company/CompanyDetail.vue'),
meta: { title: '公司详情', requiresAuth: true },
},
{
path: 'company/role-detail',
name: 'CompanyRoleDetail',
component: () => import('@/views/company/RoleDetail.vue'),
meta: { title: '公司角色详情', requiresAuth: true },
},
{
path: 'company/employee-detail',
name: 'CompanyEmployeeDetail',
component: () => import('@/views/company/EmployeeDetail.vue'),
meta: { title: '员工详情', requiresAuth: true },
},
// === 阶段 5:业务功能页 ===
{
path: 'system/message',
name: 'SystemMessage',
component: () => import('@/views/system/Message.vue'),
meta: { title: '站内信', icon: 'ChatDotRound', requiresAuth: true },
},
{
path: 'system/tenant-create',
name: 'SystemTenantCreate',
component: () => import('@/views/system/TenantCreate.vue'),
meta: { title: '租户入驻', requiresAuth: true },
},
{
path: 'system/tenant-review',
name: 'SystemTenantReview',
component: () => import('@/views/system/TenantReview.vue'),
meta: { title: '租户审核', requiresAuth: true },
},
{
path: 'system/payment-config',
name: 'SystemPaymentConfig',
component: () => import('@/views/system/PaymentConfig.vue'),
meta: { title: '支付配置', requiresAuth: true },
},
{
path: 'company/epcreate',
name: 'CompanyEpCreate',
component: () => import('@/views/company/EpCreate.vue'),
meta: { title: '企业创建向导', requiresAuth: true },
},
// === 阶段 6:独立工具 ===
{
path: 'doc/editor',
name: 'DocEditor',
component: () => import('@/views/doc/Editor.vue'),
meta: { title: '文档编辑器', requiresAuth: true },
},
{
path: 'assets/manager',
name: 'AssetsManager',
component: () => import('@/views/assets/Manager.vue'),
meta: { title: '资产管理', requiresAuth: true },
},
// === 阶段 7:管理员工具 ===
{
path: 'system/audit-log',
name: 'SystemAuditLog',
component: () => import('@/views/system/AuditLog.vue'),
meta: { title: '审计日志', requiresAuth: true },
},
{
path: 'system/settings',
name: 'SystemSettings',
component: () => import('@/views/system/Settings.vue'),
meta: { title: '系统设置', requiresAuth: true },
},
{
path: 'system/menu',
name: 'SystemMenu',
component: () => import('@/views/system/Menu.vue'),
meta: { title: '菜单管理', requiresAuth: true },
},
{
path: 'system/database',
name: 'SystemDatabase',
component: () => import('@/views/system/Database.vue'),
meta: { title: '数据库管理', requiresAuth: true },
},
{
path: 'system/dbtable',
name: 'SystemDbTable',
component: () => import('@/views/system/DbTable.vue'),
meta: { title: '表结构管理', requiresAuth: true },
},
// === 阶段 8:SKU(移动端 C 端,待决策是否拆分独立工程) ===
{
path: 'sku',
name: 'Sku',
component: () => import('@/views/SKU/Sku.vue'),
meta: { title: '商品详情', requiresAuth: false },
},
],
},
{
+133 -110
View File
@@ -1,110 +1,133 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { UserInfo, MenuItem } from '@/types/api'
import { getCurrentUser, getUserRoles, login as apiLogin, logout as apiLogout, type LoginParams } from '@/api/auth'
import { getMenuList } from '@/api/menu'
/** 用户状态管理:当前登录用户、角色、菜单、权限 */
export const useUserStore = defineStore('user', () => {
// === 状态 ===
const userInfo = ref<UserInfo | null>(null)
const roles = ref<string[]>([])
const menus = ref<MenuItem[]>([])
const token = ref<string>('')
const loading = ref(false)
// === 计算属性 ===
const isLoggedIn = computed(() => !!userInfo.value)
const isAdmin = computed(() => userInfo.value?.IsAdmin === true)
const currentRole = computed(() => userInfo.value?.CurrentRole || '')
const avatar = computed(() => userInfo.value?.Avatar || '')
const displayName = computed(() => userInfo.value?.DisplayName || userInfo.value?.UserName || '未登录')
/** 扁平化后的菜单(用于面包屑、菜单搜索) */
const flatMenus = computed<MenuItem[]>(() => {
const result: MenuItem[] = []
const walk = (items: MenuItem[]) => {
for (const item of items) {
result.push(item)
if (item.Children?.length) walk(item.Children)
}
}
walk(menus.value)
return result
})
// === 动作 ===
/** 登录 */
async function login(params: LoginParams): Promise<void> {
loading.value = true
try {
await apiLogin(params)
await fetchUserInfo()
await fetchMenus()
} finally {
loading.value = false
}
}
/** 注销 */
async function logout(): Promise<void> {
try {
await apiLogout()
} finally {
reset()
}
}
/** 获取当前用户信息 */
async function fetchUserInfo(): Promise<UserInfo> {
const info = await getCurrentUser()
userInfo.value = info
return info
}
/** 获取角色列表 */
async function fetchRoles(): Promise<string[]> {
const list = await getUserRoles()
roles.value = list
return list
}
/** 获取菜单 */
async function fetchMenus(): Promise<MenuItem[]> {
const list = await getMenuList()
menus.value = list
return list
}
/** 重置状态(登出或登录失效时调用) */
function reset(): void {
userInfo.value = null
roles.value = []
menus.value = []
token.value = ''
localStorage.removeItem('gerp-user')
}
return {
// 状态
userInfo,
roles,
menus,
token,
loading,
// 计算属性
isLoggedIn,
isAdmin,
currentRole,
avatar,
displayName,
flatMenus,
// 动作
login,
logout,
fetchUserInfo,
fetchRoles,
fetchMenus,
reset,
}
})
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { UserInfo, MenuItem } from '@/types/api'
import { getCurrentUser, getUserRoles, login as apiLogin, logout as apiLogout, type LoginParams } from '@/api/auth'
import { getMenuList } from '@/api/menu'
/** 用户状态管理:当前登录用户、角色、菜单、权限 */
export const useUserStore = defineStore('user', () => {
// === 状态 ===
const userInfo = ref<UserInfo | null>(null)
const roles = ref<string[]>([])
const menus = ref<MenuItem[]>([])
const token = ref<string>('')
const loading = ref(false)
// === 计算属性 ===
const isLoggedIn = computed(() => !!userInfo.value)
const isAdmin = computed(() => userInfo.value?.IsAdmin === true)
const currentRole = computed(() => userInfo.value?.CurrentRole || '')
const avatar = computed(() => userInfo.value?.Avatar || '')
const displayName = computed(() => userInfo.value?.DisplayName || userInfo.value?.UserName || '未登录')
/** 扁平化后的菜单(用于面包屑、菜单搜索) */
const flatMenus = computed<MenuItem[]>(() => {
const result: MenuItem[] = []
const walk = (items: MenuItem[]) => {
for (const item of items) {
result.push(item)
if (item.Children?.length) walk(item.Children)
}
}
walk(menus.value)
return result
})
// === 动作 ===
/** 登录 */
async function login(params: LoginParams): Promise<void> {
loading.value = true
try {
await apiLogin(params)
await fetchUserInfo()
await fetchMenus()
} finally {
loading.value = false
}
}
/** 注销 */
async function logout(): Promise<void> {
try {
await apiLogout()
} finally {
reset()
}
}
/** 获取当前用户信息 */
async function fetchUserInfo(): Promise<UserInfo> {
const info = await getCurrentUser()
userInfo.value = info
return info
}
/** 获取角色列表 */
async function fetchRoles(): Promise<string[]> {
const list = await getUserRoles()
roles.value = list
return list
}
/** 将后端 Page(如 pages/workorder/admin.html)规范化为 Vue 路由(/workorder/admin) */
function normalizeMenuPath(page: string | undefined): string {
if (!page) return ''
let p = page.trim()
if (!p) return ''
if (/^https?:\/\//i.test(p)) return p
if (p.startsWith('#')) p = p.slice(1)
p = p.replace(/^\/+/, '')
if (p.startsWith('pages/')) p = p.slice(6)
p = p.replace(/\.html?$/i, '')
return p ? '/' + p : ''
}
/** 递归规范化菜单树:Page → Path */
function normalizeMenuTree(items: MenuItem[]): MenuItem[] {
return items.map((item) => ({
...item,
Path: normalizeMenuPath(item.Page),
Children: item.Children?.length ? normalizeMenuTree(item.Children) : [],
}))
}
/** 获取菜单 */
async function fetchMenus(): Promise<MenuItem[]> {
const list = await getMenuList()
const normalized = normalizeMenuTree(list)
menus.value = normalized
return normalized
}
/** 重置状态(登出或登录失效时调用) */
function reset(): void {
userInfo.value = null
roles.value = []
menus.value = []
token.value = ''
localStorage.removeItem('gerp-user')
}
return {
// 状态
userInfo,
roles,
menus,
token,
loading,
// 计算属性
isLoggedIn,
isAdmin,
currentRole,
avatar,
displayName,
flatMenus,
// 动作
login,
logout,
fetchUserInfo,
fetchRoles,
fetchMenus,
reset,
}
})
-296
View File
@@ -1,296 +0,0 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#app {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+267 -105
View File
@@ -1,105 +1,267 @@
/**
* GERP 后端统一响应格式
* 所有 API(包括兼容的 PascalCase 路径与现代 DRF 路径)都遵循该格式
*/
export interface ApiResult<T = unknown> {
/** 业务状态码:0 表示成功,非 0 表示失败 */
code: number
/** 提示消息 */
msg: string
/** 业务数据 */
data: T
}
/** 分页响应数据 */
export interface PagedData<T> {
/** 当前页数据列表 */
list: T[]
/** 总条数 */
total: number
/** 当前页码(从 1 开始) */
page: number
/** 每页大小 */
pageSize: number
}
/**
* 后端 FormAPI Columns 返回的列定义
* 兼容旧 GERP TableEngine 的 column schema,前端用 vxe-table / FormKit 渲染
*/
export interface ColumnSchema {
/** 字段名(对应数据库列) */
field: string
/** 显示标题 */
title: string
/** 控件类型:text/number/select/date/datetime/switch/textarea 等 */
type?: string
/** 是否可编辑 */
editable?: boolean
/** 是否必填 */
required?: boolean
/** 是否在表格中显示 */
visible?: boolean
/** 是否主键 */
isPrimary?: boolean
/** 选项列表(type 为 select 时使用) */
options?: Array<{ label: string; value: string | number }>
/** 默认值 */
defaultValue?: unknown
/** 最小值 */
min?: number
/** 最大值 */
max?: number
/** 最大长度 */
maxLength?: number
/** 占位符 */
placeholder?: string
/** 是否支持搜索 */
searchable?: boolean
/** 是否可排序 */
sortable?: boolean
/** 自定义渲染(前端字段,后端不返回) */
_render?: string
}
/** 菜单项 */
export interface MenuItem {
/** 菜单 ID */
Id?: number
/** 菜单名/显示文本 */
Title: string
/** 菜单图标(Font Awesome 或 Element Plus Icon 名) */
Icon?: string
/** 跳转路径(前端路由) */
Path?: string
/** 子菜单 */
Children?: MenuItem[]
/** 排序 */
Sort?: number
/** 菜单类型:directory/menu/button */
Type?: 'directory' | 'menu' | 'button'
/** 权限标识 */
Permission?: string
}
/** 当前登录用户信息 */
export interface UserInfo {
/** 用户 UUID */
UserUUID: string
/** 用户名 */
UserName: string
/** 显示名 */
DisplayName?: string
/** 邮箱 */
Email?: string
/** 头像 URL */
Avatar?: string
/** 当前角色 */
CurrentRole?: string
/** 角色列表 */
Roles?: string[]
/** 是否管理员 */
IsAdmin?: boolean
/** 个人配置 */
Config?: Record<string, unknown>
}
/**
* GERP 后端统一响应格式
* 所有 API(包括兼容的 PascalCase 路径与现代 DRF 路径)都遵循该格式
*/
export interface ApiResult<T = unknown> {
/** 业务状态码:0 表示成功,非 0 表示失败 */
code: number
/** 提示消息 */
msg: string
/** 业务数据 */
data: T
}
/** 分页响应数据 */
export interface PagedData<T> {
/** 当前页数据列表 */
list: T[]
/** 总条数 */
total: number
/** 当前页码(从 1 开始) */
page: number
/** 每页大小 */
pageSize: number
}
/** statusMap 单个状态的渲染配置 */
export interface StatusMapEntry {
/** 显示文本 */
text: string
/** Element Plus tag 类型:primary/success/warning/danger/info */
type?: 'primary' | 'success' | 'warning' | 'danger' | 'info'
/** 自定义颜色(覆盖 type) */
color?: string
}
/** codeTable 码表条目 */
export interface CodeTableEntry {
/** 码值 */
code: string | number
/** 显示文本 */
label: string
}
/** EntityPicker(实体选择器)配置,替代原生 CompanyUtils.epColumn */
export interface EntityPickerConfig {
/** 搜索接口 URL,如 /API/FormAPI/UserSearch */
api: string
/** HTTP 方法,默认 GET */
method?: 'GET' | 'POST'
/** 显示字段名,如 UserName */
labelField: string
/** 值字段名(主键),如 UserUUID */
valueField: string
/** 副文本字段名(可选,如 UserEmail) */
secondaryField?: string
/** 占位符 */
placeholder?: string
/** 搜索参数名,默认 keyword */
searchParam?: string
/** 响应数据路径,默认 data */
resultPath?: string
/** 关联码表名(可选,用于回显翻译) */
codeTable?: string
}
/**
* 后端 FormAPI Columns 返回的列定义
* 兼容旧 GERP TableEngine 的 column schema,前端用 vxe-table / FormKit 渲染
*
* 字段命名遵循原生 TableEngine 约定(camelCase),与后端 Columns 接口返回一致
*/
export interface ColumnSchema {
// === 基础 ===
/** 字段名(对应数据库列) */
field: string
/** 显示标题 */
title: string
/** 英文标题(可选回退) */
titleEn?: string
/** 别名(可选) */
label?: string
/**
* 控件/渲染类型
* 渲染 + 编辑共用:text/string/number/textarea/select/date/datetime/time/
* boolean/switch/checkbox/radio/tags/password/email/tel/phone/url/color/
* file/image/hidden/entityPicker/readonly/divider/html/status/uuid
*/
type?: string
// === 主键 ===
/** 是否主键(单主键) */
isPrimary?: boolean
/** 原生 primaryKey 别名(与 isPrimary 等价,对齐 TableEngine) */
primaryKey?: boolean
/** 复合主键字段名数组(原生 PrimaryKeys) */
primaryKeys?: string[]
// === 表格展示 ===
/** 是否在表格中显示 */
visible?: boolean
/** 是否可排序 */
sortable?: boolean
/** 列宽可拖拽调整(默认 true) */
resizable?: boolean
/** 固定列:left/right */
fixed?: 'left' | 'right'
/** 列宽 */
width?: number | string
/** 最小列宽 */
minWidth?: number
/** 弹性增长(custom 模式) */
flex?: number
/** 超长截断(默认跟随 vxe-table show-overflow) */
ellipsis?: boolean
/** 最大长度(同时用于截断与表单校验) */
maxLength?: number
/** 自定义渲染函数名(前端字段,后端不返回;对应原生 formatter/render) */
_render?: string
/** 原生 formatter 函数引用(前端运行时注入,非后端字段) */
formatter?: (value: unknown, row: Record<string, unknown>, column: ColumnSchema) => string
/** 码表名,配合 /API/FormAPI/CodeDisplay 翻译 */
codeTable?: string
/** type=status 时的状态映射 */
statusMap?: Record<string, StatusMapEntry>
/** type=tags 时的分隔符(默认逗号) */
tagsSeparator?: string
// === 表单 ===
/** 是否可编辑(默认 true) */
editable?: boolean
/** 表单中隐藏 */
hideInForm?: boolean
/** 是否必填 */
required?: boolean
/** 必填但允许提交空值(原生特有,跳过 required 校验) */
passEmpty?: boolean
/** 禁用 */
disabled?: boolean
/** 默认值 */
defaultValue?: unknown
/** 占位符 */
placeholder?: string
// === 数值约束 ===
/** 最小值 */
min?: number
/** 最大值 */
max?: number
/** 步长 */
step?: number
// === 文本校验 ===
/** 最小长度 */
minLength?: number
/** 精确长度 */
length?: number
/** 正则校验 */
pattern?: string
// === 选项 ===
/** 选项列表(type 为 select/radio/checkbox 时使用) */
options?: Array<{ label: string; value: string | number; disabled?: boolean }>
// === 特定控件 ===
/** textarea 行数(默认 3) */
rows?: number
/** checkbox 文本 */
checkboxLabel?: string
/** file/image 接受的文件类型 */
accept?: string
/** file 多选 */
multiple?: boolean
/** 表单项下方说明文字 */
description?: string
// === 实体选择器 ===
/** type=entityPicker 时的配置 */
entityPicker?: EntityPickerConfig
// === 分隔/HTML ===
/** type=divider 时的分隔文本 */
text?: string
/** type=html 时的自定义 HTML */
html?: string
}
/** 菜单项 */
export interface MenuItem {
/** 菜单 ID */
Id?: number
/** 菜单名/显示文本 */
Title: string
/** 菜单图标(Font Awesome 或 Element Plus Icon 名) */
Icon?: string
/** 后端原始页面路径(如 pages/workorder/admin.html),转换后存 Path */
Page?: string
/** 跳转路径(前端路由,由 Page 规范化而来) */
Path?: string
/** 子菜单 */
Children?: MenuItem[]
/** 排序 */
Sort?: number
/** 菜单类型:directory/menu/button */
Type?: 'directory' | 'menu' | 'button'
/** 权限标识 */
Permission?: string
}
/** 当前登录用户信息 */
export interface UserInfo {
/** 用户 UUID */
UserUUID: string
/** 用户名 */
UserName: string
/** 显示名 */
DisplayName?: string
/** 邮箱 */
Email?: string
/** 头像 URL */
Avatar?: string
/** 当前角色 */
CurrentRole?: string
/** 角色列表 */
Roles?: string[]
/** 是否管理员 */
IsAdmin?: boolean
/** 个人配置 */
Config?: Record<string, unknown>
}
/**
* 用户作用域(role_scope)
*
* 对齐原生 GERP.CheckAdmin().role_scope,用于用户管理页按角色差异化显示与隔离
* - admin:管理员,全公司可见,可跨公司特权
* - dept_manager:部门负责人,仅本部门
* - workgroup_lead:工作组组长,仅本工作组
* - member:普通组员,仅自己
*/
export interface RoleScope {
/** 作用域类型 */
Type: 'admin' | 'dept_manager' | 'workgroup_lead' | 'member'
/** 类型显示文本 */
TypeLabel?: string
/** 是否管理员 */
IsAdmin?: boolean
/** 是否部门负责人 */
IsDeptManager?: boolean
/** 是否工作组组长 */
IsWorkgroupLead?: boolean
/** 是否普通组员 */
IsMember?: boolean
/** 所属部门 UUID */
DeptUUID?: string
/** 所属工作组 UUID */
WorkgroupUUID?: string
}
/** CheckAdmin 接口返回结果 */
export interface AdminCheckResult {
/** 是否管理员 */
is_admin?: boolean
/** 用户作用域 */
role_scope?: RoleScope | null
}
+58
View File
@@ -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}`
}
+152
View File
@@ -0,0 +1,152 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { get } from '@/api/request'
interface DashboardStats {
UserCount: number
OrderCount: number
TodayRevenue: number
ActiveTenants: number
}
const stats = ref<DashboardStats>({
UserCount: 0,
OrderCount: 0,
TodayRevenue: 0,
ActiveTenants: 0,
})
const loading = ref(false)
onMounted(async () => {
loading.value = true
try {
const data = await get<DashboardStats>('/API/Dashboard/Stats')
stats.value = { ...stats.value, ...data }
} finally {
loading.value = false
}
})
</script>
<template>
<div v-loading="loading" class="dashboard">
<el-row :gutter="16">
<el-col :xs="24" :sm="12" :lg="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-icon user"><el-icon :size="32"><User /></el-icon></div>
<div class="stat-info">
<div class="stat-value">{{ stats.UserCount }}</div>
<div class="stat-label">用户总数</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-icon order"><el-icon :size="32"><ShoppingCart /></el-icon></div>
<div class="stat-info">
<div class="stat-value">{{ stats.OrderCount }}</div>
<div class="stat-label">订单总数</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-icon revenue"><el-icon :size="32"><Money /></el-icon></div>
<div class="stat-info">
<div class="stat-value">¥{{ (Number(stats.TodayRevenue) || 0).toFixed(2) }}</div>
<div class="stat-label">今日收入</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-icon tenant"><el-icon :size="32"><OfficeBuilding /></el-icon></div>
<div class="stat-info">
<div class="stat-value">{{ stats.ActiveTenants }}</div>
<div class="stat-label">活跃租户</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<el-card class="welcome-card" shadow="never">
<template #header>
<span>欢迎使用 GERP 管理系统</span>
</template>
<p>当前技术栈:Vue 3 + TypeScript + Vite + Element Plus + vxe-table + FormKit</p>
<p>后端:Django + DRF(已就绪,无需修改)</p>
<p>路由模式:SPA(已移除 iframe)</p>
</el-card>
</div>
</template>
<style scoped lang="scss">
.dashboard {
display: flex;
flex-direction: column;
gap: 16px;
}
.stat-card {
margin-bottom: 16px;
}
.stat-content {
display: flex;
align-items: center;
gap: 16px;
}
.stat-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
&.user {
background: linear-gradient(135deg, #409eff, #66b1ff);
}
&.order {
background: linear-gradient(135deg, #67c23a, #85ce61);
}
&.revenue {
background: linear-gradient(135deg, #e6a23c, #f0b78a);
}
&.tenant {
background: linear-gradient(135deg, #f56c6c, #f89898);
}
}
.stat-value {
font-size: 24px;
font-weight: 600;
color: #303133;
}
.stat-label {
font-size: 13px;
color: #909399;
margin-top: 4px;
}
.welcome-card {
p {
margin: 8px 0;
color: #606266;
}
}
</style>
+124
View File
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
import type { LoginParams } from '@/api/auth'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const loading = ref(false)
const form = reactive<LoginParams>({
Username: '',
Password: '',
RememberMe: false,
})
const rules = {
Username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
Password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
}
async function handleLogin(): Promise<void> {
if (!form.Username || !form.Password) {
ElMessage.warning('请输入用户名和密码')
return
}
loading.value = true
try {
await userStore.login(form)
const redirect = (route.query.redirect as string) || '/dashboard'
router.push(redirect)
} catch {
// 错误提示已在 axios 拦截器统一处理
} finally {
loading.value = false
}
}
</script>
<template>
<div class="login-page">
<div class="login-card">
<div class="login-header">
<img src="/favicon.svg" alt="logo" class="login-logo" />
<h1 class="login-title">GERP 管理系统</h1>
<p class="login-subtitle">登录到后台管理</p>
</div>
<el-form :model="form" :rules="rules" label-position="top" size="large" @submit.prevent="handleLogin">
<el-form-item label="用户名" prop="Username">
<el-input v-model="form.Username" placeholder="请输入用户名" :prefix-icon="'User'" clearable />
</el-form-item>
<el-form-item label="密码" prop="Password">
<el-input
v-model="form.Password"
type="password"
placeholder="请输入密码"
:prefix-icon="'Lock'"
show-password
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-form-item>
<el-checkbox v-model="form.RememberMe">记住我</el-checkbox>
</el-form-item>
<el-button type="primary" size="large" class="login-btn" :loading="loading" @click="handleLogin">
登 录
</el-button>
</el-form>
</div>
</div>
</template>
<style scoped lang="scss">
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 400px;
max-width: 90vw;
padding: 40px;
background: #fff;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
}
.login-header {
text-align: center;
margin-bottom: 32px;
}
.login-logo {
width: 56px;
height: 56px;
margin-bottom: 12px;
}
.login-title {
margin: 0 0 8px;
font-size: 24px;
font-weight: 600;
color: #303133;
}
.login-subtitle {
margin: 0;
font-size: 14px;
color: #909399;
}
.login-btn {
width: 100%;
}
</style>
+23
View File
@@ -0,0 +1,23 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
</script>
<template>
<div class="not-found">
<el-result icon="warning" title="404" sub-title="页面不存在或已被移动">
<template #extra>
<el-button type="primary" @click="router.push('/dashboard')">返回首页</el-button>
</template>
</el-result>
</div>
</template>
<style scoped>
.not-found {
display: flex;
align-items: center;
justify-content: center;
min-height: 80vh;
}
</style>
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { useUserStore } from '@/stores/user'
import { getAvatarUrl } from '@/api/auth'
const userStore = useUserStore()
const avatarUrl = getAvatarUrl()
</script>
<template>
<el-card shadow="never">
<template #header>
<span>个人中心</span>
</template>
<div class="profile">
<img :src="avatarUrl" alt="头像" class="avatar" />
<el-descriptions :column="1" border>
<el-descriptions-item label="用户名">{{ userStore.userInfo?.UserName || '-' }}</el-descriptions-item>
<el-descriptions-item label="显示名">{{ userStore.userInfo?.DisplayName || '-' }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ userStore.userInfo?.Email || '-' }}</el-descriptions-item>
<el-descriptions-item label="当前角色">{{ userStore.currentRole || '-' }}</el-descriptions-item>
<el-descriptions-item label="是否管理员">
<el-tag :type="userStore.isAdmin ? 'danger' : 'info'">{{ userStore.isAdmin ? '是' : '否' }}</el-tag>
</el-descriptions-item>
</el-descriptions>
</div>
</el-card>
</template>
<style scoped>
.profile {
display: flex;
gap: 32px;
align-items: flex-start;
}
.avatar {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
}
</style>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+77
View File
@@ -0,0 +1,77 @@
<script setup lang="ts">
/**
* 公司管理
*
* 迁移自 TempLate/GERP/pages/Company/company.html
* 原生实现:CompanyTablePage 基类 + tableName='Company' + pkField='CompanyUUID'
* + adminBadge:true(特权模式徽章)+ defaultColumns fallback + onView 跳详情
*
* 原生 defaultColumns(仅作 fallback 参考,Vue 版走后端 Columns 接口):
* CompanyUUID(主键,隐藏表单) / CompanyName(公司全称) / CompanyShort(公司简称)
* / CompanyStatus(0禁用/1启用) / ParentCompanyUUID(上级公司,entityPicker,只读)
* / CreateTime(创建时间,只读)
*
* 特殊业务逻辑:
* - ParentCompanyUUID 为自引用外键(公司可挂上级公司),原生用 epColumn 渲染为
* entityPicker(CompanySearch 搜索),Vue 版由后端 Columns 返回 entityPicker 配置
* - 强公司隔离:CompanyUUID 由后端自动注入,前端隐藏(CrudTable 透传后端列配置)
*
* Vue 版(阶段 1 简化):
* - server / admin_verify 由 request.ts 拦截器统一注入,页面不拼接 buildApiUrl
* - 列定义优先走后端 /API/FormAPI/Columns/Company(CrudTable 自动处理)
* 非主实例的 getDefaultColumns fallback 暂不实现(TODO)
* - AdminBadge 自动根据 server/admin_verify 显示特权横幅(对齐原生 adminBadge:true)
* - role_scope 细粒度判断暂用 userStore.isAdmin 二分(TODO 待 checkAdmin 接入)
* - onView 跳 Vue 路由 /company/company-detail?CompanyUUID=xxx(替代 GERP.Navigate)
*/
import { useRouter } from 'vue-router'
import CrudTable from '@/components/CrudTable.vue'
import AdminBadge from '@/components/AdminBadge.vue'
import { setPageTitle } from '@/utils/serverScope'
setPageTitle('公司管理')
const router = useRouter()
// 公司记录类型(仅声明本页用到的字段)
interface CompanyRow {
CompanyUUID?: string
CompanyName?: string
ParentCompanyUUID?: string
[k: string]: unknown
}
// 跳转公司详情页
function handleView(row: CompanyRow): void {
router.push({
path: '/company/company-detail',
query: { CompanyUUID: row.CompanyUUID || '' },
})
}
</script>
<template>
<div class="company-page">
<!-- 管理员特权横幅(仅特权模式显示) -->
<AdminBadge />
<CrudTable
table="Company"
title="公司管理"
pk-field="CompanyUUID"
searchable
pageable
selectable
:on-view="handleView"
/>
</div>
</template>
<style scoped lang="scss">
.company-page {
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
</style>
+365
View File
@@ -0,0 +1,365 @@
<script setup lang="ts">
/**
* CompanyDetail 公司详情页(组织架构枢纽页)
*
* 迁移自原生页面:pages/Company/company-detail.html
*
* 业务说明:
* - 公司详情是组织架构的枢纽页,顶部展示公司基本信息,下方挂载 3 个子表。
* - 子表 1:Department(部门),按 CompanyUUID 过滤,点击行跳转部门详情。
* - 子表 2:Workgroup(工作组),按 CompanyUUID 过滤,点击行跳转工作组详情。
* - 子表 3:UserRole(员工关联,实际表名 UserRole),按 CompanyUUID 过滤,
* 点击行跳转员工详情(employee-detail)。
* - 管理员特权模式:URL 携带 admin_verify=1 时跨公司隔离关闭,由 DetailPage 内置
* AdminBadge 横幅统一提示,无需本页处理。
* - server / admin_verify 参数由 axios 请求拦截器统一注入,子表 CrudTable 与主表
* 请求均无需手动拼接 buildApiUrl。
*
* 关键实现点:
* - 主表数据通过 formApi.list('Company', { CompanyUUID, pageSize:1 }) 拉取,响应
* 拦截器已解包 {code,msg,data},直接拿到 PagedData;再从 list 中定位精确记录。
* - ParentCompanyUUID / TenantUUID 通过 useResolveUUIDs 批量解析为名称回显。
* - 编辑按钮复用 DetailPage 的 actions 插槽,弹窗内根据 formApi.getColumns 动态
* 渲染表单,保存调用 formApi.update(对齐已迁移详情页的 Modify 全量更新约定)。
*/
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import DetailPage from '@/components/DetailPage.vue'
import InfoGrid from '@/components/InfoGrid.vue'
import CrudTable from '@/components/CrudTable.vue'
import { formApi } from '@/api/form'
import { useResolveUUIDs } from '@/composables/useResolveUUIDs'
import { setPageTitle } from '@/utils/serverScope'
import type { ColumnSchema } from '@/types/api'
/** 公司记录类型(仅声明详情页用到的字段) */
interface CompanyRecord {
CompanyUUID: string
CompanyName: string
CompanyShort: string
CompanyStatus: number
ParentCompanyUUID: string
TenantUUID: string
CompanyCreateTime: string
[key: string]: unknown
}
/** 通用行记录类型 */
type RowData = Record<string, unknown>
const route = useRoute()
const router = useRouter()
const { resolveUUIDs } = useResolveUUIDs()
/** 加载态 */
const loading = ref(false)
/** 公司主数据 */
const companyData = ref<CompanyRecord | null>(null)
/** 解析后的名称映射:{ parentCompany: { uuid: name }, tenant: { uuid: name } } */
const resolvedNames = ref<Record<string, Record<string, string>>>({})
/** 错误信息(缺参 / 加载失败时展示) */
const errorMsg = ref('')
/** 从路由 query 读取公司 UUID */
const companyUUID = (route.query.CompanyUUID as string) || ''
/** 公司主键字段名 */
const PK_FIELD = 'CompanyUUID'
/** 公司状态映射:0=禁用,1=启用 */
const companyStatusMap: Record<string, { text: string; type: 'success' | 'danger' }> = {
'0': { text: '禁用', type: 'danger' },
'1': { text: '启用', type: 'success' },
}
/** 安全读取记录字段为字符串 */
function asStr(val: unknown): string {
return val == null || val === '' ? '' : String(val)
}
/** 格式化时间(对齐原生 formatTime,空值返回 -) */
function formatTime(val: unknown): string {
if (!val) return '-'
const s = String(val)
const d = new Date(s)
if (isNaN(d.getTime())) return s
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
/** 提取分页响应中的列表(兼容数组和 PagedData 两种返回) */
function pickList<T = RowData>(res: unknown): T[] {
if (Array.isArray(res)) return res as T[]
const obj = res as { list?: T[] }
return (obj?.list as T[]) || []
}
/** 拉取公司主数据 + 解析关联 UUID */
async function loadCompany(): Promise<void> {
if (!companyUUID) {
errorMsg.value = '缺少 CompanyUUID 参数,无法加载公司详情。'
return
}
loading.value = true
errorMsg.value = ''
try {
const res = await formApi.list<CompanyRecord>('Company', {
CompanyUUID: companyUUID,
pageSize: 1,
})
const list = pickList<CompanyRecord>(res)
const data = list.find((c) => c.CompanyUUID === companyUUID) || list[0]
if (!data) {
errorMsg.value = '未找到公司数据'
return
}
companyData.value = data
setPageTitle(asStr(data.CompanyName) || '公司详情')
// 解析 ParentCompanyUUID(Company 表)与 TenantUUID(Tenant 表)
const uuidMap: Record<string, { table: string; uuids: string[] }> = {}
if (data.ParentCompanyUUID) {
uuidMap.parentCompany = { table: 'Company', uuids: [data.ParentCompanyUUID] }
}
if (data.TenantUUID) {
uuidMap.tenant = { table: 'Tenant', uuids: [data.TenantUUID] }
}
if (Object.keys(uuidMap).length) {
resolvedNames.value = await resolveUUIDs(uuidMap)
}
} catch (e) {
errorMsg.value = `加载失败: ${e instanceof Error ? e.message : String(e)}`
} finally {
loading.value = false
}
}
// ===== 子表行跳转(onView)=====
/** 跳转部门详情 */
function goDeptDetail(row: RowData): void {
router.push({ path: '/company/department-detail', query: { DeptUUID: asStr(row.DeptUUID) } })
}
/** 跳转工作组详情 */
function goWorkgroupDetail(row: RowData): void {
router.push({ path: '/company/workgroup-detail', query: { WorkgroupUUID: asStr(row.WorkgroupUUID) } })
}
/** 跳转员工详情(UserRole 行携带 UserUUID) */
function goEmployeeDetail(row: RowData): void {
router.push({ path: '/company/employee-detail', query: { UserUUID: asStr(row.UserUUID) } })
}
// ===== 主表编辑弹窗(对齐原生 showEditDialog)=====
const editVisible = ref(false)
const editSubmitting = ref(false)
const editColumns = ref<ColumnSchema[]>([])
const editForm = reactive<Record<string, any>>({})
/** 弹窗内可见的表单字段(排除主键 / 隐藏 / 不可编辑 / 分隔与 HTML) */
const editFormColumns = computed<ColumnSchema[]>(() =>
editColumns.value.filter(
(c) =>
c.editable !== false &&
!c.hideInForm &&
c.type !== 'hidden' &&
c.type !== 'divider' &&
c.type !== 'html' &&
c.field !== PK_FIELD,
),
)
/** 打开编辑弹窗:加载列定义并预填当前数据 */
async function openEdit(): Promise<void> {
if (!companyData.value) return
try {
if (!editColumns.value.length) {
editColumns.value = await formApi.getColumns('Company')
}
} catch {
editColumns.value = []
}
Object.keys(editForm).forEach((k) => delete editForm[k])
Object.assign(editForm, companyData.value)
editVisible.value = true
}
/** 保存编辑(调用 ModifyItem 整行提交) */
async function saveEdit(): Promise<void> {
if (!companyData.value) return
editSubmitting.value = true
try {
await formApi.update('Company', companyData.value[PK_FIELD] as string, { ...editForm })
ElMessage.success('保存成功')
editVisible.value = false
await loadCompany()
} catch {
// 错误提示已由响应拦截器统一弹出
} finally {
editSubmitting.value = false
}
}
onMounted(loadCompany)
</script>
<template>
<DetailPage :title="companyData?.CompanyName || '公司详情'" :loading="loading">
<!-- 右上角操作按钮 -->
<template #actions>
<el-button type="primary" :disabled="!companyData" @click="openEdit">
<el-icon><Edit /></el-icon>编辑
</el-button>
</template>
<!-- 错误提示(缺参 / 加载失败) -->
<el-alert v-if="errorMsg" type="error" :title="errorMsg" show-icon :closable="false" />
<!-- 主表基本信息(InfoGrid 直接置于 DetailPage,不包 el-card) -->
<InfoGrid v-if="companyData" title="基本信息" :columns="2">
<el-descriptions-item label="公司名称">{{ asStr(companyData.CompanyName) || '-' }}</el-descriptions-item>
<el-descriptions-item label="公司简称">{{ asStr(companyData.CompanyShort) || '-' }}</el-descriptions-item>
<el-descriptions-item label="公司状态">
<el-tag :type="companyStatusMap[String(companyData.CompanyStatus)]?.type || 'info'" size="small">
{{ companyStatusMap[String(companyData.CompanyStatus)]?.text ?? companyData.CompanyStatus }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="上级公司">
{{ resolvedNames.parentCompany?.[companyData.ParentCompanyUUID] || asStr(companyData.ParentCompanyUUID) || '-' }}
</el-descriptions-item>
<el-descriptions-item label="租户">
{{ resolvedNames.tenant?.[companyData.TenantUUID] || asStr(companyData.TenantUUID) || '-' }}
</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(companyData.CompanyCreateTime) }}</el-descriptions-item>
</InfoGrid>
<!-- 子表 1:部门 -->
<el-card v-if="companyData" shadow="never">
<CrudTable
table="Department"
title="部门"
:query="{ CompanyUUID: companyUUID }"
:selectable="true"
:pageable="true"
pk-field="DeptUUID"
:on-view="goDeptDetail"
/>
</el-card>
<!-- 子表 2:工作组 -->
<el-card v-if="companyData" shadow="never">
<CrudTable
table="Workgroup"
title="工作组"
:query="{ CompanyUUID: companyUUID }"
:selectable="true"
:pageable="true"
pk-field="WorkgroupUUID"
:on-view="goWorkgroupDetail"
/>
</el-card>
<!-- 子表 3:员工(UserRole 关联表) -->
<el-card v-if="companyData" shadow="never">
<CrudTable
table="UserRole"
title="员工"
:query="{ CompanyUUID: companyUUID }"
:selectable="true"
:pageable="true"
pk-field="UserRoleUUID"
:on-view="goEmployeeDetail"
/>
</el-card>
<!-- 主表编辑弹窗 -->
<el-dialog v-model="editVisible" title="编辑公司" width="600px" destroy-on-close>
<el-form :model="editForm" label-width="120px" label-position="right">
<el-form-item
v-for="col in editFormColumns"
:key="col.field"
:label="col.title"
:required="col.required"
>
<!-- 文本类 -->
<el-input
v-if="!col.type || col.type === 'text' || col.type === 'string'"
v-model="editForm[col.field]"
:placeholder="col.placeholder"
:maxlength="col.maxLength"
:disabled="col.disabled"
/>
<!-- 数值 -->
<el-input-number
v-else-if="col.type === 'number'"
v-model="editForm[col.field]"
:min="col.min"
:max="col.max"
:step="col.step"
:disabled="col.disabled"
/>
<!-- 多行文本 -->
<el-input
v-else-if="col.type === 'textarea'"
v-model="editForm[col.field]"
type="textarea"
:rows="col.rows || 3"
:disabled="col.disabled"
/>
<!-- 密码 -->
<el-input
v-else-if="col.type === 'password'"
v-model="editForm[col.field]"
type="password"
show-password
:placeholder="col.placeholder"
/>
<!-- 下拉选择 -->
<el-select
v-else-if="col.type === 'select'"
v-model="editForm[col.field]"
:placeholder="col.placeholder"
:disabled="col.disabled"
style="width: 100%"
>
<el-option
v-for="opt in col.options"
:key="String(opt.value)"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<!-- 日期 / 日期时间 -->
<el-date-picker
v-else-if="col.type === 'date' || col.type === 'datetime'"
v-model="editForm[col.field]"
:type="col.type as 'date' | 'datetime'"
:placeholder="col.placeholder"
style="width: 100%"
/>
<!-- 开关 -->
<el-switch
v-else-if="col.type === 'switch' || col.type === 'boolean'"
v-model="editForm[col.field]"
:disabled="col.disabled"
/>
<!-- 只读 -->
<el-input
v-else-if="col.type === 'readonly'"
:model-value="String(editForm[col.field] ?? '')"
readonly
/>
<!-- 其他类型(entityPicker 等)降级为文本输入 -->
<el-input v-else v-model="editForm[col.field]" :placeholder="col.placeholder" :disabled="col.disabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="editSubmitting" @click="saveEdit">保存</el-button>
</template>
</el-dialog>
</DetailPage>
</template>
<style scoped lang="scss">
// 子表卡片之间通过 DetailPage 的 detail-content gap 控制间距,此处无需额外样式
</style>
+77
View File
@@ -0,0 +1,77 @@
<script setup lang="ts">
/**
* 部门管理
*
* 迁移自 TempLate/GERP/pages/Company/department.html
* 原生实现:CompanyTablePage 基类 + tableName='Department' + pkField='DeptUUID'
* + defaultColumns fallback + onView 跳详情(无 adminBadge)
*
* 原生 defaultColumns(仅作 fallback 参考,Vue 版走后端 Columns 接口):
* DeptUUID(主键,隐藏表单) / DeptName(部门名称) / DeptSort(排序,number)
* / DeptStatus(0禁用/1启用) / CompanyUUID(所属公司,entityPicker,隐藏表单)
* / ParentDeptUUID(上级部门,entityPicker 自引用 Department)
* / CreateTime(创建时间,只读)
*
* 特殊业务逻辑:
* - ParentDeptUUID 为自引用外键,形成树形结构(部门可挂上级部门)
* 原生用普通表格展示,Vue 版暂用普通 CrudTable(TODO:树形展示待接入
* vxe-table tree-config + 后端返回 ParentDeptUUID 关联)
* - CompanyUUID 强公司隔离:后端自动注入,前端隐藏(hideInForm)
*
* Vue 版(阶段 1 简化):
* - server / admin_verify 由 request.ts 拦截器统一注入,页面不拼接 buildApiUrl
* - 列定义优先走后端 /API/FormAPI/Columns/Department(CrudTable 自动处理)
* 非主实例的 getDefaultColumns fallback 暂不实现(TODO)
* - 无 adminBadge(原生未启用特权徽章)
* - role_scope 细粒度判断暂用 userStore.isAdmin 二分(TODO 待 checkAdmin 接入)
* - 树形展示暂未实现(TODO:待 vxe-table tree-config 接入)
* - onView 跳 Vue 路由 /company/department-detail?DeptUUID=xxx(替代 GERP.Navigate)
*/
import { useRouter } from 'vue-router'
import CrudTable from '@/components/CrudTable.vue'
import { setPageTitle } from '@/utils/serverScope'
setPageTitle('部门管理')
const router = useRouter()
// 部门记录类型(仅声明本页用到的字段)
interface DepartmentRow {
DeptUUID?: string
DeptName?: string
ParentDeptUUID?: string
[k: string]: unknown
}
// 跳转部门详情页
function handleView(row: DepartmentRow): void {
router.push({
path: '/company/department-detail',
query: { DeptUUID: row.DeptUUID || '' },
})
}
</script>
<template>
<div class="department-page">
<CrudTable
table="Department"
title="部门管理"
pk-field="DeptUUID"
searchable
pageable
selectable
:on-view="handleView"
/>
</div>
</template>
<style scoped lang="scss">
.department-page {
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
</style>
+287
View File
@@ -0,0 +1,287 @@
<script setup lang="ts">
/**
* 部门详情页 DepartmentDetail
*
* 迁移自 TempLate/GERP/pages/Company/department-detail.html
*
* 业务结构:
* 1. 基本信息(InfoGrid):部门名称 / 部门状态 / 上级部门 / 排序 / 创建时间
* 2. 部门员工子表(CrudTable: UserRole,按 DeptUUID 过滤)
* - 列:UserUUID(entityPicker User,不可编辑) / RoleUUID(entityPicker Role) / RoleType(0员工/1负责人) / CreateTime
* 3. actions:编辑按钮(动态编辑弹窗)
*
* 迁移要点:
* - server / admin_verify 由 request.ts 拦截器统一注入
* - 主表单条加载:formApi.list('Department', { DeptUUID, pageSize: 1 })
* - UUID 解析:上级部门 ParentDeptUUID → DeptName,使用 useResolveUUIDs composable(带缓存)
* - 子表列定义由后端 Columns 接口返回(含 RoleType 选项 0员工/1负责人),CrudTable 自动渲染
* - onView 跳用户详情:/system/user-detail?UserUUID=xxx(对齐工程统一用户详情入口)
*/
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import DetailPage from '@/components/DetailPage.vue'
import InfoGrid from '@/components/InfoGrid.vue'
import CrudTable from '@/components/CrudTable.vue'
import EntityPicker from '@/components/EntityPicker.vue'
import { formApi } from '@/api/form'
import { useResolveUUIDs } from '@/composables/useResolveUUIDs'
import { setPageTitle } from '@/utils/serverScope'
import type { ColumnSchema } from '@/types/api'
const route = useRoute()
const router = useRouter()
const { resolveUUIDs } = useResolveUUIDs()
// 路由参数:部门 UUID
const deptUUID = (route.query.DeptUUID as string) || ''
// === 状态 ===
const loading = ref(false)
const data = ref<Record<string, any> | null>(null)
// 上级部门名称(UUID 解析结果)
const parentDeptName = ref('')
// 编辑弹窗状态
const editDialogVisible = ref(false)
const editSubmitting = ref(false)
const columns = ref<ColumnSchema[]>([])
const editForm = reactive<Record<string, any>>({})
// 主键字段
const PK_FIELD = 'DeptUUID'
// 页面标题
const title = computed(() => data.value?.DeptName || '部门详情')
// === 显示格式化 helper ===
/** 部门状态:1→启用,0→禁用 */
function deptStatusTag(v: unknown): { text: string; type: 'success' | 'danger' | 'info' } {
if (v === 1 || v === '1') return { text: '启用', type: 'success' }
if (v === 0 || v === '0') return { text: '禁用', type: 'danger' }
// TODO: 对齐原生 CompanyUtils.statusTag 完整码表
return { text: String(v ?? '-'), type: 'info' }
}
/** 时间格式化 */
function formatTime(v: unknown): string {
if (!v) return '-'
const d = new Date(v as string)
if (isNaN(d.getTime())) return String(v)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
// === 数据加载 ===
async function loadData(): Promise<void> {
if (!deptUUID) {
ElMessage.warning('缺少 DeptUUID 参数,无法加载部门详情')
return
}
loading.value = true
try {
const result = await formApi.list('Department', { [PK_FIELD]: deptUUID, pageSize: 1 })
const list: any[] = Array.isArray(result) ? result : (result as any)?.list || []
const row = list.find((d) => d[PK_FIELD] === deptUUID) || list[0]
if (!row) {
ElMessage.error('未找到部门数据')
return
}
data.value = row
setPageTitle(row.DeptName || '部门详情')
// 解析上级部门 UUID → 名称
await resolveParentDept(row.ParentDeptUUID)
} finally {
loading.value = false
}
}
/** 解析 ParentDeptUUID → DeptName */
async function resolveParentDept(parentUUID?: string): Promise<void> {
if (!parentUUID) {
parentDeptName.value = ''
return
}
try {
const resolved = await resolveUUIDs({ parentDept: { table: 'Department', uuids: [parentUUID] } })
parentDeptName.value = resolved.parentDept?.[parentUUID] || parentUUID
} catch {
parentDeptName.value = parentUUID
}
}
async function loadColumns(): Promise<void> {
try {
columns.value = await formApi.getColumns('Department')
} catch {
// 列定义加载失败不阻塞展示
}
}
// === 编辑弹窗 ===
const formColumns = computed(() =>
columns.value.filter(
(c) =>
c.editable !== false &&
!c.hideInForm &&
c.type !== 'hidden' &&
c.type !== 'divider' &&
c.type !== 'html' &&
c.field !== PK_FIELD,
),
)
function handleEdit(): void {
if (!data.value) return
Object.keys(editForm).forEach((k) => delete editForm[k])
Object.assign(editForm, data.value)
editDialogVisible.value = true
}
async function handleSubmit(): Promise<void> {
editSubmitting.value = true
try {
await formApi.update('Department', editForm[PK_FIELD], editForm)
ElMessage.success('保存成功')
editDialogVisible.value = false
await loadData()
} finally {
editSubmitting.value = false
}
}
// === 子表跳转 ===
/** UserRole 子表查看 → 用户详情 */
function goUserDetail(row: Record<string, any>): void {
router.push({ path: '/system/user-detail', query: { UserUUID: row.UserUUID || '' } })
}
onMounted(async () => {
await Promise.all([loadData(), loadColumns()])
})
</script>
<template>
<DetailPage :title="title" :loading="loading">
<template #actions>
<el-button type="primary" @click="handleEdit">
<el-icon><Edit /></el-icon>编辑
</el-button>
</template>
<!-- 基本信息 -->
<InfoGrid title="基本信息" :columns="2">
<el-descriptions-item label="部门名称">{{ data?.DeptName || '-' }}</el-descriptions-item>
<el-descriptions-item label="部门状态">
<el-tag :type="deptStatusTag(data?.DeptStatus).type" size="small">
{{ deptStatusTag(data?.DeptStatus).text }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="上级部门">
{{ parentDeptName || data?.ParentDeptUUID || '-' }}
</el-descriptions-item>
<el-descriptions-item label="排序">{{ data?.DeptSort ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(data?.DeptCreateTime) }}</el-descriptions-item>
</InfoGrid>
<!-- 部门员工子表 -->
<el-card shadow="never">
<template #header>部门员工</template>
<CrudTable
v-if="deptUUID"
table="UserRole"
title="部门员工"
:query="{ DeptUUID: deptUUID }"
:pk-field="'UserRoleUUID'"
selectable
pageable
:on-view="goUserDetail"
/>
</el-card>
<!-- 编辑弹窗 -->
<el-dialog v-model="editDialogVisible" title="编辑部门" width="600px" destroy-on-close>
<el-form :model="editForm" label-width="120px" label-position="right">
<el-form-item
v-for="col in formColumns"
:key="col.field"
:label="col.title"
:required="col.required"
>
<EntityPicker
v-if="col.type === 'entityPicker' && col.entityPicker"
v-model="editForm[col.field]"
:config="col.entityPicker"
:existing-label="editForm[col.field + '_label']"
:placeholder="col.placeholder"
:disabled="col.disabled"
@update:label="(label: string) => (editForm[col.field + '_label'] = label)"
/>
<el-input
v-else-if="!col.type || col.type === 'text' || col.type === 'string'"
v-model="editForm[col.field]"
:placeholder="col.placeholder"
:maxlength="col.maxLength"
:disabled="col.disabled"
/>
<el-input
v-else-if="col.type === 'textarea'"
v-model="editForm[col.field]"
type="textarea"
:rows="col.rows || 3"
:disabled="col.disabled"
/>
<el-input-number
v-else-if="col.type === 'number'"
v-model="editForm[col.field]"
:min="col.min"
:max="col.max"
:step="col.step"
:disabled="col.disabled"
/>
<el-select
v-else-if="col.type === 'select'"
v-model="editForm[col.field]"
:placeholder="col.placeholder"
:disabled="col.disabled"
style="width: 100%"
>
<el-option
v-for="opt in col.options"
:key="String(opt.value)"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-date-picker
v-else-if="col.type === 'date' || col.type === 'datetime'"
v-model="editForm[col.field]"
:type="col.type as 'date' | 'datetime'"
:placeholder="col.placeholder"
style="width: 100%"
/>
<el-switch
v-else-if="col.type === 'switch' || col.type === 'boolean'"
v-model="editForm[col.field]"
:disabled="col.disabled"
/>
<el-input
v-else
:model-value="String(editForm[col.field] ?? '')"
readonly
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="editSubmitting" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
</DetailPage>
</template>
<style scoped lang="scss"></style>
+398
View File
@@ -0,0 +1,398 @@
<script setup lang="ts">
/**
* EmployeeDetail 员工详情页
*
* 迁移自原生页面:pages/Company/employee-detail.html
*
* 业务说明:
* - 顶部展示用户基本信息(含头像)+ 安全信息(2FA 状态)。
* - 头像:通过 /Query/GetELTUserAvatar 接口获取(el-avatar 直接以 img 形式请求,
* 不走 axios 拦截器,故需手动拼接 server / admin_verify 作用域参数)。
* - 2FA:调用非标准接口 /API/SiteUserManagement/GetUser2FAInfo 查询双因子状态,
* 查询失败不阻塞页面(降级为“未配置”)。
* - 子表 1:UserRole(角色与组织),按 UserUUID 过滤。
* - 子表 2:UserDept(部门关联),按 UserUUID 过滤,点击行跳转部门详情。
* - server / admin_verify 由 axios 请求拦截器统一注入(2FA 接口亦同)。
*
* 关键实现点:
* - 主表数据通过 formApi.list('User', { UserUUID, pageSize:1 }) 拉取并定位记录。
* - 2FA 通过 get('/API/SiteUserManagement/GetUser2FAInfo', { UserUUID }) 调用,
* 响应拦截器已解包为 data;外层 try/catch 保证失败不阻塞。
* - 头像 URL 手动附加 getServerScopeParams() 返回的 server / admin_verify。
* - 编辑弹窗动态渲染列定义,保存调用 formApi.update(对齐已迁移详情页约定)。
*/
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import DetailPage from '@/components/DetailPage.vue'
import InfoGrid from '@/components/InfoGrid.vue'
import CrudTable from '@/components/CrudTable.vue'
import { formApi } from '@/api/form'
import { get } from '@/api/request'
import { setPageTitle, getServerScopeParams } from '@/utils/serverScope'
import type { ColumnSchema } from '@/types/api'
/** 用户记录类型(仅声明详情页用到的字段) */
interface UserRecord {
UserUUID: string
UserName: string
UserRealName: string
UserEmail: string
UserTele: string
UserAccountLicense: number
IsStaff: number | boolean
UserCreateTime: string
[key: string]: unknown
}
/** 2FA 信息类型 */
interface TwoFaInfo {
IsEnabled: number
Verified: number
LastUsedAt: string
CreateTime: string
[key: string]: unknown
}
/** 通用行记录类型 */
type RowData = Record<string, unknown>
const route = useRoute()
const router = useRouter()
/** 加载态 */
const loading = ref(false)
/** 用户主数据 */
const userData = ref<UserRecord | null>(null)
/** 2FA 信息(null 表示未获取或未配置) */
const twoFaData = ref<TwoFaInfo | null>(null)
/** 错误信息 */
const errorMsg = ref('')
/** 从路由 query 读取用户 UUID */
const userUUID = (route.query.UserUUID as string) || ''
/** 用户主键字段名 */
const PK_FIELD = 'UserUUID'
/** 账号状态映射:0=禁用,1=启用 */
const accountStatusMap: Record<string, { text: string; type: 'success' | 'danger' }> = {
'0': { text: '禁用', type: 'danger' },
'1': { text: '启用', type: 'success' },
}
/** 头像 URL:手动附加 server / admin_verify 作用域参数(el-avatar 不走 axios 拦截器) */
const avatarUrl = computed(() => {
const params = new URLSearchParams()
params.set('UserUUID', userUUID)
const scope = getServerScopeParams()
if (scope.server) params.set('server', scope.server)
if (scope.admin_verify) params.set('admin_verify', scope.admin_verify)
return `/Query/GetELTUserAvatar?${params.toString()}`
})
/** 安全读取记录字段为字符串 */
function asStr(val: unknown): string {
return val == null || val === '' ? '' : String(val)
}
/** 格式化时间(空值返回 -) */
function formatTime(val: unknown): string {
if (!val) return '-'
const s = String(val)
const d = new Date(s)
if (isNaN(d.getTime())) return s
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
/** 提取分页响应中的列表(兼容数组和 PagedData) */
function pickList<T = RowData>(res: unknown): T[] {
if (Array.isArray(res)) return res as T[]
const obj = res as { list?: T[] }
return (obj?.list as T[]) || []
}
/** IsStaff 转布尔(兼容 0/1 与 true/false) */
function isTruthy(val: unknown): boolean {
return val === 1 || val === true || val === '1' || val === 'true'
}
/** 拉取用户主数据 + 2FA 信息(2FA 失败不阻塞) */
async function loadUser(): Promise<void> {
if (!userUUID) {
errorMsg.value = '缺少 UserUUID 参数,无法加载员工详情。'
return
}
loading.value = true
errorMsg.value = ''
twoFaData.value = null
try {
const res = await formApi.list<UserRecord>('User', { UserUUID: userUUID, pageSize: 1 })
const list = pickList<UserRecord>(res)
const data = list.find((u) => u.UserUUID === userUUID) || list[0]
if (!data) {
errorMsg.value = '未找到用户数据'
return
}
userData.value = data
setPageTitle(asStr(data.UserName) || '员工详情')
// 并行获取 2FA 信息(失败不阻塞页面)
try {
twoFaData.value = await get<TwoFaInfo>('/API/SiteUserManagement/GetUser2FAInfo', {
UserUUID: userUUID,
})
} catch {
twoFaData.value = null
}
} catch (e) {
errorMsg.value = `加载失败: ${e instanceof Error ? e.message : String(e)}`
} finally {
loading.value = false
}
}
// ===== 子表行跳转(onView)=====
/** 跳转部门详情(UserDept 行携带 DeptUUID) */
function goDeptDetail(row: RowData): void {
router.push({ path: '/company/department-detail', query: { DeptUUID: asStr(row.DeptUUID) } })
}
// ===== 主表编辑弹窗(对齐原生 showEditDialog)=====
const editVisible = ref(false)
const editSubmitting = ref(false)
const editColumns = ref<ColumnSchema[]>([])
const editForm = reactive<Record<string, any>>({})
/** 弹窗内可见的表单字段 */
const editFormColumns = computed<ColumnSchema[]>(() =>
editColumns.value.filter(
(c) =>
c.editable !== false &&
!c.hideInForm &&
c.type !== 'hidden' &&
c.type !== 'divider' &&
c.type !== 'html' &&
c.field !== PK_FIELD,
),
)
/** 打开编辑弹窗 */
async function openEdit(): Promise<void> {
if (!userData.value) return
try {
if (!editColumns.value.length) {
editColumns.value = await formApi.getColumns('User')
}
} catch {
editColumns.value = []
}
Object.keys(editForm).forEach((k) => delete editForm[k])
Object.assign(editForm, userData.value)
editVisible.value = true
}
/** 保存编辑 */
async function saveEdit(): Promise<void> {
if (!userData.value) return
editSubmitting.value = true
try {
await formApi.update('User', userData.value[PK_FIELD] as string, { ...editForm })
ElMessage.success('保存成功')
editVisible.value = false
await loadUser()
} catch {
// 错误提示已由响应拦截器统一弹出
} finally {
editSubmitting.value = false
}
}
onMounted(loadUser)
</script>
<template>
<DetailPage :title="userData?.UserName || '员工详情'" :loading="loading">
<!-- 右上角操作按钮 -->
<template #actions>
<el-button type="primary" :disabled="!userData" @click="openEdit">
<el-icon><Edit /></el-icon>编辑
</el-button>
</template>
<!-- 错误提示 -->
<el-alert v-if="errorMsg" type="error" :title="errorMsg" show-icon :closable="false" />
<!-- 基本信息(头像 + 信息网格横向排列,直接置于 DetailPage,不包 el-card) -->
<div v-if="userData" class="profile-row">
<!-- 头像:失败时显示默认图标 -->
<el-avatar :size="96" :src="avatarUrl" class="profile-avatar">
<el-icon :size="32"><User /></el-icon>
</el-avatar>
<div class="profile-info">
<el-descriptions title="基本信息" :column="2" border>
<el-descriptions-item label="用户名">{{ asStr(userData.UserName) || '-' }}</el-descriptions-item>
<el-descriptions-item label="真实姓名">{{ asStr(userData.UserRealName) || '-' }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ asStr(userData.UserEmail) || '-' }}</el-descriptions-item>
<el-descriptions-item label="电话">{{ asStr(userData.UserTele) || '-' }}</el-descriptions-item>
<el-descriptions-item label="账号状态">
<el-tag :type="accountStatusMap[String(userData.UserAccountLicense)]?.type || 'info'" size="small">
{{ accountStatusMap[String(userData.UserAccountLicense)]?.text ?? userData.UserAccountLicense }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="是否员工">
<span :style="{ color: isTruthy(userData.IsStaff) ? '#166534' : '#991b1b' }">
{{ isTruthy(userData.IsStaff) ? '是' : '否' }}
</span>
</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(userData.UserCreateTime) }}</el-descriptions-item>
</el-descriptions>
</div>
</div>
<!-- 安全信息(2FA,InfoGrid 直接置于 DetailPage,不包 el-card) -->
<InfoGrid v-if="userData" title="安全信息" :columns="2">
<el-descriptions-item label="2FA状态">
<span v-if="twoFaData" :style="{ color: twoFaData.IsEnabled === 1 ? '#166534' : '#991b1b' }">
{{ twoFaData.IsEnabled === 1 ? '已启用' : '未启用' }}
</span>
<span v-else style="color: #6b7280">未配置</span>
</el-descriptions-item>
<el-descriptions-item label="验证状态">
<span v-if="twoFaData" :style="{ color: twoFaData.Verified === 1 ? '#166534' : '#991b1b' }">
{{ twoFaData.Verified === 1 ? '已验证' : '未验证' }}
</span>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="最后使用">
{{ twoFaData && twoFaData.LastUsedAt ? formatTime(twoFaData.LastUsedAt) : '-' }}
</el-descriptions-item>
<el-descriptions-item label="2FA创建时间">
{{ twoFaData && twoFaData.CreateTime ? formatTime(twoFaData.CreateTime) : '-' }}
</el-descriptions-item>
</InfoGrid>
<!-- 子表 1:角色与组织(UserRole) -->
<el-card v-if="userData" shadow="never">
<CrudTable
table="UserRole"
title="角色与组织"
:query="{ UserUUID: userUUID }"
:selectable="true"
:pageable="true"
pk-field="UserRoleUUID"
/>
</el-card>
<!-- 子表 2:部门关联(UserDept) -->
<el-card v-if="userData" shadow="never">
<CrudTable
table="UserDept"
title="部门关联"
:query="{ UserUUID: userUUID }"
:selectable="true"
:pageable="true"
pk-field="UserDeptUUID"
:on-view="goDeptDetail"
/>
</el-card>
<!-- 主表编辑弹窗 -->
<el-dialog v-model="editVisible" title="编辑员工" width="600px" destroy-on-close>
<el-form :model="editForm" label-width="120px" label-position="right">
<el-form-item
v-for="col in editFormColumns"
:key="col.field"
:label="col.title"
:required="col.required"
>
<el-input
v-if="!col.type || col.type === 'text' || col.type === 'string'"
v-model="editForm[col.field]"
:placeholder="col.placeholder"
:maxlength="col.maxLength"
:disabled="col.disabled"
/>
<el-input-number
v-else-if="col.type === 'number'"
v-model="editForm[col.field]"
:min="col.min"
:max="col.max"
:step="col.step"
:disabled="col.disabled"
/>
<el-input
v-else-if="col.type === 'textarea'"
v-model="editForm[col.field]"
type="textarea"
:rows="col.rows || 3"
:disabled="col.disabled"
/>
<el-input
v-else-if="col.type === 'password'"
v-model="editForm[col.field]"
type="password"
show-password
:placeholder="col.placeholder"
/>
<el-select
v-else-if="col.type === 'select'"
v-model="editForm[col.field]"
:placeholder="col.placeholder"
:disabled="col.disabled"
style="width: 100%"
>
<el-option
v-for="opt in col.options"
:key="String(opt.value)"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-date-picker
v-else-if="col.type === 'date' || col.type === 'datetime'"
v-model="editForm[col.field]"
:type="col.type as 'date' | 'datetime'"
:placeholder="col.placeholder"
style="width: 100%"
/>
<el-switch
v-else-if="col.type === 'switch' || col.type === 'boolean'"
v-model="editForm[col.field]"
:disabled="col.disabled"
/>
<el-input
v-else-if="col.type === 'readonly'"
:model-value="String(editForm[col.field] ?? '')"
readonly
/>
<el-input v-else v-model="editForm[col.field]" :placeholder="col.placeholder" :disabled="col.disabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="editSubmitting" @click="saveEdit">保存</el-button>
</template>
</el-dialog>
</DetailPage>
</template>
<style scoped lang="scss">
// 头像与信息网格横向排列
.profile-row {
display: flex;
gap: 24px;
align-items: flex-start;
}
.profile-avatar {
flex-shrink: 0;
border: 2px solid #e5e7eb;
}
.profile-info {
flex: 1;
min-width: 0;
}
</style>
File diff suppressed because it is too large. Load diff
+77
View File
@@ -0,0 +1,77 @@
<script setup lang="ts">
/**
* 角色管理(Company 模块)
*
* 迁移自 TempLate/GERP/pages/Company/role.html
* 原生实现:CompanyTablePage 基类 + tableName='Role' + pkField='RoleUUID'
* + adminBadge:true(特权模式徽章)+ defaultColumns fallback + onView 跳详情
*
* 原生 defaultColumns(仅作 fallback 参考,Vue 版走后端 Columns 接口):
* RoleUUID(主键,隐藏表单) / RoleName(角色名称)
* / RoleType(0组员/1组长/2其他) / RoleStatus(0禁用/1启用)
* / AssignScope(0全租户/1公司/2部门) / RoleDesc(角色描述)
* / CreateTime(创建时间,只读)
*
* 特殊业务逻辑:
* - AssignScope 控制角色分配范围(全租户/公司/部门),影响权限边界
* - RoleType 区分组员/组长/其他,原生用 select 渲染
*
* Vue 版(阶段 1 简化):
* - server / admin_verify 由 request.ts 拦截器统一注入,页面不拼接 buildApiUrl
* - 列定义优先走后端 /API/FormAPI/Columns/Role(CrudTable 自动处理)
* 非主实例的 getDefaultColumns fallback 暂不实现(TODO)
* - AdminBadge 自动根据 server/admin_verify 显示特权横幅(对齐原生 adminBadge:true)
* - role_scope 细粒度判断暂用 userStore.isAdmin 二分(TODO 待 checkAdmin 接入)
* - onView 跳 Vue 路由 /company/role-detail?RoleUUID=xxx(替代 GERP.Navigate)
* - 注意:与 system/Roles.vue(system 模块角色管理)区分,两者迁移来源与路由不同
*/
import { useRouter } from 'vue-router'
import CrudTable from '@/components/CrudTable.vue'
import AdminBadge from '@/components/AdminBadge.vue'
import { setPageTitle } from '@/utils/serverScope'
setPageTitle('角色管理')
const router = useRouter()
// 角色记录类型(仅声明本页用到的字段)
interface RoleRow {
RoleUUID?: string
RoleName?: string
[k: string]: unknown
}
// 跳转角色详情页
function handleView(row: RoleRow): void {
router.push({
path: '/company/role-detail',
query: { RoleUUID: row.RoleUUID || '' },
})
}
</script>
<template>
<div class="role-page">
<!-- 管理员特权横幅(仅特权模式显示) -->
<AdminBadge />
<CrudTable
table="Role"
title="角色管理"
pk-field="RoleUUID"
searchable
pageable
selectable
:on-view="handleView"
/>
</div>
</template>
<style scoped lang="scss">
.role-page {
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
</style>
+332
View File
@@ -0,0 +1,332 @@
<script setup lang="ts">
/**
* RoleDetail 角色详情页
*
* 迁移自原生页面:pages/Company/role-detail.html
*
* 业务说明:
* - 顶部展示角色基本信息(角色名/类型/状态/分配范围/描述/创建时间)。
* - 角色类型 RoleType:0=组员,1=组长,2=其他。
* - 分配范围 AssignScope:0=全租户,1=公司,2=部门。
* - 范围提示横幅:特权模式下提示“显示所有公司关联记录(无公司隔离)”,
* 普通模式下提示“仅显示当前公司数据,查看全部需管理员特权”。
* DetailPage 已内置 AdminBadge 顶部横幅,本页在基本信息下方再补充一条作用域
* 说明(scope-banner),对齐原生两态提示。
* - 子表 1:RolePermission(权限列表),按 RoleUUID 过滤。
* - 子表 2:UserRole(关联用户),按 RoleUUID 过滤。
* - server / admin_verify 由请求拦截器统一注入。
*
* 关键实现点:
* - 主表数据通过 formApi.list('Role', { RoleUUID, pageSize:1 }) 拉取并定位记录。
* - 是否特权模式由 isDefaultServer()/hasAdminVerifyArg() 综合判断(与 AdminBadge 一致)。
* - 编辑弹窗动态渲染列定义,保存调用 formApi.update(对齐已迁移详情页约定)。
*/
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import DetailPage from '@/components/DetailPage.vue'
import InfoGrid from '@/components/InfoGrid.vue'
import CrudTable from '@/components/CrudTable.vue'
import { formApi } from '@/api/form'
import { setPageTitle, isDefaultServer, hasAdminVerifyArg } from '@/utils/serverScope'
import type { ColumnSchema } from '@/types/api'
/** 角色记录类型(仅声明详情页用到的字段) */
interface RoleRecord {
RoleUUID: string
RoleName: string
RoleType: number
RoleStatus: number
AssignScope: number
RoleDesc: string
CreateTime: string
[key: string]: unknown
}
/** 通用行记录类型 */
type RowData = Record<string, unknown>
const route = useRoute()
/** 加载态 */
const loading = ref(false)
/** 角色主数据 */
const roleData = ref<RoleRecord | null>(null)
/** 错误信息 */
const errorMsg = ref('')
/** 从路由 query 读取角色 UUID */
const roleUUID = (route.query.RoleUUID as string) || ''
/** 角色主键字段名 */
const PK_FIELD = 'RoleUUID'
/** 角色类型映射:0=组员,1=组长,2=其他 */
const roleTypeMap: Record<string, string> = { '0': '组员', '1': '组长', '2': '其他' }
/** 分配范围映射:0=全租户,1=公司,2=部门 */
const assignScopeMap: Record<string, string> = { '0': '全租户', '1': '公司', '2': '部门' }
/** 角色状态映射:0=禁用,1=启用 */
const roleStatusMap: Record<string, { text: string; type: 'success' | 'danger' }> = {
'0': { text: '禁用', type: 'danger' },
'1': { text: '启用', type: 'success' },
}
/** 是否特权模式(与 AdminBadge 判定逻辑一致) */
const isAdminMode = computed(() => !isDefaultServer() || hasAdminVerifyArg())
/** 作用域提示文案:特权模式 vs 普通模式 */
const scopeHint = computed(() =>
isAdminMode.value
? '特权模式:显示该角色在所有公司的关联记录(无公司隔离)'
: '仅显示当前公司数据。如需查看所有公司记录,需管理员特权。',
)
/** 安全读取记录字段为字符串 */
function asStr(val: unknown): string {
return val == null || val === '' ? '' : String(val)
}
/** 格式化时间(空值返回 -) */
function formatTime(val: unknown): string {
if (!val) return '-'
const s = String(val)
const d = new Date(s)
if (isNaN(d.getTime())) return s
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
/** 提取分页响应中的列表(兼容数组和 PagedData) */
function pickList<T = RowData>(res: unknown): T[] {
if (Array.isArray(res)) return res as T[]
const obj = res as { list?: T[] }
return (obj?.list as T[]) || []
}
/** 拉取角色主数据 */
async function loadRole(): Promise<void> {
if (!roleUUID) {
errorMsg.value = '缺少 RoleUUID 参数,无法加载角色详情。'
return
}
loading.value = true
errorMsg.value = ''
try {
const res = await formApi.list<RoleRecord>('Role', { RoleUUID: roleUUID, pageSize: 1 })
const list = pickList<RoleRecord>(res)
const data = list.find((r) => r.RoleUUID === roleUUID) || list[0]
if (!data) {
errorMsg.value = '未找到角色数据'
return
}
roleData.value = data
setPageTitle(asStr(data.RoleName) || '角色详情')
} catch (e) {
errorMsg.value = `加载失败: ${e instanceof Error ? e.message : String(e)}`
} finally {
loading.value = false
}
}
// ===== 主表编辑弹窗(对齐原生 showEditDialog)=====
const editVisible = ref(false)
const editSubmitting = ref(false)
const editColumns = ref<ColumnSchema[]>([])
const editForm = reactive<Record<string, any>>({})
/** 弹窗内可见的表单字段 */
const editFormColumns = computed<ColumnSchema[]>(() =>
editColumns.value.filter(
(c) =>
c.editable !== false &&
!c.hideInForm &&
c.type !== 'hidden' &&
c.type !== 'divider' &&
c.type !== 'html' &&
c.field !== PK_FIELD,
),
)
/** 打开编辑弹窗 */
async function openEdit(): Promise<void> {
if (!roleData.value) return
try {
if (!editColumns.value.length) {
editColumns.value = await formApi.getColumns('Role')
}
} catch {
editColumns.value = []
}
Object.keys(editForm).forEach((k) => delete editForm[k])
Object.assign(editForm, roleData.value)
editVisible.value = true
}
/** 保存编辑 */
async function saveEdit(): Promise<void> {
if (!roleData.value) return
editSubmitting.value = true
try {
await formApi.update('Role', roleData.value[PK_FIELD] as string, { ...editForm })
ElMessage.success('保存成功')
editVisible.value = false
await loadRole()
} catch {
// 错误提示已由响应拦截器统一弹出
} finally {
editSubmitting.value = false
}
}
onMounted(loadRole)
</script>
<template>
<DetailPage :title="roleData?.RoleName || '角色详情'" :loading="loading">
<!-- 右上角操作按钮 -->
<template #actions>
<el-button type="primary" :disabled="!roleData" @click="openEdit">
<el-icon><Edit /></el-icon>编辑
</el-button>
</template>
<!-- 错误提示 -->
<el-alert v-if="errorMsg" type="error" :title="errorMsg" show-icon :closable="false" />
<!-- 主表基本信息(InfoGrid 直接置于 DetailPage,不包 el-card) -->
<InfoGrid v-if="roleData" title="基本信息" :columns="2">
<el-descriptions-item label="角色名称">{{ asStr(roleData.RoleName) || '-' }}</el-descriptions-item>
<el-descriptions-item label="角色类型">{{ roleTypeMap[String(roleData.RoleType)] ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="roleStatusMap[String(roleData.RoleStatus)]?.type || 'info'" size="small">
{{ roleStatusMap[String(roleData.RoleStatus)]?.text ?? roleData.RoleStatus }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="分配范围">{{ assignScopeMap[String(roleData.AssignScope)] ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="角色描述">{{ asStr(roleData.RoleDesc) || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(roleData.CreateTime) }}</el-descriptions-item>
</InfoGrid>
<!-- 作用域范围提示横幅(scope-banner) -->
<el-alert
v-if="roleData"
:type="isAdminMode ? 'warning' : 'info'"
:closable="false"
show-icon
class="scope-banner"
>
{{ scopeHint }}
</el-alert>
<!-- 子表 1:权限列表 -->
<el-card v-if="roleData" shadow="never">
<CrudTable
table="RolePermission"
title="权限列表"
:query="{ RoleUUID: roleUUID }"
:selectable="true"
:pageable="true"
pk-field="RolePermUUID"
/>
</el-card>
<!-- 子表 2:关联用户 -->
<el-card v-if="roleData" shadow="never">
<CrudTable
table="UserRole"
title="关联用户"
:query="{ RoleUUID: roleUUID }"
:selectable="true"
:pageable="true"
pk-field="UserRoleUUID"
/>
</el-card>
<!-- 主表编辑弹窗 -->
<el-dialog v-model="editVisible" title="编辑角色" width="600px" destroy-on-close>
<el-form :model="editForm" label-width="120px" label-position="right">
<el-form-item
v-for="col in editFormColumns"
:key="col.field"
:label="col.title"
:required="col.required"
>
<el-input
v-if="!col.type || col.type === 'text' || col.type === 'string'"
v-model="editForm[col.field]"
:placeholder="col.placeholder"
:maxlength="col.maxLength"
:disabled="col.disabled"
/>
<el-input-number
v-else-if="col.type === 'number'"
v-model="editForm[col.field]"
:min="col.min"
:max="col.max"
:step="col.step"
:disabled="col.disabled"
/>
<el-input
v-else-if="col.type === 'textarea'"
v-model="editForm[col.field]"
type="textarea"
:rows="col.rows || 3"
:disabled="col.disabled"
/>
<el-input
v-else-if="col.type === 'password'"
v-model="editForm[col.field]"
type="password"
show-password
:placeholder="col.placeholder"
/>
<el-select
v-else-if="col.type === 'select'"
v-model="editForm[col.field]"
:placeholder="col.placeholder"
:disabled="col.disabled"
style="width: 100%"
>
<el-option
v-for="opt in col.options"
:key="String(opt.value)"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-date-picker
v-else-if="col.type === 'date' || col.type === 'datetime'"
v-model="editForm[col.field]"
:type="col.type as 'date' | 'datetime'"
:placeholder="col.placeholder"
style="width: 100%"
/>
<el-switch
v-else-if="col.type === 'switch' || col.type === 'boolean'"
v-model="editForm[col.field]"
:disabled="col.disabled"
/>
<el-input
v-else-if="col.type === 'readonly'"
:model-value="String(editForm[col.field] ?? '')"
readonly
/>
<el-input v-else v-model="editForm[col.field]" :placeholder="col.placeholder" :disabled="col.disabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="editSubmitting" @click="saveEdit">保存</el-button>
</template>
</el-dialog>
</DetailPage>
</template>
<style scoped lang="scss">
// 作用域提示横幅底部留白
.scope-banner {
margin-bottom: 4px;
}
</style>
+72
View File
@@ -0,0 +1,72 @@
<script setup lang="ts">
/**
* 租户管理
*
* 迁移自 TempLate/GERP/pages/Company/tenant.html
* 原生实现:CompanyTablePage 基类 + tableName='Tenant' + pkField='TenantUUID'
* + adminBadge:true(特权模式徽章)+ defaultColumns fallback + onView 跳详情
*
* 原生 defaultColumns(仅作 fallback 参考,Vue 版走后端 Columns 接口):
* TenantUUID(主键,隐藏表单) / TenantName(文本) / TenantStatus(0禁用/1启用)
* / ReviewStatus(0待审核/1已通过/2已拒绝) / TenantExpireTime(日期时间)
* / ReviewRemark(文本) / CreateTime(创建时间,只读)
*
* Vue 版(阶段 1 简化):
* - server / admin_verify 由 request.ts 拦截器统一注入,页面不拼接 buildApiUrl
* - 列定义优先走后端 /API/FormAPI/Columns/Tenant(CrudTable 自动处理)
* 非主实例的 getDefaultColumns fallback 暂不实现(TODO)
* - AdminBadge 自动根据 server/admin_verify 显示特权横幅(对齐原生 adminBadge:true)
* - role_scope 细粒度判断暂用 userStore.isAdmin 二分(TODO 待 checkAdmin 接入)
* - onView 跳 Vue 路由 /company/tenant-detail?TenantUUID=xxx(替代 GERP.Navigate)
* - 页面标题随 server 变化由 setPageTitle 统一处理
*/
import { useRouter } from 'vue-router'
import CrudTable from '@/components/CrudTable.vue'
import AdminBadge from '@/components/AdminBadge.vue'
import { setPageTitle } from '@/utils/serverScope'
setPageTitle('租户管理')
const router = useRouter()
// 租户记录类型(仅声明本页用到的字段)
interface TenantRow {
TenantUUID?: string
TenantName?: string
[k: string]: unknown
}
// 跳转租户详情页
function handleView(row: TenantRow): void {
router.push({
path: '/company/tenant-detail',
query: { TenantUUID: row.TenantUUID || '' },
})
}
</script>
<template>
<div class="tenant-page">
<!-- 管理员特权横幅(仅特权模式显示) -->
<AdminBadge />
<CrudTable
table="Tenant"
title="租户管理"
pk-field="TenantUUID"
searchable
pageable
selectable
:on-view="handleView"
/>
</div>
</template>
<style scoped lang="scss">
.tenant-page {
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
</style>
+283
View File
@@ -0,0 +1,283 @@
<script setup lang="ts">
/**
* 租户详情页 TenantDetail
*
* 迁移自 TempLate/GERP/pages/Company/tenant-detail.html
*
* 业务结构:
* 1. 基本信息(InfoGrid):租户名称 / 租户状态 / 到期时间 / 创建时间
* 2. 审核信息(InfoGrid):审核状态 / 审核时间 / 审核备注
* 3. 关联公司子表(CrudTable: Company,按 TenantUUID 过滤)
* 4. actions:编辑按钮(动态编辑弹窗)
*
* 迁移要点:
* - server / admin_verify 由 request.ts 拦截器统一注入
* - 主表单条加载:formApi.list('Tenant', { TenantUUID, pageSize: 1 })
* - 审核状态码:0→待审核,1→已通过,2→已拒绝
* - 租户状态码:0→禁用,1→启用
* - 原生 _isAdminMode(hasAdminVerifyArg)由拦截器统一处理,无需手动注入
*/
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import DetailPage from '@/components/DetailPage.vue'
import InfoGrid from '@/components/InfoGrid.vue'
import CrudTable from '@/components/CrudTable.vue'
import EntityPicker from '@/components/EntityPicker.vue'
import { formApi } from '@/api/form'
import { setPageTitle } from '@/utils/serverScope'
import type { ColumnSchema } from '@/types/api'
const route = useRoute()
const router = useRouter()
// 路由参数:租户 UUID
const tenantUUID = (route.query.TenantUUID as string) || ''
// === 状态 ===
const loading = ref(false)
const data = ref<Record<string, any> | null>(null)
// 编辑弹窗状态
const editDialogVisible = ref(false)
const editSubmitting = ref(false)
const columns = ref<ColumnSchema[]>([])
const editForm = reactive<Record<string, any>>({})
// 主键字段
const PK_FIELD = 'TenantUUID'
// 页面标题
const title = computed(() => data.value?.TenantName || '租户详情')
// === 显示格式化 helper ===
/** 租户状态:0→禁用,1→启用 */
function tenantStatusTag(v: unknown): { text: string; type: 'success' | 'danger' | 'info' } {
if (v === 1 || v === '1') return { text: '启用', type: 'success' }
if (v === 0 || v === '0') return { text: '禁用', type: 'danger' }
return { text: String(v ?? '-'), type: 'info' }
}
/** 审核状态:0→待审核,1→已通过,2→已拒绝 */
function reviewStatusTag(v: unknown): { text: string; type: 'success' | 'danger' | 'warning' | 'info' } {
if (v === 0 || v === '0') return { text: '待审核', type: 'warning' }
if (v === 1 || v === '1') return { text: '已通过', type: 'success' }
if (v === 2 || v === '2') return { text: '已拒绝', type: 'danger' }
return { text: String(v ?? '-'), type: 'info' }
}
/** 时间格式化 */
function formatTime(v: unknown): string {
if (!v) return '-'
const d = new Date(v as string)
if (isNaN(d.getTime())) return String(v)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
// 编辑弹窗可见列
const formColumns = computed(() =>
columns.value.filter(
(c) =>
c.editable !== false &&
!c.hideInForm &&
c.type !== 'hidden' &&
c.type !== 'divider' &&
c.type !== 'html' &&
c.field !== PK_FIELD,
),
)
// === 数据加载 ===
async function loadData(): Promise<void> {
if (!tenantUUID) {
ElMessage.warning('缺少 TenantUUID 参数,无法加载租户详情')
return
}
loading.value = true
try {
const result = await formApi.list('Tenant', { [PK_FIELD]: tenantUUID, pageSize: 1 })
const list: any[] = Array.isArray(result) ? result : (result as any)?.list || []
const row = list.find((t) => t[PK_FIELD] === tenantUUID) || list[0]
if (!row) {
ElMessage.error('未找到租户数据')
return
}
data.value = row
setPageTitle(row.TenantName || '租户详情')
} finally {
loading.value = false
}
}
async function loadColumns(): Promise<void> {
try {
columns.value = await formApi.getColumns('Tenant')
} catch {
// 列定义加载失败不阻塞展示
}
}
// === 编辑弹窗 ===
function handleEdit(): void {
if (!data.value) return
Object.keys(editForm).forEach((k) => delete editForm[k])
Object.assign(editForm, data.value)
editDialogVisible.value = true
}
async function handleSubmit(): Promise<void> {
editSubmitting.value = true
try {
await formApi.update('Tenant', editForm[PK_FIELD], editForm)
ElMessage.success('保存成功')
editDialogVisible.value = false
await loadData()
} finally {
editSubmitting.value = false
}
}
// === 子表跳转 ===
/** Company 子表查看 → 公司详情 */
function goCompanyDetail(row: Record<string, any>): void {
router.push({ path: '/company/company-detail', query: { CompanyUUID: row.CompanyUUID || '' } })
}
onMounted(async () => {
await Promise.all([loadData(), loadColumns()])
})
</script>
<template>
<DetailPage :title="title" :loading="loading">
<template #actions>
<el-button type="primary" @click="handleEdit">
<el-icon><Edit /></el-icon>编辑
</el-button>
</template>
<!-- 基本信息 -->
<InfoGrid title="基本信息" :columns="2">
<el-descriptions-item label="租户名称">{{ data?.TenantName || '-' }}</el-descriptions-item>
<el-descriptions-item label="租户状态">
<el-tag :type="tenantStatusTag(data?.TenantStatus).type" size="small">
{{ tenantStatusTag(data?.TenantStatus).text }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="到期时间">{{ formatTime(data?.TenantExpireTime) }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(data?.CreateTime) }}</el-descriptions-item>
</InfoGrid>
<!-- 审核信息 -->
<InfoGrid title="审核信息" :columns="2">
<el-descriptions-item label="审核状态">
<el-tag :type="reviewStatusTag(data?.ReviewStatus).type" size="small">
{{ reviewStatusTag(data?.ReviewStatus).text }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="审核时间">{{ formatTime(data?.ReviewTime) }}</el-descriptions-item>
<el-descriptions-item label="审核备注">{{ data?.ReviewRemark || '-' }}</el-descriptions-item>
</InfoGrid>
<!-- 关联公司子表 -->
<el-card shadow="never">
<template #header>关联公司</template>
<CrudTable
v-if="tenantUUID"
table="Company"
title="租户公司"
:query="{ TenantUUID: tenantUUID }"
:pk-field="'CompanyUUID'"
selectable
pageable
:on-view="goCompanyDetail"
/>
</el-card>
<!-- 编辑弹窗 -->
<el-dialog v-model="editDialogVisible" title="编辑租户" width="600px" destroy-on-close>
<el-form :model="editForm" label-width="120px" label-position="right">
<el-form-item
v-for="col in formColumns"
:key="col.field"
:label="col.title"
:required="col.required"
>
<EntityPicker
v-if="col.type === 'entityPicker' && col.entityPicker"
v-model="editForm[col.field]"
:config="col.entityPicker"
:existing-label="editForm[col.field + '_label']"
:placeholder="col.placeholder"
:disabled="col.disabled"
@update:label="(label: string) => (editForm[col.field + '_label'] = label)"
/>
<el-input
v-else-if="!col.type || col.type === 'text' || col.type === 'string'"
v-model="editForm[col.field]"
:placeholder="col.placeholder"
:maxlength="col.maxLength"
:disabled="col.disabled"
/>
<el-input
v-else-if="col.type === 'textarea'"
v-model="editForm[col.field]"
type="textarea"
:rows="col.rows || 3"
:disabled="col.disabled"
/>
<el-input-number
v-else-if="col.type === 'number'"
v-model="editForm[col.field]"
:min="col.min"
:max="col.max"
:step="col.step"
:disabled="col.disabled"
/>
<el-select
v-else-if="col.type === 'select'"
v-model="editForm[col.field]"
:placeholder="col.placeholder"
:disabled="col.disabled"
style="width: 100%"
>
<el-option
v-for="opt in col.options"
:key="String(opt.value)"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-date-picker
v-else-if="col.type === 'date' || col.type === 'datetime'"
v-model="editForm[col.field]"
:type="col.type as 'date' | 'datetime'"
:placeholder="col.placeholder"
style="width: 100%"
/>
<el-switch
v-else-if="col.type === 'switch' || col.type === 'boolean'"
v-model="editForm[col.field]"
:disabled="col.disabled"
/>
<el-input
v-else
:model-value="String(editForm[col.field] ?? '')"
readonly
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="editSubmitting" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
</DetailPage>
</template>
<style scoped lang="scss"></style>
+73
View File
@@ -0,0 +1,73 @@
<script setup lang="ts">
/**
* 工作组管理
*
* 迁移自 TempLate/GERP/pages/Company/workgroup.html
* 原生实现:CompanyTablePage 基类 + tableName='Workgroup'
* + pkField 默认='WorkgroupUUID' + defaultColumns fallback + onView 跳详情
* (无 adminBadge,无显式 pkField,由基类默认推导)
*
* 原生 defaultColumns(仅作 fallback 参考,Vue 版走后端 Columns 接口):
* WorkgroupUUID(主键,隐藏表单) / WorkgroupName(工作组名称)
* / WorkgroupEnglishName(英文名称) / WorkgroupIntroduction(工作组介绍,textarea)
* / CompanyUUID(所属公司,entityPicker)
*
* 特殊业务逻辑:
* - 无状态字段、无创建时间字段(列定义较精简)
* - CompanyUUID 为 entityPicker(CompanySearch 搜索),强公司隔离由后端注入
*
* Vue 版(阶段 1 简化):
* - server / admin_verify 由 request.ts 拦截器统一注入,页面不拼接 buildApiUrl
* - 列定义优先走后端 /API/FormAPI/Columns/Workgroup(CrudTable 自动处理)
* 非主实例的 getDefaultColumns fallback 暂不实现(TODO)
* - 无 adminBadge(原生未启用特权徽章)
* - role_scope 细粒度判断暂用 userStore.isAdmin 二分(TODO 待 checkAdmin 接入)
* - onView 跳 Vue 路由 /company/workgroup-detail?WorkgroupUUID=xxx(替代 GERP.Navigate)
*/
import { useRouter } from 'vue-router'
import CrudTable from '@/components/CrudTable.vue'
import { setPageTitle } from '@/utils/serverScope'
setPageTitle('工作组管理')
const router = useRouter()
// 工作组记录类型(仅声明本页用到的字段)
interface WorkgroupRow {
WorkgroupUUID?: string
WorkgroupName?: string
[k: string]: unknown
}
// 跳转工作组详情页
function handleView(row: WorkgroupRow): void {
router.push({
path: '/company/workgroup-detail',
query: { WorkgroupUUID: row.WorkgroupUUID || '' },
})
}
</script>
<template>
<div class="workgroup-page">
<CrudTable
table="Workgroup"
title="工作组管理"
pk-field="WorkgroupUUID"
searchable
pageable
selectable
:on-view="handleView"
/>
</div>
</template>
<style scoped lang="scss">
.workgroup-page {
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
</style>
+262
View File
@@ -0,0 +1,262 @@
<script setup lang="ts">
/**
* 工作组详情页 WorkgroupDetail
*
* 迁移自 TempLate/GERP/pages/Company/workgroup-detail.html
*
* 业务结构:
* 1. 基本信息(InfoGrid):工作组名称 / 英文名称 / 所属公司 / 工作组介绍
* 2. 工作组角色子表(CrudTable: WorkgroupRole,按 WorkgroupUUID 过滤)
* - 列:RoleUUID(entityPicker Role) / RoleType(0组员/1组长) / CreateTime
* 3. actions:编辑按钮(动态编辑弹窗)
*
* 迁移要点:
* - server / admin_verify 由 request.ts 拦截器统一注入
* - 主表单条加载:formApi.list('Workgroup', { WorkgroupUUID, pageSize: 1 })
* - UUID 解析:所属公司 CompanyUUID → CompanyName,使用 useResolveUUIDs composable(带缓存)
* - 原生 buildApiUrl 无 admin_verify 分支(本页未启用特权模式),拦截器按 URL 自动处理
*/
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import DetailPage from '@/components/DetailPage.vue'
import InfoGrid from '@/components/InfoGrid.vue'
import CrudTable from '@/components/CrudTable.vue'
import EntityPicker from '@/components/EntityPicker.vue'
import { formApi } from '@/api/form'
import { useResolveUUIDs } from '@/composables/useResolveUUIDs'
import { setPageTitle } from '@/utils/serverScope'
import type { ColumnSchema } from '@/types/api'
const route = useRoute()
const router = useRouter()
const { resolveUUIDs } = useResolveUUIDs()
// 路由参数:工作组 UUID
const workgroupUUID = (route.query.WorkgroupUUID as string) || ''
// === 状态 ===
const loading = ref(false)
const data = ref<Record<string, any> | null>(null)
// 所属公司名称(UUID 解析结果)
const companyName = ref('')
// 编辑弹窗状态
const editDialogVisible = ref(false)
const editSubmitting = ref(false)
const columns = ref<ColumnSchema[]>([])
const editForm = reactive<Record<string, any>>({})
// 主键字段
const PK_FIELD = 'WorkgroupUUID'
// 页面标题
const title = computed(() => data.value?.WorkgroupName || '工作组详情')
// === 数据加载 ===
async function loadData(): Promise<void> {
if (!workgroupUUID) {
ElMessage.warning('缺少 WorkgroupUUID 参数,无法加载工作组详情')
return
}
loading.value = true
try {
const result = await formApi.list('Workgroup', { [PK_FIELD]: workgroupUUID, pageSize: 1 })
const list: any[] = Array.isArray(result) ? result : (result as any)?.list || []
const row = list.find((w) => w[PK_FIELD] === workgroupUUID) || list[0]
if (!row) {
ElMessage.error('未找到工作组数据')
return
}
data.value = row
setPageTitle(row.WorkgroupName || '工作组详情')
// 解析所属公司 UUID → 名称
await resolveCompany(row.CompanyUUID)
} finally {
loading.value = false
}
}
/** 解析 CompanyUUID → CompanyName */
async function resolveCompany(companyUUID?: string): Promise<void> {
if (!companyUUID) {
companyName.value = ''
return
}
try {
const resolved = await resolveUUIDs({ company: { table: 'Company', uuids: [companyUUID] } })
companyName.value = resolved.company?.[companyUUID] || companyUUID
} catch {
companyName.value = companyUUID
}
}
async function loadColumns(): Promise<void> {
try {
columns.value = await formApi.getColumns('Workgroup')
} catch {
// 列定义加载失败不阻塞展示
}
}
// === 编辑弹窗 ===
const formColumns = computed(() =>
columns.value.filter(
(c) =>
c.editable !== false &&
!c.hideInForm &&
c.type !== 'hidden' &&
c.type !== 'divider' &&
c.type !== 'html' &&
c.field !== PK_FIELD,
),
)
function handleEdit(): void {
if (!data.value) return
Object.keys(editForm).forEach((k) => delete editForm[k])
Object.assign(editForm, data.value)
editDialogVisible.value = true
}
async function handleSubmit(): Promise<void> {
editSubmitting.value = true
try {
await formApi.update('Workgroup', editForm[PK_FIELD], editForm)
ElMessage.success('保存成功')
editDialogVisible.value = false
await loadData()
} finally {
editSubmitting.value = false
}
}
// === 子表跳转 ===
/** WorkgroupRole 子表查看 → 角色详情 */
function goRoleDetail(row: Record<string, any>): void {
router.push({ path: '/system/role-detail', query: { RoleUUID: row.RoleUUID || '' } })
}
onMounted(async () => {
await Promise.all([loadData(), loadColumns()])
})
</script>
<template>
<DetailPage :title="title" :loading="loading">
<template #actions>
<el-button type="primary" @click="handleEdit">
<el-icon><Edit /></el-icon>编辑
</el-button>
</template>
<!-- 基本信息 -->
<InfoGrid title="基本信息" :columns="2">
<el-descriptions-item label="工作组名称">{{ data?.WorkgroupName || '-' }}</el-descriptions-item>
<el-descriptions-item label="英文名称">{{ data?.WorkgroupEnglishName || '-' }}</el-descriptions-item>
<el-descriptions-item label="所属公司">
{{ companyName || data?.CompanyUUID || '-' }}
</el-descriptions-item>
<el-descriptions-item label="工作组介绍">{{ data?.WorkgroupIntroduction || '-' }}</el-descriptions-item>
</InfoGrid>
<!-- 工作组角色子表 -->
<el-card shadow="never">
<template #header>工作组角色</template>
<CrudTable
v-if="workgroupUUID"
table="WorkgroupRole"
title="工作组角色"
:query="{ WorkgroupUUID: workgroupUUID }"
:pk-field="'WorkgroupRoleUUID'"
selectable
pageable
:on-view="goRoleDetail"
/>
</el-card>
<!-- 编辑弹窗 -->
<el-dialog v-model="editDialogVisible" title="编辑工作组" width="600px" destroy-on-close>
<el-form :model="editForm" label-width="120px" label-position="right">
<el-form-item
v-for="col in formColumns"
:key="col.field"
:label="col.title"
:required="col.required"
>
<EntityPicker
v-if="col.type === 'entityPicker' && col.entityPicker"
v-model="editForm[col.field]"
:config="col.entityPicker"
:existing-label="editForm[col.field + '_label']"
:placeholder="col.placeholder"
:disabled="col.disabled"
@update:label="(label: string) => (editForm[col.field + '_label'] = label)"
/>
<el-input
v-else-if="!col.type || col.type === 'text' || col.type === 'string'"
v-model="editForm[col.field]"
:placeholder="col.placeholder"
:maxlength="col.maxLength"
:disabled="col.disabled"
/>
<el-input
v-else-if="col.type === 'textarea'"
v-model="editForm[col.field]"
type="textarea"
:rows="col.rows || 3"
:disabled="col.disabled"
/>
<el-input-number
v-else-if="col.type === 'number'"
v-model="editForm[col.field]"
:min="col.min"
:max="col.max"
:step="col.step"
:disabled="col.disabled"
/>
<el-select
v-else-if="col.type === 'select'"
v-model="editForm[col.field]"
:placeholder="col.placeholder"
:disabled="col.disabled"
style="width: 100%"
>
<el-option
v-for="opt in col.options"
:key="String(opt.value)"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-date-picker
v-else-if="col.type === 'date' || col.type === 'datetime'"
v-model="editForm[col.field]"
:type="col.type as 'date' | 'datetime'"
:placeholder="col.placeholder"
style="width: 100%"
/>
<el-switch
v-else-if="col.type === 'switch' || col.type === 'boolean'"
v-model="editForm[col.field]"
:disabled="col.disabled"
/>
<el-input
v-else
:model-value="String(editForm[col.field] ?? '')"
readonly
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="editSubmitting" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
</DetailPage>
</template>
<style scoped lang="scss"></style>
+695
View File
@@ -0,0 +1,695 @@
<script setup lang="ts">
/**
* Editor 文档编辑器
*
* 迁移自 TempLate/GERP/pages/doc/editor.html(约 417 行)
*
* 原生实现要点:
* - 左侧文档列表 + 右侧 TinyMCE 5.10.9 富文本编辑器(CDN 加载 + 中文语言包)
* - 顶部工具栏四个操作:新建 / 保存 / 导出 .docx / 删除
* - API 路径:POST /API/doc/doc/<action>,全部 JSON 请求
* · GetDocs { keyword? } → 文档列表
* · GetDoc { DocUUID } → 文档详情(标题 + 内容)
* · AddDoc { DocTitle, DocContent } → 新建,返回 { data: { DocUUID } }
* · ModifyDocSettings { DocUUID, DocTitle, DocContent } → 保存
* · DeleteDoc { DocUUID } → 删除
* · ExportDocx { DocUUID } → 二进制 .docx 流(Content-Disposition 携带文件名)
* - 切换文档/新建时若有未保存改动,二次确认丢弃
* - 搜索框 300ms 防抖触发列表刷新
* - Ctrl+S / Cmd+S 快捷键保存
* - 保存状态文本与颜色联动(未保存/保存中/已保存/失败)
*
* Vue 版关键改造:
* - TinyMCE 改用 @tinymce/tinymce-vue 官方 Vue 组件
* TODO:需执行 `npm install @tinymce/tinymce-vue tinymce` 后取消下方注释启用真组件
* - 原生 alert/confirm/prompt → ElMessage / ElMessageBox
* - 原生 <input> → el-input
* - server / admin_verify 由 @/api/request 拦截器统一注入,无需页面关心
* - 导出 .docx 走底层 axios 实例 + responseType: 'blob' 拿二进制流
* (响应拦截器对非业务响应直接放行,故返回完整 AxiosResponse,需取 .data)
* - 复用 AdminBadge 顶部特权模式横幅
* - 复用 setPageTitle 设置浏览器标题(含 server 前缀)
*/
import { ref, computed, onMounted, onBeforeUnmount, shallowRef } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { post } from '@/api/request'
import request from '@/api/request'
import AdminBadge from '@/components/AdminBadge.vue'
import { setPageTitle } from '@/utils/serverScope'
// TODO: 安装 @tinymce/tinymce-vue + tinymce 后启用
// import Editor from '@tinymce/tinymce-vue'
// import 'tinymce/tinymce'
// import 'tinymce/themes/silver'
// import 'tinymce/icons/default/icons'
// import 'tinymce/plugins/advlist' // 其它插件依此类推
setPageTitle('文档编辑器')
// ============================================================================
// 类型定义
// ============================================================================
/** 文档列表项(GetDocs 接口返回) */
interface DocListItem {
/** 文档 UUID(主键) */
DocUUID: string
/** 文档标题 */
DocTitle: string
/** 创建时间(后端字符串) */
DocCreateTime?: string
[k: string]: unknown
}
/** 文档详情(GetDoc 接口返回) */
interface DocDetail {
DocTitle: string
DocContent: string
[k: string]: unknown
}
/** 新建文档响应(AddDoc 接口的 data 字段) */
interface AddDocResult {
DocUUID: string
[k: string]: unknown
}
/** 保存状态文本(驱动颜色映射) */
type SaveStatus =
| ''
| '未保存'
| '保存中...'
| '已保存'
| '保存失败'
| '导出中...'
| '导出成功'
| '导出失败'
// ============================================================================
// 响应式状态
// ============================================================================
/** 文档列表 */
const DocList = ref<DocListItem[]>([])
/** 列表加载中标志 */
const ListLoading = ref(false)
/** 列表加载错误消息(为空表示无错误) */
const ListError = ref('')
/** 搜索关键字(与输入框双向绑定) */
const SearchKeyword = ref('')
/** 搜索防抖定时器句柄 */
let SearchTimer: ReturnType<typeof setTimeout> | null = null
/** 当前选中的文档 UUID(为 null 表示未选择任何文档) */
const CurrentDocUUID = ref<string | null>(null)
/** 当前文档标题(绑定标题输入框) */
const CurrentDocTitle = ref('')
/** 当前文档内容(绑定 TinyMCE 编辑器) */
const CurrentDocContent = ref('')
/** 文档是否被修改(脏标记,切换/新建/删除前用于二次确认) */
const Dirty = ref(false)
/** 保存状态文本 */
const SaveStatusText = ref<SaveStatus>('')
/** 保存状态对应的颜色(与原生 SaveStatus.style.color 对齐) */
const SaveStatusColor = computed<string>(() => {
switch (SaveStatusText.value) {
case '已保存':
case '导出成功':
return '#10b981'
case '未保存':
return '#f59e0b'
case '保存失败':
case '导出失败':
return '#ef4444'
default:
return '#6b7280'
}
})
/**
* TinyMCE 编辑器实例
* 使用 shallowRef 避免对第三方实例做深层响应式代理(性能 + 兼容性)
*/
const EditorInstance = shallowRef<{ getContent: () => string; remove: () => void } | null>(null)
// ============================================================================
// 计算属性:工具栏按钮 disabled 状态
// ============================================================================
/** 保存按钮可用:已选文档 且 有未保存改动 */
const CanSave = computed(() => !!CurrentDocUUID.value && Dirty.value)
/** 导出按钮可用:已选文档 */
const CanExport = computed(() => !!CurrentDocUUID.value)
/** 删除按钮可用:已选文档 */
const CanDelete = computed(() => !!CurrentDocUUID.value)
// ============================================================================
// API 调用
// ============================================================================
/**
* 调用文档 API
* 对齐原生 CallDocAPI:POST /API/doc/doc/<action>
* request.ts 响应拦截器会自动剥离外层 {code,msg,data},故这里直接拿到 data
*/
async function CallDocAPI<T = unknown>(
action: string,
data: Record<string, unknown> = {},
): Promise<T> {
return post<T>(`/API/doc/doc/${action}`, data)
}
/** 加载文档列表 */
async function LoadDocList(keyword = ''): Promise<void> {
ListLoading.value = true
ListError.value = ''
try {
const result = await CallDocAPI<DocListItem[]>('GetDocs', keyword ? { keyword } : {})
DocList.value = Array.isArray(result) ? result : []
} catch (e) {
ListError.value = (e as Error)?.message || '加载失败'
DocList.value = []
} finally {
ListLoading.value = false
}
}
/** 选择文档:切换前若有未保存改动,二次确认丢弃 */
async function SelectDoc(docUUID: string): Promise<void> {
if (Dirty.value && CurrentDocUUID.value) {
try {
await ElMessageBox.confirm('当前文档未保存,是否丢弃改动?', '提示', {
type: 'warning',
confirmButtonText: '丢弃',
cancelButtonText: '取消',
})
} catch {
return // 用户取消
}
}
CurrentDocUUID.value = docUUID
Dirty.value = false
try {
const result = await CallDocAPI<DocDetail>('GetDoc', { DocUUID: docUUID })
CurrentDocTitle.value = result.DocTitle || ''
CurrentDocContent.value = result.DocContent || ''
SaveStatusText.value = ''
} catch (e) {
ElMessage.error('加载文档失败: ' + ((e as Error)?.message || ''))
}
}
/** 新建文档:提示输入标题,新建后自动选中文档 */
async function NewDoc(): Promise<void> {
if (Dirty.value && CurrentDocUUID.value) {
try {
await ElMessageBox.confirm('当前文档未保存,是否丢弃改动?', '提示', {
type: 'warning',
confirmButtonText: '丢弃',
cancelButtonText: '取消',
})
} catch {
return
}
}
let title: string
try {
const result = await ElMessageBox.prompt('请输入文档标题:', '新建文档', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: '未命名文档',
inputValidator: (v) => (v && v.trim() ? true : '标题不能为空'),
})
title = result.value.trim()
} catch {
return // 用户取消
}
try {
const result = await CallDocAPI<AddDocResult>('AddDoc', {
DocTitle: title,
DocContent: '',
})
await LoadDocList(SearchKeyword.value)
if (result?.DocUUID) {
await SelectDoc(result.DocUUID)
}
} catch (e) {
ElMessage.error('新建失败: ' + ((e as Error)?.message || ''))
}
}
/** 保存文档:从 TinyMCE 取最新内容并提交 ModifyDocSettings */
async function SaveDoc(): Promise<void> {
if (!CurrentDocUUID.value || !EditorInstance.value) return
const title = CurrentDocTitle.value.trim()
if (!title) {
ElMessage.warning('请输入文档标题')
return
}
const content = EditorInstance.value.getContent()
SaveStatusText.value = '保存中...'
try {
await CallDocAPI('ModifyDocSettings', {
DocUUID: CurrentDocUUID.value,
DocTitle: title,
DocContent: content,
})
Dirty.value = false
SaveStatusText.value = '已保存'
await LoadDocList(SearchKeyword.value)
} catch (e) {
SaveStatusText.value = '保存失败'
ElMessage.error('保存失败: ' + ((e as Error)?.message || ''))
}
}
/**
* 导出 .docx
* 二进制响应需要走底层 axios 实例 + responseType: 'blob'
* 响应拦截器对非业务响应(无 code 字段)会原样放行,故返回完整 AxiosResponse
*/
async function ExportDocx(): Promise<void> {
if (!CurrentDocUUID.value) return
SaveStatusText.value = '导出中...'
try {
const response = await request.post('/API/doc/doc/ExportDocx', {
DocUUID: CurrentDocUUID.value,
}, { responseType: 'blob' })
const blob = response.data as Blob
// 从 Content-Disposition 头解析文件名(后端按 RFC 5987 编码)
const disposition = (response.headers['content-disposition'] as string) || ''
let filename = 'document.docx'
const match = disposition.match(/filename="?([^"]+)"?/)
if (match) filename = decodeURIComponent(match[1])
// 触发浏览器下载
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
SaveStatusText.value = '导出成功'
} catch (e) {
SaveStatusText.value = '导出失败'
ElMessage.error('导出失败: ' + ((e as Error)?.message || ''))
}
}
/** 删除文档:二次确认后调用 DeleteDoc,并清空当前文档状态 */
async function DeleteDoc(): Promise<void> {
if (!CurrentDocUUID.value) return
try {
await ElMessageBox.confirm('确定删除此文档?此操作不可撤销。', '危险操作', {
type: 'error',
confirmButtonText: '删除',
cancelButtonText: '取消',
confirmButtonClass: 'el-button--danger',
})
} catch {
return
}
try {
await CallDocAPI('DeleteDoc', { DocUUID: CurrentDocUUID.value })
// 清空当前文档状态
CurrentDocUUID.value = null
CurrentDocTitle.value = ''
CurrentDocContent.value = ''
Dirty.value = false
SaveStatusText.value = ''
await LoadDocList(SearchKeyword.value)
} catch (e) {
ElMessage.error('删除失败: ' + ((e as Error)?.message || ''))
}
}
// ============================================================================
// 编辑器 / 表单事件
// ============================================================================
/** 标记文档已修改 */
function MarkDirty(): void {
Dirty.value = true
SaveStatusText.value = '未保存'
}
/** 搜索输入事件:300ms 防抖后刷新列表 */
function HandleSearchInput(): void {
if (SearchTimer) clearTimeout(SearchTimer)
SearchTimer = setTimeout(() => {
LoadDocList(SearchKeyword.value.trim())
}, 300)
}
/** 刷新列表按钮 */
function HandleRefresh(): void {
LoadDocList(SearchKeyword.value.trim())
}
/** 全局 Ctrl+S / Cmd+S 快捷键保存 */
function HandleKeydown(e: KeyboardEvent): void {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
if (CanSave.value) SaveDoc()
}
}
// ============================================================================
// 生命周期
// ============================================================================
onMounted(() => {
LoadDocList()
document.addEventListener('keydown', HandleKeydown)
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', HandleKeydown)
if (SearchTimer) clearTimeout(SearchTimer)
// 主动销毁 TinyMCE 实例,避免泄漏
if (EditorInstance.value) {
EditorInstance.value.remove()
EditorInstance.value = null
}
})
// ============================================================================
// 工具函数
// ============================================================================
/** 格式化文档时间(zh-CN 本地化) */
function FormatTime(time?: string): string {
if (!time) return ''
return new Date(time).toLocaleString('zh-CN')
}
</script>
<template>
<div class="gerp-page">
<!-- 顶部管理员特权模式横幅(仅在特权模式下显示) -->
<AdminBadge />
<div class="doc-page">
<!-- 顶部工具栏 -->
<div class="doc-header">
<h1 class="doc-title">
<el-icon><Document /></el-icon>
文档编辑器
</h1>
<div class="spacer" />
<div class="doc-toolbar">
<el-button type="primary" @click="NewDoc">
<el-icon><Plus /></el-icon>&nbsp;新建
</el-button>
<el-button :disabled="!CanSave" @click="SaveDoc">
<el-icon><Save /></el-icon>&nbsp;保存
</el-button>
<el-button :disabled="!CanExport" @click="ExportDocx">
<el-icon><Download /></el-icon>&nbsp;导出 .docx
</el-button>
<el-button type="danger" :disabled="!CanDelete" @click="DeleteDoc">
<el-icon><Delete /></el-icon>&nbsp;删除
</el-button>
</div>
</div>
<!-- 主体:左侧文档列表 + 右侧编辑器 -->
<div class="doc-body">
<!-- 左侧文档列表 -->
<div class="doc-sidebar">
<div class="sidebar-header">
<el-input
v-model="SearchKeyword"
placeholder="搜索文档..."
clearable
@input="HandleSearchInput"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button title="刷新列表" @click="HandleRefresh">
<el-icon><Refresh /></el-icon>
</el-button>
</div>
<div v-loading="ListLoading" class="doc-list">
<div v-if="ListError" class="doc-empty">{{ ListError }}</div>
<div v-else-if="!DocList.length && !ListLoading" class="doc-empty">
暂无文档,点击"新建"创建
</div>
<div
v-for="doc in DocList"
:key="doc.DocUUID"
class="doc-item"
:class="{ active: doc.DocUUID === CurrentDocUUID }"
@click="SelectDoc(doc.DocUUID)"
>
<div class="doc-item-title">{{ doc.DocTitle || '未命名' }}</div>
<div class="doc-item-time">{{ FormatTime(doc.DocCreateTime) }}</div>
</div>
</div>
</div>
<!-- 右侧编辑器 -->
<div class="doc-editor">
<div class="doc-title-bar">
<el-input
v-model="CurrentDocTitle"
placeholder="请选择或新建文档"
:disabled="!CurrentDocUUID"
@input="MarkDirty"
/>
<span class="doc-save-status" :style="{ color: SaveStatusColor }">
{{ SaveStatusText }}
</span>
</div>
<div class="editor-container">
<!-- 当前未选择文档时的占位 -->
<div v-if="!CurrentDocUUID" class="doc-loading">
请从左侧选择或新建文档
</div>
<!--
TODO: 安装依赖后启用 TinyMCE 真组件
npm install @tinymce/tinymce-vue tinymce
并在文件顶部 import Editor from '@tinymce/tinymce-vue'
以及按需 import 'tinymce/plugins/*' 子模块
<Editor
v-else
:key="CurrentDocUUID"
v-model="CurrentDocContent"
:init="TinyMceConfig"
@init="HandleEditorInit"
@change="HandleEditorChange"
@input="HandleEditorChange"
/>
-->
<!-- 临时占位:未集成 TinyMCE 时用 textarea 维持基础可读可写 -->
<el-input
v-else
v-model="CurrentDocContent"
type="textarea"
:autosize="{ minRows: 20 }"
placeholder="TinyMCE 待 npm install @tinymce/tinymce-vue tinymce 后集成(已标 TODO)"
@input="MarkDirty"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
// 页面根容器:撑满父级,纵向排布
.gerp-page {
padding: 0;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
// 编辑器主体:纵向占满
.doc-page {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
// 顶部工具栏:横向排列,左标题右操作
.doc-header {
display: flex;
align-items: center;
padding: 12px 20px;
background: var(--GERP-Bg-Card, #fff);
border-bottom: 1px solid var(--GERP-Border, #e5e7eb);
gap: 12px;
flex-shrink: 0;
}
// 标题:左对齐,含图标
.doc-title {
margin: 0;
font-size: 18px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 8px;
}
// 占位间隔:把工具栏推到最右侧
.spacer {
flex: 1;
}
// 工具栏按钮组
.doc-toolbar {
display: flex;
gap: 8px;
}
// 主体区:横向左列表 + 右编辑器
.doc-body {
flex: 1;
min-height: 0;
display: flex;
overflow: hidden;
}
// 左侧边栏:固定宽度 260px
.doc-sidebar {
width: 260px;
flex-shrink: 0;
border-right: 1px solid var(--GERP-Border, #e5e7eb);
background: var(--GERP-Bg-Card, #fff);
display: flex;
flex-direction: column;
}
// 侧边栏头部:搜索框 + 刷新按钮
.sidebar-header {
padding: 10px 12px;
border-bottom: 1px solid var(--GERP-Border, #e5e7eb);
display: flex;
gap: 8px;
align-items: center;
}
// 文档列表:可滚动
.doc-list {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
// 文档项:悬停高亮 + 选中态左竖条
.doc-item {
padding: 10px 14px;
cursor: pointer;
border-bottom: 1px solid var(--GERP-Border-Light, #f3f4f6);
transition: background 0.1s;
&:hover {
background: var(--GERP-Bg-Hover, #f9fafb);
}
&.active {
background: rgba(30, 64, 175, 0.08);
border-left: 3px solid #1e40af;
padding-left: 11px;
}
}
// 文档项标题:单行省略
.doc-item-title {
font-size: 13px;
font-weight: 500;
color: var(--GERP-Text, #1f2937);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// 文档项时间:弱化文字
.doc-item-time {
font-size: 11px;
color: var(--GERP-Text-Muted, #9ca3af);
margin-top: 2px;
}
// 列表空态 / 错误态
.doc-empty {
padding: 20px;
text-align: center;
color: var(--GERP-Text-Muted, #9ca3af);
font-size: 13px;
}
// 右侧编辑器:纵向占满
.doc-editor {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
background: var(--GERP-Bg, #f9fafb);
}
// 标题栏:标题输入 + 保存状态
.doc-title-bar {
padding: 10px 16px;
background: var(--GERP-Bg-Card, #fff);
border-bottom: 1px solid var(--GERP-Border, #e5e7eb);
flex-shrink: 0;
display: flex;
align-items: center;
gap: 10px;
}
// 保存状态文本
.doc-save-status {
font-size: 12px;
color: var(--GERP-Text-Muted, #9ca3af);
white-space: nowrap;
}
// 编辑器容器:撑满剩余空间
.editor-container {
flex: 1;
min-height: 0;
overflow: hidden;
// TinyMCE 容器去掉边框与圆角,撑满编辑区
:deep(.tox-tinymce) {
border: none;
border-radius: 0;
}
}
// 占位 / 加载态
.doc-loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--GERP-Text-Muted, #9ca3af);
}
</style>
+101
View File
@@ -0,0 +1,101 @@
<script setup lang="ts">
/**
* 产品管理(订单模板)
*
* 迁移自 TempLate/GERP/pages/inventory/products.html(154 行)
* 原生实现:tableName=OrderTemplate(非 Product),server 作用域 + admin_verify 特权 +
* role_scope 横幅(admin/normal 二色)+ 非主实例列 fallback + onView 跳详情
*
* Vue 版(阶段 1 简化):
* - server / admin_verify 由 request.ts 拦截器统一注入
* - 作用域判断暂用 userStore.isAdmin 二分(admin / normal)
* - 细粒度 role_scope(dept_manager / workgroup_lead)待 checkAdmin 接入(TODO)
* - 管理员开启行选择;普通组员只读(editable 待 CrudTable 暴露 editable prop 后接入,TODO)
* - onView 跳 /inventory/product-detail?TemplateUUID=xxx
*/
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import CrudTable from '@/components/CrudTable.vue'
import AdminBadge from '@/components/AdminBadge.vue'
import { useUserStore } from '@/stores/user'
import { setPageTitle } from '@/utils/serverScope'
const userStore = useUserStore()
const router = useRouter()
setPageTitle('产品管理')
const isAdmin = computed(() => userStore.isAdmin)
const bannerClass = computed(() => `scope-banner ${isAdmin.value ? 'admin' : 'normal'}`)
const scopeHint = computed(() =>
isAdmin.value
? '管理员视图:可查看和管理全公司产品模板,含跨公司特权模式。'
: '普通组员视图:可查看可用产品列表。',
)
interface TemplateRow {
TemplateUUID?: string
TemplateName?: string
[k: string]: unknown
}
function handleView(row: TemplateRow): void {
router.push({
path: '/inventory/product-detail',
query: { TemplateUUID: row.TemplateUUID || '' },
})
}
</script>
<template>
<div class="product-page">
<AdminBadge />
<div :class="bannerClass">
<i class="fas" :class="isAdmin ? 'fa-shield-alt' : 'fa-info-circle'" />
<span>{{ scopeHint }}</span>
</div>
<CrudTable
table="OrderTemplate"
title="产品管理"
pk-field="TemplateUUID"
searchable
pageable
:selectable="isAdmin"
:on-view="handleView"
/>
</div>
</template>
<style scoped lang="scss">
.product-page {
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.scope-banner {
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
i { font-size: 16px; }
&.admin {
background: rgba(30, 64, 175, 0.08);
color: #1e40af;
border: 1px solid rgba(30, 64, 175, 0.2);
}
&.normal {
background: rgba(100, 116, 139, 0.08);
color: #475569;
border: 1px solid rgba(100, 116, 139, 0.2);
}
}
</style>
+101
View File
@@ -0,0 +1,101 @@
<script setup lang="ts">
/**
* 销售订单
*
* 迁移自 TempLate/GERP/pages/sales/orders.html(156 行)
* 原生实现:tableName=Order,server 作用域 + admin_verify + role_scope 横幅(admin/normal)+
* 非主实例列 fallback + 总是开启行选择 + onView 跳详情
*
* Vue 版(阶段 1 简化):
* - server / admin_verify 由 request.ts 拦截器统一注入
* - 作用域判断暂用 userStore.isAdmin 二分
* - 行选择始终开启(对齐原生 showCheckbox: true)
* - editable 待 CrudTable 暴露 editable prop 后按 isAdmin 控制(TODO)
* - onView 跳 /sales/order-detail?OrderUUID=xxx
*/
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import CrudTable from '@/components/CrudTable.vue'
import AdminBadge from '@/components/AdminBadge.vue'
import { useUserStore } from '@/stores/user'
import { setPageTitle } from '@/utils/serverScope'
const userStore = useUserStore()
const router = useRouter()
setPageTitle('销售订单')
const isAdmin = computed(() => userStore.isAdmin)
const bannerClass = computed(() => `scope-banner ${isAdmin.value ? 'admin' : 'normal'}`)
const scopeHint = computed(() =>
isAdmin.value
? '管理员视图:可查看全公司订单,含跨公司特权模式。'
: '普通组员视图:仅可查看自己的订单。',
)
interface OrderRow {
OrderUUID?: string
OrderNO?: string
[k: string]: unknown
}
function handleView(row: OrderRow): void {
router.push({
path: '/sales/order-detail',
query: { OrderUUID: row.OrderUUID || '' },
})
}
</script>
<template>
<div class="order-page">
<AdminBadge />
<div :class="bannerClass">
<i class="fas" :class="isAdmin ? 'fa-shield-alt' : 'fa-info-circle'" />
<span>{{ scopeHint }}</span>
</div>
<CrudTable
table="Order"
title="销售订单"
pk-field="OrderUUID"
searchable
pageable
selectable
:on-view="handleView"
/>
</div>
</template>
<style scoped lang="scss">
.order-page {
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.scope-banner {
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
i { font-size: 16px; }
&.admin {
background: rgba(30, 64, 175, 0.08);
color: #1e40af;
border: 1px solid rgba(30, 64, 175, 0.2);
}
&.normal {
background: rgba(100, 116, 139, 0.08);
color: #475569;
border: 1px solid rgba(100, 116, 139, 0.2);
}
}
</style>
+453
View File
@@ -0,0 +1,453 @@
<script setup lang="ts">
/**
* 操作审计日志(AuditLog)
*
* 迁移自 TempLate/GERP/pages/system/audit-log.html(287 行)
*
* 原生实现要点:
* - 仅管理员可访问(GERP.CheckAdmin),非管理员显示无权访问
* - 筛选条件:表名 / 操作类型 / 时间范围(CreateTime__gte / CreateTime__lte)
* - 分页查询接口 GET /API/FormAPI/Get/AuditLog,参数 page / page_size / ordering=-CreateTime / admin_verify=1
* - 表格列:时间、表名、操作(add/modify/delete 色彩标签)、目标UUID、IP地址、操作人、详情
* - 详情弹窗:基本信息 + OldData / NewData JSON 展示
*
* Vue 版改动:
* - server / admin_verify 由 request.ts 拦截器统一注入(不再手动拼接 buildApiUrl)
* - 权限校验改用 userStore.isAdmin 前端预拦截(后端仍会校验);非管理员显示无权访问占位
* - 表格 / 分页 / 弹窗改用 Element Plus(el-table / el-pagination / el-dialog)
* - 日期筛选改用 el-date-picker daterange,提交时拆分为 CreateTime__gte / CreateTime__lte
* - 响应格式兼容:后端可能返回数组或 { records, total },统一适配
*/
import { ref, computed, onMounted } from 'vue'
import { get } from '@/api/request'
import { setPageTitle } from '@/utils/serverScope'
import { useUserStore } from '@/stores/user'
import AdminBadge from '@/components/AdminBadge.vue'
setPageTitle('操作审计日志')
const userStore = useUserStore()
/** 是否管理员(前端预拦截,后端仍校验) */
const isAdmin = computed(() => userStore.isAdmin)
/** 无权访问标记:非管理员进入时置 true */
const denied = ref(false)
/** 审计日志记录(对齐后端字段,PascalCase) */
interface AuditLogRow {
AuditLogUUID?: string
CreateTime?: string
TableName?: string
Action?: string
TargetUUID?: string
IPAddress?: string
UserUUID?: string
UserUUID_label?: string
OldData?: string | Record<string, unknown> | null
NewData?: string | Record<string, unknown> | null
[k: string]: unknown
}
/** 表名筛选选项(对齐原生固定列表) */
const tableOptions = ['User', 'Role', 'Permission', 'Tenant', 'Company']
/** 操作类型筛选选项 */
const actionOptions = [
{ value: 'add', label: '新增' },
{ value: 'modify', label: '修改' },
{ value: 'delete', label: '删除' },
]
/** 筛选条件 */
const filterTable = ref('')
const filterAction = ref('')
/** 日期范围 [开始, 结束],el-date-picker daterange 绑定值 */
const filterDateRange = ref<[string, string] | null>(null)
/** 分页状态 */
const currentPage = ref(1)
const pageSize = 20
const total = ref(0)
/** 表格数据与加载态 */
const logList = ref<AuditLogRow[]>([])
const loading = ref(false)
/** 错误信息(非空时展示错误占位) */
const errorMsg = ref('')
/** 详情弹窗 */
const detailVisible = ref(false)
const detailRow = ref<AuditLogRow | null>(null)
/** 操作类型 → 显示文本 */
function actionLabel(action?: string): string {
const map: Record<string, string> = { add: '新增', modify: '修改', delete: '删除' }
return (action && map[action]) || action || '-'
}
/** 操作类型 → el-tag 类型 */
function actionTagType(action?: string): 'success' | 'warning' | 'danger' | 'info' {
const map: Record<string, 'success' | 'warning' | 'danger'> = {
add: 'success',
modify: 'warning',
delete: 'danger',
}
return (action && map[action]) || 'info'
}
/** 时间格式化:去掉 T,截取到秒 */
function formatTime(t?: string): string {
if (!t) return '-'
return String(t).replace('T', ' ').substring(0, 19)
}
/** 安全 JSON 解析:字符串解析失败时原样返回 */
function safeJsonParse(str: unknown): unknown {
if (typeof str !== 'string') return str
try {
return JSON.parse(str)
} catch {
return str
}
}
/** 将 OldData / NewData 规范化为可序列化对象 */
function normalizeData(data: unknown): Record<string, unknown> | null {
if (!data) return null
if (typeof data === 'string') {
const parsed = safeJsonParse(data)
return typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : null
}
return typeof data === 'object' ? (data as Record<string, unknown>) : null
}
/** 详情弹窗:变更前数据 */
const detailOldData = computed(() => normalizeData(detailRow.value?.OldData))
/** 详情弹窗:变更后数据 */
const detailNewData = computed(() => normalizeData(detailRow.value?.NewData))
/** 构造查询参数(对齐原生 buildFilters) */
function buildQuery(): Record<string, unknown> {
const params: Record<string, unknown> = {
page: currentPage.value,
page_size: pageSize,
ordering: '-CreateTime',
}
if (filterTable.value) params.TableName = filterTable.value
if (filterAction.value) params.Action = filterAction.value
if (filterDateRange.value && filterDateRange.value.length === 2) {
params.CreateTime__gte = filterDateRange.value[0] + ' 00:00:00'
params.CreateTime__lte = filterDateRange.value[1] + ' 23:59:59'
}
return params
}
/**
* 加载审计日志
* 注:admin_verify 由 request.ts 拦截器按 URL query 自动注入,此处不再手动拼接
* 响应拦截器返回 result.data,兼容数组或 { records, total } 两种结构
*/
async function loadAuditLogs(): Promise<void> {
loading.value = true
errorMsg.value = ''
try {
// 拦截器返回 result.data;total 可能在 data 内或顶层(此处通过 as any 兼容读取)
const data = await get<AuditLogRow[] | { records: AuditLogRow[]; total?: number } | null>(
'/API/FormAPI/Get/AuditLog',
buildQuery(),
)
// 兼容数组 / { records, total } 两种结构
if (Array.isArray(data)) {
logList.value = data
total.value = data.length
} else if (data && Array.isArray((data as { records?: AuditLogRow[] }).records)) {
logList.value = (data as { records: AuditLogRow[] }).records
total.value = (data as { total?: number }).total ?? logList.value.length
} else {
logList.value = []
total.value = 0
}
} catch (err) {
// 业务错误拦截器已弹提示,此处仅展示占位
errorMsg.value = '加载审计日志失败:' + (err instanceof Error ? err.message : String(err))
logList.value = []
total.value = 0
} finally {
loading.value = false
}
}
/** 点击查询:重置到第一页 */
function onFilter(): void {
currentPage.value = 1
loadAuditLogs()
}
/** 重置筛选条件 */
function onResetFilter(): void {
filterTable.value = ''
filterAction.value = ''
filterDateRange.value = null
currentPage.value = 1
loadAuditLogs()
}
/** 分页变化 */
function onPageChange(page: number): void {
currentPage.value = page
loadAuditLogs()
}
/** 打开详情弹窗 */
function showDetail(row: AuditLogRow): void {
detailRow.value = row
detailVisible.value = true
}
/** 详情 JSON 文本(美化输出) */
function prettyJson(data: unknown): string {
if (!data) return ''
try {
return JSON.stringify(data, null, 2)
} catch {
return String(data)
}
}
onMounted(() => {
// 非管理员直接拦截,展示无权访问
if (!isAdmin.value) {
denied.value = true
return
}
loadAuditLogs()
})
</script>
<template>
<div class="audit-log-page">
<!-- 管理员特权横幅(自动判断 server / admin_verify) -->
<AdminBadge />
<!-- 无权访问占位 -->
<el-empty
v-if="denied"
description="无权访问:该页面仅管理员可查看,请切换至管理员角色或联系管理员"
>
<template #image>
<i class="fas fa-lock" style="font-size: 48px; color: var(--el-color-danger)" />
</template>
</el-empty>
<template v-else>
<!-- 页头 -->
<div class="audit-log-header">
<h1 class="page-title">
<i class="fas fa-history icon-primary" />
<span>操作审计日志</span>
</h1>
<el-button :icon="'Refresh'" @click="loadAuditLogs">刷新</el-button>
</div>
<!-- 筛选条件 -->
<div class="audit-log-filters">
<span class="filter-label">表名:</span>
<el-select v-model="filterTable" placeholder="全部" clearable style="width: 140px">
<el-option v-for="t in tableOptions" :key="t" :label="t" :value="t" />
</el-select>
<span class="filter-label">操作:</span>
<el-select v-model="filterAction" placeholder="全部" clearable style="width: 120px">
<el-option v-for="a in actionOptions" :key="a.value" :label="a.label" :value="a.value" />
</el-select>
<span class="filter-label">时间范围:</span>
<el-date-picker
v-model="filterDateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 280px"
/>
<el-button type="primary" :icon="'Search'" @click="onFilter">查询</el-button>
<el-button :icon="'RefreshLeft'" @click="onResetFilter">重置</el-button>
</div>
<!-- 表格 -->
<el-table
v-loading="loading"
:data="logList"
border
stripe
style="width: 100%"
class="audit-log-table"
>
<el-table-column label="时间" width="170">
<template #default="{ row }">{{ formatTime(row.CreateTime) }}</template>
</el-table-column>
<el-table-column prop="TableName" label="表名" width="120" />
<el-table-column label="操作" width="90">
<template #default="{ row }">
<el-tag :type="actionTagType(row.Action)" size="small">{{ actionLabel(row.Action) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="目标UUID" width="190">
<template #default="{ row }">
<code class="mono">{{ row.TargetUUID || '-' }}</code>
</template>
</el-table-column>
<el-table-column label="IP地址" width="150">
<template #default="{ row }">
<code class="mono">{{ row.IPAddress || '-' }}</code>
</template>
</el-table-column>
<el-table-column label="操作人" min-width="140">
<template #default="{ row }">{{ row.UserUUID_label || row.UserUUID || '-' }}</template>
</el-table-column>
<el-table-column label="详情" width="90" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="primary" @click="showDetail(row)">
<i class="fas fa-eye" /> 详情
</el-button>
</template>
</el-table-column>
<!-- 空数据 / 错误占位 -->
<template #empty>
<div v-if="errorMsg" style="color: var(--el-color-danger); padding: 20px">
<i class="fas fa-exclamation-circle" /> {{ errorMsg }}
</div>
<el-empty v-else description="暂无审计日志" />
</template>
</el-table>
<!-- 分页 -->
<div class="audit-log-pagination">
<el-pagination
background
layout="total, prev, pager, next"
:total="total"
:page-size="pageSize"
:current-page="currentPage"
@current-change="onPageChange"
/>
</div>
</template>
<!-- 详情弹窗 -->
<el-dialog v-model="detailVisible" title="审计日志详情" width="800px" top="6vh">
<template v-if="detailRow">
<div class="detail-section">
<h4>基本信息</h4>
<div class="detail-base">
<div><strong>时间:</strong> {{ formatTime(detailRow.CreateTime) }}</div>
<div><strong>表名:</strong> {{ detailRow.TableName || '' }}</div>
<div>
<strong>操作:</strong>
<el-tag :type="actionTagType(detailRow.Action)" size="small">{{ actionLabel(detailRow.Action) }}</el-tag>
</div>
<div><strong>目标UUID:</strong> <code class="mono">{{ detailRow.TargetUUID || '-' }}</code></div>
<div><strong>IP地址:</strong> <code class="mono">{{ detailRow.IPAddress || '-' }}</code></div>
<div><strong>操作人:</strong> {{ detailRow.UserUUID_label || detailRow.UserUUID || '-' }}</div>
</div>
</div>
<div v-if="detailOldData" class="detail-section">
<h4>变更前数据 (OldData)</h4>
<pre class="detail-json">{{ prettyJson(detailOldData) }}</pre>
</div>
<div v-if="detailNewData" class="detail-section">
<h4>变更后数据 (NewData)</h4>
<pre class="detail-json">{{ prettyJson(detailNewData) }}</pre>
</div>
</template>
</el-dialog>
</div>
</template>
<style scoped lang="scss">
.audit-log-page {
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.audit-log-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
.page-title {
margin: 0;
flex: 1;
font-size: 20px;
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
.icon-primary {
color: var(--el-color-primary);
}
}
}
.audit-log-filters {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 12px;
.filter-label {
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
.audit-log-table {
flex: 1;
min-height: 0;
.mono {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
}
}
.audit-log-pagination {
display: flex;
justify-content: center;
margin-top: 12px;
}
.detail-section {
margin-bottom: 16px;
h4 {
margin: 0 0 8px;
font-size: 13px;
color: var(--el-text-color-secondary);
font-weight: 600;
}
.detail-base {
font-size: 13px;
line-height: 1.9;
}
.detail-json {
background: var(--el-fill-color-light);
border: 1px solid var(--el-border-color);
border-radius: 6px;
padding: 12px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
white-space: pre-wrap;
word-break: break-all;
max-height: 240px;
overflow: auto;
margin: 0;
}
}
</style>
+263
View File
@@ -0,0 +1,263 @@
<script setup lang="ts">
/**
* 数据库管理(Database)
*
* 迁移自 TempLate/GERP/pages/system/database.html(166 行)
*
* 原生实现要点:
* - 仅管理员可访问(GERP.CheckAdmin),非管理员显示无权访问
* - 主实例(默认服务器):GET /API/FormAPI/Tables 返回全部表,按 type=static/dynamic 分两组展示
* - 子服务器:GET /api/gerp/msync/servers/{server}/form/?Action=tables 返回该服务器表列表
* - 表项字段:name / title / type(static|dynamic) / count(记录数)
* - 点击"查看"跳转 dbtable.html?server&table&title
*
* Vue 版改动:
* - server / admin_verify 由 request.ts 拦截器统一注入(主实例 FormAPI 路径自动注入)
* - 权限校验改用 userStore.isAdmin 前端预拦截;非管理员显示无权访问占位
* - 子服务器表列表接口路径已含 serverName,admin_verify 仍由拦截器按 URL query 注入
* - 跳转改用 vue-router:/system/dbtable?table=xxx&title=xxx(子服务器追加 server)
* - 返回服务器列表改用 router.push(替代 GERP.Navigate)
*/
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { get } from '@/api/request'
import { setPageTitle, getServer, isDefaultServer } from '@/utils/serverScope'
import { useUserStore } from '@/stores/user'
import AdminBadge from '@/components/AdminBadge.vue'
const router = useRouter()
const userStore = useUserStore()
const isAdmin = computed(() => userStore.isAdmin)
const denied = ref(false)
/** 服务器标识(主实例为 gvsds-master) */
const serverName = getServer()
/** 是否主实例 */
const isMain = isDefaultServer()
/** 表项(对齐后端字段) */
interface TableItem {
name: string
title?: string
type?: 'static' | 'dynamic'
count?: number
}
const loading = ref(false)
/** 静态表列表(仅主实例有分组) */
const staticTables = ref<TableItem[]>([])
/** 系统表 / 子服务器数据表列表 */
const dynamicTables = ref<TableItem[]>([])
/** 页面标题 */
const pageTitle = computed(() => (isMain ? '数据库管理' : `${serverName} — 数据库管理`))
setPageTitle(pageTitle.value)
/** 加载主实例表列表:GET /API/FormAPI/Tables */
async function loadMainTables(): Promise<void> {
loading.value = true
try {
const data = await get<TableItem[]>('/API/FormAPI/Tables')
const tables = Array.isArray(data) ? data : []
staticTables.value = tables.filter((t) => t.type === 'static')
dynamicTables.value = tables.filter((t) => t.type === 'dynamic')
} catch {
/* 拦截器已弹错误 */
} finally {
loading.value = false
}
}
/** 加载子服务器表列表:GET /api/gerp/msync/servers/{server}/form/?Action=tables */
async function loadSubTables(): Promise<void> {
loading.value = true
try {
const data = await get<TableItem[]>(`/api/gerp/msync/servers/${encodeURIComponent(serverName)}/form/`, {
Action: 'tables',
})
dynamicTables.value = Array.isArray(data) ? data : []
} catch {
dynamicTables.value = []
} finally {
loading.value = false
}
}
/** 查看表:跳转 DbTable 页面 */
function viewTable(table: TableItem): void {
const query: Record<string, string> = {
table: table.name,
title: table.title || table.name,
}
if (!isMain) query.server = serverName
router.push({ path: '/system/dbtable', query })
}
/** 返回服务器列表 */
function goBack(): void {
router.push({ path: '/system/msync-servers' })
}
/** 表项展示文本:标题与名称 */
function tableLabel(table: TableItem): string {
return table.title && table.title !== table.name ? `${table.title}(${table.name})` : table.name
}
/** 表项记录数 / 类型描述 */
function tableMeta(table: TableItem): string {
if (typeof table.count === 'number' && table.count >= 0) return `${table.count} 条记录`
return table.type === 'dynamic' ? '系统表' : '自定义表'
}
onMounted(() => {
if (!isAdmin.value) {
denied.value = true
return
}
if (isMain) {
loadMainTables()
} else {
loadSubTables()
}
})
</script>
<template>
<div class="db-page">
<AdminBadge />
<el-empty
v-if="denied"
description="无权访问:该页面仅管理员可查看,请切换至管理员角色或联系管理员"
>
<template #image>
<i class="fas fa-lock" style="font-size: 48px; color: var(--el-color-danger)" />
</template>
</el-empty>
<template v-else>
<!-- 子服务器:返回服务器列表 -->
<div v-if="!isMain" class="db-back" @click="goBack">
<i class="fas fa-arrow-left" /> <span>返回服务器列表</span>
</div>
<!-- 页头 -->
<div class="db-header">
<h1 class="page-title">
<i class="fas fa-database icon-primary" />
<span>{{ pageTitle }}</span>
</h1>
</div>
<!-- 静态表分组(仅主实例) -->
<div v-if="isMain" class="db-section">
<h3>静态表</h3>
<div v-loading="loading" class="db-list">
<div v-for="t in staticTables" :key="t.name" class="db-table-item">
<span class="db-table-name">{{ tableLabel(t) }}</span>
<span class="db-table-meta">{{ tableMeta(t) }}</span>
<el-button size="small" type="primary" @click="viewTable(t)">
<i class="fas fa-table" /> 查看
</el-button>
</div>
<el-empty v-if="!loading && staticTables.length === 0" description="暂无数据" :image-size="60" />
</div>
</div>
<!-- 系统表 / 子服务器数据表分组 -->
<div class="db-section">
<h3>{{ isMain ? '系统表' : '数据表' }}</h3>
<div v-loading="loading" class="db-list">
<div v-for="t in dynamicTables" :key="t.name" class="db-table-item">
<span class="db-table-name">{{ tableLabel(t) }}</span>
<span class="db-table-meta">{{ tableMeta(t) }}</span>
<el-button size="small" type="primary" @click="viewTable(t)">
<i class="fas fa-table" /> 查看
</el-button>
</div>
<el-empty v-if="!loading && dynamicTables.length === 0" description="暂无数据表" :image-size="60" />
</div>
</div>
</template>
</div>
</template>
<style scoped lang="scss">
.db-page {
padding: 20px;
height: 100%;
box-sizing: border-box;
}
.db-back {
cursor: pointer;
color: var(--el-color-primary);
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 14px;
margin-bottom: 16px;
&:hover {
text-decoration: underline;
}
}
.db-header {
margin-bottom: 20px;
.page-title {
margin: 0;
font-size: 20px;
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
.icon-primary {
color: var(--el-color-primary);
}
}
}
.db-section {
margin-bottom: 30px;
h3 {
margin-bottom: 10px;
font-size: 16px;
}
}
.db-list {
min-height: 60px;
}
.db-table-item {
display: flex;
align-items: center;
padding: 12px 16px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color);
border-radius: 8px;
margin-bottom: 8px;
transition: all 0.2s;
&:hover {
border-color: var(--el-color-primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.db-table-name {
flex: 1;
font-weight: 500;
}
.db-table-meta {
margin-right: 16px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
</style>
+201
View File
@@ -0,0 +1,201 @@
<script setup lang="ts">
/**
* 表结构管理(DbTable)
*
* 迁移自 TempLate/GERP/pages/system/dbtable.html(100 行)
*
* 原生实现要点:
* - 仅管理员可访问(GERP.CheckAdmin),非管理员显示无权访问
* - 从 URL query 读取 server / table / title 参数
* - 核心依赖原生全局对象 DbTableHelper.init(),支持两种 API 模式:
* - formapi(主实例):直接走 /API/FormAPI/... 通用表接口
* - subproxy(子服务器):走 /api/gerp/msync/servers/{server}/form/ 代理接口
* - 返回按钮跳回 database.html?server=xxx
*
* Vue 版改动(先搭骨架,标 TODO):
* - server / admin_verify 由 request.ts 拦截器统一注入
* - 权限校验改用 userStore.isAdmin 前端预拦截;非管理员显示无权访问占位
* - 参数改从 vue-router useRoute 获取(替代 URLSearchParams)
* - 主实例暂用 CrudTable 提供基本表数据浏览/编辑能力(table=tableName)
* - 子服务器 subproxy 模式 CrudTable 暂不支持,标 TODO 待 DbTableHelper 迁移
* - TODO: 完整迁移 DbTableHelper(列定义动态渲染、子服务器代理、表结构编辑等高级能力)
*/
import { computed, ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { setPageTitle, getServer, isDefaultServer } from '@/utils/serverScope'
import { useUserStore } from '@/stores/user'
import AdminBadge from '@/components/AdminBadge.vue'
import CrudTable from '@/components/CrudTable.vue'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const isAdmin = computed(() => userStore.isAdmin)
const denied = ref(false)
/** 服务器标识(主实例为 gvsds-master) */
const serverName = getServer()
/** 是否主实例 */
const isMain = isDefaultServer()
/** 表名(路由 query) */
const tableName = (route.query.table as string) || ''
/** 表标题(路由 query,缺省回退表名) */
const tableTitle = (route.query.title as string) || tableName
/** 页面标题 */
const pageTitle = computed(() => (isMain ? tableTitle : `${serverName} — ${tableTitle}`))
setPageTitle(pageTitle.value)
/** 返回数据库管理页 */
function goBack(): void {
const query: Record<string, string> = {}
if (!isMain) query.server = serverName
router.push({ path: '/system/database', query })
}
onMounted(() => {
// 非管理员直接拦截,展示无权访问
if (!isAdmin.value) {
denied.value = true
}
})
</script>
<template>
<div class="dbtable-page">
<AdminBadge />
<el-empty
v-if="denied"
description="无权访问:该页面仅管理员可查看,请切换至管理员角色或联系管理员"
>
<template #image>
<i class="fas fa-lock" style="font-size: 48px; color: var(--el-color-danger)" />
</template>
</el-empty>
<el-empty
v-else-if="!tableName"
description="未指定表名"
>
<template #image>
<i class="fas fa-table" style="font-size: 48px; opacity: 0.3" />
</template>
</el-empty>
<template v-else>
<!-- 页头 -->
<div class="dbtable-header">
<div class="dbtable-back" @click="goBack">
<i class="fas fa-arrow-left" /> 返回数据库
</div>
<h1 class="page-title">
<i class="fas fa-table icon-primary" />
<span>{{ pageTitle }}</span>
</h1>
</div>
<!-- 表信息 -->
<div class="dbtable-info">
<span><i class="fas fa-server" /> 服务器: {{ isMain ? '主实例' : serverName }}</span>
<span><i class="fas fa-table" /> 表名: {{ tableName }}</span>
</div>
<!-- 表数据区域 -->
<div class="dbtable-container">
<!--
主实例:暂用 CrudTable 提供基本表数据浏览/编辑能力
TODO: 完整迁移 DbTableHelper 高级能力(动态列定义、表结构编辑、数据导入导出等)
-->
<CrudTable
v-if="isMain"
:table="tableName"
:title="tableTitle"
searchable
pageable
selectable
/>
<!--
子服务器 subproxy 模式:CrudTable 暂不支持代理路径
TODO: 待迁移 DbTableHelper subproxy 模式(/api/gerp/msync/servers/{server}/form/)
-->
<el-alert
v-else
type="warning"
:closable="false"
show-icon
title="子服务器表浏览待迁移"
description="子服务器 subproxy 代理模式(/api/gerp/msync/servers/{server}/form/)暂未迁移,待 DbTableHelper 完整迁移后接入。"
/>
</div>
</template>
</div>
</template>
<style scoped lang="scss">
.dbtable-page {
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.dbtable-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
.dbtable-back {
cursor: pointer;
color: var(--el-color-primary);
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
&:hover {
text-decoration: underline;
}
}
.page-title {
margin: 0;
flex: 1;
font-size: 20px;
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
.icon-primary {
color: var(--el-color-primary);
}
}
}
.dbtable-info {
font-size: 13px;
color: var(--el-text-color-secondary);
margin-bottom: 12px;
display: flex;
gap: 16px;
flex-wrap: wrap;
span {
display: flex;
align-items: center;
gap: 4px;
}
}
.dbtable-container {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
</style>
File diff suppressed because it is too large. Load diff
+794
View File
@@ -0,0 +1,794 @@
<script setup lang="ts">
/**
* 站内信
*
* 迁移自 TempLate/GERP/pages/system/message.html(高频页面)
*
* 原生业务逻辑:
* - 顶部 Tab 筛选:全部 / 未读(带角标)/ 系统通知(0) / 审核通知(1) / 工单通知(2) / 个人消息(3)
* - 工具栏:发送消息(弹窗,含收件人搜索)、全部已读、刷新
* - 列表:每条消息卡片(类型图标、标题、内容预览、发送人、类型、时间、未读高亮、悬停删除)
* - 分页:PageSize=20
* - 详情弹窗:标题、发送人、类型、时间、内容;个人消息且发送人非系统时支持回复;底部删除
* - 发送弹窗:收件人搜索(用户/角色,防抖 300ms)、标题、内容;MsgType 固定 3(个人消息)
* - URL query id=xxx 时自动打开该消息详情
*
* 接口协议(非 FormAPI,使用 /API/Message 自定义接口):
* - GET /API/Message?action=list&PageIndex=&PageSize=&IsRead=&MsgType= → { code, data:[...], total }
* - GET /API/Message?action=detail&MessageUUID= → { code, data: msg }
* - GET /API/Message?action=unread_count → { code, UnreadCount } ⚠ 顶层字段
* - GET /API/Message?action=search_recipients&keyword=&limit= → { code, data:[{type,uuid,name,label}] }
* - DELETE /API/Message body:{MessageUUID} → { code }
* - PATCH /API/Message body:action=read_all (form-urlencoded) → { code }
* - POST /API/Message body:ReceiverUUID/ReceiverType/Title/Content/MsgType (form-urlencoded) → { code }
*
* 关键迁移取舍:
* - Message 接口为自定义协议(非 FormAPI),且核心功能(Tab 筛选/未读角标/收件人搜索/发送/回复)
* 均非 CrudTable 能覆盖,故不套用 CrudTable,自建列表 + el-dialog 详情/发送,完整保留原生能力。
* - server / admin_verify 由 request.ts 请求拦截器统一注入,本文件不手动拼接。
* - ⚠ 全局响应拦截器在 code===0 时仅返回 result.data,会剥离 result.total / result.UnreadCount
* 顶层字段。因此 list / unread_count / detail / search_recipients 改用 rawGet(responseType:'text'
* 绕过剥离,自行 JSON.parse 取完整响应体)。写操作(DELETE/PATCH/POST)不需要顶层字段,仍用标准封装。
* - 消息类型图标沿用原生 FontAwesome(项目已全局引入 fa)。
*/
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import request, { del, patch, post } from '@/api/request'
import { setPageTitle } from '@/utils/serverScope'
import type { AxiosResponse } from 'axios'
// === 类型定义 ===
/** 消息类型:0 系统通知 / 1 审核通知 / 2 工单通知 / 3 个人消息 */
type MsgType = 0 | 1 | 2 | 3
/** 消息记录 */
interface MessageItem {
MessageUUID: string
Title: string
Content: string
SenderName: string
SenderUUID: string
MsgType: MsgType
IsRead: 0 | 1
CreateTime: string
}
/** 收件人搜索结果项 */
interface Recipient {
type: 'user' | 'role'
uuid: string
name: string
label: string
}
/** 后端原始响应体(含顶层 total / UnreadCount) */
interface RawResult<T = unknown> {
code: number
msg?: string
data?: T
total?: number
UnreadCount?: number
}
// === 常量 ===
/** 消息类型 → FontAwesome 图标 */
const MSG_TYPE_ICONS: Record<MsgType, string> = {
0: 'fa-info-circle',
1: 'fa-clipboard-check',
2: 'fa-tools',
3: 'fa-envelope',
}
/** 消息类型 → 中文名 */
const MSG_TYPE_NAMES: Record<MsgType, string> = {
0: '系统通知',
1: '审核通知',
2: '工单通知',
3: '个人消息',
}
/** 消息类型 → 图标背景/前景色(对齐原生 type-0..3 配色) */
const MSG_TYPE_COLORS: Record<MsgType, { bg: string; color: string }> = {
0: { bg: 'rgba(37,99,235,0.1)', color: '#2563eb' },
1: { bg: 'rgba(34,197,94,0.1)', color: '#16a34a' },
2: { bg: 'rgba(234,179,8,0.1)', color: '#ca8a04' },
3: { bg: 'rgba(139,92,246,0.1)', color: '#8b5cf6' },
}
/** Tab 筛选项配置 */
const TABS: Array<{ name: string; label: string }> = [
{ name: 'all', label: '全部' },
{ name: 'unread', label: '未读' },
{ name: '0', label: '系统通知' },
{ name: '1', label: '审核通知' },
{ name: '2', label: '工单通知' },
{ name: '3', label: '个人消息' },
]
const PAGE_SIZE = 20
// === 状态 ===
const route = useRoute()
/** 当前筛选 tab(all / unread / 0 / 1 / 2 / 3) */
const currentFilter = ref<string>('all')
/** 当前页码(从 1 开始) */
const currentPage = ref(1)
/** 消息列表 */
const list = ref<MessageItem[]>([])
/** 总条数(来自后端 total 顶层字段) */
const total = ref(0)
/** 未读数(来自后端 UnreadCount 顶层字段,驱动未读 tab 角标) */
const unreadCount = ref(0)
/** 列表加载态 */
const loading = ref(false)
/** 详情弹窗可见 */
const detailVisible = ref(false)
/** 详情数据 */
const detailData = ref<MessageItem | null>(null)
/** 发送弹窗可见 */
const composeVisible = ref(false)
/** 发送表单 */
const composeForm = reactive<{ title: string; content: string }>({ title: '', content: '' })
/** 已选收件人 */
const selectedRecipient = ref<Recipient | null>(null)
/** 收件人搜索关键字 */
const recipientKeyword = ref('')
/** 收件人搜索下拉候选 */
const recipientDropdown = ref<Recipient[]>([])
/** 收件人下拉是否可见 */
const recipientDropdownVisible = ref(false)
/** 发送提交中 */
const sending = ref(false)
/** 收件人搜索防抖计时器 */
let searchTimer: ReturnType<typeof setTimeout> | null = null
// === rawGet:绕过全局响应拦截器的剥离,取完整响应体 ===
// 说明:拦截器对含 code 字段的响应在 code===0 时仅返回 result.data,会丢失 result.total /
// result.UnreadCount 等顶层字段。此处用 responseType:'text' 让拦截器按"非业务响应"放行,
// 再自行 JSON.parse 得到完整 { code, data, total, UnreadCount }。
async function rawGet<T = unknown>(url: string, params?: Record<string, unknown>): Promise<RawResult<T>> {
const resp = (await request.get(url, { params, responseType: 'text' })) as unknown as AxiosResponse<string>
return JSON.parse(resp.data) as RawResult<T>
}
// === 数据加载 ===
/** 加载消息列表(按 currentFilter / currentPage 拼参) */
async function loadList(): Promise<void> {
loading.value = true
try {
const params: Record<string, unknown> = {
action: 'list',
PageIndex: currentPage.value,
PageSize: PAGE_SIZE,
}
if (currentFilter.value === 'unread') {
params.IsRead = 0
} else if (currentFilter.value !== 'all') {
params.MsgType = currentFilter.value
}
const result = await rawGet<MessageItem[]>('/API/Message', params)
if (result.code === 0) {
list.value = result.data || []
total.value = result.total || 0
} else {
list.value = []
total.value = 0
}
} catch {
list.value = []
total.value = 0
} finally {
loading.value = false
}
}
/** 加载未读数(驱动未读 tab 角标) */
async function loadUnread(): Promise<void> {
try {
const result = await rawGet('/API/Message', { action: 'unread_count' })
if (result.code === 0) {
unreadCount.value = result.UnreadCount || 0
}
} catch {
// 未读数加载失败静默处理,不阻断主流程
}
}
// === Tab / 分页 ===
/** Tab 切换:重置页码并重新加载 */
function handleTabChange(name: string | number): void {
currentFilter.value = String(name)
currentPage.value = 1
loadList()
}
/** 分页变化 */
function handlePageChange(page: number): void {
currentPage.value = page
loadList()
}
// === 详情 ===
/** 打开消息详情(同时触发后端标记已读,再刷新列表与未读数) */
async function showDetail(uuid: string): Promise<void> {
try {
const result = await rawGet<MessageItem>('/API/Message', {
action: 'detail',
MessageUUID: uuid,
})
if (result.code === 0 && result.data) {
detailData.value = result.data
detailVisible.value = true
// 详情接口会标记已读,刷新列表与未读角标
loadList()
loadUnread()
}
} catch {
// 详情加载失败静默处理
}
}
/** 关闭详情弹窗 */
function closeDetail(): void {
detailVisible.value = false
detailData.value = null
}
// === 删除 ===
/** 删除消息(带二次确认;fromDetail=true 时同步关闭详情弹窗) */
async function deleteMsg(uuid: string, fromDetail = false): Promise<void> {
try {
await ElMessageBox.confirm('确定要删除这条消息吗?此操作不可撤销。', '删除消息', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
} catch {
return // 用户取消
}
try {
// DELETE + JSON body:通过 config.data 传递 MessageUUID
await del('/API/Message', undefined, { data: { MessageUUID: uuid } })
ElMessage.success('已删除')
if (fromDetail) closeDetail()
loadList()
loadUnread()
} catch {
// 业务错误已由拦截器统一提示
}
}
// === 全部已读 ===
/** 全部标记已读(PATCH + form-urlencoded body: action=read_all) */
async function markAllRead(): Promise<void> {
try {
await patch('/API/Message', 'action=read_all', {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
ElMessage.success('已全部标记为已读')
loadList()
loadUnread()
} catch {
// 业务错误已由拦截器统一提示
}
}
// === 发送消息 ===
/** 打开发送弹窗(清空表单与收件人) */
function openCompose(): void {
selectedRecipient.value = null
recipientKeyword.value = ''
composeForm.title = ''
composeForm.content = ''
recipientDropdown.value = []
recipientDropdownVisible.value = false
composeVisible.value = true
}
/** 从详情回复:预填收件人(仅个人消息且发送人非系统时调用) */
function openComposeTo(uuid: string, name: string): void {
openCompose()
selectedRecipient.value = { type: 'user', uuid, name, label: name }
recipientKeyword.value = name
}
/** 收件人输入:300ms 防抖后搜索 */
function onRecipientInput(val: string): void {
if (searchTimer) clearTimeout(searchTimer)
if (!val) {
recipientDropdown.value = []
recipientDropdownVisible.value = false
return
}
searchTimer = setTimeout(() => {
void searchRecipients(val)
}, 300)
}
/** 搜索收件人(用户/角色) */
async function searchRecipients(keyword: string): Promise<void> {
try {
const result = await rawGet<Recipient[]>('/API/Message', {
action: 'search_recipients',
keyword,
limit: 10,
})
if (result.code === 0 && result.data) {
recipientDropdown.value = result.data
recipientDropdownVisible.value = result.data.length > 0
} else {
recipientDropdown.value = []
recipientDropdownVisible.value = false
}
} catch {
recipientDropdown.value = []
recipientDropdownVisible.value = false
}
}
/** 选中收件人 */
function selectRecipient(item: Recipient): void {
selectedRecipient.value = item
recipientKeyword.value = item.name
recipientDropdownVisible.value = false
}
/** 移除已选收件人 */
function removeRecipient(): void {
selectedRecipient.value = null
recipientKeyword.value = ''
}
/** 发送消息(form-urlencoded;MsgType 固定 3=个人消息) */
async function sendMessage(): Promise<void> {
if (!selectedRecipient.value) {
ElMessage.warning('请选择收件人')
return
}
if (!composeForm.title.trim()) {
ElMessage.warning('请输入标题')
return
}
const payload = {
ReceiverUUID: selectedRecipient.value.uuid,
ReceiverType: selectedRecipient.value.type,
Title: composeForm.title.trim(),
Content: composeForm.content.trim(),
MsgType: '3',
}
sending.value = true
try {
await post('/API/Message', new URLSearchParams(payload).toString(), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
ElMessage.success('发送成功')
composeVisible.value = false
// 个人消息 tab 或全部 tab 下刷新列表
if (currentFilter.value === '3' || currentFilter.value === 'all') {
loadList()
}
} catch {
// 业务错误已由拦截器统一提示
} finally {
sending.value = false
}
}
// === 生命周期 ===
onMounted(() => {
setPageTitle('站内信')
loadList()
loadUnread()
// URL query id=xxx 时自动打开该消息详情
const id = route.query.id as string | undefined
if (id) showDetail(id)
})
onBeforeUnmount(() => {
if (searchTimer) clearTimeout(searchTimer)
})
</script>
<template>
<div class="message-page" v-loading="loading">
<h1 class="page-title">
<i class="fas fa-envelope icon-primary" />站内信
</h1>
<!-- 筛选 Tab -->
<el-tabs v-model="currentFilter" class="msg-tabs" @tab-change="handleTabChange">
<el-tab-pane v-for="tab in TABS" :key="tab.name" :name="tab.name">
<template #label>
<span v-if="tab.name === 'unread'">
{{ tab.label }}
<el-badge v-if="unreadCount > 0" :value="unreadCount" class="unread-badge" />
</span>
<span v-else>{{ tab.label }}</span>
</template>
</el-tab-pane>
</el-tabs>
<!-- 工具栏 -->
<div class="msg-toolbar">
<el-button type="primary" size="small" @click="openCompose">
<i class="fas fa-paper-plane" /> 发送消息
</el-button>
<el-button size="small" @click="markAllRead">
<i class="fas fa-check-double" /> 全部已读
</el-button>
<el-button size="small" @click="loadList">
<i class="fas fa-sync-alt" /> 刷新
</el-button>
</div>
<!-- 消息列表 -->
<div class="msg-list">
<el-empty v-if="!loading && list.length === 0" description="暂无消息" :image-size="60" />
<div
v-for="msg in list"
:key="msg.MessageUUID"
class="msg-item"
:class="{ unread: msg.IsRead === 0 }"
@click="showDetail(msg.MessageUUID)"
>
<!-- 类型图标 -->
<div
class="msg-item-icon"
:style="{ background: MSG_TYPE_COLORS[msg.MsgType].bg, color: MSG_TYPE_COLORS[msg.MsgType].color }"
>
<i class="fas" :class="MSG_TYPE_ICONS[msg.MsgType]" />
</div>
<!-- 主体 -->
<div class="msg-item-body">
<div class="msg-item-title">{{ msg.Title || '(无标题)' }}</div>
<div class="msg-item-content">{{ msg.Content || '' }}</div>
<div class="msg-item-meta">
<span><i class="fas fa-user" /> {{ msg.SenderName || '系统' }}</span>
<span>{{ MSG_TYPE_NAMES[msg.MsgType] || '消息' }}</span>
<span>{{ msg.CreateTime || '' }}</span>
</div>
</div>
<!-- 悬停删除 -->
<div class="msg-item-actions">
<el-button
circle
size="small"
type="danger"
title="删除"
@click.stop="deleteMsg(msg.MessageUUID)"
>
<i class="fas fa-trash-alt" />
</el-button>
</div>
</div>
</div>
<!-- 分页 -->
<div v-if="total > PAGE_SIZE" class="msg-pagination">
<el-pagination
:current-page="currentPage"
:page-size="PAGE_SIZE"
:total="total"
layout="prev, pager, next"
background
@current-change="handlePageChange"
/>
</div>
<!-- 详情弹窗 -->
<el-dialog v-model="detailVisible" width="600px" :show-close="true" @close="closeDetail">
<template #header>
<div class="detail-title">
<i class="fas" :class="MSG_TYPE_ICONS[detailData?.MsgType ?? 3]" />
<span>{{ detailData?.Title || '(无标题)' }}</span>
</div>
</template>
<div v-if="detailData" class="detail-body">
<div class="detail-meta">
<span><i class="fas fa-user" /> {{ detailData.SenderName || '系统' }}</span>
<span><i class="fas fa-tag" /> {{ MSG_TYPE_NAMES[detailData.MsgType] || '消息' }}</span>
<span><i class="fas fa-clock" /> {{ detailData.CreateTime || '' }}</span>
</div>
<div class="detail-content">{{ detailData.Content || '(无内容)' }}</div>
</div>
<template #footer>
<div class="detail-footer">
<!-- 个人消息且发送人非系统:回复按钮 -->
<el-button
v-if="detailData && detailData.MsgType === 3 && detailData.SenderName && detailData.SenderName !== '系统'"
type="primary"
@click="closeDetail(); openComposeTo(detailData.SenderUUID || '', detailData.SenderName || '')"
>
<i class="fas fa-reply" /> 回复
</el-button>
<el-button type="danger" @click="detailData && deleteMsg(detailData.MessageUUID, true)">
<i class="fas fa-trash-alt" /> 删除
</el-button>
</div>
</template>
</el-dialog>
<!-- 发送弹窗 -->
<el-dialog v-model="composeVisible" title="发送消息" width="560px">
<el-form label-position="top">
<!-- 收件人搜索 -->
<el-form-item label="收件人" required>
<div class="recipient-search">
<el-input
v-model="recipientKeyword"
placeholder="输入用户名或角色名搜索..."
autocomplete="off"
@input="onRecipientInput"
@focus="recipientDropdown.length && (recipientDropdownVisible = true)"
/>
<div v-if="recipientDropdownVisible" class="recipient-dropdown">
<div
v-for="item in recipientDropdown"
:key="item.uuid"
class="recipient-item"
@click="selectRecipient(item)"
>
<el-tag size="small" :type="item.type === 'role' ? 'success' : 'primary'">
{{ item.type === 'role' ? '角色' : '用户' }}
</el-tag>
<span>{{ item.label }}</span>
</div>
</div>
</div>
<!-- 已选收件人 -->
<div v-if="selectedRecipient" class="selected-recipient">
<el-tag size="small" :type="selectedRecipient.type === 'role' ? 'success' : 'primary'">
{{ selectedRecipient.type === 'role' ? '角色' : '用户' }}
</el-tag>
<span>{{ selectedRecipient.name }}</span>
<i class="fas fa-times remove" @click="removeRecipient" />
</div>
</el-form-item>
<el-form-item label="标题" required>
<el-input v-model="composeForm.title" placeholder="消息标题" />
</el-form-item>
<el-form-item label="内容">
<el-input v-model="composeForm.content" type="textarea" :rows="5" placeholder="消息内容..." />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="composeVisible = false">取消</el-button>
<el-button type="primary" :loading="sending" @click="sendMessage">发送</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped lang="scss">
.message-page {
padding: 20px;
max-width: 900px;
margin: 0 auto;
box-sizing: border-box;
}
.page-title {
font-size: 18px;
font-weight: 600;
margin: 0 0 16px;
display: flex;
align-items: center;
gap: 8px;
.icon-primary {
color: var(--el-color-primary);
}
}
.msg-tabs {
margin-bottom: 12px;
}
// 未读 tab 角标位置微调
.unread-badge {
margin-left: 6px;
}
.msg-toolbar {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.msg-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.msg-item {
background: var(--el-bg-color, #fff);
border-radius: 8px;
border: 1px solid var(--el-border-color, #e2e8f0);
padding: 16px 20px;
cursor: pointer;
transition: all 0.2s;
display: flex;
gap: 14px;
align-items: flex-start;
position: relative;
&:hover {
border-color: var(--el-color-primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.msg-item-actions {
opacity: 1;
}
}
// 未读:左侧主色边
&.unread {
border-left: 3px solid var(--el-color-primary);
.msg-item-title {
font-weight: 700;
}
}
}
.msg-item-icon {
width: 36px;
height: 36px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex-shrink: 0;
}
.msg-item-body {
flex: 1;
min-width: 0;
}
.msg-item-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.msg-item-content {
font-size: 13px;
color: var(--el-text-color-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
}
.msg-item-meta {
font-size: 12px;
color: var(--el-text-color-placeholder);
display: flex;
gap: 12px;
}
.msg-item-actions {
position: absolute;
top: 50%;
right: 12px;
transform: translateY(-50%);
opacity: 0;
transition: opacity 0.2s;
}
.msg-pagination {
display: flex;
justify-content: center;
margin-top: 20px;
}
// 详情弹窗
.detail-title {
font-size: 18px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
i {
color: var(--el-color-primary);
}
}
.detail-meta {
font-size: 13px;
color: var(--el-text-color-secondary);
margin-bottom: 16px;
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.detail-content {
font-size: 14px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
}
.detail-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
// 发送弹窗 - 收件人搜索
.recipient-search {
position: relative;
width: 100%;
}
.recipient-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 10;
background: var(--el-bg-color, #fff);
border: 1px solid var(--el-border-color, #e2e8f0);
border-radius: 8px;
max-height: 200px;
overflow-y: auto;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.recipient-item {
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
display: flex;
align-items: center;
gap: 8px;
&:hover {
background: var(--el-color-primary-light-9);
}
}
.selected-recipient {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
padding: 4px 10px;
border-radius: 6px;
font-size: 13px;
margin-top: 8px;
.remove {
cursor: pointer;
opacity: 0.6;
&:hover {
opacity: 1;
}
}
}
</style>
+510
View File
@@ -0,0 +1,510 @@
<script setup lang="ts">
/**
* 支付网关配置
*
* 迁移自 TempLate/GERP/pages/system/payment-config.html
*
* 原生业务逻辑:
* - 仅管理员可访问(原生 GERP.CheckAdmin()),非管理员显示"无权访问"错误
* - 网关列表:GET /API/Payment/Configs → 卡片网格展示
* 每张卡片:平台、默认标记、启用状态、AppID、商户号、APIKey/PrivateKey/PublicKey 设置状态(已设置 *** / 未设置)、回调
* - 新增/编辑弹窗:平台、AppID、MchID、APIKey、PrivateKey、PublicKey、NotifyURL、ReturnURL、CertPath、CertKeyPath、设为默认、启用
* - 保存:POST /API/Payment/SaveConfig (JSON)
* - 删除:POST /API/Payment/DeleteConfig/{ConfigUUID}(二次确认)
*
* 敏感字段(APIKey / PrivateKey / PublicKey)处理:
* - 原生:编辑时显示 ***,提示"*** 保留原值,清空将删除";提交时直接传 input 值(*** 或空)
* - TODO: 本文件暂用 el-input type="password" placeholder="留空保留原值" 代替原计划的 SecretInput
* 自定义类型。当前实现遵循"空值=保留原值"语义:
* · 编辑模式:输入留空 → 提交 "***"(保留原值标记,对齐原生后端协议);输入新值 → 提交新值
* · 新增模式:输入留空 → 提交空字符串(无原值可保留)
* 后续接入 SecretInput 自定义类型后,应统一为组件内封装的"空值=保留原值"行为。
*
* 迁移取舍:
* - server / admin_verify 由 request.ts 请求拦截器统一注入
* - 权限校验改用 userStore.isAdmin 前端预拦截(后端仍会校验);非管理员显示无权访问占位
* - 业务错误(code!==0)由拦截器统一 ElMessage.error 并 reject,本文件 catch 后恢复按钮状态
* - AdminBadge 自动根据 server/admin_verify 显示特权横幅
*/
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { get, post } from '@/api/request'
import { useUserStore } from '@/stores/user'
import { setPageTitle } from '@/utils/serverScope'
import AdminBadge from '@/components/AdminBadge.vue'
const userStore = useUserStore()
// === 类型定义 ===
/** 支付网关配置记录(列表展示用,敏感字段仅返回 Set 标记) */
interface PaymentConfigItem {
ConfigUUID: string
Platform: string
AppID: string
MchID: string
/** APIKey 是否已设置(后端不回传明文) */
APIKeySet: boolean
PrivateKeySet: boolean
PublicKeySet: boolean
NotifyURL: string
ReturnURL: string
CertPath: string
CertKeyPath: string
ConfigDefault: boolean | 0 | 1
ConfigStatus: 0 | 1
}
/** 编辑表单数据 */
interface PaymentForm {
Platform: string
AppID: string
MchID: string
APIKey: string
PrivateKey: string
PublicKey: string
NotifyURL: string
ReturnURL: string
CertPath: string
CertKeyPath: string
ConfigDefault: boolean
ConfigStatus: boolean
}
// === 常量 ===
/** 平台选项 */
const PLATFORM_OPTIONS = [
{ value: 'wechat', label: '微信支付' },
{ value: 'alipay', label: '支付宝' },
{ value: 'bank', label: '银行直连' },
{ value: 'other', label: '其他' },
]
/** 平台 value → 中文 label 映射 */
const PLATFORM_LABELS: Record<string, string> = {
wechat: '微信支付',
alipay: '支付宝',
bank: '银行直连',
other: '其他',
}
/** 空表单模板 */
function createEmptyForm(): PaymentForm {
return {
Platform: 'wechat',
AppID: '',
MchID: '',
APIKey: '',
PrivateKey: '',
PublicKey: '',
NotifyURL: '',
ReturnURL: '',
CertPath: '',
CertKeyPath: '',
ConfigDefault: false,
ConfigStatus: true,
}
}
// === 状态 ===
/** 网关配置列表 */
const configs = ref<PaymentConfigItem[]>([])
/** 加载态 */
const loading = ref(false)
/** 错误状态文案 */
const errorState = ref('')
/** 编辑弹窗可见 */
const editVisible = ref(false)
/** 当前编辑的 ConfigUUID(null 表示新增) */
const editingUuid = ref<string | null>(null)
/** 是否编辑模式 */
const isEdit = computed(() => editingUuid.value !== null)
/** 编辑表单 */
const form = reactive<PaymentForm>(createEmptyForm())
/** 保存中 */
const saving = ref(false)
/** 是否管理员(前端预拦截) */
const isAdmin = computed(() => userStore.isAdmin)
// === 数据加载 ===
/** 加载支付网关配置列表 */
async function loadConfigs(): Promise<void> {
// 前端权限预拦截
if (!isAdmin.value) {
errorState.value = '无权访问:仅管理员可查看支付网关配置'
configs.value = []
return
}
loading.value = true
errorState.value = ''
try {
const data = await get<PaymentConfigItem[]>('/API/Payment/Configs')
configs.value = Array.isArray(data) ? data : []
} catch (err) {
configs.value = []
errorState.value = err instanceof Error ? err.message : '加载失败'
} finally {
loading.value = false
}
}
// === 新增 / 编辑 ===
/** 打开新增弹窗 */
function openCreate(): void {
editingUuid.value = null
Object.assign(form, createEmptyForm())
editVisible.value = true
}
/** 打开编辑弹窗(敏感字段不回填,留空保留原值) */
function openEdit(config: PaymentConfigItem): void {
editingUuid.value = config.ConfigUUID
Object.assign(form, {
Platform: config.Platform,
AppID: config.AppID || '',
MchID: config.MchID || '',
// TODO: SecretInput 自定义类型未实现,编辑时敏感字段留空(placeholder 提示"留空保留原值")
APIKey: '',
PrivateKey: '',
PublicKey: '',
NotifyURL: config.NotifyURL || '',
ReturnURL: config.ReturnURL || '',
CertPath: config.CertPath || '',
CertKeyPath: config.CertKeyPath || '',
ConfigDefault: !!config.ConfigDefault,
ConfigStatus: config.ConfigStatus === 1,
})
editVisible.value = true
}
/**
* 构造敏感字段提交值:
* - 编辑模式 + 输入为空 → 传 "***"(保留原值标记,对齐原生后端协议)
* - 新增模式 + 输入为空 → 传空字符串(无原值可保留)
* - 输入非空 → 传实际值
*/
function resolveSecret(value: string): string {
if (value) return value
return isEdit.value ? '***' : ''
}
/** 保存配置 */
async function saveConfig(): Promise<void> {
const payload = {
ConfigUUID: editingUuid.value || null,
Platform: form.Platform,
AppID: form.AppID,
MchID: form.MchID,
APIKey: resolveSecret(form.APIKey),
PrivateKey: resolveSecret(form.PrivateKey),
PublicKey: resolveSecret(form.PublicKey),
NotifyURL: form.NotifyURL,
ReturnURL: form.ReturnURL,
CertPath: form.CertPath,
CertKeyPath: form.CertKeyPath,
ConfigDefault: form.ConfigDefault,
ConfigStatus: form.ConfigStatus ? 1 : 0,
}
saving.value = true
try {
await post('/API/Payment/SaveConfig', payload)
ElMessage.success('保存成功')
editVisible.value = false
loadConfigs()
} catch {
// 业务错误已由拦截器统一提示
} finally {
saving.value = false
}
}
// === 删除 ===
/** 删除配置(二次确认) */
async function deleteConfig(uuid: string, platform: string): Promise<void> {
const label = PLATFORM_LABELS[platform] || platform
try {
await ElMessageBox.confirm(`确认删除 ${label} 网关配置?`, '提示', {
type: 'warning',
confirmButtonText: '确认删除',
cancelButtonText: '取消',
})
} catch {
return // 用户取消
}
try {
await post(`/API/Payment/DeleteConfig/${uuid}`)
ElMessage.success('已删除')
loadConfigs()
} catch {
// 业务错误已由拦截器统一提示
}
}
onMounted(() => {
setPageTitle('支付网关配置')
loadConfigs()
})
</script>
<template>
<div class="payment-config-page">
<!-- 管理员特权横幅 -->
<AdminBadge />
<!-- 页头 -->
<div class="pc-header">
<h1 class="page-title">
<i class="fas fa-credit-card icon-primary" />
<span>支付网关配置</span>
</h1>
<el-button type="primary" @click="openCreate">
<i class="fas fa-plus" /> 新增配置
</el-button>
<el-button @click="loadConfigs">
<i class="fas fa-sync" /> 刷新
</el-button>
</div>
<!-- 配置卡片网格 -->
<div class="pc-grid" v-loading="loading">
<!-- 错误状态 -->
<div v-if="errorState" class="pc-error">
<i class="fas fa-exclamation-triangle" />
<div>{{ errorState }}</div>
</div>
<!-- 空状态 -->
<el-empty
v-else-if="!loading && configs.length === 0"
description="暂无支付网关配置"
:image-size="60"
/>
<!-- 网关卡片 -->
<div v-for="c in configs" :key="c.ConfigUUID" class="pc-card">
<div class="pc-card-header">
<span class="platform">{{ PLATFORM_LABELS[c.Platform] || c.Platform }}</span>
<el-tag v-if="c.ConfigDefault" type="primary" size="small">默认</el-tag>
<el-tag :type="c.ConfigStatus === 1 ? 'success' : 'danger'" size="small">
{{ c.ConfigStatus === 1 ? '启用' : '停用' }}
</el-tag>
</div>
<div class="pc-card-body">
<div class="row"><span class="label">AppID</span><span class="value">{{ c.AppID || '-' }}</span></div>
<div class="row"><span class="label">商户号</span><span class="value">{{ c.MchID || '-' }}</span></div>
<div class="row">
<span class="label">APIKey</span>
<span class="value" :class="c.APIKeySet ? 'secret-set' : 'secret-unset'">
{{ c.APIKeySet ? '已设置 ***' : '未设置' }}
</span>
</div>
<div class="row">
<span class="label">PrivateKey</span>
<span class="value" :class="c.PrivateKeySet ? 'secret-set' : 'secret-unset'">
{{ c.PrivateKeySet ? '已设置 ***' : '未设置' }}
</span>
</div>
<div class="row">
<span class="label">PublicKey</span>
<span class="value" :class="c.PublicKeySet ? 'secret-set' : 'secret-unset'">
{{ c.PublicKeySet ? '已设置 ***' : '未设置' }}
</span>
</div>
<div v-if="c.NotifyURL" class="row"><span class="label">回调</span><span class="value">{{ c.NotifyURL }}</span></div>
</div>
<div class="pc-card-actions">
<el-button size="small" @click="openEdit(c)">
<i class="fas fa-edit" /> 编辑
</el-button>
<el-button size="small" type="danger" @click="deleteConfig(c.ConfigUUID, c.Platform)">
<i class="fas fa-trash" /> 删除
</el-button>
</div>
</div>
</div>
<!-- 新增/编辑弹窗 -->
<el-dialog
v-model="editVisible"
:title="isEdit ? `编辑 - ${PLATFORM_LABELS[form.Platform] || form.Platform}` : '新增支付网关配置'"
width="600px"
>
<el-form :model="form" label-position="top">
<el-form-item label="平台 *">
<el-select v-model="form.Platform" style="width: 100%">
<el-option v-for="opt in PLATFORM_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item label="AppID">
<el-input v-model="form.AppID" placeholder="应用ID" />
</el-form-item>
<el-form-item label="商户号 MchID">
<el-input v-model="form.MchID" placeholder="商户号" />
</el-form-item>
<!-- TODO: SecretInput 自定义类型未实现,暂用 el-input type=password 代替 -->
<el-form-item label="APIKey">
<el-input v-model="form.APIKey" type="password" show-password placeholder="留空保留原值" />
<div v-if="isEdit" class="secret-hint">已设置时留空表示保留原值</div>
</el-form-item>
<el-form-item label="PrivateKey">
<el-input v-model="form.PrivateKey" type="textarea" :rows="3" placeholder="留空保留原值" />
<div v-if="isEdit" class="secret-hint">已设置时留空表示保留原值</div>
</el-form-item>
<el-form-item label="PublicKey">
<el-input v-model="form.PublicKey" type="textarea" :rows="3" placeholder="留空保留原值" />
<div v-if="isEdit" class="secret-hint">已设置时留空表示保留原值</div>
</el-form-item>
<el-form-item label="NotifyURL">
<el-input v-model="form.NotifyURL" placeholder="支付回调通知地址" />
</el-form-item>
<el-form-item label="ReturnURL">
<el-input v-model="form.ReturnURL" placeholder="支付完成跳转地址" />
</el-form-item>
<el-form-item label="CertPath">
<el-input v-model="form.CertPath" placeholder="证书路径(可选)" />
</el-form-item>
<el-form-item label="CertKeyPath">
<el-input v-model="form.CertKeyPath" placeholder="证书密钥路径(可选)" />
</el-form-item>
<el-form-item>
<el-checkbox v-model="form.ConfigDefault">设为默认网关</el-checkbox>
<el-checkbox v-model="form.ConfigStatus">启用</el-checkbox>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveConfig">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped lang="scss">
.payment-config-page {
padding: 24px;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.pc-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
flex-shrink: 0;
.page-title {
margin: 0;
flex: 1;
font-size: 18px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
.icon-primary {
color: var(--el-color-primary);
}
}
}
.pc-grid {
flex: 1;
min-height: 0;
overflow: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
gap: 16px;
align-content: start;
}
.pc-card {
background: var(--el-bg-color, #fff);
border: 1px solid var(--el-border-color, #e2e8f0);
border-radius: 10px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.pc-card-header {
display: flex;
align-items: center;
gap: 8px;
.platform {
font-size: 16px;
font-weight: 600;
flex: 1;
}
}
.pc-card-body {
font-size: 13px;
color: var(--el-text-color-secondary);
display: flex;
flex-direction: column;
gap: 4px;
.row {
display: flex;
gap: 8px;
.label {
min-width: 80px;
color: var(--el-text-color-placeholder);
}
.value {
color: var(--el-text-color-primary);
word-break: break-all;
}
.secret-set {
color: var(--el-color-success);
font-weight: 500;
}
.secret-unset {
color: var(--el-text-color-placeholder);
}
}
}
.pc-card-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
padding-top: 8px;
border-top: 1px solid var(--el-border-color, #e2e8f0);
}
.pc-error {
color: var(--el-color-danger);
padding: 40px;
text-align: center;
grid-column: 1 / -1;
i {
font-size: 40px;
display: block;
margin-bottom: 12px;
}
}
.secret-hint {
font-size: 12px;
color: var(--el-text-color-placeholder);
margin-top: 4px;
}
</style>
+351
View File
@@ -0,0 +1,351 @@
<script setup lang="ts">
/**
* 角色详情页 RoleDetail
*
* 迁移自 TempLate/GERP/pages/system/role-detail.html
*
* 业务结构:
* 1. 基本信息(InfoGrid):角色名称 / 角色类型 / 角色状态 / 分配范围 / 角色描述 / 创建时间
* 2. 关联用户子表(CrudTable: UserRole,按 RoleUUID 过滤)
* 3. 角色权限子表(CrudTable: RolePermission,按 RoleUUID 过滤)
* - 备注:添加 admin:super 权限可使该角色成为超级管理员
* 4. 隔离提示横幅:管理员跨公司 vs 仅当前公司
* 5. actions:编辑按钮(动态编辑弹窗)+ 权限树分配按钮(跳 RolePermissions)
*
* 迁移要点:
* - server / admin_verify 由 request.ts 拦截器统一注入
* - 主表单条加载:formApi.list('Role', { RoleUUID, pageSize: 1 })
* - 权限树分配:跳 /system/role-permissions?RoleUUID=xxx(RolePermissions 页用 el-tree 实现)
* - role_scope 细粒度判断暂用 userStore.isAdmin 二分(TODO)
*/
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import DetailPage from '@/components/DetailPage.vue'
import InfoGrid from '@/components/InfoGrid.vue'
import CrudTable from '@/components/CrudTable.vue'
import EntityPicker from '@/components/EntityPicker.vue'
import { formApi } from '@/api/form'
import { useUserStore } from '@/stores/user'
import { setPageTitle } from '@/utils/serverScope'
import type { ColumnSchema } from '@/types/api'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
// 路由参数:角色 UUID
const roleUUID = (route.query.RoleUUID as string) || ''
// === 状态 ===
const loading = ref(false)
const data = ref<Record<string, any> | null>(null)
// 编辑弹窗状态
const editDialogVisible = ref(false)
const editSubmitting = ref(false)
const columns = ref<ColumnSchema[]>([])
const editForm = reactive<Record<string, any>>({})
// 主键字段
const PK_FIELD = 'RoleUUID'
// 页面标题
const title = computed(() => data.value?.RoleName || '角色详情')
// 隔离提示
// TODO: 接入 checkAdmin → role_scope
const isolationHint = computed(() =>
userStore.isAdmin
? '特权模式:显示该角色在所有公司的关联记录(无公司隔离)'
: '仅显示当前公司数据。如需查看所有公司记录,需管理员特权。',
)
// === 显示格式化 helper ===
/** 角色类型:0→组员,1→组长 */
function roleTypeText(t: unknown): string {
if (t === 0 || t === '0') return '组员'
if (t === 1 || t === '1') return '组长'
return '-'
}
/** 角色状态标签 */
function roleStatusTag(v: unknown): { text: string; type: 'success' | 'danger' | 'warning' | 'info' } {
if (v === 1 || v === '1') return { text: '启用', type: 'success' }
if (v === 0 || v === '0') return { text: '禁用', type: 'danger' }
// TODO: 对齐原生 CompanyUtils.statusTag 完整码表
return { text: String(v ?? '-'), type: 'warning' }
}
/** 分配范围:0→全租户可分发,1→指定公司,2→指定部门 */
function assignScopeText(s: unknown): { text: string; type: 'success' | 'primary' | 'warning' | 'info' } {
if (s === 0 || s === '0') return { text: '全租户可分发', type: 'success' }
if (s === 1 || s === '1') return { text: '指定公司可分发', type: 'primary' }
if (s === 2 || s === '2') return { text: '指定部门可分发', type: 'warning' }
return { text: '-', type: 'info' }
}
/** 时间格式化 */
function formatTime(v: unknown): string {
if (!v) return '-'
const d = new Date(v as string)
if (isNaN(d.getTime())) return String(v)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
// 编辑弹窗可见列
const formColumns = computed(() =>
columns.value.filter(
(c) =>
c.editable !== false &&
!c.hideInForm &&
c.type !== 'hidden' &&
c.type !== 'divider' &&
c.type !== 'html' &&
c.field !== PK_FIELD,
),
)
// === 数据加载 ===
async function loadData(): Promise<void> {
if (!roleUUID) {
ElMessage.warning('缺少 RoleUUID 参数,无法加载角色详情')
return
}
loading.value = true
try {
const result = await formApi.list('Role', { [PK_FIELD]: roleUUID, pageSize: 1 })
const list: any[] = Array.isArray(result) ? result : (result as any)?.list || []
const row = list.find((r) => r[PK_FIELD] === roleUUID) || list[0]
if (!row) {
ElMessage.error('未找到角色数据')
return
}
data.value = row
setPageTitle(row.RoleName || '角色详情')
} finally {
loading.value = false
}
}
async function loadColumns(): Promise<void> {
try {
columns.value = await formApi.getColumns('Role')
} catch {
// 列定义加载失败不阻塞展示
}
}
// === 编辑弹窗 ===
function handleEdit(): void {
if (!data.value) return
Object.keys(editForm).forEach((k) => delete editForm[k])
Object.assign(editForm, data.value)
editDialogVisible.value = true
}
async function handleSubmit(): Promise<void> {
editSubmitting.value = true
try {
await formApi.update('Role', editForm[PK_FIELD], editForm)
ElMessage.success('保存成功')
editDialogVisible.value = false
await loadData()
} finally {
editSubmitting.value = false
}
}
// === 权限树分配 ===
/** 跳转权限树分配页(RolePermissions) */
function goPermissionTree(): void {
if (!roleUUID) {
ElMessage.warning('缺少 RoleUUID')
return
}
router.push({ path: '/system/role-permissions', query: { RoleUUID: roleUUID } })
}
// === 子表跳转 ===
/** UserRole 子表查看 → 用户详情 */
function goUserDetail(row: Record<string, any>): void {
router.push({ path: '/system/user-detail', query: { UserUUID: row.UserUUID || '' } })
}
onMounted(async () => {
await Promise.all([loadData(), loadColumns()])
})
</script>
<template>
<DetailPage :title="title" :loading="loading">
<template #actions>
<el-button type="primary" @click="handleEdit">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button @click="goPermissionTree" title="按权限组勾选分配权限">
<el-icon><Connection /></el-icon>权限树分配
</el-button>
</template>
<!-- 隔离提示横幅 -->
<el-alert :type="userStore.isAdmin ? 'warning' : 'info'" :closable="false" show-icon>
{{ isolationHint }}
</el-alert>
<!-- 基本信息 -->
<InfoGrid title="基本信息" :columns="2">
<el-descriptions-item label="角色名称">{{ data?.RoleName || '-' }}</el-descriptions-item>
<el-descriptions-item label="角色类型">{{ roleTypeText(data?.RoleType) }}</el-descriptions-item>
<el-descriptions-item label="角色状态">
<el-tag :type="roleStatusTag(data?.RoleStatus).type" size="small">
{{ roleStatusTag(data?.RoleStatus).text }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="分配范围">
<el-tag :type="assignScopeText(data?.AssignScope).type" size="small">
{{ assignScopeText(data?.AssignScope).text }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="角色描述">{{ data?.RoleDesc || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(data?.CreateTime) }}</el-descriptions-item>
</InfoGrid>
<!-- 关联用户子表 -->
<el-card shadow="never">
<template #header>关联用户</template>
<CrudTable
v-if="roleUUID"
table="UserRole"
title="角色用户"
:query="{ RoleUUID: roleUUID }"
:pk-field="'UserRoleUUID'"
selectable
pageable
:on-view="goUserDetail"
/>
</el-card>
<!-- 角色权限子表 -->
<el-card shadow="never">
<template #header>角色权限</template>
<CrudTable
v-if="roleUUID"
table="RolePermission"
title="角色权限"
:query="{ RoleUUID: roleUUID }"
:pk-field="'RolePermUUID'"
selectable
pageable
/>
<div class="perm-hint">
<el-icon><InfoFilled /></el-icon>
添加 <code>admin:super</code> 权限可使该角色成为超级管理员
</div>
</el-card>
<!-- 编辑弹窗 -->
<el-dialog v-model="editDialogVisible" title="编辑角色" width="600px" destroy-on-close>
<el-form :model="editForm" label-width="120px" label-position="right">
<el-form-item
v-for="col in formColumns"
:key="col.field"
:label="col.title"
:required="col.required"
>
<EntityPicker
v-if="col.type === 'entityPicker' && col.entityPicker"
v-model="editForm[col.field]"
:config="col.entityPicker"
:existing-label="editForm[col.field + '_label']"
:placeholder="col.placeholder"
:disabled="col.disabled"
@update:label="(label: string) => (editForm[col.field + '_label'] = label)"
/>
<el-input
v-else-if="!col.type || col.type === 'text' || col.type === 'string'"
v-model="editForm[col.field]"
:placeholder="col.placeholder"
:maxlength="col.maxLength"
:disabled="col.disabled"
/>
<el-input
v-else-if="col.type === 'textarea'"
v-model="editForm[col.field]"
type="textarea"
:rows="col.rows || 3"
:disabled="col.disabled"
/>
<el-input-number
v-else-if="col.type === 'number'"
v-model="editForm[col.field]"
:min="col.min"
:max="col.max"
:step="col.step"
:disabled="col.disabled"
/>
<el-select
v-else-if="col.type === 'select'"
v-model="editForm[col.field]"
:placeholder="col.placeholder"
:disabled="col.disabled"
style="width: 100%"
>
<el-option
v-for="opt in col.options"
:key="String(opt.value)"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-date-picker
v-else-if="col.type === 'date' || col.type === 'datetime'"
v-model="editForm[col.field]"
:type="col.type as 'date' | 'datetime'"
:placeholder="col.placeholder"
style="width: 100%"
/>
<el-switch
v-else-if="col.type === 'switch' || col.type === 'boolean'"
v-model="editForm[col.field]"
:disabled="col.disabled"
/>
<el-input
v-else
:model-value="String(editForm[col.field] ?? '')"
readonly
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="editSubmitting" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
</DetailPage>
</template>
<style scoped lang="scss">
// 权限子表下方提示
.perm-hint {
margin-top: 8px;
font-size: 12px;
color: #909399;
display: flex;
align-items: center;
gap: 4px;
code {
padding: 1px 6px;
background: #f4f4f5;
border-radius: 3px;
font-family: 'Consolas', 'Monaco', monospace;
}
}
</style>
+475
View File
@@ -0,0 +1,475 @@
<script setup lang="ts">
/**
* 角色权限分配页 RolePermissions
*
* 迁移自 TempLate/GERP/pages/system/role-permissions.html
*
* 业务结构(非标准详情页,是权限树勾选页):
* 1. 工具栏:角色下拉 + 公司下拉 + 统计 + 全部展开/折叠 + 全选/清空
* 2. 权限树:按 Category 分组,叶子为权限项(PermName / PermCode / PermDesc),可勾选
* 3. 底部:保存权限按钮(POST 勾选的 PermUUIDs)
* 4. 非管理员访问时显示"无权访问"拒绝态
*
* 迁移要点(对齐工程迁移规则):
* - server / admin_verify 由 request.ts 拦截器统一注入
* - 权限树用 el-tree 实现(TODO:待 PermissionTreePicker 组件封装后替换)
* - role_scope 细粒度判断暂用 userStore.isAdmin 二分(TODO)
* - 仅管理员可访问:非管理员显示拒绝页
* - 离开页面前提示未保存(beforeunload + 路由守卫)
*
* 接口:
* - GET /API/Role/PermissionsTree?RoleUUID=&CompanyUUID= → { Groups: [{ Category, Permissions: [{ PermUUID, PermName, PermCode, PermDesc, Checked }] }] }
* - POST /API/Role/SavePermissions { RoleUUID, CompanyUUID, PermUUIDs } → { Added, Removed, Kept }
*/
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useRoute, onBeforeRouteLeave } from 'vue-router'
import { ElMessage } from 'element-plus'
import DetailPage from '@/components/DetailPage.vue'
import { formApi } from '@/api/form'
import { get, post } from '@/api/request'
import { useUserStore } from '@/stores/user'
import { setPageTitle } from '@/utils/serverScope'
const route = useRoute()
const userStore = useUserStore()
// 路由初始参数
const initRoleUUID = (route.query.RoleUUID as string) || ''
const initCompanyUUID = (route.query.CompanyUUID as string) || ''
// 选中值
const selectedRoleUUID = ref(initRoleUUID)
const selectedCompanyUUID = ref(initCompanyUUID)
// === 状态 ===
const roles = ref<Array<Record<string, any>>>([])
const companies = ref<Array<Record<string, any>>>([])
const permissionTree = ref<PermGroup[]>([])
const loading = ref(false)
const saving = ref(false)
const denied = ref(false)
const hasChanges = ref(false)
// 原始勾选集合(用于变更检测)
const originalCheckedSet = ref<Set<string>>(new Set())
// 当前勾选的 PermUUID 列表(驱动 default-checked-keys)
const checkedKeys = ref<string[]>([])
// 展开/折叠控制(通过重建 tree 应用)
const expandAllFlag = ref(false)
const treeVersion = ref(0)
// el-tree 实例引用
const treeRef = ref<any>(null)
// 权限组 / 权限项类型
interface PermItem {
PermUUID: string
PermName: string
PermCode: string
PermDesc?: string
Checked: boolean
}
interface PermGroup {
Category: string
Permissions: PermItem[]
}
// 仅管理员可访问
// TODO: 接入 checkAdmin → role_scope,细化 dept_manager / workgroup_lead 分支
const isAdmin = computed(() => userStore.isAdmin)
// el-tree 数据:组 → 权限,统一 _nodeKey 作为 node-key
const treeData = computed(() =>
permissionTree.value.map((g, gi) => ({
_nodeKey: `group_${gi}`,
label: g.Category,
children: g.Permissions.map((p) => ({
_nodeKey: p.PermUUID,
label: p.PermName,
permCode: p.PermCode,
permDesc: p.PermDesc || '',
isLeaf: true,
})),
})),
)
// 统计:总权限数
const totalCount = computed(() =>
permissionTree.value.reduce((s, g) => s + g.Permissions.length, 0),
)
// 统计:已勾选数
const checkedCount = computed(() => checkedKeys.value.length)
// 保存按钮可用:选中角色+公司 且 无保存中
const canSave = computed(
() => !!selectedRoleUUID.value && !!selectedCompanyUUID.value && !saving.value,
)
// === 数据加载 ===
async function loadRoles(): Promise<void> {
try {
const result = await formApi.list('Role', { pageSize: 500 })
roles.value = Array.isArray(result) ? result : (result as any)?.list || []
} catch {
// 拦截器已统一提示
}
}
async function loadCompanies(): Promise<void> {
try {
const result = await formApi.list('Company', { pageSize: 500 })
companies.value = Array.isArray(result) ? result : (result as any)?.list || []
} catch {
// 拦截器已统一提示
}
}
/** 加载权限树 */
async function loadTree(): Promise<void> {
const roleUUID = selectedRoleUUID.value
const companyUUID = selectedCompanyUUID.value
if (!roleUUID || !companyUUID) {
permissionTree.value = []
return
}
loading.value = true
hasChanges.value = false
try {
const result = await get<{ Groups?: PermGroup[] }>('/API/Role/PermissionsTree', {
RoleUUID: roleUUID,
CompanyUUID: companyUUID,
})
permissionTree.value = result?.Groups || []
const checked = permissionTree.value.flatMap((g) =>
g.Permissions.filter((p) => p.Checked).map((p) => p.PermUUID),
)
originalCheckedSet.value = new Set(checked)
checkedKeys.value = [...checked]
// 重建 tree 以应用 default-checked-keys / default-expand-all
treeVersion.value++
await nextTick()
updateHasChanges()
} finally {
loading.value = false
}
}
// === 交互 ===
function onRoleChange(): void {
if (selectedCompanyUUID.value) void loadTree()
}
function onCompanyChange(): void {
if (selectedRoleUUID.value) void loadTree()
}
/** 勾选变化:同步 checkedKeys 与 hasChanges */
function onCheckChange(): void {
const keys = treeRef.value?.getCheckedKeys?.(true) as string[] | undefined
checkedKeys.value = keys || []
updateHasChanges()
}
function updateHasChanges(): void {
const currentSet = new Set(checkedKeys.value)
hasChanges.value = !(
currentSet.size === originalCheckedSet.value.size &&
[...currentSet].every((x) => originalCheckedSet.value.has(x))
)
}
/** 全部展开:重建 tree 并默认展开 */
function expandAll(): void {
expandAllFlag.value = true
treeVersion.value++
void nextTick(() => onCheckChange())
}
/** 全部折叠:重建 tree 并默认折叠 */
function collapseAll(): void {
expandAllFlag.value = false
treeVersion.value++
void nextTick(() => onCheckChange())
}
/** 全选所有权限 */
function checkAll(): void {
const all = permissionTree.value.flatMap((g) => g.Permissions.map((p) => p.PermUUID))
treeRef.value?.setCheckedKeys?.(all)
checkedKeys.value = all
updateHasChanges()
}
/** 清空所有勾选 */
function uncheckAll(): void {
treeRef.value?.setCheckedKeys?.([])
checkedKeys.value = []
updateHasChanges()
}
/** 保存权限 */
async function savePermissions(): Promise<void> {
const roleUUID = selectedRoleUUID.value
const companyUUID = selectedCompanyUUID.value
if (!roleUUID || !companyUUID) {
ElMessage.warning('请先选择角色和公司')
return
}
saving.value = true
try {
const result = await post<{ Added?: number; Removed?: number; Kept?: number }>(
'/API/Role/SavePermissions',
{ RoleUUID: roleUUID, CompanyUUID: companyUUID, PermUUIDs: checkedKeys.value },
)
const d = result || {}
ElMessage.success(`保存成功(新增 ${d.Added || 0},移除 ${d.Removed || 0},保留 ${d.Kept || 0})`)
originalCheckedSet.value = new Set(checkedKeys.value)
hasChanges.value = false
} finally {
saving.value = false
}
}
// === 离开提示 ===
function beforeUnloadHandler(e: BeforeUnloadEvent): void {
if (hasChanges.value) {
e.preventDefault()
e.returnValue = ''
}
}
onBeforeRouteLeave((_to, _from, next) => {
if (!hasChanges.value) {
next()
return
}
// 未保存提示:用户确认后放行
if (window.confirm('有未保存的权限变更,确定离开?')) {
next()
} else {
next(false)
}
})
onMounted(async () => {
setPageTitle('角色权限分配')
// 入口校验:仅管理员可访问
if (!isAdmin.value) {
denied.value = true
return
}
await Promise.all([loadRoles(), loadCompanies()])
// 自动选中初始角色 / 公司
if (initRoleUUID) selectedRoleUUID.value = initRoleUUID
if (initCompanyUUID) selectedCompanyUUID.value = initCompanyUUID
if (initRoleUUID && initCompanyUUID) {
await loadTree()
}
})
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', beforeUnloadHandler)
})
window.addEventListener('beforeunload', beforeUnloadHandler)
</script>
<template>
<DetailPage title="角色权限分配" :loading="loading">
<!-- 非管理员:无权访问 -->
<div v-if="denied" class="denied-state">
<el-icon class="denied-icon"><Lock /></el-icon>
<div class="denied-title">无权访问</div>
<div class="denied-desc">该页面仅管理员可查看,请切换至管理员角色或联系管理员。</div>
</div>
<template v-else>
<!-- 工具栏:角色 / 公司选择 + 统计 + 批量操作 -->
<el-card shadow="never" class="toolbar">
<div class="toolbar-row">
<span class="toolbar-label">角色:</span>
<el-select
v-model="selectedRoleUUID"
placeholder="请选择角色"
filterable
style="min-width: 200px"
@change="onRoleChange"
>
<el-option label="请选择角色" value="" />
<el-option
v-for="r in roles"
:key="r.RoleUUID"
:label="r.RoleName"
:value="r.RoleUUID"
/>
</el-select>
<span class="toolbar-label">公司:</span>
<el-select
v-model="selectedCompanyUUID"
placeholder="请选择公司"
filterable
style="min-width: 200px"
@change="onCompanyChange"
>
<el-option label="请选择公司" value="" />
<el-option
v-for="c in companies"
:key="c.CompanyUUID"
:label="c.CompanyName"
:value="c.CompanyUUID"
/>
</el-select>
<div class="toolbar-stat">
共 <strong>{{ totalCount }}</strong> 项权限,已勾选 <strong>{{ checkedCount }}</strong> 项
</div>
<div class="toolbar-actions">
<el-button @click="expandAll">全部展开</el-button>
<el-button @click="collapseAll">全部折叠</el-button>
<el-button @click="checkAll">全选</el-button>
<el-button @click="uncheckAll">清空</el-button>
</div>
</div>
</el-card>
<!-- 权限树 -->
<el-card shadow="never" class="tree-wrap">
<div v-if="!selectedRoleUUID || !selectedCompanyUUID" class="empty-state">
<el-icon class="empty-icon"><Lock /></el-icon>
<div>请先选择角色和公司</div>
</div>
<el-empty v-else-if="treeData.length === 0 && !loading" description="暂无权限定义" />
<!-- TODO: 待 PermissionTreePicker 组件封装后替换此处 el-tree -->
<el-tree
v-else
ref="treeRef"
:key="treeVersion"
:data="treeData"
node-key="_nodeKey"
show-checkbox
:default-checked-keys="checkedKeys"
:default-expand-all="expandAllFlag"
:props="{ label: 'label', children: 'children' }"
@check="onCheckChange"
>
<template #default="{ data: node }">
<div class="perm-row">
<span class="perm-name">{{ node.label }}</span>
<el-tag v-if="node.permCode" size="small" type="info" class="perm-code">
{{ node.permCode }}
</el-tag>
<span v-if="node.permDesc" class="perm-desc">{{ node.permDesc }}</span>
</div>
</template>
</el-tree>
</el-card>
<!-- 底部保存 -->
<el-card shadow="never" class="footer">
<el-button type="primary" :loading="saving" :disabled="!canSave" @click="savePermissions">
<el-icon><Select /></el-icon>保存权限
</el-button>
</el-card>
</template>
</DetailPage>
</template>
<style scoped lang="scss">
.toolbar-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.toolbar-label {
font-size: 13px;
color: #606266;
font-weight: 600;
}
.toolbar-stat {
margin-left: auto;
font-size: 13px;
color: #909399;
strong {
color: var(--el-color-primary);
}
}
.toolbar-actions {
display: flex;
gap: 8px;
}
.tree-wrap {
min-height: 300px;
}
.perm-row {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
}
.perm-name {
font-weight: 500;
color: #303133;
}
.perm-code {
font-family: 'Consolas', 'Monaco', monospace;
}
.perm-desc {
color: #909399;
font-size: 12px;
margin-left: auto;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #909399;
}
.empty-icon {
font-size: 48px;
margin-bottom: 12px;
display: block;
color: #c0c4cc;
}
.footer {
display: flex;
justify-content: flex-end;
}
.denied-state {
text-align: center;
padding: 60px 20px;
color: #909399;
}
.denied-icon {
font-size: 48px;
margin-bottom: 12px;
display: block;
color: var(--el-color-danger);
}
.denied-title {
font-size: 16px;
font-weight: 600;
color: #303133;
margin-bottom: 6px;
}
.denied-desc {
color: #909399;
}
</style>
+60
View File
@@ -0,0 +1,60 @@
<script setup lang="ts">
/**
* 角色管理
*
* 迁移自 TempLate/GERP/pages/system/roles.html(108 行)
* 原生实现:server 作用域 + admin_verify 特权 + 列 fallback(getDefaultColumns)+ onView 跳详情
*
* Vue 版:
* - server / admin_verify 由 request.ts 拦截器统一注入
* - 列定义优先走后端 /API/FormAPI/Columns/Role;非主实例后端不可用时由 useCrudTable fallback 处理(TODO)
* - onView 跳 Vue 路由 /system/role-detail?RoleUUID=xxx(替代 GERP.Navigate)
* - 页面标题随 server 变化由 setPageTitle 统一处理
*/
import { useRouter } from 'vue-router'
import CrudTable from '@/components/CrudTable.vue'
import { setPageTitle } from '@/utils/serverScope'
setPageTitle('角色管理')
const router = useRouter()
// 角色记录类型(仅声明本页用到的字段)
interface RoleRow {
RoleUUID?: string
RoleName?: string
[k: string]: unknown
}
// 跳转角色详情页
function handleView(row: RoleRow): void {
router.push({
path: '/system/role-detail',
query: { RoleUUID: row.RoleUUID || '' },
})
}
</script>
<template>
<div class="role-page">
<CrudTable
table="Role"
title="角色管理"
pk-field="RoleUUID"
searchable
pageable
selectable
:on-view="handleView"
/>
</div>
</template>
<style scoped lang="scss">
.role-page {
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
</style>
+493
View File
@@ -0,0 +1,493 @@
<script setup lang="ts">
/**
* 系统设置(Settings)
*
* 迁移自 TempLate/GERP/pages/system/settings.html(528 行)
*
* 原生实现要点:
* - 设置项:主题模式、侧边栏默认展开、最大标签页数、允许重复标签页、关闭确认、
* 桌面通知、通知声音、动画效果、预加载页面
* - 主题选项:default / light / dark / green / purple / red
* - 存储:优先后端 /API/Settings(GET 读取 / POST 保存),失败回退 localStorage(key: GERPSettings)
* - 主题管理经 window.GERPApp.ThemeManager + window.GERPThemeSync 跨 iframe 同步
* - 通过 postMessage 与父页面通信:SETTINGS_UPDATE(下发设置)、GET_THEME(请求主题)、
* 监听 THEME_CHANGE(接收父页面主题色变量)
* - 即时保存:所有输入变更 debounce 200~300ms 静默提交
* - 危险操作:清除缓存、重置所有设置
*
* Vue 版改动(重点重构):
* - 【废弃 GERPThemeSync】:不再调用 window.GERPThemeSync.init(),主题改由 Pinia appStore 统一管理
* - 【移除 postMessage】:删除 SETTINGS_UPDATE / GET_THEME / THEME_CHANGE 全部跨窗口通信,
* Vue 单页应用内主题与设置直接通过 store / 响应式数据驱动,无需 iframe 消息桥
* - 主题管理用 useAppStore:appStore 当前仅支持 light / dark,选择这两项时调用 appStore.setTheme;
* 其余主题(default / green / purple / red)仅本地记录,TODO 待 appStore 扩展多主题支持
* - 侧边栏折叠改用 appStore.sidebarCollapsed / toggleSidebar(替代 GERPUI.SetSidebarCollapsed)
* - 动画开关通过 watch 设置 CSS 变量 --TransitionSpeed / --AnimationDuration(替代原生 DOM 操作)
* - server / admin_verify 由 request.ts 拦截器统一注入
* - 确认弹窗改用 ElMessageBox.confirm(替代 GERPUI.Confirm)
*/
import { ref, reactive, watch, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { get, post } from '@/api/request'
import { setPageTitle } from '@/utils/serverScope'
import { useAppStore } from '@/stores/app'
setPageTitle('系统设置')
const appStore = useAppStore()
/** 本地存储键(兜底用,对齐原生 GERPSettings) */
const STORAGE_KEY = 'GERPSettings'
/** 设置接口地址 */
const API_URL = '/API/Settings'
/** 设置数据结构 */
interface Settings {
/** 主题模式:default / light / dark / green / purple / red */
themeMode: string
/** 侧边栏默认展开 */
sidebarExpanded: boolean
/** 最大标签页数量 */
maxTabs: number
/** 允许重复标签页 */
allowDuplicate: boolean
/** 关闭标签页时确认 */
confirmClose: boolean
/** 启用桌面通知 */
desktopNotify: boolean
/** 通知声音 */
notifySound: boolean
/** 动画效果 */
animations: boolean
/** 预加载页面 */
preload: boolean
}
/** 默认设置(对齐原生 defaultSettings) */
const defaultSettings: Settings = {
themeMode: 'default',
sidebarExpanded: true,
maxTabs: 20,
allowDuplicate: false,
confirmClose: false,
desktopNotify: true,
notifySound: true,
animations: true,
preload: true,
}
/** 主题选项 */
const themeOptions = [
{ value: 'default', label: '默认主题' },
{ value: 'light', label: '浅色主题' },
{ value: 'dark', label: '深色主题' },
{ value: 'green', label: '绿色主题' },
{ value: 'purple', label: '紫色主题' },
{ value: 'red', label: '红色主题' },
]
/** 当前设置(响应式) */
const settings = reactive<Settings>({ ...defaultSettings })
/** 是否已加载完成(避免初始化期间触发即时保存) */
const initialized = ref(false)
/**
* 读取设置:优先后端 /API/Settings,失败回退 localStorage
* 响应拦截器返回 result.data(设置对象)
*/
async function loadSettings(): Promise<Settings> {
const merged: Settings = { ...defaultSettings }
try {
const data = await get<Partial<Settings> | null>(API_URL)
if (data && Object.keys(data).length > 0) {
Object.assign(merged, data)
}
} catch {
// 后端读取失败:回退 localStorage
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
try {
Object.assign(merged, JSON.parse(saved))
} catch {
/* 忽略损坏的本地数据 */
}
}
}
return merged
}
/** 静默保存:写入 localStorage + 后端,不弹成功提示(用于即时保存) */
let saveTimer: ReturnType<typeof setTimeout> | null = null
function debounceSave(delay = 200): void {
if (!initialized.value) return
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => saveSettings(true), delay)
}
/** 保存设置到 localStorage 与后端 */
async function saveSettings(silent = false): Promise<void> {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings))
try {
await post(API_URL, settings)
if (!silent) ElMessage.success('已保存')
} catch {
if (!silent) ElMessage.warning('保存到服务器失败')
}
}
/**
* 将设置应用到运行时(主题 / 侧边栏 / 动画)
* 替代原生 applySettingsToDOM + applySettingsToParent
*/
function applySettingsToRuntime(s: Settings): void {
// 动画:设置 CSS 变量(对齐原生 --TransitionSpeed / --AnimationDuration)
const root = document.documentElement
root.style.setProperty('--TransitionSpeed', s.animations ? '0.2s' : '0s')
root.style.setProperty('--AnimationDuration', s.animations ? '0.3s' : '0s')
root.classList.toggle('GERPNoAnimations', !s.animations)
// 侧边栏:同步到 appStore(sidebarExpanded 取反即 sidebarCollapsed)
if (appStore.sidebarCollapsed === s.sidebarExpanded) {
// 状态不一致时翻转一次;一致则不动
appStore.sidebarCollapsed = !s.sidebarExpanded
}
// 主题:appStore 仅支持 light / dark,其余主题 TODO
applyTheme(s.themeMode)
}
/** 应用主题:light/dark 走 appStore,其余主题 TODO 待 appStore 扩展 */
function applyTheme(mode: string): void {
if (mode === 'light' || mode === 'dark') {
appStore.setTheme(mode)
return
}
// TODO: appStore 当前仅支持 light/dark,default/green/purple/red 等主题待扩展后接入
// default 暂按 light 处理,避免无主题
if (mode === 'default') {
appStore.setTheme('light')
}
}
/** 主题切换处理:立即应用并即时保存 */
function onThemeChange(value: string): void {
applyTheme(value)
debounceSave(300)
}
/** 侧边栏切换处理:立即生效并即时保存 */
function onSidebarChange(value: string | number | boolean): void {
appStore.sidebarCollapsed = !value
debounceSave(200)
}
/** 动画切换处理:立即生效并即时保存 */
function onAnimationsChange(value: string | number | boolean): void {
const root = document.documentElement
root.style.setProperty('--TransitionSpeed', value ? '0.2s' : '0s')
root.style.setProperty('--AnimationDuration', value ? '0.3s' : '0s')
root.classList.toggle('GERPNoAnimations', !value)
debounceSave(200)
}
/** 清除缓存:清空本地设置并重新加载 */
async function onClearCache(): Promise<void> {
try {
await ElMessageBox.confirm('确定要清除所有缓存吗?', '清除缓存', {
type: 'warning',
confirmButtonText: '确定',
cancelButtonText: '取消',
})
} catch {
return
}
localStorage.removeItem(STORAGE_KEY)
try {
await post(API_URL, {})
} catch {
/* 忽略 */
}
const s = await loadSettings()
Object.assign(settings, s)
applySettingsToRuntime(settings)
ElMessage.success('缓存已清除')
}
/** 重置所有设置为默认值 */
async function onResetSettings(): Promise<void> {
try {
await ElMessageBox.confirm('确定要重置所有设置为默认值吗?', '重置设置', {
type: 'error',
confirmButtonText: '确定重置',
cancelButtonText: '取消',
})
} catch {
return
}
Object.assign(settings, defaultSettings)
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings))
try {
await post(API_URL, settings)
} catch {
/* 忽略 */
}
applyTheme('default')
applySettingsToRuntime(settings)
ElMessage.success('设置已重置')
}
// 即时保存:监听非主题/动画/侧边栏的开关与数值变化(这三个已单独处理立即生效)
watch(
() => [settings.maxTabs, settings.allowDuplicate, settings.confirmClose, settings.desktopNotify, settings.notifySound, settings.preload] as const,
() => debounceSave(200),
)
onMounted(async () => {
const s = await loadSettings()
Object.assign(settings, s)
applySettingsToRuntime(settings)
initialized.value = true
})
</script>
<template>
<div class="settings-page">
<!-- 页头 -->
<div class="settings-header">
<h1 class="settings-title">
<i class="fas fa-cog icon-primary" />
<span>系统设置</span>
</h1>
<p class="settings-desc">管理系统外观、行为和功能配置</p>
</div>
<!-- 外观设置 -->
<div class="settings-section">
<h2 class="settings-section-title">外观设置</h2>
<div class="settings-item">
<div class="settings-item-info">
<div class="settings-item-title">主题模式</div>
<div class="settings-item-desc">选择系统的主题风格</div>
</div>
<div class="settings-item-control">
<el-select
:model-value="settings.themeMode"
style="width: 160px"
@update:model-value="onThemeChange"
>
<el-option v-for="t in themeOptions" :key="t.value" :label="t.label" :value="t.value" />
</el-select>
</div>
</div>
<div class="settings-item">
<div class="settings-item-info">
<div class="settings-item-title">侧边栏默认展开</div>
<div class="settings-item-desc">启动时是否默认展开侧边栏</div>
</div>
<div class="settings-item-control">
<el-switch
:model-value="settings.sidebarExpanded"
@update:model-value="onSidebarChange"
/>
</div>
</div>
</div>
<!-- 标签页设置 -->
<div class="settings-section">
<h2 class="settings-section-title">标签页设置</h2>
<div class="settings-item">
<div class="settings-item-info">
<div class="settings-item-title">最大标签页数量</div>
<div class="settings-item-desc">超过数量时自动关闭最早的标签页</div>
</div>
<div class="settings-item-control">
<el-input-number v-model="settings.maxTabs" :min="5" :max="50" :step="1" />
</div>
</div>
<div class="settings-item">
<div class="settings-item-info">
<div class="settings-item-title">允许重复标签页</div>
<div class="settings-item-desc">是否允许打开相同页面的多个标签页</div>
</div>
<div class="settings-item-control">
<el-switch v-model="settings.allowDuplicate" />
</div>
</div>
<div class="settings-item">
<div class="settings-item-info">
<div class="settings-item-title">关闭标签页时确认</div>
<div class="settings-item-desc">关闭标签页前是否弹出确认对话框</div>
</div>
<div class="settings-item-control">
<el-switch v-model="settings.confirmClose" />
</div>
</div>
</div>
<!-- 通知设置 -->
<div class="settings-section">
<h2 class="settings-section-title">通知设置</h2>
<div class="settings-item">
<div class="settings-item-info">
<div class="settings-item-title">启用桌面通知</div>
<div class="settings-item-desc">接收系统桌面通知推送</div>
</div>
<div class="settings-item-control">
<el-switch v-model="settings.desktopNotify" />
</div>
</div>
<div class="settings-item">
<div class="settings-item-info">
<div class="settings-item-title">通知声音</div>
<div class="settings-item-desc">收到通知时播放提示音</div>
</div>
<div class="settings-item-control">
<el-switch v-model="settings.notifySound" />
</div>
</div>
</div>
<!-- 性能设置 -->
<div class="settings-section">
<h2 class="settings-section-title">性能设置</h2>
<div class="settings-item">
<div class="settings-item-info">
<div class="settings-item-title">动画效果</div>
<div class="settings-item-desc">启用界面过渡动画效果</div>
</div>
<div class="settings-item-control">
<el-switch
:model-value="settings.animations"
@update:model-value="onAnimationsChange"
/>
</div>
</div>
<div class="settings-item">
<div class="settings-item-info">
<div class="settings-item-title">预加载页面</div>
<div class="settings-item-desc">预加载常用页面以提升响应速度</div>
</div>
<div class="settings-item-control">
<el-switch v-model="settings.preload" />
</div>
</div>
</div>
<!-- 危险操作 -->
<div class="settings-section danger-zone">
<h2 class="settings-section-title">危险操作</h2>
<div class="settings-item">
<div class="settings-item-info">
<div class="settings-item-title">清除缓存</div>
<div class="settings-item-desc">清除所有本地存储的设置和缓存数据</div>
</div>
<div class="settings-item-control">
<el-button @click="onClearCache">清除缓存</el-button>
</div>
</div>
<div class="settings-item">
<div class="settings-item-info">
<div class="settings-item-title">重置所有设置</div>
<div class="settings-item-desc">将所有设置恢复为默认值</div>
</div>
<div class="settings-item-control">
<el-button type="danger" @click="onResetSettings">重置设置</el-button>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.settings-page {
padding: 24px;
max-width: 900px;
margin: 0 auto;
}
.settings-header {
margin-bottom: 24px;
.settings-title {
font-size: 24px;
font-weight: 700;
margin: 0 0 8px;
display: flex;
align-items: center;
gap: 8px;
.icon-primary {
color: var(--el-color-primary);
}
}
.settings-desc {
font-size: 14px;
color: var(--el-text-color-secondary);
margin: 0;
}
}
.settings-section {
background: var(--el-bg-color);
border-radius: 12px;
padding: 24px;
margin-bottom: 16px;
box-shadow: var(--el-box-shadow-light);
.settings-section-title {
font-size: 16px;
font-weight: 600;
margin: 0 0 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--el-border-color);
}
&.danger-zone {
border: 1px solid var(--el-color-danger);
background: var(--el-color-danger-light-9);
.settings-section-title {
color: var(--el-color-danger);
border-bottom-color: var(--el-color-danger-light-5);
}
}
}
.settings-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid var(--el-border-color);
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
&:first-child {
padding-top: 0;
}
.settings-item-info {
flex: 1;
}
.settings-item-title {
font-size: 14px;
font-weight: 500;
margin-bottom: 4px;
}
.settings-item-desc {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.settings-item-control {
margin-left: 16px;
}
}
</style>
+230
View File
@@ -0,0 +1,230 @@
<script setup lang="ts">
/**
* 租户入驻申请
*
* 迁移自 TempLate/GERP/pages/system/tenant-create.html
*
* 原生业务逻辑:
* - 表单:租户名称(必填)、有效期(必填,datetime)、申请说明(可选)
* - 提交申请:POST /API/Tenant/Apply (JSON: { TenantName, TenantExpireTime, ReviewRemark })
* - 提交成功后切换到成功提示页(申请已提交,等待审核通过后激活)
* - 成功页提供"继续申请"(重置表单)与"返回"(router.back)操作
*
* 迁移取舍:
* - server / admin_verify 由 request.ts 请求拦截器统一注入
* - 原生有效期用 GERPDateTimePicker 自定义组件,此处改用 el-date-picker type="datetime"
* - 表单校验用 el-form rules
* - 业务错误(code!==0)由拦截器统一 ElMessage.error,本文件 catch 后恢复按钮状态即可
*/
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { post } from '@/api/request'
import { setPageTitle } from '@/utils/serverScope'
const router = useRouter()
// === 表单数据 ===
const formRef = ref<FormInstance>()
const form = reactive({
TenantName: '',
TenantExpireTime: '',
ReviewRemark: '',
})
// === 校验规则 ===
const rules: FormRules = {
TenantName: [{ required: true, message: '请输入租户名称', trigger: 'blur' }],
TenantExpireTime: [{ required: true, message: '请选择有效期', trigger: 'change' }],
}
// === 状态 ===
/** 提交中 */
const submitting = ref(false)
/** 是否已提交成功(切换到成功页) */
const submitted = ref(false)
// === 提交申请 ===
async function submitTenant(): Promise<void> {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch {
return // 校验失败
}
submitting.value = true
try {
await post('/API/Tenant/Apply', {
TenantName: form.TenantName.trim(),
TenantExpireTime: form.TenantExpireTime,
ReviewRemark: form.ReviewRemark.trim(),
})
// 成功:切换到成功页
submitted.value = true
ElMessage.success('申请已提交')
} catch {
// 业务错误已由拦截器统一提示
} finally {
submitting.value = false
}
}
// === 成功页操作 ===
/** 继续申请:重置表单并回到表单页 */
function resetForNew(): void {
submitted.value = false
form.TenantName = ''
form.TenantExpireTime = ''
form.ReviewRemark = ''
formRef.value?.clearValidate()
}
/** 返回上一页 */
function goBack(): void {
router.back()
}
onMounted(() => {
setPageTitle('租户入驻申请')
})
</script>
<template>
<div class="tenant-create-page">
<div class="tenant-create-card">
<!-- 表单区 -->
<div v-if="!submitted" class="form-section">
<div class="card-title">
<i class="fas fa-key icon-primary" />
<span>租户入驻申请</span>
</div>
<div class="card-desc">提交租户入驻申请,等待超管或审核员审核通过后激活。</div>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
class="tenant-form"
>
<el-form-item label="租户名称" prop="TenantName">
<el-input v-model="form.TenantName" placeholder="请输入租户名称" />
</el-form-item>
<el-form-item label="有效期" prop="TenantExpireTime">
<el-date-picker
v-model="form.TenantExpireTime"
type="datetime"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择有效期"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="申请说明">
<el-input
v-model="form.ReviewRemark"
type="textarea"
:rows="3"
placeholder="可选,填写申请理由或备注"
/>
</el-form-item>
</el-form>
<div class="card-actions">
<el-button @click="goBack">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitTenant">提交申请</el-button>
</div>
</div>
<!-- 成功页 -->
<div v-else class="success-section">
<i class="fas fa-check-circle success-icon" />
<h3>申请已提交</h3>
<p>租户入驻申请已提交,等待审核通过后激活。</p>
<div class="success-actions">
<el-button type="primary" @click="resetForNew">继续申请</el-button>
<el-button @click="goBack">返回</el-button>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.tenant-create-page {
max-width: 640px;
margin: 0 auto;
padding: 24px;
box-sizing: border-box;
}
.tenant-create-card {
background: var(--el-bg-color, #fff);
border-radius: 12px;
border: 1px solid var(--el-border-color, #e2e8f0);
padding: 32px;
}
.card-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 10px;
.icon-primary {
color: var(--el-color-primary);
}
}
.card-desc {
font-size: 13px;
color: var(--el-text-color-secondary);
margin-bottom: 24px;
}
.tenant-form {
display: flex;
flex-direction: column;
gap: 4px;
}
.card-actions {
display: flex;
gap: 12px;
margin-top: 24px;
justify-content: flex-end;
}
// 成功页
.success-section {
text-align: center;
padding: 40px 0;
}
.success-icon {
font-size: 48px;
color: var(--el-color-success);
margin-bottom: 16px;
}
.success-section h3 {
font-size: 18px;
margin: 0 0 8px;
}
.success-section p {
font-size: 14px;
color: var(--el-text-color-secondary);
margin: 0 0 24px;
}
.success-actions {
display: flex;
gap: 12px;
justify-content: center;
}
</style>
+324
View File
@@ -0,0 +1,324 @@
<script setup lang="ts">
/**
* 租户审核工作台
*
* 迁移自 TempLate/GERP/pages/system/tenant-review.html
*
* 原生业务逻辑:
* - 加载待审核租户列表:GET /API/Tenant/Pending
* - 权限校验:业务 code 403 → 显示"无权访问审核工作台";其他非 0 → 显示错误信息
* - 卡片列表:租户名称、有效期、申请时间、申请说明(备注)
* - 批准:二次确认后 POST /API/Tenant/Approve/{TenantUUID}
* - 拒绝:弹窗输入拒绝理由(必填)→ POST /API/Tenant/Reject/{TenantUUID} (JSON: { ReviewRemark })
* - 操作成功后刷新列表
*
* 迁移取舍:
* - server / admin_verify 由 request.ts 请求拦截器统一注入
* - 原生用 GERP.SetTitle 设标题,此处用 setPageTitle
* - ⚠ 全局响应拦截器在 code!==0 时统一 ElMessage.error 并 reject。原生对 403 显示固定文案
* "无权访问审核工作台",新架构下 403 会被拦截器以 result.msg 形式提示并 reject,本文件在
* catch 中将 error.message 写入页面 errorState 占位显示(与拦截器 toast 形成双保险)。
* 若需还原固定文案,可在 catch 中按 error.message 匹配关键字判断(TODO)。
* - 批准/拒绝成功后由拦截器统一提示,本文件额外不重复 toast,仅刷新列表
*/
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { get, post } from '@/api/request'
import { setPageTitle } from '@/utils/serverScope'
// === 类型定义 ===
/** 待审核租户记录 */
interface PendingTenant {
TenantUUID: string
TenantName: string
TenantExpireTime: string
CreateTime: string
ReviewRemark: string
}
// === 状态 ===
/** 待审核列表 */
const list = ref<PendingTenant[]>([])
/** 加载态 */
const loading = ref(false)
/** 错误状态文案(非空时替代列表显示) */
const errorState = ref('')
/** 拒绝弹窗可见 */
const rejectVisible = ref(false)
/** 拒绝目标租户 UUID */
const rejectTargetUuid = ref('')
/** 拒绝目标租户名称(弹窗展示) */
const rejectTargetName = ref('')
/** 拒绝理由 */
const rejectReason = ref('')
/** 拒绝提交中 */
const rejecting = ref(false)
// === 工具函数 ===
/** 时间格式化:去掉 T 分隔,截取到秒 */
function formatTime(t: string): string {
if (!t) return '-'
return t.replace('T', ' ').substring(0, 19)
}
// === 数据加载 ===
/** 加载待审核租户列表 */
async function loadPending(): Promise<void> {
loading.value = true
errorState.value = ''
try {
// 拦截器在 code===0 时返回 result.data(数组)
const data = await get<PendingTenant[]>('/API/Tenant/Pending')
list.value = Array.isArray(data) ? data : []
} catch (err) {
// code!==0(含 403 无权访问)由拦截器 reject,此处显示页面内错误占位
list.value = []
errorState.value = err instanceof Error ? err.message : '加载失败'
} finally {
loading.value = false
}
}
// === 批准 ===
/** 批准租户入驻(二次确认) */
async function approveTenant(uuid: string): Promise<void> {
try {
await ElMessageBox.confirm('确认批准该租户入驻申请?', '提示', {
type: 'warning',
confirmButtonText: '确认批准',
cancelButtonText: '取消',
})
} catch {
return // 用户取消
}
try {
await post(`/API/Tenant/Approve/${uuid}`)
ElMessage.success('审核通过')
loadPending()
} catch {
// 业务错误已由拦截器统一提示
}
}
// === 拒绝 ===
/** 打开拒绝弹窗 */
function openReject(uuid: string, name: string): void {
rejectTargetUuid.value = uuid
rejectTargetName.value = name
rejectReason.value = ''
rejectVisible.value = true
}
/** 确认拒绝(理由必填) */
async function confirmReject(): Promise<void> {
if (!rejectReason.value.trim()) {
ElMessage.warning('请输入拒绝理由')
return
}
rejecting.value = true
try {
await post(`/API/Tenant/Reject/${rejectTargetUuid.value}`, {
ReviewRemark: rejectReason.value.trim(),
})
ElMessage.success('已拒绝申请')
rejectVisible.value = false
loadPending()
} catch {
// 业务错误已由拦截器统一提示
} finally {
rejecting.value = false
}
}
onMounted(() => {
setPageTitle('租户审核工作台')
loadPending()
})
</script>
<template>
<div class="tenant-review-page">
<!-- 页头 -->
<div class="review-header">
<h1 class="page-title">
<i class="fas fa-clipboard-check icon-primary" />
<span>租户审核工作台</span>
</h1>
<el-button @click="loadPending">
<i class="fas fa-sync" /> 刷新
</el-button>
</div>
<!-- 列表区 -->
<div class="review-container" v-loading="loading">
<!-- 错误状态 -->
<div v-if="errorState" class="error-state">
<i class="fas fa-exclamation-triangle" />
<div>{{ errorState }}</div>
</div>
<!-- 空状态 -->
<el-empty
v-else-if="!loading && list.length === 0"
description="暂无待审核的租户申请"
:image-size="60"
/>
<!-- 待审核卡片列表 -->
<div v-else class="review-list">
<div v-for="t in list" :key="t.TenantUUID" class="review-card">
<div class="info">
<div class="name">{{ t.TenantName }}</div>
<div class="meta">
<span><i class="fas fa-calendar" /> 有效期: {{ formatTime(t.TenantExpireTime) }}</span>
<span><i class="fas fa-clock" /> 申请时间: {{ formatTime(t.CreateTime) }}</span>
</div>
<div v-if="t.ReviewRemark" class="remark">
<i class="fas fa-comment" /> {{ t.ReviewRemark }}
</div>
</div>
<div class="actions">
<el-button type="primary" @click="approveTenant(t.TenantUUID)">
<i class="fas fa-check" /> 批准
</el-button>
<el-button type="danger" @click="openReject(t.TenantUUID, t.TenantName)">
<i class="fas fa-times" /> 拒绝
</el-button>
</div>
</div>
</div>
</div>
<!-- 拒绝弹窗 -->
<el-dialog v-model="rejectVisible" title="拒绝入驻申请" width="420px">
<p class="reject-target">租户:<span>{{ rejectTargetName }}</span></p>
<el-input
v-model="rejectReason"
type="textarea"
:rows="4"
placeholder="请输入拒绝理由"
/>
<template #footer>
<el-button @click="rejectVisible = false">取消</el-button>
<el-button type="danger" :loading="rejecting" @click="confirmReject">确认拒绝</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped lang="scss">
.tenant-review-page {
padding: 24px;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.review-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
flex-shrink: 0;
.page-title {
margin: 0;
flex: 1;
font-size: 18px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
.icon-primary {
color: var(--el-color-primary);
}
}
}
.review-container {
flex: 1;
min-height: 0;
overflow: auto;
}
.review-list {
display: grid;
gap: 12px;
}
.review-card {
background: var(--el-bg-color, #fff);
border: 1px solid var(--el-border-color, #e2e8f0);
border-radius: 10px;
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
.info {
flex: 1;
min-width: 0;
}
.name {
font-size: 16px;
font-weight: 600;
margin-bottom: 4px;
}
.meta {
font-size: 13px;
color: var(--el-text-color-secondary);
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.remark {
font-size: 13px;
color: var(--el-text-color-secondary);
margin-top: 6px;
padding: 8px 12px;
background: var(--el-fill-color-light, #f8fafc);
border-radius: 6px;
}
.actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
}
.error-state {
color: var(--el-color-danger);
padding: 40px;
text-align: center;
i {
font-size: 40px;
display: block;
margin-bottom: 12px;
}
}
.reject-target {
font-size: 13px;
color: var(--el-text-color-secondary);
margin: 0 0 12px;
span {
color: var(--el-text-color-primary);
font-weight: 600;
}
}
</style>
+360
View File
@@ -0,0 +1,360 @@
<script setup lang="ts">
/**
* 用户详情页 UserDetail
*
* 迁移自 TempLate/GERP/pages/system/user-detail.html
*
* 业务结构:
* 1. 基本信息(InfoGrid):用户名 / 邮箱 / 电话 / 性别 / 账号状态 / 是否员工 / 是否超级用户 / 创建时间 / 最后登录
* 2. 2FA 双因素信息卡(TODO:GetUser2FAInfo 接口契约确认后补全字段渲染)
* 3. 角色与组织子表(CrudTable: UserRole,按 UserUUID 过滤)
* 4. 部门关联子表(CrudTable: UserDept,按 UserUUID 过滤)
* 5. 隔离提示横幅:管理员特权(跨公司)vs 仅当前公司
* 6. 编辑按钮:基于列定义的动态编辑弹窗(formApi.update)
*
* 迁移要点(对齐工程迁移规则):
* - server / admin_verify 由 request.ts 拦截器统一注入,不再手动拼 buildApiUrl
* - 主表单条加载:formApi.list('User', { UserUUID, pageSize: 1 }) 取首条
* - 子表 CrudTable 通过 query 透传父级 UUID,无需手动建表
* - UUID 解析使用 useResolveUUIDs composable(本页主表暂无 UUID 字段需解析)
* - role_scope 细粒度判断暂用 userStore.isAdmin 二分(TODO:接入 checkAdmin 后补 dept_manager / workgroup_lead)
* - 2FA 接口:get('/API/SiteUserManagement/GetUser2FAInfo', { UserUUID })(TODO:契约确认)
*/
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import DetailPage from '@/components/DetailPage.vue'
import InfoGrid from '@/components/InfoGrid.vue'
import CrudTable from '@/components/CrudTable.vue'
import EntityPicker from '@/components/EntityPicker.vue'
import { formApi } from '@/api/form'
import { get } from '@/api/request'
import { useUserStore } from '@/stores/user'
import { setPageTitle } from '@/utils/serverScope'
import type { ColumnSchema } from '@/types/api'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
// 路由参数:用户 UUID
const userUUID = (route.query.UserUUID as string) || ''
// === 状态 ===
const loading = ref(false)
const data = ref<Record<string, any> | null>(null)
const twoFactorInfo = ref<Record<string, any> | null>(null)
// 编辑弹窗状态
const editDialogVisible = ref(false)
const editSubmitting = ref(false)
const columns = ref<ColumnSchema[]>([])
const editForm = reactive<Record<string, any>>({})
// 主键字段
const PK_FIELD = 'UserUUID'
// 页面标题:有数据时显示用户名
const title = computed(() => data.value?.UserName || '用户详情')
// 隔离提示:管理员跨公司 vs 仅当前公司
// TODO: 接入 checkAdmin → role_scope,补 dept_manager / workgroup_lead 分支
const isolationHint = computed(() =>
userStore.isAdmin
? '特权模式:显示该用户在所有公司、所有部门的关联记录(无公司隔离)'
: '仅显示当前公司数据。如需查看所有公司记录,需管理员特权。',
)
// === 显示格式化 helper ===
/** 性别:1→男,0→女 */
function genderText(g: unknown): string {
if (g === 1 || g === '1') return '男'
if (g === 0 || g === '0') return '女'
return '-'
}
/** 是 / 否 文本 */
function yesNoText(val: unknown): string {
return val ? '是' : '否'
}
/** 账号状态标签:1→启用,0→禁用,其他→待审核 */
function accountLicenseTag(v: unknown): { text: string; type: 'success' | 'danger' | 'warning' | 'info' } {
if (v === 1 || v === '1') return { text: '启用', type: 'success' }
if (v === 0 || v === '0') return { text: '禁用', type: 'danger' }
// TODO: 对齐原生 CompanyUtils.statusTag 的完整码表
return { text: String(v ?? '-'), type: 'warning' }
}
/** 时间格式化:ISO/时间戳 → YYYY-MM-DD HH:mm,无法解析则原样返回 */
function formatTime(v: unknown): string {
if (!v) return '-'
const d = new Date(v as string)
if (isNaN(d.getTime())) return String(v)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
// 编辑弹窗可见列:排除隐藏 / 主键 / 不可编辑 / 非表单类型
const formColumns = computed(() =>
columns.value.filter(
(c) =>
c.editable !== false &&
!c.hideInForm &&
c.type !== 'hidden' &&
c.type !== 'divider' &&
c.type !== 'html' &&
c.field !== PK_FIELD,
),
)
// === 数据加载 ===
/** 加载用户主表单条数据 */
async function loadData(): Promise<void> {
if (!userUUID) {
ElMessage.warning('缺少 UserUUID 参数,无法加载用户详情')
return
}
loading.value = true
try {
const result = await formApi.list('User', { [PK_FIELD]: userUUID, pageSize: 1 })
const list: any[] = Array.isArray(result) ? result : (result as any)?.list || []
const row = list.find((u) => u[PK_FIELD] === userUUID) || list[0]
if (!row) {
ElMessage.error('未找到用户数据')
return
}
data.value = row
setPageTitle(row.UserName || '用户详情')
} finally {
loading.value = false
}
}
/** 加载 2FA 双因素信息(TODO:接口契约确认后补全渲染) */
async function loadTwoFactor(): Promise<void> {
if (!userUUID) return
try {
// TODO: GetUser2FAInfo 返回字段契约确认(Enabled / BackupCodes / BoundAt 等)
twoFactorInfo.value = await get('/API/SiteUserManagement/GetUser2FAInfo', { UserUUID: userUUID })
} catch {
// 2FA 信息加载失败不阻塞主流程(拦截器已统一提示)
twoFactorInfo.value = null
}
}
/** 加载列定义(用于编辑弹窗动态渲染) */
async function loadColumns(): Promise<void> {
try {
columns.value = await formApi.getColumns('User')
} catch {
// 列定义加载失败不阻塞展示
}
}
// === 编辑弹窗 ===
/** 打开编辑弹窗:拷贝当前数据到表单 */
function handleEdit(): void {
if (!data.value) return
Object.keys(editForm).forEach((k) => delete editForm[k])
Object.assign(editForm, data.value)
editDialogVisible.value = true
}
/** 提交编辑:formApi.update 全量更新 */
async function handleSubmit(): Promise<void> {
editSubmitting.value = true
try {
await formApi.update('User', editForm[PK_FIELD], editForm)
ElMessage.success('保存成功')
editDialogVisible.value = false
await loadData()
} finally {
editSubmitting.value = false
}
}
// === 子表跳转 ===
/** UserRole 子表查看 → 角色详情 */
function goRoleDetail(row: Record<string, any>): void {
router.push({ path: '/system/role-detail', query: { RoleUUID: row.RoleUUID || '' } })
}
/** UserDept 子表查看 → 部门详情 */
function goDeptDetail(row: Record<string, any>): void {
router.push({ path: '/company/department-detail', query: { DeptUUID: row.DeptUUID || '' } })
}
onMounted(async () => {
await Promise.all([loadData(), loadColumns()])
void loadTwoFactor()
})
</script>
<template>
<DetailPage :title="title" :loading="loading">
<template #actions>
<el-button type="primary" @click="handleEdit">
<el-icon><Edit /></el-icon>编辑
</el-button>
</template>
<!-- 隔离提示横幅 -->
<el-alert :type="userStore.isAdmin ? 'warning' : 'info'" :closable="false" show-icon>
{{ isolationHint }}
</el-alert>
<!-- 基本信息 -->
<InfoGrid title="基本信息" :columns="2">
<el-descriptions-item label="用户名">{{ data?.UserName || '-' }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ data?.UserEmail || '-' }}</el-descriptions-item>
<el-descriptions-item label="电话">{{ data?.UserTele || '-' }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ genderText(data?.UserGender) }}</el-descriptions-item>
<el-descriptions-item label="账号状态">
<el-tag :type="accountLicenseTag(data?.UserAccountLicense).type" size="small">
{{ accountLicenseTag(data?.UserAccountLicense).text }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="是否员工">{{ yesNoText(data?.IsStaff) }}</el-descriptions-item>
<el-descriptions-item label="是否超级用户">{{ yesNoText(data?.IsSuperuser) }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(data?.UserCreateTime) }}</el-descriptions-item>
<el-descriptions-item label="最后登录">{{ formatTime(data?.UserLastLoginDate) }}</el-descriptions-item>
</InfoGrid>
<!-- 2FA 双因素信息(TODO:契约确认后补全字段) -->
<InfoGrid title="双因素认证 (2FA)" :columns="2">
<el-descriptions-item label="2FA 状态">
<!-- TODO: GetUser2FAInfo 字段契约确认后替换为真实状态渲染 -->
<el-tag v-if="twoFactorInfo" type="success" size="small">已加载</el-tag>
<el-tag v-else type="info" size="small">未启用 / 未加载</el-tag>
</el-descriptions-item>
<el-descriptions-item label="说明">
<span style="color: #909399; font-size: 12px">2FA 详情字段待 GetUser2FAInfo 契约确认后接入</span>
</el-descriptions-item>
</InfoGrid>
<!-- 角色与组织子表 -->
<el-card shadow="never">
<template #header>角色与组织</template>
<CrudTable
v-if="userUUID"
table="UserRole"
title="用户角色"
:query="{ UserUUID: userUUID }"
:pk-field="'UserRoleUUID'"
selectable
pageable
:on-view="goRoleDetail"
/>
</el-card>
<!-- 部门关联子表 -->
<el-card shadow="never">
<template #header>部门关联</template>
<CrudTable
v-if="userUUID"
table="UserDept"
title="用户部门"
:query="{ UserUUID: userUUID }"
:pk-field="'UserDeptUUID'"
selectable
pageable
:on-view="goDeptDetail"
/>
</el-card>
<!-- 编辑弹窗:基于列定义动态渲染 -->
<el-dialog v-model="editDialogVisible" title="编辑用户" width="600px" destroy-on-close>
<el-form :model="editForm" label-width="120px" label-position="right">
<el-form-item
v-for="col in formColumns"
:key="col.field"
:label="col.title"
:required="col.required"
>
<EntityPicker
v-if="col.type === 'entityPicker' && col.entityPicker"
v-model="editForm[col.field]"
:config="col.entityPicker"
:existing-label="editForm[col.field + '_label']"
:placeholder="col.placeholder"
:disabled="col.disabled"
@update:label="(label: string) => (editForm[col.field + '_label'] = label)"
/>
<el-input
v-else-if="!col.type || col.type === 'text' || col.type === 'string'"
v-model="editForm[col.field]"
:placeholder="col.placeholder"
:maxlength="col.maxLength"
:disabled="col.disabled"
/>
<el-input
v-else-if="col.type === 'textarea'"
v-model="editForm[col.field]"
type="textarea"
:rows="col.rows || 3"
:disabled="col.disabled"
/>
<el-input-number
v-else-if="col.type === 'number'"
v-model="editForm[col.field]"
:min="col.min"
:max="col.max"
:step="col.step"
:disabled="col.disabled"
/>
<el-select
v-else-if="col.type === 'select'"
v-model="editForm[col.field]"
:placeholder="col.placeholder"
:disabled="col.disabled"
style="width: 100%"
>
<el-option
v-for="opt in col.options"
:key="String(opt.value)"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-date-picker
v-else-if="col.type === 'date' || col.type === 'datetime'"
v-model="editForm[col.field]"
:type="col.type as 'date' | 'datetime'"
:placeholder="col.placeholder"
style="width: 100%"
/>
<el-switch
v-else-if="col.type === 'switch' || col.type === 'boolean'"
v-model="editForm[col.field]"
:disabled="col.disabled"
/>
<el-input
v-else-if="col.type === 'password'"
v-model="editForm[col.field]"
type="password"
show-password
:placeholder="col.placeholder"
/>
<el-input
v-else
:model-value="String(editForm[col.field] ?? '')"
readonly
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="editSubmitting" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
</DetailPage>
</template>
<style scoped lang="scss">
// el-card 之间留白由 DetailPage 的 detail-content gap 控制
</style>
+128
View File
@@ -0,0 +1,128 @@
<script setup lang="ts">
/**
* 用户管理
*
* 迁移自 TempLate/GERP/pages/system/users.html(151 行)
* 原生实现:调 GERP.CheckAdmin() 获取 role_scope → 按作用域差异化标题/横幅/可编辑性/行选择
* + onView 跳详情 + 非主实例列 fallback + AdminBadge 特权徽章
*
* Vue 版(阶段 1 简化):
* - server / admin_verify 由 request.ts 拦截器统一注入
* - 作用域判断暂用 userStore.isAdmin 二分(管理员 / 普通组员)
* - 细粒度 role_scope(dept_manager / workgroup_lead)待 checkAdmin 接口确认后接入(TODO)
* - 列 fallback 暂未实现,主实例走后端 Columns 接口(TODO:非主实例 getDefaultColumns)
* - AdminBadge 自动根据 server/admin_verify 显示特权横幅
* - onView 跳 /system/user-detail?UserUUID=xxx
*/
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import CrudTable from '@/components/CrudTable.vue'
import AdminBadge from '@/components/AdminBadge.vue'
import { useUserStore } from '@/stores/user'
import { setPageTitle } from '@/utils/serverScope'
const userStore = useUserStore()
const router = useRouter()
setPageTitle('用户管理')
// 阶段 1 简化:仅区分管理员 / 普通组员
// TODO: 接入 checkAdmin → role_scope,补 dept_manager / workgroup_lead 分支
const scopeType = computed<'admin' | 'member'>(() =>
userStore.isAdmin ? 'admin' : 'member',
)
// 作用域提示文本(对齐原生 hintMap)
const scopeHint = computed(() => {
switch (scopeType.value) {
case 'admin':
return '管理员视图:可查看和管理全公司用户,含跨公司特权模式。'
case 'member':
default:
return '普通组员视图:仅可查看和编辑自己的资料,无法查看他人数据。'
}
})
// 作用域横幅样式类(对齐原生 user-scope-banner 样式分支)
const scopeBannerClass = computed(() => `user-scope-banner ${scopeType.value}`)
// 普通组员:禁用行选择与编辑
const isMember = computed(() => scopeType.value === 'member')
// 用户记录类型
interface UserRow {
UserUUID?: string
UserName?: string
[k: string]: unknown
}
// 跳转用户详情页
function handleView(row: UserRow): void {
router.push({
path: '/system/user-detail',
query: { UserUUID: row.UserUUID || '' },
})
}
</script>
<template>
<div class="user-page">
<!-- 管理员特权横幅(仅特权模式显示) -->
<AdminBadge />
<!-- 作用域提示横幅 -->
<div :class="scopeBannerClass">
<i class="fas" :class="isMember ? 'fa-user' : 'fa-shield-alt'" />
<span>{{ scopeHint }}</span>
</div>
<CrudTable
table="User"
title="用户管理"
pk-field="UserUUID"
searchable
pageable
:selectable="!isMember"
:on-view="handleView"
/>
</div>
</template>
<style scoped lang="scss">
.user-page {
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
// 作用域横幅样式(对齐原生 user-scope-banner)
.user-scope-banner {
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
i {
font-size: 16px;
}
// 管理员:蓝色
&.admin {
background: rgba(30, 64, 175, 0.08);
color: #1e40af;
border: 1px solid rgba(30, 64, 175, 0.2);
}
// 普通组员:灰色
&.member {
background: rgba(100, 116, 139, 0.08);
color: #475569;
border: 1px solid rgba(100, 116, 139, 0.2);
}
}
</style>
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
/**
* 工单管理(管理员视图)
*
* 迁移自 TempLate/GERP/pages/workorder/admin.html(55 行)
* 原生实现:拉取 /API/FormAPI/Columns/WorkOrder 后实例化 TableEngine,全权委托 CRUD
*
* Vue 版:直接挂载 CrudTable,表名 WorkOrder,开启分页/搜索/行选择
* server / admin_verify 由 request.ts 拦截器统一注入,无需页面关心
*/
import CrudTable from '@/components/CrudTable.vue'
import { setPageTitle } from '@/utils/serverScope'
setPageTitle('工单管理')
</script>
<template>
<div class="wo-admin-page">
<CrudTable table="WorkOrder" title="工单管理" searchable pageable selectable />
</div>
</template>
<style scoped lang="scss">
.wo-admin-page {
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
</style>
+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
/**
* 我的工单(当前用户视图)
*
* 迁移自 TempLate/GERP/pages/workorder/my.html(72 行)
* 原生实现:跨域请求 https://ugm.gvsds.com/Query/GetELTUserInfo 获取 UserUUID,
* 再以 ?CreatorUUID=<uuid> 拼接到 Get 接口过滤;含"提交工单"按钮触发 openAddDialog
*
* Vue 版:
* - UserUUID 改由 Pinia userStore 提供(已通过 /Query/GetELTUserInfo 拉取),消除跨域
* - 过滤条件走 CrudTable 的 query 参数,由 useCrudTable 注入到 list 请求
* - "提交工单"即新增,复用 CrudTable 内置"新增"按钮,不另设
* - 不开启行选择(个人工单无需批量删除)
*/
import { computed } from 'vue'
import CrudTable from '@/components/CrudTable.vue'
import { useUserStore } from '@/stores/user'
import { setPageTitle } from '@/utils/serverScope'
const userStore = useUserStore()
setPageTitle('我的工单')
// 以当前用户 UUID 作为过滤条件;未登录时为空对象(CrudTable 会拉全部,但路由守卫已保证登录)
const query = computed(() => {
const uuid = userStore.userInfo?.UserUUID
return uuid ? { CreatorUUID: uuid } : {}
})
</script>
<template>
<div class="wo-my-page">
<CrudTable table="WorkOrder" title="我的工单" :query="query" searchable pageable :selectable="false" />
</div>
</template>
<style scoped lang="scss">
.wo-my-page {
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
</style>
+1 -2
View File
@@ -4,9 +4,8 @@
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client", "node"],
"allowArbitraryExtensions": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
"@/*": ["./src/*"]
},
/* Linting */