34 Commits

Author SHA1 Message Date
dk
9d147ee263 feat(feedback): 增加反馈搜索状态筛选功能
- 在反馈搜索组件中引入状态字典选项
- 为反馈搜索区域增加状态选择器字段
- 设置反馈页面初始搜索参数中的状态值为1

fix(personal-center): 限制已完成项目的编辑操作
- 根据项目完成状态动态控制编辑按钮的显示
- 从个人中心我的项目详情对话框中移除已完成项目的操作权限

refactor(project): 优化任务操作权限控制逻辑
- 移除原有的任务权限钩子函数依赖
- 使用当前用户身份直接判断任务操作权限
- 只有任务负责人才能进行编辑和删除操作
- 已完成任务不可编辑
- 重构成更清晰的任务操作按钮显示逻辑

fix(project): 完成任务禁用工时记录删除功能
- 添加计算属性控制完成状态下的工时记录删除权限
- 在工时记录面板中根据任务状态控制删除操作的显示
2026-07-02 16:34:08 +08:00
dk
a8e2bac65a feat(log、post、user、project、work-report): 添加用户姓名字段并实现岗位用户导出功能
- 在API访问日志、API错误日志中新增用户姓名字段显示
- 优化日志管理页面布局样式,修复高度自适应问题
- 添加系统岗位数据导出功能及相应API接口
- 添加系统用户数据导出功能及相应API接口
- 新增通用文件下载和导出文件名生成工具函数
- 修复工作报表删除确认提示文案
- 调整项目列表默认展开状态逻辑
- 完善岗位和用户管理页面导出按钮权限控制
- 更新日志实体类型定义,增加用户昵称字段
2026-07-01 17:15:12 +08:00
dk
a650f6e96d fix(我的事项、产品/项目、工作台我的待办): 修复已知的一些问题。 2026-06-30 11:55:43 +08:00
4f357a35a9 fix(projects): 微调布局显示 2026-06-29 09:53:24 +08:00
dk
499f2115b2 fix(工作报告、我的绩效、产品/项目、消息通知): 修复已知的一些问题。 2026-06-28 14:04:28 +08:00
a4884035cd feat(projects): 增加意见反馈 2026-06-27 08:55:33 +08:00
dk
570f284230 feat(日志管理): 开发日志管理功能。
fix(项目任务): 1、任务完成后需要依然能够修改工作日志,但是只能修改工作内容和上传附件。2、任务完成后,协办人的工作日志不应该能删除、所有任务里的成员不能新增工作日志,前端不显示新增、删除按钮。3、团队成员的面板,在成员排序时,让有下属的成员提前。4、在任务弹出框有个快速用执行的信息填充的icon。
2026-06-25 21:34:23 +08:00
dk
ea6a816d58 fix(工作报告- 周报): 修复工作日志依然溢出到任务层级的问题。 2026-06-24 18:28:00 +08:00
dk
3ffdad142d fix(产品需求、项目需求、工作报告、我的绩效、加班申请): 1、修复搜索区域的下拉框,无法搜索的问题。2、修复工作报告内容溢出的问题。3、修复我的待办 - 待审批,里面的二级tabs没有加权限的问题。4、优化周报里工作日志展示时的分隔符。5、优化项目需求池、我的绩效请求重复发送、影响效率的问题。
feat(我的绩效): 1、增加我的绩效在工作台可以直接处理的功能。2、增加我的绩效全部导出时,合并多个excel的sheet为一个excel的功能。
2026-06-24 18:02:36 +08:00
dk
b26a9c8a39 fix(产品管理、项目管理、登录密码校验、工作报告): 修复用户们提出的一系列问题。 2026-06-23 17:59:42 +08:00
dk
10418fea0a fix(table-search-fields): 修复搜索框的组件无法使用的问题。 2026-06-23 10:04:39 +08:00
dk
632c123112 fix(加班申请、工作报告、我的绩效): 重构页面样式、修复一系列bug、对不合理的地方进行调整。 2026-06-22 23:07:21 +08:00
b1d52b852f style(projects): 微调 2026-06-22 20:47:20 +08:00
4a7f54b0ed refactor(projects): 1 工作台缓存优化;2 工作日志总工时下进度显示 2026-06-22 19:28:39 +08:00
61fe9ef143 style(projects): 微调 2026-06-22 14:31:41 +08:00
dk
9a5845708d feat(我的绩效): 开发我的绩效功能。
fix(加班申请、工作报告): 重构加班申请在审批时的样式,工作报告在新增时的对话框、报告详情页的样式。
2026-06-21 18:22:44 +08:00
dk
cd64cf42cc fix(产品需求、项目需求): 修复权限码的语义问题。 2026-06-17 20:49:18 +08:00
31344f1d58 feat(projects): 微调 2026-06-17 19:27:17 +08:00
dk
1543bf76a9 feat(加班申请): 修复加班申请的权限码绑定错误的问题。 2026-06-17 14:21:16 +08:00
dk
622d8d5a4d feat(产品需求、项目需求): 支持手动录入"来源业务编号",前端的sourceBizId改为sourceBizCode,也支持按"来源业务编号"搜索。 2026-06-15 11:30:07 +08:00
dk
3c1cf6c7fa feat(工作报告、加班申请团队视角): 工作报告、加班申请现在可以查看团队视角了(查看下属)。
fix(工作报告): 修复周报在新增/编辑时,不能展示工作日志。
2026-06-14 23:57:42 +08:00
17690283f6 Merge remote-tracking branch 'origin/main' 2026-06-14 09:11:33 +08:00
dk
030dc737fc feat(工作报告定时生成): 工作报告现在可以定时生成,并且可以刷新当前报告。 2026-06-13 22:13:44 +08:00
609a01dc8a refactor(projects): 消息提示增加等级区分 2026-06-13 14:59:31 +08:00
dk
80f028bcb9 fix(工作报告): 修复工作报告存在的若干问题。
feat(加班申请): 支持批量审批。
2026-06-13 13:06:39 +08:00
5061eced32 refactor(projects): 登录页面重新设计 2026-06-12 22:42:23 +08:00
6896a86130 feat(projects): 工作台接口切换为真实数据 2026-06-12 19:49:17 +08:00
0652a24c5e feat(projects): 1、站内信、通知功能完善;2、项目列表按会议需求重新开发 2026-06-11 14:02:26 +08:00
dk
d53a8dfae5 fix(加班申请): 去掉撤销相关的状态和动作。
feat(工作报告): 开发工作报告功能
2026-06-11 10:56:24 +08:00
2e369b23a9 refactor(projects): 删除废弃代码 2026-06-05 16:29:35 +08:00
b72ad00912 fix(error-message): 删除用户可见错误文案规范HTML文档
- 移除了完整的用户可见错误文案规范HTML文件
2026-06-04 21:07:44 +08:00
7cc29e0a35 fix(projects): 针对技术负债去优化代码 2026-06-04 21:06:05 +08:00
39458386ae feat(projects): 工作台部分组件调成真实数据 2026-06-04 11:26:51 +08:00
dk
acef4418d8 fix(加班申请): 使用后端专门返回状态的接口,代替使用字典。
fix(status-tag.ts):把产品需求、项目需求的状态颜色定义收敛到此处。
2026-06-04 10:49:34 +08:00
265 changed files with 37168 additions and 16160 deletions

4
.env
View File

@@ -2,9 +2,9 @@
# 如果部署在子目录下,结尾必须带 "/",例如 "/admin/",不能写成 "/admin" # 如果部署在子目录下,结尾必须带 "/",例如 "/admin/",不能写成 "/admin"
VITE_BASE_URL=/ VITE_BASE_URL=/
VITE_APP_TITLE=研发内部管理系统 VITE_APP_TITLE=研发管理系统
VITE_APP_DESC=Frontend application for 灿能研发内部管理系统 VITE_APP_DESC=Frontend application for 灿能研发管理系统
# 图标名称前缀 # 图标名称前缀
VITE_ICON_PREFIX=icon VITE_ICON_PREFIX=icon

View File

@@ -1,5 +1,5 @@
# 生产环境的后端服务地址 # 生产环境的后端服务地址
VITE_SERVICE_BASE_URL=https://mock.apifox.cn/m1/3109515-0-default VITE_SERVICE_BASE_URL=
# 生产环境下的其他后端服务地址 # 生产环境下的其他后端服务地址
VITE_OTHER_SERVICE_BASE_URL= `{ VITE_OTHER_SERVICE_BASE_URL= `{

2
.trae/rules/vue-need.md Normal file
View File

@@ -0,0 +1,2 @@
1. 每次开发新功能、编写代码时都添加好相应的注释。
2. 所有的vue文件编码必须是UTF-8的。

View File

@@ -27,7 +27,7 @@ export function setupElegantRouter() {
onRouteMetaGen(routeName) { onRouteMetaGen(routeName) {
const key = routeName as RouteKey; const key = routeName as RouteKey;
const constantRoutes: RouteKey[] = ['login', '403', '404', '500', 'workbench']; const constantRoutes: RouteKey[] = ['login', '403', '404', '500', 'workbench', 'feedback'];
const routeMetaMap: Partial<Record<RouteKey, Partial<RouteMeta>>> = { const routeMetaMap: Partial<Record<RouteKey, Partial<RouteMeta>>> = {
workbench: { workbench: {
icon: 'mdi:view-dashboard-outline', icon: 'mdi:view-dashboard-outline',
@@ -131,16 +131,23 @@ export function setupElegantRouter() {
order: 1, order: 1,
keepAlive: true keepAlive: true
}, },
'personal-center_my-weekly': { 'personal-center_work-report': {
icon: 'mdi:calendar-week-outline', icon: 'mdi:file-chart-outline',
order: 2,
keepAlive: true
},
'personal-center_my-monthly': {
icon: 'mdi:calendar-month-outline',
order: 3, order: 3,
keepAlive: true keepAlive: true
}, },
'personal-center_work-report_weekly': {
hideInMenu: true,
activeMenu: 'personal-center_work-report'
},
'personal-center_work-report_monthly': {
hideInMenu: true,
activeMenu: 'personal-center_work-report'
},
'personal-center_work-report_project': {
hideInMenu: true,
activeMenu: 'personal-center_work-report'
},
'personal-center_my-performance': { 'personal-center_my-performance': {
icon: 'mdi:trophy-outline', icon: 'mdi:trophy-outline',
order: 4, order: 4,
@@ -193,6 +200,11 @@ export function setupElegantRouter() {
roles: ['R_ADMIN'], roles: ['R_ADMIN'],
activeMenu: 'system_user' activeMenu: 'system_user'
}, },
feedback: {
icon: 'mdi:message-alert-outline',
order: 10,
keepAlive: true
},
infra: { infra: {
icon: 'ep:monitor', icon: 'ep:monitor',
order: 20 order: 20
@@ -202,9 +214,30 @@ export function setupElegantRouter() {
order: 1, order: 1,
keepAlive: true keepAlive: true
}, },
'infra_log-management': {
icon: 'mdi:text-box-search-outline',
order: 2,
keepAlive: true
},
'infra_log-management_login-log': {
hideInMenu: true,
activeMenu: 'infra_log-management'
},
'infra_log-management_operate-log': {
hideInMenu: true,
activeMenu: 'infra_log-management'
},
'infra_log-management_api-access-log': {
hideInMenu: true,
activeMenu: 'infra_log-management'
},
'infra_log-management_api-error-log': {
hideInMenu: true,
activeMenu: 'infra_log-management'
},
'infra_rd-code': { 'infra_rd-code': {
icon: 'mdi:identifier', icon: 'mdi:identifier',
order: 2, order: 3,
keepAlive: true keepAlive: true
} }
}; };

View File

@@ -1,5 +1,5 @@
{ {
"generatedAt": "2026-06-01T01:55:51.875Z", "generatedAt": "2026-06-27T00:49:28.085Z",
"description": "Frontend visible page resource whitelist for backend route/menu configuration.", "description": "Frontend visible page resource whitelist for backend route/menu configuration.",
"rules": { "rules": {
"directoryComponent": "layout.base", "directoryComponent": "layout.base",
@@ -306,79 +306,13 @@
"source": "generated" "source": "generated"
}, },
{ {
"name": "personal-center_my-weekly", "name": "personal-center_work-report",
"path": "/personal-center/my-weekly", "path": "/personal-center/work-report",
"component": "view.personal-center_my-weekly", "component": "view.personal-center_work-report",
"title": "我的周报", "title": "工作报告",
"routeTitle": "personal-center_my-weekly", "routeTitle": "personal-center_work-report",
"i18nKey": "route.personal-center_my-weekly", "i18nKey": "route.personal-center_work-report",
"icon": "mdi:calendar-week-outline", "icon": "mdi:file-chart-outline",
"localIcon": null,
"order": 1,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "我的周报",
"i18nKey": "route.personal-center_my-weekly",
"icon": "mdi:calendar-week-outline",
"localIcon": null,
"order": 1,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "personal-center",
"pageType": "leaf",
"source": "generated"
},
{
"name": "personal-center_my-monthly",
"path": "/personal-center/my-monthly",
"component": "view.personal-center_my-monthly",
"title": "我的月报",
"routeTitle": "personal-center_my-monthly",
"i18nKey": "route.personal-center_my-monthly",
"icon": "mdi:calendar-month-outline",
"localIcon": null,
"order": 2,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "我的月报",
"i18nKey": "route.personal-center_my-monthly",
"icon": "mdi:calendar-month-outline",
"localIcon": null,
"order": 2,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "personal-center",
"pageType": "leaf",
"source": "generated"
},
{
"name": "personal-center_my-performance",
"path": "/personal-center/my-performance",
"component": "view.personal-center_my-performance",
"title": "我的绩效",
"routeTitle": "personal-center_my-performance",
"i18nKey": "route.personal-center_my-performance",
"icon": "mdi:trophy-outline",
"localIcon": null, "localIcon": null,
"order": 3, "order": 3,
"hideInMenu": false, "hideInMenu": false,
@@ -389,9 +323,9 @@
"redirect": null, "redirect": null,
"props": null, "props": null,
"meta": { "meta": {
"title": "我的绩效", "title": "工作报告",
"i18nKey": "route.personal-center_my-performance", "i18nKey": "route.personal-center_work-report",
"icon": "mdi:trophy-outline", "icon": "mdi:file-chart-outline",
"localIcon": null, "localIcon": null,
"order": 3, "order": 3,
"keepAlive": true, "keepAlive": true,
@@ -438,15 +372,15 @@
"source": "generated" "source": "generated"
}, },
{ {
"name": "personal-center_pending-approval", "name": "personal-center_my-performance",
"path": "/personal-center/pending-approval", "path": "/personal-center/my-performance",
"component": "view.personal-center_pending-approval", "component": "view.personal-center_my-performance",
"title": "待我审批", "title": "我的绩效",
"routeTitle": "personal-center_pending-approval", "routeTitle": "personal-center_my-performance",
"i18nKey": "route.personal-center_pending-approval", "i18nKey": "route.personal-center_my-performance",
"icon": "mdi:check-decagram-outline", "icon": "mdi:trophy-outline",
"localIcon": null, "localIcon": null,
"order": 5, "order": 4,
"hideInMenu": false, "hideInMenu": false,
"keepAlive": true, "keepAlive": true,
"activeMenu": null, "activeMenu": null,
@@ -455,11 +389,11 @@
"redirect": null, "redirect": null,
"props": null, "props": null,
"meta": { "meta": {
"title": "待我审批", "title": "我的绩效",
"i18nKey": "route.personal-center_pending-approval", "i18nKey": "route.personal-center_my-performance",
"icon": "mdi:check-decagram-outline", "icon": "mdi:trophy-outline",
"localIcon": null, "localIcon": null,
"order": 5, "order": 4,
"keepAlive": true, "keepAlive": true,
"hideInMenu": false, "hideInMenu": false,
"activeMenu": null, "activeMenu": null,
@@ -503,6 +437,39 @@
"pageType": "leaf", "pageType": "leaf",
"source": "generated" "source": "generated"
}, },
{
"name": "personal-center_pending-approval",
"path": "/personal-center/pending-approval",
"component": "view.personal-center_pending-approval",
"title": "待我审批",
"routeTitle": "personal-center_pending-approval",
"i18nKey": "route.personal-center_pending-approval",
"icon": "mdi:check-decagram-outline",
"localIcon": null,
"order": 7,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "待我审批",
"i18nKey": "route.personal-center_pending-approval",
"icon": "mdi:check-decagram-outline",
"localIcon": null,
"order": 7,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "personal-center",
"pageType": "leaf",
"source": "generated"
},
{ {
"name": "system_user", "name": "system_user",
"path": "/system/user", "path": "/system/user",
@@ -734,6 +701,39 @@
"pageType": "leaf", "pageType": "leaf",
"source": "generated" "source": "generated"
}, },
{
"name": "infra_log-management",
"path": "/infra/log-management",
"component": "view.infra_log-management",
"title": "日志管理",
"routeTitle": "infra_log-management",
"i18nKey": "route.infra_log-management",
"icon": "mdi:text-box-search-outline",
"localIcon": null,
"order": 2,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "日志管理",
"i18nKey": "route.infra_log-management",
"icon": "mdi:text-box-search-outline",
"localIcon": null,
"order": 2,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "infra",
"pageType": "leaf",
"source": "generated"
},
{ {
"name": "infra_rd-code", "name": "infra_rd-code",
"path": "/infra/rd-code", "path": "/infra/rd-code",
@@ -743,7 +743,7 @@
"i18nKey": "route.infra_rd-code", "i18nKey": "route.infra_rd-code",
"icon": "mdi:identifier", "icon": "mdi:identifier",
"localIcon": null, "localIcon": null,
"order": 2, "order": 3,
"hideInMenu": false, "hideInMenu": false,
"keepAlive": true, "keepAlive": true,
"activeMenu": null, "activeMenu": null,
@@ -756,7 +756,7 @@
"i18nKey": "route.infra_rd-code", "i18nKey": "route.infra_rd-code",
"icon": "mdi:identifier", "icon": "mdi:identifier",
"localIcon": null, "localIcon": null,
"order": 2, "order": 3,
"keepAlive": true, "keepAlive": true,
"hideInMenu": false, "hideInMenu": false,
"activeMenu": null, "activeMenu": null,

View File

@@ -37,60 +37,42 @@
"update-pkg": "sa update-pkg" "update-pkg": "sa update-pkg"
}, },
"dependencies": { "dependencies": {
"@antv/data-set": "0.11.8",
"@antv/g2": "5.4.0",
"@antv/g6": "5.0.49",
"@better-scroll/core": "2.5.1", "@better-scroll/core": "2.5.1",
"@iconify-vue/mingcute": "^1.0.5",
"@iconify/vue": "5.0.0", "@iconify/vue": "5.0.0",
"@sa/axios": "workspace:*", "@sa/axios": "workspace:*",
"@sa/color": "workspace:*", "@sa/color": "workspace:*",
"@sa/hooks": "workspace:*", "@sa/hooks": "workspace:*",
"@sa/materials": "workspace:*", "@sa/materials": "workspace:*",
"@sa/utils": "workspace:*", "@sa/utils": "workspace:*",
"@visactor/vchart": "2.0.4", "@univerjs/preset-sheets-core": "^0.25.0",
"@visactor/vchart-theme": "1.12.2", "@univerjs/presets": "^0.25.0",
"@visactor/vtable-editors": "1.19.8",
"@visactor/vtable-gantt": "1.19.8",
"@visactor/vue-vtable": "1.19.8",
"@vueuse/components": "13.9.0",
"@vueuse/core": "13.9.0", "@vueuse/core": "13.9.0",
"@wangeditor/editor": "^5.1.23", "@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12", "@wangeditor/editor-for-vue": "^5.1.12",
"@zwight/luckyexcel": "^1.1.6",
"clipboard": "2.0.11", "clipboard": "2.0.11",
"dayjs": "1.11.18", "dayjs": "1.11.18",
"defu": "^6.1.4", "defu": "^6.1.4",
"dhtmlx-gantt": "9.0.14",
"dompurify": "3.2.6", "dompurify": "3.2.6",
"echarts": "6.0.0", "echarts": "6.0.0",
"element-plus": "^2.11.1", "element-plus": "^2.11.1",
"jsbarcode": "3.12.1", "grid-layout-plus": "^1.1.1",
"jsencrypt": "^3.5.4", "jsencrypt": "^3.5.4",
"json5": "2.2.3", "json5": "2.2.3",
"nprogress": "0.2.0", "nprogress": "0.2.0",
"pinia": "3.0.3", "pinia": "3.0.3",
"pinyin-pro": "3.27.0",
"print-js": "1.6.0",
"swiper": "11.2.10",
"tailwind-merge": "3.3.1", "tailwind-merge": "3.3.1",
"typeit": "8.8.7",
"vditor": "3.11.2",
"vue": "3.5.20", "vue": "3.5.20",
"vue-draggable-plus": "0.6.0", "vue-draggable-plus": "0.6.0",
"vue-i18n": "11.1.11", "vue-i18n": "11.1.11",
"vue-pdf-embed": "2.1.3", "vue-router": "4.5.1"
"vue-router": "4.5.1",
"xgplayer": "3.0.23",
"xlsx": "0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@amap/amap-jsapi-types": "0.0.15",
"@elegant-router/vue": "0.3.8", "@elegant-router/vue": "0.3.8",
"@iconify/json": "2.2.380", "@iconify/json": "2.2.380",
"@sa/scripts": "workspace:*", "@sa/scripts": "workspace:*",
"@sa/uno-preset": "workspace:*", "@sa/uno-preset": "workspace:*",
"@soybeanjs/eslint-config": "1.7.1", "@soybeanjs/eslint-config": "1.7.1",
"@types/bmapgl": "0.0.7",
"@types/node": "24.3.0", "@types/node": "24.3.0",
"@types/nprogress": "0.2.3", "@types/nprogress": "0.2.3",
"@unocss/eslint-config": "66.5.0", "@unocss/eslint-config": "66.5.0",

5971
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref } from 'vue'; import { computed, onBeforeUnmount, reactive, ref } from 'vue';
import { ArrowDown, Delete, Document, Loading, Picture, QuestionFilled, Upload } from '@element-plus/icons-vue'; import { ArrowDown, Delete, Document, Loading, Picture, QuestionFilled, Upload } from '@element-plus/icons-vue';
import { deleteFile, downloadFile, uploadFile } from '@/service/api/file'; import { buildFileProxyUrl, deleteFile, downloadFile, uploadFile } from '@/service/api/file';
defineOptions({ name: 'BusinessAttachmentUploader' }); defineOptions({ name: 'BusinessAttachmentUploader' });
@@ -31,7 +31,7 @@ const props = withDefaults(defineProps<Props>(), {
const model = defineModel<Api.Project.AttachmentItem[]>({ default: () => [] }); const model = defineModel<Api.Project.AttachmentItem[]>({ default: () => [] });
/** 给用户看的简短分类hint 行展示) */ /** 给用户看的简短分类hint 行展示) */
const ALLOWED_EXTENSIONS_HINT = '支持 PDF、Word、Excel、PPT、TXT/MD/CSV、图片、ZIP/RAR/7Z、MP3/MP4'; const ALLOWED_EXTENSIONS_HINT = '支持 PDF、Word、Excel、PPT、TXT/MD/CSV、图片、ZIP/RAR/7Z、MP3/MP4、SQL/JSON/XML';
// 与后端 AttachmentValidator 白/黑名单保持一致5.16 // 与后端 AttachmentValidator 白/黑名单保持一致5.16
const ALLOWED_EXTENSIONS = new Set([ const ALLOWED_EXTENSIONS = new Set([
@@ -55,7 +55,10 @@ const ALLOWED_EXTENSIONS = new Set([
'rar', 'rar',
'7z', '7z',
'mp4', 'mp4',
'mp3' 'mp3',
'sql',
'xml',
'json'
]); ]);
const FORBIDDEN_EXTENSIONS = new Set([ const FORBIDDEN_EXTENSIONS = new Set([
@@ -230,7 +233,7 @@ async function uploadOne(file: File) {
return; return;
} }
const { id, url } = result.data; const { id, configId, path, url } = result.data;
// 组件已卸载用户上传过程中关弹层onBeforeUnmount 已跑过且看不到这个 id // 组件已卸载用户上传过程中关弹层onBeforeUnmount 已跑过且看不到这个 id
// 这里立刻调删除,避免孤儿文件 // 这里立刻调删除,避免孤儿文件
@@ -248,7 +251,9 @@ async function uploadOne(file: File) {
url, url,
name: file.name, name: file.name,
size: file.size, size: file.size,
contentType: file.type || undefined contentType: file.type || undefined,
configId,
path
} }
]; ];
session.addedIds.add(id); session.addedIds.add(id);
@@ -419,6 +424,16 @@ defineExpose({
/** 父组件在提交前可读此值判断是否还有 pending 上传 */ /** 父组件在提交前可读此值判断是否还有 pending 上传 */
get hasUploading() { get hasUploading() {
return hasUploading.value; return hasUploading.value;
},
/**
* 返回当前已选附件的「永久代理 URL」数组私有/公开桶都不过期)。
* 缺 configId/path 时回退 item.url。供反馈等"按 URL 字符串存储"的场景使用。
*/
getPermanentUrls(): string[] {
return model.value.map(item =>
item.configId && item.path ? buildFileProxyUrl(item.configId, item.path) : item.url
);
} }
}); });

View File

@@ -82,6 +82,12 @@ function handleConfirm() {
footer-class="business-form-dialog__footer" footer-class="business-form-dialog__footer"
v-bind="$attrs" v-bind="$attrs"
> >
<template #header="{ close, titleId, titleClass }">
<slot name="title" :close="close" :title="props.title" :title-id="titleId" :title-class="titleClass">
<span :id="titleId" :class="titleClass">{{ props.title }}</span>
</slot>
</template>
<ElScrollbar <ElScrollbar
v-if="props.scrollbar" v-if="props.scrollbar"
:max-height="props.maxBodyHeight" :max-height="props.maxBodyHeight"

View File

@@ -12,7 +12,9 @@ defineProps<Props>();
<template> <template>
<section class="business-form-section"> <section class="business-form-section">
<h4 class="business-form-section__title">{{ title }}</h4> <h4 class="business-form-section__title">
<slot name="title">{{ title }}</slot>
</h4>
<slot /> <slot />
</section> </section>
</template> </template>

View File

@@ -178,7 +178,9 @@ const toolbarConfig: Partial<IToolbarConfig> = {
'insertLink', 'insertLink',
'editLink', 'editLink',
'unLink', 'unLink',
'viewLink' 'viewLink',
// 全屏:弹层内全屏体验割裂,隐藏
'fullScreen'
] ]
}; };

View File

@@ -2,6 +2,8 @@ import { computed, defineComponent, h, ref } from 'vue';
import type { Component, PropType } from 'vue'; import type { Component, PropType } from 'vue';
import { ElButton, ElPopover, ElTooltip } from 'element-plus'; import { ElButton, ElPopover, ElTooltip } from 'element-plus';
import { $t } from '@/locales'; import { $t } from '@/locales';
import IconMdiDotsHorizontal from '~icons/mdi/dots-horizontal';
import IconMdiChevronDown from '~icons/mdi/chevron-down';
export type BusinessTableAction = { export type BusinessTableAction = {
key: string; key: string;
@@ -162,11 +164,11 @@ export default defineComponent({
onClick={event => event.stopPropagation()} onClick={event => event.stopPropagation()}
> >
{props.variant === 'icon' ? ( {props.variant === 'icon' ? (
<icon-mdi-dots-horizontal class="business-table-action-icon" /> <IconMdiDotsHorizontal class="business-table-action-icon" />
) : ( ) : (
<span class="inline-flex items-center gap-4px"> <span class="inline-flex items-center gap-4px">
{$t('common.more')} {$t('common.more')}
<icon-mdi-chevron-down class="text-14px" /> <IconMdiChevronDown class="text-14px" />
</span> </span>
)} )}
</ElButton> </ElButton>

View File

@@ -0,0 +1,84 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ElTag } from 'element-plus';
defineOptions({ name: 'CurrentUserRoleTags' });
interface Props {
/** 当前登录用户在该对象(产品/项目)的角色;无角色为 []。后端只读计算字段,随登录身份变化 */
roles?: Api.Common.CurrentUserRole[] | null;
/** 无业务角色时的占位文案 */
emptyText?: string;
}
const props = withDefaults(defineProps<Props>(), { roles: () => [], emptyText: '--' });
/**
* 角色族按 roleKey 后缀匹配。
*
* 后端 roleKey 为域前缀风格product_manager / project_manager / product_creator /
* project_creator / *_observer 等(见 src/constants/business.ts 的经理 code
* 文档示例里的裸 creator / implicit_observer 不是真实 key故不能按字面量精确匹配。
*/
function isManagerRole(roleKey: string) {
return /manager$/i.test(roleKey);
}
function isCreatorRole(roleKey: string) {
return /creator$/i.test(roleKey);
}
/** 系统隐式角色:创建者 / 隐式观察者,弱化为淡灰标签 */
function isMuted(roleKey: string) {
return isCreatorRole(roleKey) || /observer$/i.test(roleKey) || roleKey.includes('implicit');
}
const items = computed(() => {
const roles = props.roles ?? [];
// 当前用户是产品经理 / 项目经理时,隐藏创建者标签(隐式观察者不受影响)
const hasManager = roles.some(role => isManagerRole(role.roleKey));
const visibleRoles = hasManager ? roles.filter(role => !isCreatorRole(role.roleKey)) : roles;
return visibleRoles.map((role, index) => ({
key: `${role.roleKey}-${index}`,
roleName: role.roleName,
muted: isMuted(role.roleKey)
}));
});
</script>
<template>
<div v-if="items.length" class="current-user-role-tags">
<ElTag
v-for="item in items"
:key="item.key"
size="small"
effect="plain"
round
:type="item.muted ? 'info' : 'primary'"
:class="{ 'current-user-role-tags__muted': item.muted }"
>
{{ item.roleName }}
</ElTag>
</div>
<span v-else class="current-user-role-tags__empty">{{ emptyText }}</span>
</template>
<style lang="scss" scoped>
.current-user-role-tags {
display: flex;
flex-wrap: wrap;
// 列设置 align="center" 只影响文本流flex 容器需显式居中标签
justify-content: center;
gap: 4px;
}
// 隐式角色(创建者 / 隐式观察者)弱化:在灰色 info 标签基础上再降透明度
.current-user-role-tags__muted {
opacity: 0.65;
}
.current-user-role-tags__empty {
color: var(--el-text-color-placeholder);
}
</style>

View File

@@ -0,0 +1,145 @@
<script setup lang="ts">
import { computed } from 'vue';
defineOptions({ name: 'SubordinateSelector' });
interface Props {
loading?: boolean;
data?: Api.SystemManage.MySubordinateTreeNode | null;
emptyText?: string;
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
data: null,
emptyText: '暂无下属数据'
});
const selectedUserId = defineModel<string | null>('selectedUserId', {
default: null
});
function sortSubordinateNodes(
nodes: Api.SystemManage.MySubordinateTreeNode[] | null | undefined
): Api.SystemManage.MySubordinateTreeNode[] | null {
if (!nodes?.length) return null;
return nodes
.map((node, index) => ({
...node,
children: sortSubordinateNodes(node.children),
originalIndex: index
}))
.sort((left, right) => {
const leftHasChildren = (left.children?.length ?? 0) > 0 ? 1 : 0;
const rightHasChildren = (right.children?.length ?? 0) > 0 ? 1 : 0;
if (leftHasChildren !== rightHasChildren) {
return rightHasChildren - leftHasChildren;
}
return left.originalIndex - right.originalIndex;
})
.map(({ originalIndex: _ignored, ...node }) => node);
}
const treeData = computed<Api.SystemManage.MySubordinateTreeNode[] | null>(() => {
if (!props.data) return null;
return [
{
...props.data,
children: sortSubordinateNodes(props.data.children)
}
];
});
function handleNodeClick(node: Api.SystemManage.MySubordinateTreeNode) {
selectedUserId.value = node.userId;
}
function renderNodeLabel(node: Api.SystemManage.MySubordinateTreeNode) {
const label = node.isRoot ? '全部下属' : node.userNickname;
return `${label}${node.subordinateCount ? `${node.subordinateCount}` : ''}`;
}
</script>
<template>
<ElCard class="subordinate-selector" body-class="subordinate-selector__body">
<template #header>
<div class="flex items-center justify-between gap-12px">
<span class="text-14px font-600">团队成员</span>
<ElTag v-if="props.data" effect="plain">{{ props.data.subordinateCount }}</ElTag>
</div>
</template>
<div v-loading="props.loading" class="subordinate-selector__content">
<ElEmpty v-if="!props.data" :image-size="88" :description="props.emptyText" />
<ElTree
v-else
:data="treeData || []"
node-key="userId"
:current-node-key="selectedUserId || undefined"
:props="{ label: 'userNickname', children: 'children' }"
highlight-current
:default-expanded-keys="[props.data.userId]"
expand-on-click-node
class="subordinate-selector__tree"
@node-click="handleNodeClick"
>
<template #default="{ data: node }">
<span class="subordinate-selector__node-label">{{ renderNodeLabel(node) }}</span>
</template>
</ElTree>
</div>
</ElCard>
</template>
<style scoped lang="scss">
.subordinate-selector {
display: flex;
flex-direction: column;
height: 100%;
border: 1px solid var(--el-border-color-light);
box-shadow: none;
}
:deep(.subordinate-selector__body) {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
padding: 12px;
}
.subordinate-selector__content {
flex: 1;
min-height: 240px;
overflow: auto;
}
.subordinate-selector__tree {
height: 100%;
background: transparent;
}
.subordinate-selector__node-label {
display: inline-flex;
align-items: center;
min-width: 0;
color: var(--el-text-color-regular);
}
:deep(.subordinate-selector__tree .el-tree-node__content) {
height: 36px;
border-radius: 8px;
}
:deep(.subordinate-selector__tree .el-tree-node__content:hover) {
background: var(--el-fill-color-light);
}
:deep(.subordinate-selector__tree .el-tree-node.is-current > .el-tree-node__content) {
background: var(--el-color-primary-light-9);
}
</style>

View File

@@ -18,6 +18,16 @@ export interface SearchField {
label: string; label: string;
/** 字段类型 */ /** 字段类型 */
type: 'input' | 'select' | 'date' | 'dateRange' | 'dict'; type: 'input' | 'select' | 'date' | 'dateRange' | 'dict';
/** date 字段的日期粒度 */
dateType?: 'date' | 'month';
/** dateRange 字段的日期范围粒度 */
dateRangeType?: 'daterange' | 'monthrange';
/** 日期面板展示格式 */
format?: string;
/** 自定义范围分隔文案 */
rangeSeparator?: string;
/** 日期字段提交格式 */
valueFormat?: string;
/** 占位列数,默认 1 */ /** 占位列数,默认 1 */
span?: number; span?: number;
/** select 类型的选项 */ /** select 类型的选项 */
@@ -30,6 +40,12 @@ export interface SearchField {
placeholder?: string; placeholder?: string;
/** select 类型的自定义选项渲染函数 */ /** select 类型的自定义选项渲染函数 */
renderOption?: (option: Option) => VNode | VNode[] | string; renderOption?: (option: Option) => VNode | VNode[] | string;
/** select/dict 类型是否支持搜索 */
filterable?: boolean;
/** 值写回模型前的转换函数 */
transformValue?: (value: any) => any;
/** 从模型值解析展示值 */
resolveValue?: (value: any) => any;
} }
interface Props { interface Props {
@@ -116,6 +132,16 @@ function handleReset() {
function handleSearch() { function handleSearch() {
emit('search'); emit('search');
} }
function updateFieldValue(field: SearchField, value: any) {
// eslint-disable-next-line vue/no-mutating-props
props.modelValue[field.key] = field.transformValue ? field.transformValue(value) : value;
}
function getFieldValue(field: SearchField) {
const value = props.modelValue[field.key];
return field.resolveValue ? field.resolveValue(value) : value;
}
</script> </script>
<!-- eslint-disable vue/no-mutating-props --> <!-- eslint-disable vue/no-mutating-props -->
@@ -133,19 +159,20 @@ function handleSearch() {
<ElFormItem :label="field.label"> <ElFormItem :label="field.label">
<ElInput <ElInput
v-if="field.type === 'input'" v-if="field.type === 'input'"
:model-value="props.modelValue[field.key]" :model-value="getFieldValue(field)"
:placeholder="field.placeholder" :placeholder="field.placeholder"
clearable clearable
:disabled="props.disabled" :disabled="props.disabled"
@update:model-value="val => (props.modelValue[field.key] = val)" @update:model-value="val => updateFieldValue(field, val)"
/> />
<ElSelect <ElSelect
v-else-if="field.type === 'select'" v-else-if="field.type === 'select'"
:model-value="props.modelValue[field.key]" :model-value="getFieldValue(field)"
:placeholder="field.placeholder" :placeholder="field.placeholder"
clearable clearable
:filterable="field.filterable"
:disabled="props.disabled" :disabled="props.disabled"
@update:model-value="val => (props.modelValue[field.key] = val)" @update:model-value="val => updateFieldValue(field, val)"
> >
<ElOption v-for="opt in field.options" :key="opt.value" :label="opt.label" :value="opt.value"> <ElOption v-for="opt in field.options" :key="opt.value" :label="opt.label" :value="opt.value">
<template v-if="field.renderOption" #default> <template v-if="field.renderOption" #default>
@@ -155,34 +182,38 @@ function handleSearch() {
</ElSelect> </ElSelect>
<ElDatePicker <ElDatePicker
v-else-if="field.type === 'date'" v-else-if="field.type === 'date'"
:model-value="props.modelValue[field.key]" :model-value="getFieldValue(field)"
type="date" :type="field.dateType || 'date'"
:placeholder="field.placeholder" :placeholder="field.placeholder"
clearable clearable
:disabled="props.disabled" :disabled="props.disabled"
value-format="YYYY-MM-DD" :format="field.format"
@update:model-value="val => (props.modelValue[field.key] = val)" :value-format="field.valueFormat || 'YYYY-MM-DD'"
@update:model-value="val => updateFieldValue(field, val)"
/> />
<ElDatePicker <ElDatePicker
v-else-if="field.type === 'dateRange'" v-else-if="field.type === 'dateRange'"
:model-value="props.modelValue[field.key]" :model-value="getFieldValue(field)"
type="daterange" :type="field.dateRangeType || 'daterange'"
:placeholder="field.placeholder" :placeholder="field.placeholder"
clearable clearable
:disabled="props.disabled" :disabled="props.disabled"
value-format="YYYY-MM-DD" :format="field.format"
start-placeholder="开始日期" :value-format="field.valueFormat || 'YYYY-MM-DD'"
end-placeholder="结束日期" :range-separator="field.rangeSeparator || '至'"
@update:model-value="val => (props.modelValue[field.key] = val)" :start-placeholder="field.dateRangeType === 'monthrange' ? '开始月份' : '开始日期'"
:end-placeholder="field.dateRangeType === 'monthrange' ? '结束月份' : '结束日期'"
@update:model-value="val => updateFieldValue(field, val)"
/> />
<DictSelect <DictSelect
v-else-if="field.type === 'dict'" v-else-if="field.type === 'dict'"
:model-value="props.modelValue[field.key]" :model-value="getFieldValue(field)"
:dict-code="field.dictCode!" :dict-code="field.dictCode!"
:placeholder="field.placeholder" :placeholder="field.placeholder"
:filterable="field.filterable"
:disabled="props.disabled" :disabled="props.disabled"
:show-remark="field.showRemark" :show-remark="field.showRemark"
@update:model-value="val => (props.modelValue[field.key] = val)" @update:model-value="val => updateFieldValue(field, val)"
/> />
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
@@ -230,19 +261,20 @@ function handleSearch() {
<ElFormItem :label="field.label"> <ElFormItem :label="field.label">
<ElInput <ElInput
v-if="field.type === 'input'" v-if="field.type === 'input'"
:model-value="props.modelValue[field.key]" :model-value="getFieldValue(field)"
:placeholder="field.placeholder" :placeholder="field.placeholder"
clearable clearable
:disabled="props.disabled" :disabled="props.disabled"
@update:model-value="val => (props.modelValue[field.key] = val)" @update:model-value="val => updateFieldValue(field, val)"
/> />
<ElSelect <ElSelect
v-else-if="field.type === 'select'" v-else-if="field.type === 'select'"
:model-value="props.modelValue[field.key]" :model-value="getFieldValue(field)"
:placeholder="field.placeholder" :placeholder="field.placeholder"
clearable clearable
:filterable="field.filterable"
:disabled="props.disabled" :disabled="props.disabled"
@update:model-value="val => (props.modelValue[field.key] = val)" @update:model-value="val => updateFieldValue(field, val)"
> >
<ElOption v-for="opt in field.options" :key="opt.value" :label="opt.label" :value="opt.value"> <ElOption v-for="opt in field.options" :key="opt.value" :label="opt.label" :value="opt.value">
<template v-if="field.renderOption" #default> <template v-if="field.renderOption" #default>
@@ -252,34 +284,38 @@ function handleSearch() {
</ElSelect> </ElSelect>
<ElDatePicker <ElDatePicker
v-else-if="field.type === 'date'" v-else-if="field.type === 'date'"
:model-value="props.modelValue[field.key]" :model-value="getFieldValue(field)"
type="date" :type="field.dateType || 'date'"
:placeholder="field.placeholder" :placeholder="field.placeholder"
clearable clearable
:disabled="props.disabled" :disabled="props.disabled"
value-format="YYYY-MM-DD" :format="field.format"
@update:model-value="val => (props.modelValue[field.key] = val)" :value-format="field.valueFormat || 'YYYY-MM-DD'"
@update:model-value="val => updateFieldValue(field, val)"
/> />
<ElDatePicker <ElDatePicker
v-else-if="field.type === 'dateRange'" v-else-if="field.type === 'dateRange'"
:model-value="props.modelValue[field.key]" :model-value="getFieldValue(field)"
type="daterange" :type="field.dateRangeType || 'daterange'"
:placeholder="field.placeholder" :placeholder="field.placeholder"
clearable clearable
:disabled="props.disabled" :disabled="props.disabled"
value-format="YYYY-MM-DD" :format="field.format"
start-placeholder="开始日期" :value-format="field.valueFormat || 'YYYY-MM-DD'"
end-placeholder="结束日期" :range-separator="field.rangeSeparator || '至'"
@update:model-value="val => (props.modelValue[field.key] = val)" :start-placeholder="field.dateRangeType === 'monthrange' ? '开始月份' : '开始日期'"
:end-placeholder="field.dateRangeType === 'monthrange' ? '结束月份' : '结束日期'"
@update:model-value="val => updateFieldValue(field, val)"
/> />
<DictSelect <DictSelect
v-else-if="field.type === 'dict'" v-else-if="field.type === 'dict'"
:model-value="props.modelValue[field.key]" :model-value="getFieldValue(field)"
:dict-code="field.dictCode!" :dict-code="field.dictCode!"
:placeholder="field.placeholder" :placeholder="field.placeholder"
:filterable="field.filterable"
:disabled="props.disabled" :disabled="props.disabled"
:show-remark="field.showRemark" :show-remark="field.showRemark"
@update:model-value="val => (props.modelValue[field.key] = val)" @update:model-value="val => updateFieldValue(field, val)"
/> />
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>

View File

@@ -0,0 +1,163 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { TeamViewMode } from '@/views/personal-center/shared/team-dashboard';
defineOptions({ name: 'TeamContextPanel' });
interface Props {
loading?: boolean;
selectedLabel?: string;
subordinateCount?: number;
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
selectedLabel: '',
subordinateCount: 0
});
const mode = defineModel<TeamViewMode>('mode', {
required: true
});
const scopeOptions = computed(() => [
{ label: '个人视角', value: 'self' satisfies TeamViewMode },
{ label: '团队视角', value: 'team' satisfies TeamViewMode }
]);
const contextText = computed(() => {
if (mode.value === 'self') {
return '当前查看我自己的数据。';
}
if (props.selectedLabel) {
return `当前范围:${props.selectedLabel}`;
}
return '当前查看团队数据。';
});
</script>
<template>
<ElCard class="team-context-panel" body-class="team-context-panel__body">
<div v-loading="props.loading" class="team-context-panel__layout">
<div class="team-context-panel__controls">
<ElSegmented v-model="mode" :options="scopeOptions" class="team-context-panel__segmented" />
</div>
<div class="team-context-panel__info">
<div class="team-context-panel__info-main">
<div class="team-context-panel__info-item">
<span class="team-context-panel__info-label">当前范围</span>
<strong class="team-context-panel__info-value">
{{ props.selectedLabel || (mode === 'self' ? '我自己' : '--') }}
</strong>
</div>
<div v-if="mode === 'team'" class="team-context-panel__info-item">
<span class="team-context-panel__info-label">下属人数</span>
<strong class="team-context-panel__info-value">{{ props.subordinateCount }}</strong>
</div>
</div>
<p class="team-context-panel__info-desc">{{ contextText }}</p>
<div v-if="$slots.default" class="team-context-panel__summary">
<slot />
</div>
</div>
</div>
</ElCard>
</template>
<style scoped lang="scss">
.team-context-panel {
border: 1px solid var(--el-border-color-light);
background: var(--el-fill-color-blank);
box-shadow: none;
}
:deep(.team-context-panel__body) {
padding: 16px 18px;
}
.team-context-panel__layout {
display: flex;
align-items: flex-start;
gap: 20px;
}
.team-context-panel__controls {
flex-shrink: 0;
}
:deep(.team-context-panel__segmented) {
padding: 6px;
background: var(--el-fill-color-light);
border-radius: 12px;
}
:deep(.team-context-panel__segmented .el-segmented__item) {
min-width: 96px;
min-height: 40px;
padding: 0 22px;
font-size: 14px;
}
.team-context-panel__info {
flex: 1;
min-width: 0;
padding-left: 20px;
border-left: 1px solid var(--el-border-color-lighter);
}
.team-context-panel__info-main {
display: flex;
flex-wrap: wrap;
gap: 24px;
}
.team-context-panel__info-item {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 180px;
}
.team-context-panel__info-label {
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.5;
}
.team-context-panel__info-value {
color: var(--el-text-color-primary);
font-size: 15px;
font-weight: 600;
line-height: 1.5;
}
.team-context-panel__info-desc {
margin: 10px 0 0;
color: var(--el-text-color-regular);
font-size: 13px;
line-height: 1.6;
}
.team-context-panel__summary {
margin-top: 14px;
}
@media (width <= 1200px) {
.team-context-panel__layout {
flex-direction: column;
align-items: stretch;
}
.team-context-panel__info {
padding-left: 0;
padding-top: 14px;
border-left: none;
border-top: 1px solid var(--el-border-color-lighter);
}
}
</style>

View File

@@ -1,61 +0,0 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { getPaletteColorByNumber } from '@sa/color';
defineOptions({ name: 'WaveBg' });
interface Props {
/** Theme color */
themeColor: string;
}
const props = defineProps<Props>();
const lightColor = computed(() => getPaletteColorByNumber(props.themeColor, 200));
const darkColor = computed(() => getPaletteColorByNumber(props.themeColor, 500));
</script>
<template>
<div class="absolute-lt z-1 size-full overflow-hidden">
<div class="absolute -right-300px -top-900px lt-sm:(-right-100px -top-1170px)">
<svg height="1337" width="1337">
<defs>
<path
id="path-1"
opacity="1"
fill-rule="evenodd"
d="M1337,668.5 C1337,1037.455193874239 1037.455193874239,1337 668.5,1337 C523.6725684305388,1337 337,1236 370.50000000000006,1094 C434.03835568300906,824.6732385973953 6.906089672974592e-14,892.6277623047779 0,668.5000000000001 C0,299.5448061257611 299.5448061257609,1.1368683772161603e-13 668.4999999999999,0 C1037.455193874239,0 1337,299.544806125761 1337,668.5Z"
/>
<linearGradient id="linearGradient-2" x1="0.79" y1="0.62" x2="0.21" y2="0.86">
<stop offset="0" :stop-color="lightColor" stop-opacity="1" />
<stop offset="1" :stop-color="darkColor" stop-opacity="1" />
</linearGradient>
</defs>
<g opacity="1">
<use xlink:href="#path-1" fill="url(#linearGradient-2)" fill-opacity="1" />
</g>
</svg>
</div>
<div class="absolute -bottom-400px -left-200px lt-sm:(-bottom-760px -left-100px)">
<svg height="896" width="967.8852157128662">
<defs>
<path
id="path-2"
opacity="1"
fill-rule="evenodd"
d="M896,448 C1142.6325445712241,465.5747656464056 695.2579309733121,896 448,896 C200.74206902668806,896 5.684341886080802e-14,695.2579309733121 0,448.0000000000001 C0,200.74206902668806 200.74206902668791,5.684341886080802e-14 447.99999999999994,0 C695.2579309733121,0 475,418 896,448Z"
/>
<linearGradient id="linearGradient-3" x1="0.5" y1="0" x2="0.5" y2="1">
<stop offset="0" :stop-color="darkColor" stop-opacity="1" />
<stop offset="1" :stop-color="lightColor" stop-opacity="1" />
</linearGradient>
</defs>
<g opacity="1">
<use xlink:href="#path-2" fill="url(#linearGradient-3)" fill-opacity="1" />
</g>
</svg>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -112,14 +112,6 @@ export const RDMS_REQ_CAN_DELETE_STATUS_DICT_CODE = 'rdms_req_can_delete_status'
*/ */
export const RDMS_WORKLOG_DIFFICULTY_DICT_CODE = 'rdms_task_item_worklog_difficulty'; export const RDMS_WORKLOG_DIFFICULTY_DICT_CODE = 'rdms_task_item_worklog_difficulty';
/**
* 加班申请状态字典编码
*
* 对应业务字段:加班申请中的 statusCode
* 来源口径:`overtime-application-design.md` 明确状态字典为 rdms_overtime_application_status
*/
export const RDMS_OVERTIME_APPLICATION_STATUS_DICT_CODE = 'rdms_overtime_application_status';
/** /**
* 加班时长快捷选项字典编码 * 加班时长快捷选项字典编码
* *
@@ -127,3 +119,76 @@ export const RDMS_OVERTIME_APPLICATION_STATUS_DICT_CODE = 'rdms_overtime_applica
* 来源口径:`overtime-application-design.md` 明确时长下拉字典为 rdms_overtime_duration * 来源口径:`overtime-application-design.md` 明确时长下拉字典为 rdms_overtime_duration
*/ */
export const RDMS_OVERTIME_DURATION_DICT_CODE = 'rdms_overtime_duration'; export const RDMS_OVERTIME_DURATION_DICT_CODE = 'rdms_overtime_duration';
/**
* 站内信消息等级字典编码
*
* 对应业务字段:站内信 NotifyMessage.level1=普通 2=提醒 3=警告 4=严重,数字越大越紧急)
* 来源口径:`2026-06-13-站内信消息等级-前端对接.html` 明确等级字典为 notify_message_level
* 显示名与颜色hex均走字典前端按 level 取色不硬编码。
*/
export const NOTIFY_MESSAGE_LEVEL_DICT_CODE = 'notify_message_level';
/**
* 意见反馈分类字典编码
*
* 对应业务字段:意见反馈 type1 缺陷 / 2 体验问题 / 3 功能建议)
* 来源口径:`2026-06-25-意见反馈-前端API.html` 明确分类字典为 feedback_type后端已落库。
*/
export const FEEDBACK_TYPE_DICT_CODE = 'feedback_type';
/**
* 意见反馈处理状态字典编码
*
* 对应业务字段:意见反馈 status1 待处理 / 2 处理中 / 3 已处理 / 4 已忽略)
* 来源口径:同上,后端已落库。提交不传 status后端强制「待处理」。
*/
export const FEEDBACK_STATUS_DICT_CODE = 'feedback_status';
/**
* 系统用户类型字典编码
*
* 对应业务字段:系统日志中的 userType
* 来源口径:后端 DictTypeConstants.USER_TYPE = user_type
*/
export const SYSTEM_USER_TYPE_DICT_CODE = 'user_type';
/**
* 系统登录日志类型字典编码
*
* 对应业务字段:登录日志中的 logType
* 来源口径:后端 DictTypeConstants.LOGIN_TYPE = system_login_type
*/
export const SYSTEM_LOGIN_TYPE_DICT_CODE = 'system_login_type';
/**
* 系统登录结果字典编码
*
* 对应业务字段:登录日志中的 result
* 来源口径:后端 DictTypeConstants.LOGIN_RESULT = system_login_result
*/
export const SYSTEM_LOGIN_RESULT_DICT_CODE = 'system_login_result';
/**
* 基础设施操作分类字典编码
*
* 对应业务字段API 访问日志中的 operateType
* 来源口径:后端 DictTypeConstants.OPERATE_TYPE = infra_operate_type
*/
export const INFRA_OPERATE_TYPE_DICT_CODE = 'infra_operate_type';
/**
* API 错误日志处理状态字典编码
*
* 对应业务字段API 错误日志中的 processStatus
* 来源口径:后端 DictTypeConstants.API_ERROR_LOG_PROCESS_STATUS = infra_api_error_log_process_status
*/
export const INFRA_API_ERROR_LOG_PROCESS_STATUS_DICT_CODE = 'infra_api_error_log_process_status';
/**
* 系统请求方式字典编码
*
* 对应业务字段:操作日志中的 requestMethod
* 来源口径:用户明确指定请求方式下拉来自运行时字典 system_request_method
*/
export const SYSTEM_REQUEST_METHOD_DICT_CODE = 'system_request_method';

View File

@@ -1,8 +0,0 @@
/** baidu map sdk url */
export const BAIDU_MAP_SDK_URL = `https://api.map.baidu.com/getscript?v=3.0&ak=KSezYymXPth1DIGILRX3oYN9PxbOQQmU&services=&t=20210201100830&s=1`;
/** Amap sdk url */
export const AMAP_SDK_URL = 'https://webapi.amap.com/maps?v=2.0&key=e7bd02bd504062087e6563daf4d6721d';
/** tencent sdk url */
export const TENCENT_MAP_SDK_URL = 'https://map.qq.com/api/gljs?v=1.exp&key=A6DBZ-KXPLW-JKSRY-ONZF4-CPHY3-K6BL7';

View File

@@ -14,10 +14,14 @@ export type StatusDomain =
| 'taskAssigneeMember' | 'taskAssigneeMember'
| 'project' | 'project'
| 'product' | 'product'
| 'requirement' | 'productRequirement'
| 'projectRequirement'
| 'workOrder' | 'workOrder'
| 'workReport'
| 'performanceSheet'
| 'personalItem' | 'personalItem'
| 'overtimeApplication'; | 'overtimeApplication'
| 'feedback';
const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>> = { const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>> = {
// 项目-执行 // 项目-执行
@@ -52,10 +56,47 @@ const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>>
project: {}, project: {},
// 产品(待补全) // 产品(待补全)
product: {}, product: {},
// 需求(待补全) // 产品需求
requirement: {}, productRequirement: {
pending_claim: 'info',
pending_review: 'info',
pending_dispatch: 'primary',
reviewed: 'success',
review_rejected: 'danger',
implementing: 'primary',
accepted: 'success',
closed: 'danger',
rejected: 'danger',
cancelled: 'danger'
},
// 项目需求
projectRequirement: {
pending_claim: 'info',
pending_review: 'info',
reviewed: 'success',
review_rejected: 'danger',
implementing: 'primary',
accepted: 'success',
closed: 'danger',
rejected: 'danger',
cancelled: 'danger'
},
// 工单(待补全) // 工单(待补全)
workOrder: {}, workOrder: {},
// 工作报告
workReport: {
draft: 'info',
pending_approval: 'warning',
approved: 'success',
rejected: 'danger'
},
// 绩效表
performanceSheet: {
draft: 'info',
sent: 'warning',
confirmed: 'success',
rejected: 'danger'
},
// 个人事项 // 个人事项
personalItem: { personalItem: {
pending: 'info', pending: 'info',
@@ -67,8 +108,14 @@ const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>>
overtimeApplication: { overtimeApplication: {
pending: 'warning', pending: 'warning',
approved: 'success', approved: 'success',
rejected: 'danger', rejected: 'danger'
cancelled: 'info' },
// 意见反馈
feedback: {
'1': 'warning', // 待处理
'2': 'primary', // 处理中
'3': 'success', // 已处理
'4': 'info' // 已忽略
} }
}; };
@@ -84,6 +131,9 @@ export function getPersonalItemStatusTagType(statusCode: string | null | undefin
return getStatusTagType('personalItem', statusCode); return getStatusTagType('personalItem', statusCode);
} }
export function getOvertimeApplicationStatusTagType(statusCode: string | null | undefined) { export function getFeedbackStatusTagType(statusCode: string | number | null | undefined) {
return getStatusTagType('overtimeApplication', statusCode); return getStatusTagType(
'feedback',
statusCode === null || statusCode === undefined ? statusCode : String(statusCode)
);
} }

View File

@@ -131,12 +131,14 @@ export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: C
* @param callback callback function * @param callback callback function
*/ */
async function updateOptions(callback: (opts: T, optsFactory: () => T) => ECOption = () => chartOptions) { async function updateOptions(callback: (opts: T, optsFactory: () => T) => ECOption = () => chartOptions) {
if (!isRendered()) return;
const updatedOpts = callback(chartOptions, optionsFactory); const updatedOpts = callback(chartOptions, optionsFactory);
Object.assign(chartOptions, updatedOpts); Object.assign(chartOptions, updatedOpts);
// 图表未初始化(容器尺寸未就绪)时只缓存最新 options待 render() 初始化时一并应用;
// 否则数据先于初始化到达会被静默丢弃,首屏永远停留在空数据
if (!isRendered()) return;
if (isRendered()) { if (isRendered()) {
chart?.clear(); chart?.clear();
} }

View File

@@ -1,158 +0,0 @@
import { computed, effectScope, onScopeDispose, ref, watch } from 'vue';
import { useElementSize } from '@vueuse/core';
import VChart, { registerLiquidChart } from '@visactor/vchart';
import type { ISpec, ITheme } from '@visactor/vchart';
import light from '@visactor/vchart-theme/public/light.json';
import dark from '@visactor/vchart-theme/public/dark.json';
import { useThemeStore } from '@/store/modules/theme';
registerLiquidChart();
// register the theme
VChart.ThemeManager.registerTheme('light', light as ITheme);
VChart.ThemeManager.registerTheme('dark', dark as ITheme);
interface ChartHooks {
onRender?: (chart: VChart) => void | Promise<void>;
onUpdated?: (chart: VChart) => void | Promise<void>;
onDestroy?: (chart: VChart) => void | Promise<void>;
}
export function useVChart<T extends ISpec>(specFactory: () => T, hooks: ChartHooks = {}) {
const scope = effectScope();
const themeStore = useThemeStore();
const darkMode = computed(() => themeStore.darkMode);
const domRef = ref<HTMLElement | null>(null);
const initialSize = { width: 0, height: 0 };
const { width, height } = useElementSize(domRef, initialSize);
let chart: VChart | null = null;
const spec: T = specFactory();
const { onRender, onUpdated, onDestroy } = hooks;
/**
* whether can render chart
*
* when domRef is ready and initialSize is valid
*/
function canRender() {
return domRef.value && initialSize.width > 0 && initialSize.height > 0;
}
/** is chart rendered */
function isRendered() {
return Boolean(domRef.value && chart);
}
/**
* update chart spec
*
* @param callback callback function
*/
async function updateSpec(callback: (opts: T, optsFactory: () => T) => ISpec = () => spec) {
if (!isRendered()) return;
const updatedOpts = callback(spec, specFactory);
Object.assign(spec, updatedOpts);
// if (isRendered()) {
// chart?.release();
// }
chart?.updateSpec({ ...updatedOpts }, true);
await onUpdated?.(chart!);
}
function setSpec(newSpec: T) {
chart?.updateSpec(newSpec);
}
/** render chart */
async function render() {
if (!isRendered()) {
// apply the theme
if (darkMode.value) {
VChart.ThemeManager.setCurrentTheme('dark');
} else {
VChart.ThemeManager.setCurrentTheme('light');
}
chart = new VChart(spec, { dom: domRef.value as HTMLElement });
chart.renderSync();
await onRender?.(chart);
}
}
/** resize chart */
function resize() {
// chart?.resize();
}
/** destroy chart */
async function destroy() {
if (!chart) return;
await onDestroy?.(chart);
chart?.release();
chart = null;
}
/** change chart theme */
async function changeTheme() {
await destroy();
await render();
await onUpdated?.(chart!);
}
/**
* render chart by size
*
* @param w width
* @param h height
*/
async function renderChartBySize(w: number, h: number) {
initialSize.width = w;
initialSize.height = h;
// size is abnormal, destroy chart
if (!canRender()) {
await destroy();
return;
}
// resize chart
if (isRendered()) {
resize();
}
// render chart
await render();
}
scope.run(() => {
watch([width, height], ([newWidth, newHeight]) => {
renderChartBySize(newWidth, newHeight);
});
watch(darkMode, () => {
changeTheme();
});
});
onScopeDispose(() => {
destroy();
scope.stop();
});
return {
domRef,
updateSpec,
setSpec
};
}

View File

@@ -1,76 +1,133 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue'; import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { useInfiniteScroll } from '@vueuse/core'; import { useDebounceFn, useInfiniteScroll } from '@vueuse/core';
import { NOTIFY_MESSAGE_LEVEL_DICT_CODE } from '@/constants/dict';
import {
fetchGetMyNotifyMessagePage,
fetchGetUnreadNotifyCount,
fetchUpdateAllNotifyMessageRead,
fetchUpdateNotifyMessageRead
} from '@/service/api';
import { useDictStore } from '@/store/modules/dict';
import { formatDateTime, formatRelativeTime } from '@/utils/datetime';
defineOptions({ name: 'NotificationBell' }); defineOptions({ name: 'NotificationBell' });
interface NotificationItem { const dictStore = useDictStore();
id: string;
title: string;
timeLabel: string;
unread: boolean;
}
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
const UNREAD_COUNT_POLL_INTERVAL = 15 * 1000;
// 通知 mock扩到 60 条以演示分页 / 搜索;等真接口落地后整体迁移 type TabKey = 'unread' | 'read';
function buildMockNotifications(): NotificationItem[] {
const titles = [ interface MessageListState {
'你被指派为执行「迭代 24.06」负责人', items: Api.NotifyMessage.NotifyMessage[];
'任务「SSO 改造」状态变更:开发中 → 待验收', pageNo: number;
'需求「多币种支持」评审通过', total: number;
'工单 #1042 已分派给你', loading: boolean;
'需求「订单导出」被退回,请补充材料', /** 是否已按当前关键字拉过第一页tab 懒加载 / 失效重拉用) */
'@ 你的评论已被回复', loaded: boolean;
'项目「客户中心 2.0」周报已生成', /** 竞态令牌:重置后递增,过期响应直接丢弃 */
'工单 #1098 客户回复待处理', token: number;
'执行「迭代 24.05」已结束',
'需求「批量审批」分配给你'
];
const times = ['10min 前', '30min 前', '1h 前', '2h 前', '4h 前', '昨日', '前天', '3 天前', '1 周前', '2 周前'];
return Array.from({ length: 60 }, (_, i) => ({
id: `m${i + 1}`,
title: `${titles[i % titles.length]}#${i + 1}`,
timeLabel: times[Math.floor(i / 6) % times.length],
unread: i < 14
}));
} }
const notifications = ref<NotificationItem[]>(buildMockNotifications()); function createListState(): MessageListState {
return { items: [], pageNo: 1, total: 0, loading: false, loaded: false, token: 0 };
}
const unreadAll = computed(() => notifications.value.filter(n => n.unread)); const listStates = reactive<Record<TabKey, MessageListState>>({
const readAll = computed(() => notifications.value.filter(n => !n.unread)); unread: createListState(),
const unreadCount = computed(() => unreadAll.value.length); read: createListState()
});
const unreadCount = ref(0);
const badgeLabel = computed(() => (unreadCount.value > 99 ? '99+' : String(unreadCount.value))); const badgeLabel = computed(() => (unreadCount.value > 99 ? '99+' : String(unreadCount.value)));
const drawerOpen = ref(false); const drawerOpen = ref(false);
const activeTab = ref<'unread' | 'read'>('unread'); const activeTab = ref<TabKey>('unread');
const searchKeyword = ref(''); const searchKeyword = ref('');
function matchesKeyword(item: NotificationItem) { const detailVisible = ref(false);
const kw = searchKeyword.value.trim(); const detailMessage = ref<Api.NotifyMessage.NotifyMessage | null>(null);
if (!kw) return true;
return item.title.toLowerCase().includes(kw.toLowerCase()); function keywordParam() {
return searchKeyword.value.trim() || undefined;
} }
const filteredUnread = computed(() => unreadAll.value.filter(matchesKeyword)); /** 列表圆点颜色:跟随消息等级(与等级徽标同一字典色源);取不到时回 undefined由 CSS 兜底 */
const filteredRead = computed(() => readAll.value.filter(matchesKeyword)); function levelDotColor(level: number) {
return dictStore.getDictItem(NOTIFY_MESSAGE_LEVEL_DICT_CODE, level)?.colorType ?? undefined;
}
const unreadPageSize = ref(PAGE_SIZE); async function refreshUnreadCount() {
const readPageSize = ref(PAGE_SIZE); const { data, error } = await fetchGetUnreadNotifyCount();
if (!error && typeof data === 'number') {
unreadCount.value = data;
}
}
const visibleUnread = computed(() => filteredUnread.value.slice(0, unreadPageSize.value)); function resetList(tab: TabKey) {
const visibleRead = computed(() => filteredRead.value.slice(0, readPageSize.value)); const state = listStates[tab];
state.token += 1;
state.items = [];
state.pageNo = 1;
state.total = 0;
state.loading = false;
state.loaded = false;
}
const hasMoreUnread = computed(() => unreadPageSize.value < filteredUnread.value.length); async function loadPage(tab: TabKey) {
const hasMoreRead = computed(() => readPageSize.value < filteredRead.value.length); const state = listStates[tab];
if (state.loading) return;
const token = state.token;
state.loading = true;
const { data, error } = await fetchGetMyNotifyMessagePage({
pageNo: state.pageNo,
pageSize: PAGE_SIZE,
readStatus: tab === 'read',
keyword: keywordParam()
});
if (token !== state.token) return;
state.loading = false;
state.loaded = true;
if (error || !data) return;
state.items.push(...data.list);
state.total = data.total;
state.pageNo += 1;
}
function hasMore(tab: TabKey) {
const state = listStates[tab];
return state.loaded && state.items.length < state.total;
}
function ensureLoaded(tab: TabKey) {
const state = listStates[tab];
if (!state.loaded && !state.loading) {
loadPage(tab);
}
}
const applyKeywordSearch = useDebounceFn(() => {
if (!drawerOpen.value) return;
resetList('unread');
resetList('read');
loadPage(activeTab.value);
}, 300);
watch(searchKeyword, () => { watch(searchKeyword, () => {
unreadPageSize.value = PAGE_SIZE; applyKeywordSearch();
readPageSize.value = PAGE_SIZE;
}); });
// 已读列表数量会因"标已读"动态增长 / 未读会缩小;切换 tab 不重置已展示页数,体感更自然 watch(activeTab, tab => {
ensureLoaded(tab);
});
type ScrollbarRefValue = { wrapRef?: HTMLElement } | null; type ScrollbarRefValue = { wrapRef?: HTMLElement } | null;
const unreadScrollbar = ref<ScrollbarRefValue>(null); const unreadScrollbar = ref<ScrollbarRefValue>(null);
@@ -79,7 +136,9 @@ const readScrollbar = ref<ScrollbarRefValue>(null);
useInfiniteScroll( useInfiniteScroll(
() => unreadScrollbar.value?.wrapRef, () => unreadScrollbar.value?.wrapRef,
() => { () => {
if (hasMoreUnread.value) unreadPageSize.value += PAGE_SIZE; if (drawerOpen.value && hasMore('unread') && !listStates.unread.loading) {
loadPage('unread');
}
}, },
{ distance: 48 } { distance: 48 }
); );
@@ -87,43 +146,90 @@ useInfiniteScroll(
useInfiniteScroll( useInfiniteScroll(
() => readScrollbar.value?.wrapRef, () => readScrollbar.value?.wrapRef,
() => { () => {
if (hasMoreRead.value) readPageSize.value += PAGE_SIZE; if (drawerOpen.value && hasMore('read') && !listStates.read.loading) {
loadPage('read');
}
}, },
{ distance: 48 } { distance: 48 }
); );
function openDrawer() { function openDrawer() {
drawerOpen.value = true; drawerOpen.value = true;
// 每次打开面板都从第 1 页重拉(与后端对齐的消费口径)
resetList('unread');
resetList('read');
loadPage(activeTab.value);
refreshUnreadCount();
} }
function closeDrawer() { function closeDrawer() {
drawerOpen.value = false; drawerOpen.value = false;
} }
function markRead(item: NotificationItem) {
if (!item.unread) return;
item.unread = false;
// eslint-disable-next-line no-console
console.warn('[notification] mark-read', item.id);
}
function markAllRead() {
notifications.value.forEach(item => {
item.unread = false;
});
// eslint-disable-next-line no-console
console.warn('[notification] mark-all-read');
}
function openItem(item: NotificationItem) {
markRead(item);
// eslint-disable-next-line no-console
console.warn('[notification] open', item.id);
}
function onDrawerClosed() { function onDrawerClosed() {
searchKeyword.value = ''; searchKeyword.value = '';
} }
async function markRead(item: Api.NotifyMessage.NotifyMessage) {
const { error } = await fetchUpdateNotifyMessageRead([item.id]);
if (error) return;
// 本地移除、不按原页号回拉,避免未读集合收缩导致的分页漂移
const state = listStates.unread;
const index = state.items.findIndex(row => row.id === item.id);
if (index >= 0) {
state.items.splice(index, 1);
state.total = Math.max(0, state.total - 1);
}
unreadCount.value = Math.max(0, unreadCount.value - 1);
// 已读列表失效,下次进入已读 tab 时从第 1 页重拉
resetList('read');
// 移除后剩余条目不足一页且还有更多时补拉,防止列表不再触发滚动加载
if (state.items.length < PAGE_SIZE && hasMore('unread')) {
loadPage('unread');
}
}
function openDetail(row: Api.NotifyMessage.NotifyMessage) {
// 弹框持有该行引用,正文不随未读列表移除而消失
detailMessage.value = row;
detailVisible.value = true;
// 未读消息「打开即已读」:后台静默标记,避免"看一半就跑到已读"
if (!row.readStatus) {
markRead(row);
}
}
async function markAllRead() {
const { error } = await fetchUpdateAllNotifyMessageRead();
if (error) return;
unreadCount.value = 0;
resetList('unread');
resetList('read');
loadPage(activeTab.value);
}
let pollTimer: ReturnType<typeof setInterval> | null = null;
onMounted(() => {
// 等级徽标颜色/文案走字典:若未在登录缓存内则按编码补拉一次(已缓存时不发请求)
dictStore.ensureDictData(NOTIFY_MESSAGE_LEVEL_DICT_CODE);
refreshUnreadCount();
pollTimer = setInterval(() => {
if (document.hidden) return;
refreshUnreadCount();
}, UNREAD_COUNT_POLL_INTERVAL);
});
onBeforeUnmount(() => {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
});
</script> </script>
<template> <template>
@@ -137,21 +243,18 @@ function onDrawerClosed() {
<span v-if="unreadCount > 0" class="notification-bell__badge">{{ badgeLabel }}</span> <span v-if="unreadCount > 0" class="notification-bell__badge">{{ badgeLabel }}</span>
</button> </button>
<ElDrawer v-model="drawerOpen" size="480px" :with-header="false" @closed="onDrawerClosed"> <ElDrawer v-model="drawerOpen" size="480px" @closed="onDrawerClosed">
<div class="notification-bell__panel"> <template #header>
<header class="notification-bell__header"> <div class="notification-bell__header-main">
<span class="notification-bell__title"> <span class="notification-bell__title">
通知 通知
<span v-if="unreadCount > 0" class="notification-bell__title-count">未读 {{ unreadCount }}</span> <span v-if="unreadCount > 0" class="notification-bell__title-count">未读 {{ unreadCount }}</span>
</span> </span>
<span class="notification-bell__header-actions"> <ElButton v-if="unreadCount > 0" link size="small" @click="markAllRead">全部已读</ElButton>
<ElButton v-if="unreadCount > 0" link size="small" @click="markAllRead">全部已读</ElButton> </div>
<button class="notification-bell__close" type="button" aria-label="关闭" @click="closeDrawer"> </template>
<SvgIcon icon="mdi:close" />
</button>
</span>
</header>
<div class="notification-bell__panel">
<div class="notification-bell__search"> <div class="notification-bell__search">
<ElInput v-model="searchKeyword" placeholder="搜索通知" clearable> <ElInput v-model="searchKeyword" placeholder="搜索通知" clearable>
<template #prefix> <template #prefix>
@@ -165,61 +268,95 @@ function onDrawerClosed() {
<template #label> <template #label>
<span class="notification-bell__tab-label"> <span class="notification-bell__tab-label">
未读 未读
<span class="notification-bell__tab-count">{{ filteredUnread.length }}</span> <span class="notification-bell__tab-count">{{ listStates.unread.total }}</span>
</span> </span>
</template> </template>
<ElScrollbar ref="unreadScrollbar" class="notification-bell__scroll"> <ElScrollbar ref="unreadScrollbar" class="notification-bell__scroll">
<ul v-if="visibleUnread.length > 0" class="notification-bell__list"> <ul v-if="listStates.unread.items.length > 0" class="notification-bell__list">
<li <li
v-for="row in visibleUnread" v-for="row in listStates.unread.items"
:key="row.id" :key="row.id"
class="notification-bell__row is-unread" class="notification-bell__row is-unread"
@click="openItem(row)" @click="openDetail(row)"
> >
<span class="notification-bell__row-dot" /> <span class="notification-bell__row-dot" :style="{ backgroundColor: levelDotColor(row.level) }" />
<div class="notification-bell__row-body"> <div class="notification-bell__row-body">
<div class="notification-bell__row-title">{{ row.title }}</div> <div class="notification-bell__row-title">{{ row.templateContent }}</div>
<div class="notification-bell__row-time">{{ row.timeLabel }}</div> <div class="notification-bell__row-meta">
<DictTag :dict-code="NOTIFY_MESSAGE_LEVEL_DICT_CODE" :value="row.level" size="small" round />
<span class="notification-bell__row-time">{{ formatRelativeTime(row.createTime) }}</span>
</div>
</div> </div>
</li> </li>
</ul> </ul>
<div v-else class="notification-bell__empty"> <div v-else class="notification-bell__empty">
{{ searchKeyword ? '没有匹配的通知' : '暂无未读通知' }} {{ listStates.unread.loading ? '加载中…' : searchKeyword ? '没有匹配的通知' : '暂无未读通知' }}
</div> </div>
<div v-if="visibleUnread.length > 0" class="notification-bell__footer-hint"> <div v-if="listStates.unread.items.length > 0" class="notification-bell__footer-hint">
{{ hasMoreUnread ? '滚动加载更多…' : '— 已经到底了 —' }} {{ listStates.unread.loading ? '加载中…' : hasMore('unread') ? '滚动加载更多…' : '— 已经到底了 —' }}
</div> </div>
</ElScrollbar> </ElScrollbar>
</ElTabPane> </ElTabPane>
<ElTabPane name="read"> <ElTabPane name="read">
<template #label> <template #label>
<span class="notification-bell__tab-label"> <span class="notification-bell__tab-label">已读</span>
已读
<span class="notification-bell__tab-count">{{ filteredRead.length }}</span>
</span>
</template> </template>
<ElScrollbar ref="readScrollbar" class="notification-bell__scroll"> <ElScrollbar ref="readScrollbar" class="notification-bell__scroll">
<ul v-if="visibleRead.length > 0" class="notification-bell__list"> <ul v-if="listStates.read.items.length > 0" class="notification-bell__list">
<li v-for="row in visibleRead" :key="row.id" class="notification-bell__row" @click="openItem(row)"> <li
<span class="notification-bell__row-dot" /> v-for="row in listStates.read.items"
:key="row.id"
class="notification-bell__row"
@click="openDetail(row)"
>
<span class="notification-bell__row-dot" :style="{ backgroundColor: levelDotColor(row.level) }" />
<div class="notification-bell__row-body"> <div class="notification-bell__row-body">
<div class="notification-bell__row-title">{{ row.title }}</div> <div class="notification-bell__row-title">{{ row.templateContent }}</div>
<div class="notification-bell__row-time">{{ row.timeLabel }}</div> <div class="notification-bell__row-meta">
<DictTag :dict-code="NOTIFY_MESSAGE_LEVEL_DICT_CODE" :value="row.level" size="small" round />
<span class="notification-bell__row-time">{{ formatRelativeTime(row.createTime) }}</span>
</div>
</div> </div>
</li> </li>
</ul> </ul>
<div v-else class="notification-bell__empty"> <div v-else class="notification-bell__empty">
{{ searchKeyword ? '没有匹配的通知' : '暂无已读通知' }} {{ listStates.read.loading ? '加载中…' : searchKeyword ? '没有匹配的通知' : '暂无已读通知' }}
</div> </div>
<div v-if="visibleRead.length > 0" class="notification-bell__footer-hint"> <div v-if="listStates.read.items.length > 0" class="notification-bell__footer-hint">
{{ hasMoreRead ? '滚动加载更多…' : '— 已经到底了 —' }} {{ listStates.read.loading ? '加载中…' : hasMore('read') ? '滚动加载更多…' : '— 已经到底了 —' }}
</div> </div>
</ElScrollbar> </ElScrollbar>
</ElTabPane> </ElTabPane>
</ElTabs> </ElTabs>
</div> </div>
<template #footer>
<ElButton @click="closeDrawer">关闭</ElButton>
</template>
</ElDrawer> </ElDrawer>
<ElDialog v-model="detailVisible" width="520px" align-center class="notification-bell__detail">
<template #header>
<div class="notification-bell__detail-head">
<span class="notification-bell__detail-sender">{{ detailMessage?.templateNickname || '系统通知' }}</span>
<DictTag
v-if="detailMessage"
:dict-code="NOTIFY_MESSAGE_LEVEL_DICT_CODE"
:value="detailMessage.level"
size="small"
round
/>
</div>
</template>
<div v-if="detailMessage" class="notification-bell__detail-body">
<div class="notification-bell__detail-content">{{ detailMessage.templateContent }}</div>
<div class="notification-bell__detail-time">收到于 {{ formatDateTime(detailMessage.createTime) }}</div>
</div>
<template #footer>
<ElButton @click="detailVisible = false">关闭</ElButton>
</template>
</ElDialog>
</template> </template>
<style scoped> <style scoped>
@@ -258,18 +395,53 @@ function onDrawerClosed() {
.notification-bell__badge { .notification-bell__badge {
position: absolute; position: absolute;
top: 4px; top: 2px;
right: 4px; right: 2px;
min-width: 16px; min-width: 18px;
height: 16px; height: 18px;
padding: 0 4px; padding: 0 5px;
border: 1px solid #fff;
border-radius: 999px; border-radius: 999px;
background-color: var(--el-color-danger); background-color: var(--el-color-danger);
color: #fff; color: #fff;
font-size: 10px; font-size: 11px;
font-weight: 600; font-weight: 700;
line-height: 16px; line-height: 16px;
text-align: center; text-align: center;
animation: notification-badge-pulse 1.6s ease-in-out infinite;
}
/* 扩散波纹:跟随心跳节奏向外晕开,增强未读提醒的醒目度 */
.notification-bell__badge::before {
content: '';
position: absolute;
inset: -1px;
border-radius: 999px;
background-color: var(--el-color-danger);
animation: notification-badge-ping 1.6s ease-out infinite;
z-index: -1;
}
@keyframes notification-badge-pulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.18);
}
}
@keyframes notification-badge-ping {
0% {
transform: scale(1);
opacity: 0.6;
}
70%,
100% {
transform: scale(1.9);
opacity: 0;
}
} }
.notification-bell__panel { .notification-bell__panel {
@@ -278,13 +450,14 @@ function onDrawerClosed() {
height: 100%; height: 100%;
} }
.notification-bell__header { .notification-bell__header-main {
display: flex; display: flex;
flex: 1;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
padding-bottom: 12px; min-width: 0;
border-bottom: 1px solid var(--el-border-color-lighter); margin-right: 8px;
} }
.notification-bell__title { .notification-bell__title {
@@ -305,37 +478,8 @@ function onDrawerClosed() {
font-weight: 600; font-weight: 600;
} }
.notification-bell__header-actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
.notification-bell__close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 6px;
background-color: transparent;
color: var(--el-text-color-secondary);
cursor: pointer;
font-size: 18px;
transition:
background-color 120ms ease,
color 120ms ease;
}
.notification-bell__close:hover {
background-color: var(--el-fill-color-light);
color: var(--el-text-color-primary);
}
.notification-bell__search { .notification-bell__search {
padding: 12px 0 4px; padding: 0 0 4px;
} }
.notification-bell__tabs { .notification-bell__tabs {
@@ -393,8 +537,8 @@ function onDrawerClosed() {
align-items: flex-start; align-items: flex-start;
gap: 10px; gap: 10px;
padding: 12px 4px; padding: 12px 4px;
cursor: pointer;
border-radius: 8px; border-radius: 8px;
cursor: pointer;
transition: background-color 120ms ease; transition: background-color 120ms ease;
} }
@@ -434,8 +578,14 @@ function onDrawerClosed() {
font-weight: 500; font-weight: 500;
} }
.notification-bell__row-meta {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
}
.notification-bell__row-time { .notification-bell__row-time {
margin-top: 4px;
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
font-size: 12px; font-size: 12px;
} }
@@ -454,4 +604,41 @@ function onDrawerClosed() {
font-size: 12px; font-size: 12px;
user-select: none; user-select: none;
} }
.notification-bell__detail-body {
display: flex;
flex-direction: column;
gap: 14px;
}
.notification-bell__detail-head {
display: flex;
align-items: center;
gap: 10px;
padding-right: 8px;
min-width: 0;
}
.notification-bell__detail-sender {
min-width: 0;
overflow: hidden;
color: var(--el-text-color-primary);
font-size: 16px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.notification-bell__detail-content {
color: var(--el-text-color-regular);
font-size: 14px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
}
.notification-bell__detail-time {
color: var(--el-text-color-secondary);
font-size: 12px;
}
</style> </style>

View File

@@ -41,11 +41,6 @@ const { isFullscreen, toggle } = useFullscreen();
<div> <div>
<FullScreen v-if="!appStore.isMobile" :full="isFullscreen" @click="toggle" /> <FullScreen v-if="!appStore.isMobile" :full="isFullscreen" @click="toggle" />
</div> </div>
<ThemeSchemaSwitch
:theme-schema="themeStore.themeScheme"
:is-dark="themeStore.darkMode"
@switch="themeStore.toggleThemeScheme"
/>
<div> <div>
<ThemeButton /> <ThemeButton />
</div> </div>

View File

@@ -12,7 +12,7 @@ const { selectedKeyDummy, handleSelect } = useMenu();
</script> </script>
<template> <template>
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`"> <Teleport defer :to="`#${GLOBAL_HEADER_MENU_ID}`">
<ElMenu <ElMenu
ellipsis ellipsis
class="w-full" class="w-full"

View File

@@ -93,7 +93,8 @@ function isMenuActive(menu: App.Global.Menu | App.ObjectContext.Menu): boolean {
</script> </script>
<template> <template>
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`"> <!-- deferBaseLayout 二次挂载时 GlobalMenu 已缓存为同步挂载目标 div 还未插入 document不延迟解析会静默失败且不重试 -->
<Teleport defer :to="`#${GLOBAL_HEADER_MENU_ID}`">
<div class="mix-header-nav size-full min-w-0 flex-y-center"> <div class="mix-header-nav size-full min-w-0 flex-y-center">
<button <button
v-if="activeFirstLevelMenu" v-if="activeFirstLevelMenu"
@@ -161,7 +162,7 @@ function isMenuActive(menu: App.Global.Menu | App.ObjectContext.Menu): boolean {
</div> </div>
</div> </div>
</Teleport> </Teleport>
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`"> <Teleport defer :to="`#${GLOBAL_SIDER_MENU_ID}`">
<FirstLevelMenu <FirstLevelMenu
:menus="allMenus" :menus="allMenus"
:active-menu-key="activeFirstLevelMenuKey" :active-menu-key="activeFirstLevelMenuKey"

View File

@@ -55,7 +55,7 @@ watch(
</script> </script>
<template> <template>
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`"> <Teleport defer :to="`#${GLOBAL_HEADER_MENU_ID}`">
<ElMenu <ElMenu
ellipsis ellipsis
class="w-full" class="w-full"
@@ -66,7 +66,7 @@ watch(
<MenuItem v-for="item in firstLevelMenus" :key="item.key" :item="item" :index="item.key" /> <MenuItem v-for="item in firstLevelMenus" :key="item.key" :item="item" :index="item.key" />
</ElMenu> </ElMenu>
</Teleport> </Teleport>
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`"> <Teleport defer :to="`#${GLOBAL_SIDER_MENU_ID}`">
<SimpleScrollbar> <SimpleScrollbar>
<ElMenu <ElMenu
mode="vertical" mode="vertical"

View File

@@ -38,7 +38,7 @@ watch(
</script> </script>
<template> <template>
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`"> <Teleport defer :to="`#${GLOBAL_SIDER_MENU_ID}`">
<SimpleScrollbar> <SimpleScrollbar>
<ElMenu <ElMenu
mode="vertical" mode="vertical"

View File

@@ -90,7 +90,7 @@ watch(
</script> </script>
<template> <template>
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`"> <Teleport defer :to="`#${GLOBAL_SIDER_MENU_ID}`">
<div class="h-full flex" @mouseleave="handleResetActiveMenu"> <div class="h-full flex" @mouseleave="handleResetActiveMenu">
<FirstLevelMenu <FirstLevelMenu
:menus="allMenus" :menus="allMenus"

View File

@@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { themeSchemaRecord } from '@/constants/app';
import { useThemeStore } from '@/store/modules/theme'; import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales'; import { $t } from '@/locales';
import SettingItem from '../components/setting-item.vue'; import SettingItem from '../components/setting-item.vue';
@@ -9,16 +8,6 @@ defineOptions({ name: 'DarkMode' });
const themeStore = useThemeStore(); const themeStore = useThemeStore();
const icons: Record<UnionKey.ThemeScheme, string> = {
light: 'material-symbols:sunny',
dark: 'material-symbols:nightlight-rounded',
auto: 'material-symbols:hdr-auto'
};
function handleSegmentChange(value: string | number) {
themeStore.setThemeScheme(value as UnionKey.ThemeScheme);
}
function handleGrayscaleChange(value: boolean) { function handleGrayscaleChange(value: boolean) {
themeStore.setGrayscale(value); themeStore.setGrayscale(value);
} }
@@ -33,15 +22,6 @@ const showSiderInverted = computed(() => !themeStore.darkMode && themeStore.layo
<template> <template>
<ElDivider>{{ $t('theme.themeSchema.title') }}</ElDivider> <ElDivider>{{ $t('theme.themeSchema.title') }}</ElDivider>
<div class="flex-col-stretch gap-16px"> <div class="flex-col-stretch gap-16px">
<div class="i-flex-center">
<ElTabs v-model="themeStore.themeScheme" type="border-card" class="segment" @tab-change="handleSegmentChange">
<ElTabPane v-for="(_, key) in themeSchemaRecord" :key="key" :name="key">
<template #label>
<SvgIcon :icon="icons[key]" class="h-23px text-icon-small" />
</template>
</ElTabPane>
</ElTabs>
</div>
<Transition name="sider-inverted"> <Transition name="sider-inverted">
<SettingItem v-if="showSiderInverted" :label="$t('theme.sider.inverted')"> <SettingItem v-if="showSiderInverted" :label="$t('theme.sider.inverted')">
<ElSwitch v-model="themeStore.sider.inverted" /> <ElSwitch v-model="themeStore.sider.inverted" />

View File

@@ -169,25 +169,23 @@ const local: App.I18n.Schema = {
'personal-center': 'Personal Center', 'personal-center': 'Personal Center',
'personal-center_my-profile': 'My Profile', 'personal-center_my-profile': 'My Profile',
'personal-center_my-item': 'My Items', 'personal-center_my-item': 'My Items',
'personal-center_my-weekly': 'My Weekly Report', 'personal-center_work-report': 'Work Report',
'personal-center_my-monthly': 'My Monthly Report', 'personal-center_work-report_weekly': 'Weekly Report',
'personal-center_work-report_monthly': 'Monthly Report',
'personal-center_work-report_project': 'Project Fortnightly Report',
'personal-center_my-performance': 'My Performance', 'personal-center_my-performance': 'My Performance',
'personal-center_my-application': 'My Application', 'personal-center_my-application': 'My Application',
'personal-center_overtime-application': 'Overtime Application', 'personal-center_overtime-application': 'Overtime Application',
'personal-center_pending-approval': 'Pending Approval', 'personal-center_pending-approval': 'Pending Approval',
feedback: 'Feedback',
infra: 'Infra', infra: 'Infra',
'infra_state-machine': 'State Machine', 'infra_state-machine': 'State Machine',
'infra_log-management': 'Log Management',
'infra_log-management_login-log': 'Login Log',
'infra_log-management_operate-log': 'Operate Log',
'infra_log-management_api-access-log': 'API Access Log',
'infra_log-management_api-error-log': 'API Error Log',
'infra_rd-code': 'R&D Code', 'infra_rd-code': 'R&D Code',
function: 'System Function',
function_tab: 'Tab',
'function_multi-tab': 'Multi Tab',
'function_hide-child': 'Hide Child',
'function_hide-child_one': 'Hide Child',
'function_hide-child_two': 'Two',
'function_hide-child_three': 'Three',
function_request: 'Request',
'function_toggle-auth': 'Toggle Auth',
'function_super-page': 'Super Admin Visible',
product: 'Product', product: 'Product',
product_list: 'Product List', product_list: 'Product List',
product_dashboard: 'Dashboard', product_dashboard: 'Dashboard',
@@ -211,28 +209,7 @@ const local: App.I18n.Schema = {
exception: 'Exception', exception: 'Exception',
exception_403: '403', exception_403: '403',
exception_404: '404', exception_404: '404',
exception_500: '500', exception_500: '500'
plugin: 'Plugin',
plugin_copy: 'Copy',
plugin_charts: 'Charts',
plugin_charts_echarts: 'ECharts',
plugin_charts_antv: 'AntV',
plugin_charts_vchart: 'VChart',
plugin_icon: 'Icon',
plugin_map: 'Map',
plugin_print: 'Print',
plugin_swiper: 'Swiper',
plugin_video: 'Video',
plugin_barcode: 'Barcode',
plugin_pinyin: 'pinyin',
plugin_excel: 'Excel',
plugin_pdf: 'PDF preview',
plugin_gantt: 'Gantt Chart',
plugin_gantt_dhtmlx: 'dhtmlxGantt',
plugin_gantt_vtable: 'VTableGantt',
plugin_typeit: 'Typeit',
plugin_tables: 'Tables',
plugin_tables_vtable: 'VTable'
}, },
page: { page: {
login: { login: {
@@ -328,45 +305,6 @@ const local: App.I18n.Schema = {
}, },
creativity: 'Creativity' creativity: 'Creativity'
}, },
function: {
tab: {
tabOperate: {
title: 'Tab Operation',
addTab: 'Add Tab',
addTabDesc: 'To user management page',
closeTab: 'Close Tab',
closeCurrentTab: 'Close Current Tab',
closeAboutTab: 'Close "User Management" Tab',
addMultiTab: 'Add Multi Tab',
addMultiTabDesc1: 'To MultiTab page',
addMultiTabDesc2: 'To MultiTab page(with query params)'
},
tabTitle: {
title: 'Tab Title',
changeTitle: 'Change Title',
change: 'Change',
resetTitle: 'Reset Title',
reset: 'Reset'
}
},
multiTab: {
routeParam: 'Route Param',
backTab: 'Back function_tab'
},
toggleAuth: {
toggleAccount: 'Toggle Account',
authHook: 'Auth Hook Function `hasAuth`',
superAdminVisible: 'Super Admin Visible',
adminVisible: 'Admin Visible',
adminOrUserVisible: 'Admin and User Visible'
},
request: {
repeatedErrorOccurOnce: 'Repeated Request Error Occurs Once',
repeatedError: 'Repeated Request Error',
repeatedErrorMsg1: 'Custom Request Error 1',
repeatedErrorMsg2: 'Custom Request Error 2'
}
},
system: { system: {
common: { common: {
status: { status: {

View File

@@ -1,6 +1,6 @@
const local: App.I18n.Schema = { const local: App.I18n.Schema = {
system: { system: {
title: '研发内部管理系统' title: '研发管理系统'
}, },
common: { common: {
action: '操作', action: '操作',
@@ -169,25 +169,23 @@ const local: App.I18n.Schema = {
'personal-center': '个人中心', 'personal-center': '个人中心',
'personal-center_my-profile': '个人信息', 'personal-center_my-profile': '个人信息',
'personal-center_my-item': '我的事项', 'personal-center_my-item': '我的事项',
'personal-center_my-weekly': '我的周报', 'personal-center_work-report': '工作报告',
'personal-center_my-monthly': '我的月报', 'personal-center_work-report_weekly': '个人周报',
'personal-center_work-report_monthly': '个人月报',
'personal-center_work-report_project': '项目半月报',
'personal-center_my-performance': '我的绩效', 'personal-center_my-performance': '我的绩效',
'personal-center_my-application': '我的申请', 'personal-center_my-application': '我的申请',
'personal-center_overtime-application': '加班申请', 'personal-center_overtime-application': '加班申请',
'personal-center_pending-approval': '待我审批', 'personal-center_pending-approval': '待我审批',
feedback: '意见反馈',
infra: '基础设施', infra: '基础设施',
'infra_state-machine': '状态机管理', 'infra_state-machine': '状态机管理',
'infra_log-management': '日志管理',
'infra_log-management_login-log': '登录日志',
'infra_log-management_operate-log': '操作日志',
'infra_log-management_api-access-log': 'API访问日志',
'infra_log-management_api-error-log': 'API错误日志',
'infra_rd-code': '研发令号', 'infra_rd-code': '研发令号',
function: '系统功能',
function_tab: '标签页',
'function_multi-tab': '多标签页',
'function_hide-child': '隐藏子菜单',
'function_hide-child_one': '隐藏子菜单',
'function_hide-child_two': '菜单二',
'function_hide-child_three': '菜单三',
function_request: '请求',
'function_toggle-auth': '切换权限',
'function_super-page': '超级管理员可见',
product: '产品管理', product: '产品管理',
product_list: '产品列表', product_list: '产品列表',
product_dashboard: '产品仪表盘', product_dashboard: '产品仪表盘',
@@ -211,28 +209,7 @@ const local: App.I18n.Schema = {
exception: '异常页', exception: '异常页',
exception_403: '403', exception_403: '403',
exception_404: '404', exception_404: '404',
exception_500: '500', exception_500: '500'
plugin: '插件示例',
plugin_copy: '剪贴板',
plugin_charts: '图表',
plugin_charts_echarts: 'ECharts',
plugin_charts_antv: 'AntV',
plugin_charts_vchart: 'VChart',
plugin_icon: '图标',
plugin_map: '地图',
plugin_print: '打印',
plugin_swiper: 'Swiper',
plugin_video: '视频',
plugin_barcode: '条形码',
plugin_pinyin: '拼音',
plugin_excel: 'Excel',
plugin_pdf: 'PDF 预览',
plugin_gantt: '甘特图',
plugin_gantt_dhtmlx: 'dhtmlxGantt',
plugin_gantt_vtable: 'VTableGantt',
plugin_typeit: '打字机',
plugin_tables: '表格',
plugin_tables_vtable: 'VTable'
}, },
page: { page: {
login: { login: {
@@ -284,7 +261,7 @@ const local: App.I18n.Schema = {
about: { about: {
title: '关于', title: '关于',
introduction: introduction:
'灿能研发内部管理系统是灿能电力内部使用的研发管理前端系统,用于承载内部业务模块、工程协作流程和日常管理能力。', '灿能研发管理系统是灿能电力内部使用的研发管理前端系统,用于承载内部业务模块、工程协作流程和日常管理能力。',
projectInfo: { projectInfo: {
title: '项目信息', title: '项目信息',
version: '版本', version: '版本',
@@ -327,45 +304,6 @@ const local: App.I18n.Schema = {
}, },
creativity: '创意' creativity: '创意'
}, },
function: {
tab: {
tabOperate: {
title: '标签页操作',
addTab: '添加标签页',
addTabDesc: '跳转到用户管理页面',
closeTab: '关闭标签页',
closeCurrentTab: '关闭当前标签页',
closeAboutTab: '关闭"用户管理"标签页',
addMultiTab: '添加多标签页',
addMultiTabDesc1: '跳转到多标签页页面',
addMultiTabDesc2: '跳转到多标签页页面(带有查询参数)'
},
tabTitle: {
title: '标签页标题',
changeTitle: '修改标题',
change: '修改',
resetTitle: '重置标题',
reset: '重置'
}
},
multiTab: {
routeParam: '路由参数',
backTab: '返回 function_tab'
},
toggleAuth: {
toggleAccount: '切换账号',
authHook: '权限钩子函数 `hasAuth`',
superAdminVisible: '超级管理员可见',
adminVisible: '管理员可见',
adminOrUserVisible: '管理员和用户可见'
},
request: {
repeatedErrorOccurOnce: '重复请求错误只出现一次',
repeatedError: '重复请求错误',
repeatedErrorMsg1: '自定义请求错误 1',
repeatedErrorMsg2: '自定义请求错误 2'
}
},
system: { system: {
common: { common: {
status: { status: {
@@ -729,7 +667,7 @@ const local: App.I18n.Schema = {
}, },
pwd: { pwd: {
required: '请输入密码', required: '请输入密码',
invalid: '密码格式不正确,6-18位字符,包含字母、数字、下划线' invalid: '密码格式不正确,4-30位字符,包含字母、数字、下划线'
}, },
confirmPwd: { confirmPwd: {
required: '请输入确认密码', required: '请输入确认密码',

View File

@@ -3,6 +3,3 @@ import 'element-plus/dist/index.css';
import 'element-plus/theme-chalk/dark/css-vars.css'; import 'element-plus/theme-chalk/dark/css-vars.css';
import 'uno.css'; import 'uno.css';
import '../styles/css/global.css'; import '../styles/css/global.css';
import 'swiper/css';
import 'swiper/css/navigation';
import 'swiper/css/pagination';

View File

@@ -20,14 +20,12 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
500: () => import("@/views/_builtin/500/index.vue"), 500: () => import("@/views/_builtin/500/index.vue"),
"iframe-page": () => import("@/views/_builtin/iframe-page/[url].vue"), "iframe-page": () => import("@/views/_builtin/iframe-page/[url].vue"),
login: () => import("@/views/_builtin/login/index.vue"), login: () => import("@/views/_builtin/login/index.vue"),
"function_hide-child_one": () => import("@/views/function/hide-child/one/index.vue"), feedback: () => import("@/views/feedback/index.vue"),
"function_hide-child_three": () => import("@/views/function/hide-child/three/index.vue"), "infra_log-management_api-access-log": () => import("@/views/infra/log-management/api-access-log/index.vue"),
"function_hide-child_two": () => import("@/views/function/hide-child/two/index.vue"), "infra_log-management_api-error-log": () => import("@/views/infra/log-management/api-error-log/index.vue"),
"function_multi-tab": () => import("@/views/function/multi-tab/index.vue"), "infra_log-management": () => import("@/views/infra/log-management/index.vue"),
function_request: () => import("@/views/function/request/index.vue"), "infra_log-management_login-log": () => import("@/views/infra/log-management/login-log/index.vue"),
"function_super-page": () => import("@/views/function/super-page/index.vue"), "infra_log-management_operate-log": () => import("@/views/infra/log-management/operate-log/index.vue"),
function_tab: () => import("@/views/function/tab/index.vue"),
"function_toggle-auth": () => import("@/views/function/toggle-auth/index.vue"),
"infra_rd-code": () => import("@/views/infra/rd-code/index.vue"), "infra_rd-code": () => import("@/views/infra/rd-code/index.vue"),
"infra_state-machine": () => import("@/views/infra/state-machine/index.vue"), "infra_state-machine": () => import("@/views/infra/state-machine/index.vue"),
"metrics_member-efficiency": () => import("@/views/metrics/member-efficiency/index.vue"), "metrics_member-efficiency": () => import("@/views/metrics/member-efficiency/index.vue"),
@@ -35,29 +33,14 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
metrics_worktime: () => import("@/views/metrics/worktime/index.vue"), metrics_worktime: () => import("@/views/metrics/worktime/index.vue"),
"personal-center_my-application": () => import("@/views/personal-center/my-application/index.vue"), "personal-center_my-application": () => import("@/views/personal-center/my-application/index.vue"),
"personal-center_my-item": () => import("@/views/personal-center/my-item/index.vue"), "personal-center_my-item": () => import("@/views/personal-center/my-item/index.vue"),
"personal-center_my-monthly": () => import("@/views/personal-center/my-monthly/index.vue"),
"personal-center_my-performance": () => import("@/views/personal-center/my-performance/index.vue"), "personal-center_my-performance": () => import("@/views/personal-center/my-performance/index.vue"),
"personal-center_my-profile": () => import("@/views/personal-center/my-profile/index.vue"), "personal-center_my-profile": () => import("@/views/personal-center/my-profile/index.vue"),
"personal-center_my-weekly": () => import("@/views/personal-center/my-weekly/index.vue"),
"personal-center_overtime-application": () => import("@/views/personal-center/overtime-application/index.vue"), "personal-center_overtime-application": () => import("@/views/personal-center/overtime-application/index.vue"),
"personal-center_pending-approval": () => import("@/views/personal-center/pending-approval/index.vue"), "personal-center_pending-approval": () => import("@/views/personal-center/pending-approval/index.vue"),
plugin_barcode: () => import("@/views/plugin/barcode/index.vue"), "personal-center_work-report": () => import("@/views/personal-center/work-report/index.vue"),
plugin_charts_antv: () => import("@/views/plugin/charts/antv/index.vue"), "personal-center_work-report_monthly": () => import("@/views/personal-center/work-report/monthly/index.vue"),
plugin_charts_echarts: () => import("@/views/plugin/charts/echarts/index.vue"), "personal-center_work-report_project": () => import("@/views/personal-center/work-report/project/index.vue"),
plugin_charts_vchart: () => import("@/views/plugin/charts/vchart/index.vue"), "personal-center_work-report_weekly": () => import("@/views/personal-center/work-report/weekly/index.vue"),
plugin_copy: () => import("@/views/plugin/copy/index.vue"),
plugin_excel: () => import("@/views/plugin/excel/index.vue"),
plugin_gantt_dhtmlx: () => import("@/views/plugin/gantt/dhtmlx/index.vue"),
plugin_gantt_vtable: () => import("@/views/plugin/gantt/vtable/index.vue"),
plugin_icon: () => import("@/views/plugin/icon/index.vue"),
plugin_map: () => import("@/views/plugin/map/index.vue"),
plugin_pdf: () => import("@/views/plugin/pdf/index.vue"),
plugin_pinyin: () => import("@/views/plugin/pinyin/index.vue"),
plugin_print: () => import("@/views/plugin/print/index.vue"),
plugin_swiper: () => import("@/views/plugin/swiper/index.vue"),
plugin_tables_vtable: () => import("@/views/plugin/tables/vtable/index.vue"),
plugin_typeit: () => import("@/views/plugin/typeit/index.vue"),
plugin_video: () => import("@/views/plugin/video/index.vue"),
product_dashboard: () => import("@/views/product/dashboard/index.vue"), product_dashboard: () => import("@/views/product/dashboard/index.vue"),
product_list: () => import("@/views/product/list/index.vue"), product_list: () => import("@/views/product/list/index.vue"),
product_requirement: () => import("@/views/product/requirement/index.vue"), product_requirement: () => import("@/views/product/requirement/index.vue"),

View File

@@ -40,122 +40,17 @@ export const generatedRoutes: GeneratedRoute[] = [
} }
}, },
{ {
name: 'function', name: 'feedback',
path: '/function', path: '/feedback',
component: 'layout.base', component: 'layout.base$view.feedback',
meta: { meta: {
title: 'function', title: 'feedback',
i18nKey: 'route.function', i18nKey: 'route.feedback',
icon: 'icon-park-outline:all-application', icon: 'mdi:message-alert-outline',
order: 6 order: 10,
}, keepAlive: true,
children: [ constant: true
{ }
name: 'function_hide-child',
path: '/function/hide-child',
meta: {
title: 'function_hide-child',
i18nKey: 'route.function_hide-child',
icon: 'material-symbols:filter-list-off',
order: 2
},
redirect: '/function/hide-child/one',
children: [
{
name: 'function_hide-child_one',
path: '/function/hide-child/one',
component: 'view.function_hide-child_one',
meta: {
title: 'function_hide-child_one',
i18nKey: 'route.function_hide-child_one',
icon: 'material-symbols:filter-list-off',
hideInMenu: true,
activeMenu: 'function_hide-child'
}
},
{
name: 'function_hide-child_three',
path: '/function/hide-child/three',
component: 'view.function_hide-child_three',
meta: {
title: 'function_hide-child_three',
i18nKey: 'route.function_hide-child_three',
hideInMenu: true,
activeMenu: 'function_hide-child'
}
},
{
name: 'function_hide-child_two',
path: '/function/hide-child/two',
component: 'view.function_hide-child_two',
meta: {
title: 'function_hide-child_two',
i18nKey: 'route.function_hide-child_two',
hideInMenu: true,
activeMenu: 'function_hide-child'
}
}
]
},
{
name: 'function_multi-tab',
path: '/function/multi-tab',
component: 'view.function_multi-tab',
meta: {
title: 'function_multi-tab',
i18nKey: 'route.function_multi-tab',
icon: 'ic:round-tab',
multiTab: true,
hideInMenu: true,
activeMenu: 'function_tab'
}
},
{
name: 'function_request',
path: '/function/request',
component: 'view.function_request',
meta: {
title: 'function_request',
i18nKey: 'route.function_request',
icon: 'carbon:network-overlay',
order: 3
}
},
{
name: 'function_super-page',
path: '/function/super-page',
component: 'view.function_super-page',
meta: {
title: 'function_super-page',
i18nKey: 'route.function_super-page',
icon: 'ic:round-supervisor-account',
order: 5,
roles: ['R_SUPER']
}
},
{
name: 'function_tab',
path: '/function/tab',
component: 'view.function_tab',
meta: {
title: 'function_tab',
i18nKey: 'route.function_tab',
icon: 'ic:round-tab',
order: 1
}
},
{
name: 'function_toggle-auth',
path: '/function/toggle-auth',
component: 'view.function_toggle-auth',
meta: {
title: 'function_toggle-auth',
i18nKey: 'route.function_toggle-auth',
icon: 'ic:round-construction',
order: 4
}
}
]
}, },
{ {
name: 'iframe-page', name: 'iframe-page',
@@ -181,6 +76,64 @@ export const generatedRoutes: GeneratedRoute[] = [
order: 20 order: 20
}, },
children: [ children: [
{
name: 'infra_log-management',
path: '/infra/log-management',
component: 'view.infra_log-management',
meta: {
title: 'infra_log-management',
i18nKey: 'route.infra_log-management',
icon: 'mdi:text-box-search-outline',
order: 2,
keepAlive: true
},
children: [
{
name: 'infra_log-management_api-access-log',
path: '/infra/log-management/api-access-log',
component: 'view.infra_log-management_api-access-log',
meta: {
title: 'infra_log-management_api-access-log',
i18nKey: 'route.infra_log-management_api-access-log',
hideInMenu: true,
activeMenu: 'infra_log-management'
}
},
{
name: 'infra_log-management_api-error-log',
path: '/infra/log-management/api-error-log',
component: 'view.infra_log-management_api-error-log',
meta: {
title: 'infra_log-management_api-error-log',
i18nKey: 'route.infra_log-management_api-error-log',
hideInMenu: true,
activeMenu: 'infra_log-management'
}
},
{
name: 'infra_log-management_login-log',
path: '/infra/log-management/login-log',
component: 'view.infra_log-management_login-log',
meta: {
title: 'infra_log-management_login-log',
i18nKey: 'route.infra_log-management_login-log',
hideInMenu: true,
activeMenu: 'infra_log-management'
}
},
{
name: 'infra_log-management_operate-log',
path: '/infra/log-management/operate-log',
component: 'view.infra_log-management_operate-log',
meta: {
title: 'infra_log-management_operate-log',
i18nKey: 'route.infra_log-management_operate-log',
hideInMenu: true,
activeMenu: 'infra_log-management'
}
}
]
},
{ {
name: 'infra_rd-code', name: 'infra_rd-code',
path: '/infra/rd-code', path: '/infra/rd-code',
@@ -189,7 +142,7 @@ export const generatedRoutes: GeneratedRoute[] = [
title: 'infra_rd-code', title: 'infra_rd-code',
i18nKey: 'route.infra_rd-code', i18nKey: 'route.infra_rd-code',
icon: 'mdi:identifier', icon: 'mdi:identifier',
order: 2, order: 3,
keepAlive: true keepAlive: true
} }
}, },
@@ -303,18 +256,6 @@ export const generatedRoutes: GeneratedRoute[] = [
keepAlive: true keepAlive: true
} }
}, },
{
name: 'personal-center_my-monthly',
path: '/personal-center/my-monthly',
component: 'view.personal-center_my-monthly',
meta: {
title: 'personal-center_my-monthly',
i18nKey: 'route.personal-center_my-monthly',
icon: 'mdi:calendar-month-outline',
order: 2,
keepAlive: true
}
},
{ {
name: 'personal-center_my-performance', name: 'personal-center_my-performance',
path: '/personal-center/my-performance', path: '/personal-center/my-performance',
@@ -323,7 +264,7 @@ export const generatedRoutes: GeneratedRoute[] = [
title: 'personal-center_my-performance', title: 'personal-center_my-performance',
i18nKey: 'route.personal-center_my-performance', i18nKey: 'route.personal-center_my-performance',
icon: 'mdi:trophy-outline', icon: 'mdi:trophy-outline',
order: 3, order: 4,
keepAlive: true keepAlive: true
} }
}, },
@@ -339,18 +280,6 @@ export const generatedRoutes: GeneratedRoute[] = [
keepAlive: true keepAlive: true
} }
}, },
{
name: 'personal-center_my-weekly',
path: '/personal-center/my-weekly',
component: 'view.personal-center_my-weekly',
meta: {
title: 'personal-center_my-weekly',
i18nKey: 'route.personal-center_my-weekly',
icon: 'mdi:calendar-week-outline',
order: 1,
keepAlive: true
}
},
{ {
name: 'personal-center_overtime-application', name: 'personal-center_overtime-application',
path: '/personal-center/overtime-application', path: '/personal-center/overtime-application',
@@ -374,223 +303,53 @@ export const generatedRoutes: GeneratedRoute[] = [
order: 7, order: 7,
keepAlive: true keepAlive: true
} }
}
]
},
{
name: 'plugin',
path: '/plugin',
component: 'layout.base',
meta: {
title: '插件示例',
i18nKey: 'route.plugin',
order: 7,
icon: 'clarity:plugin-line'
},
children: [
{
name: 'plugin_barcode',
path: '/plugin/barcode',
component: 'view.plugin_barcode',
meta: {
title: 'plugin_barcode',
i18nKey: 'route.plugin_barcode',
icon: 'ic:round-barcode'
}
}, },
{ {
name: 'plugin_charts', name: 'personal-center_work-report',
path: '/plugin/charts', path: '/personal-center/work-report',
component: 'view.personal-center_work-report',
meta: { meta: {
title: 'plugin_charts', title: 'personal-center_work-report',
i18nKey: 'route.plugin_charts', i18nKey: 'route.personal-center_work-report',
icon: 'mdi:chart-areaspline' icon: 'mdi:file-chart-outline',
}, order: 3,
children: [
{
name: 'plugin_charts_antv',
path: '/plugin/charts/antv',
component: 'view.plugin_charts_antv',
meta: {
title: 'plugin_charts_antv',
i18nKey: 'route.plugin_charts_antv',
icon: 'hugeicons:flow-square'
}
},
{
name: 'plugin_charts_echarts',
path: '/plugin/charts/echarts',
component: 'view.plugin_charts_echarts',
meta: {
title: 'plugin_charts_echarts',
i18nKey: 'route.plugin_charts_echarts',
icon: 'simple-icons:apacheecharts'
}
},
{
name: 'plugin_charts_vchart',
path: '/plugin/charts/vchart',
component: 'view.plugin_charts_vchart',
meta: {
title: 'plugin_charts_vchart',
i18nKey: 'route.plugin_charts_vchart',
localIcon: 'visactor'
}
}
]
},
{
name: 'plugin_copy',
path: '/plugin/copy',
component: 'view.plugin_copy',
meta: {
title: 'plugin_copy',
i18nKey: 'route.plugin_copy',
icon: 'mdi:clipboard-outline'
}
},
{
name: 'plugin_excel',
path: '/plugin/excel',
component: 'view.plugin_excel',
meta: {
title: 'plugin_excel',
i18nKey: 'route.plugin_excel',
icon: 'ri:file-excel-2-line',
keepAlive: true keepAlive: true
}
},
{
name: 'plugin_gantt',
path: '/plugin/gantt',
meta: {
title: 'plugin_gantt',
i18nKey: 'route.plugin_gantt',
icon: 'ant-design:bar-chart-outlined'
}, },
children: [ children: [
{ {
name: 'plugin_gantt_dhtmlx', name: 'personal-center_work-report_monthly',
path: '/plugin/gantt/dhtmlx', path: '/personal-center/work-report/monthly',
component: 'view.plugin_gantt_dhtmlx', component: 'view.personal-center_work-report_monthly',
meta: { meta: {
title: 'plugin_gantt_dhtmlx', title: 'personal-center_work-report_monthly',
i18nKey: 'route.plugin_gantt_dhtmlx', i18nKey: 'route.personal-center_work-report_monthly',
icon: 'gridicons:posts' hideInMenu: true,
activeMenu: 'personal-center_work-report'
} }
}, },
{ {
name: 'plugin_gantt_vtable', name: 'personal-center_work-report_project',
path: '/plugin/gantt/vtable', path: '/personal-center/work-report/project',
component: 'view.plugin_gantt_vtable', component: 'view.personal-center_work-report_project',
meta: { meta: {
title: 'plugin_gantt_vtable', title: 'personal-center_work-report_project',
i18nKey: 'route.plugin_gantt_vtable', i18nKey: 'route.personal-center_work-report_project',
localIcon: 'visactor' hideInMenu: true,
activeMenu: 'personal-center_work-report'
} }
} },
]
},
{
name: 'plugin_icon',
path: '/plugin/icon',
component: 'view.plugin_icon',
meta: {
title: 'plugin_icon',
i18nKey: 'route.plugin_icon',
localIcon: 'custom-icon'
}
},
{
name: 'plugin_map',
path: '/plugin/map',
component: 'view.plugin_map',
meta: {
title: 'plugin_map',
i18nKey: 'route.plugin_map',
icon: 'mdi:map'
}
},
{
name: 'plugin_pdf',
path: '/plugin/pdf',
component: 'view.plugin_pdf',
meta: {
title: 'plugin_pdf',
i18nKey: 'route.plugin_pdf',
icon: 'uiw:file-pdf'
}
},
{
name: 'plugin_pinyin',
path: '/plugin/pinyin',
component: 'view.plugin_pinyin',
meta: {
title: 'plugin_pinyin',
i18nKey: 'route.plugin_pinyin',
icon: 'entypo-social:google-hangouts'
}
},
{
name: 'plugin_print',
path: '/plugin/print',
component: 'view.plugin_print',
meta: {
title: 'plugin_print',
i18nKey: 'route.plugin_print',
icon: 'mdi:printer'
}
},
{
name: 'plugin_swiper',
path: '/plugin/swiper',
component: 'view.plugin_swiper',
meta: {
title: 'plugin_swiper',
i18nKey: 'route.plugin_swiper',
icon: 'simple-icons:swiper'
}
},
{
name: 'plugin_tables',
path: '/plugin/tables',
meta: {
title: 'plugin_tables',
i18nKey: 'route.plugin_tables',
icon: 'icon-park-outline:table'
},
children: [
{ {
name: 'plugin_tables_vtable', name: 'personal-center_work-report_weekly',
path: '/plugin/tables/vtable', path: '/personal-center/work-report/weekly',
component: 'view.plugin_tables_vtable', component: 'view.personal-center_work-report_weekly',
meta: { meta: {
title: 'plugin_tables_vtable', title: 'personal-center_work-report_weekly',
i18nKey: 'route.plugin_tables_vtable', i18nKey: 'route.personal-center_work-report_weekly',
localIcon: 'visactor' hideInMenu: true,
activeMenu: 'personal-center_work-report'
} }
} }
] ]
},
{
name: 'plugin_typeit',
path: '/plugin/typeit',
component: 'view.plugin_typeit',
meta: {
title: 'plugin_typeit',
i18nKey: 'route.plugin_typeit',
icon: 'mdi:typewriter'
}
},
{
name: 'plugin_video',
path: '/plugin/video',
component: 'view.plugin_video',
meta: {
title: 'plugin_video',
i18nKey: 'route.plugin_video',
icon: 'mdi:video'
}
} }
] ]
}, },

View File

@@ -170,18 +170,14 @@ const routeMap: RouteMap = {
"403": "/403", "403": "/403",
"404": "/404", "404": "/404",
"500": "/500", "500": "/500",
"function": "/function", "feedback": "/feedback",
"function_hide-child": "/function/hide-child",
"function_hide-child_one": "/function/hide-child/one",
"function_hide-child_three": "/function/hide-child/three",
"function_hide-child_two": "/function/hide-child/two",
"function_multi-tab": "/function/multi-tab",
"function_request": "/function/request",
"function_super-page": "/function/super-page",
"function_tab": "/function/tab",
"function_toggle-auth": "/function/toggle-auth",
"iframe-page": "/iframe-page/:url", "iframe-page": "/iframe-page/:url",
"infra": "/infra", "infra": "/infra",
"infra_log-management": "/infra/log-management",
"infra_log-management_api-access-log": "/infra/log-management/api-access-log",
"infra_log-management_api-error-log": "/infra/log-management/api-error-log",
"infra_log-management_login-log": "/infra/log-management/login-log",
"infra_log-management_operate-log": "/infra/log-management/operate-log",
"infra_rd-code": "/infra/rd-code", "infra_rd-code": "/infra/rd-code",
"infra_state-machine": "/infra/state-machine", "infra_state-machine": "/infra/state-machine",
"login": "/login/:module(pwd-login|reset-pwd)?", "login": "/login/:module(pwd-login|reset-pwd)?",
@@ -192,33 +188,14 @@ const routeMap: RouteMap = {
"personal-center": "/personal-center", "personal-center": "/personal-center",
"personal-center_my-application": "/personal-center/my-application", "personal-center_my-application": "/personal-center/my-application",
"personal-center_my-item": "/personal-center/my-item", "personal-center_my-item": "/personal-center/my-item",
"personal-center_my-monthly": "/personal-center/my-monthly",
"personal-center_my-performance": "/personal-center/my-performance", "personal-center_my-performance": "/personal-center/my-performance",
"personal-center_my-profile": "/personal-center/my-profile", "personal-center_my-profile": "/personal-center/my-profile",
"personal-center_my-weekly": "/personal-center/my-weekly",
"personal-center_overtime-application": "/personal-center/overtime-application", "personal-center_overtime-application": "/personal-center/overtime-application",
"personal-center_pending-approval": "/personal-center/pending-approval", "personal-center_pending-approval": "/personal-center/pending-approval",
"plugin": "/plugin", "personal-center_work-report": "/personal-center/work-report",
"plugin_barcode": "/plugin/barcode", "personal-center_work-report_monthly": "/personal-center/work-report/monthly",
"plugin_charts": "/plugin/charts", "personal-center_work-report_project": "/personal-center/work-report/project",
"plugin_charts_antv": "/plugin/charts/antv", "personal-center_work-report_weekly": "/personal-center/work-report/weekly",
"plugin_charts_echarts": "/plugin/charts/echarts",
"plugin_charts_vchart": "/plugin/charts/vchart",
"plugin_copy": "/plugin/copy",
"plugin_excel": "/plugin/excel",
"plugin_gantt": "/plugin/gantt",
"plugin_gantt_dhtmlx": "/plugin/gantt/dhtmlx",
"plugin_gantt_vtable": "/plugin/gantt/vtable",
"plugin_icon": "/plugin/icon",
"plugin_map": "/plugin/map",
"plugin_pdf": "/plugin/pdf",
"plugin_pinyin": "/plugin/pinyin",
"plugin_print": "/plugin/print",
"plugin_swiper": "/plugin/swiper",
"plugin_tables": "/plugin/tables",
"plugin_tables_vtable": "/plugin/tables/vtable",
"plugin_typeit": "/plugin/typeit",
"plugin_video": "/plugin/video",
"product": "/product", "product": "/product",
"product_dashboard": "/product/dashboard", "product_dashboard": "/product/dashboard",
"product_list": "/product/list", "product_list": "/product/list",

View File

@@ -8,6 +8,6 @@ export function createDocumentTitleGuard(router: Router) {
const documentTitle = i18nKey ? $t(i18nKey) : title; const documentTitle = i18nKey ? $t(i18nKey) : title;
useTitle(documentTitle); useTitle(`研发管理系统 - ${documentTitle}`);
}); });
} }

View File

@@ -15,6 +15,7 @@ interface BackendUserInfoDTO {
userId: string | number; userId: string | number;
userName?: string | null; userName?: string | null;
nickname?: string | null; nickname?: string | null;
deptId?: string | number | null;
roles?: string[] | null; roles?: string[] | null;
buttons?: string[] | null; buttons?: string[] | null;
} }
@@ -61,6 +62,7 @@ function mapUserInfo(data: BackendUserInfoDTO): Api.Auth.UserInfo {
userId: String(data.userId ?? ''), userId: String(data.userId ?? ''),
userName: data.userName ?? '', userName: data.userName ?? '',
nickname: data.nickname ?? '', nickname: data.nickname ?? '',
deptId: safeStringId(data.deptId),
roles: data.roles ?? [], roles: data.roles ?? [],
buttons: data.buttons ?? [] buttons: data.buttons ?? []
}; };

View File

@@ -19,6 +19,7 @@ function createBatchDeleteQuery(ids: number[]) {
type DictDataResponse = Omit<Api.Dict.DictData, 'colorType'> & { type DictDataResponse = Omit<Api.Dict.DictData, 'colorType'> & {
colorType?: string | null; colorType?: string | null;
color_type?: string | null; color_type?: string | null;
css_class?: string | null;
}; };
type DictDataPageResponse = Omit<Api.Dict.PageResult<Api.Dict.DictData>, 'list'> & { type DictDataPageResponse = Omit<Api.Dict.PageResult<Api.Dict.DictData>, 'list'> & {
@@ -28,6 +29,7 @@ type DictDataPageResponse = Omit<Api.Dict.PageResult<Api.Dict.DictData>, 'list'>
type FrontendDictDataResponse = Omit<Api.Dict.FrontendDictData, 'colorType'> & { type FrontendDictDataResponse = Omit<Api.Dict.FrontendDictData, 'colorType'> & {
colorType?: string | null; colorType?: string | null;
color_type?: string | null; color_type?: string | null;
css_class?: string | null;
}; };
type FrontendDictCacheResponse = Record<string, FrontendDictDataResponse[]>; type FrontendDictCacheResponse = Record<string, FrontendDictDataResponse[]>;
@@ -37,20 +39,22 @@ function normalizeColorType(value?: string | null) {
} }
function normalizeDictData(data: DictDataResponse): Api.Dict.DictData { function normalizeDictData(data: DictDataResponse): Api.Dict.DictData {
const { color_type: colorTypeFromSnakeCase, ...rest } = data; const { color_type: colorTypeFromSnakeCase, css_class: cssClassFromSnakeCase, ...rest } = data;
return { return {
...rest, ...rest,
colorType: normalizeColorType(data.colorType ?? colorTypeFromSnakeCase) colorType: normalizeColorType(data.colorType ?? colorTypeFromSnakeCase),
cssClass: normalizeColorType(data.cssClass ?? cssClassFromSnakeCase)
}; };
} }
function normalizeFrontendDictData(data: FrontendDictDataResponse): Api.Dict.FrontendDictData { function normalizeFrontendDictData(data: FrontendDictDataResponse): Api.Dict.FrontendDictData {
const { color_type: colorTypeFromSnakeCase, ...rest } = data; const { color_type: colorTypeFromSnakeCase, css_class: cssClassFromSnakeCase, ...rest } = data;
return { return {
...rest, ...rest,
colorType: normalizeColorType(data.colorType ?? colorTypeFromSnakeCase) colorType: normalizeColorType(data.colorType ?? colorTypeFromSnakeCase),
cssClass: normalizeColorType(data.cssClass ?? cssClassFromSnakeCase)
}; };
} }
@@ -187,6 +191,6 @@ export async function fetchGetDictDataByCode(code: string) {
return mapServiceResult(result as ServiceRequestResult<DictDataPageResponse>, data => ({ return mapServiceResult(result as ServiceRequestResult<DictDataPageResponse>, data => ({
...data, ...data,
list: data.list.map(normalizeDictData) list: (data.list ?? []).map(normalizeDictData)
})); }));
} }

View File

@@ -0,0 +1,34 @@
/** 后端统计原始返回type/status 可能为 number 或 stringcount 为整数) */
export type FeedbackStatResponse = {
total: number;
typeCounts?: Array<{ type: string | number; count: number }> | null;
statusCounts?: Array<{ status: string | number; count: number }> | null;
};
/** 后端统计原始返回 → 业务层type/status 一律 String 化为 Record 键count 缺省兜底 0data 为空全兜底 0 */
export function normalizeFeedbackStat(data: FeedbackStatResponse | null | undefined): Api.Feedback.FeedbackStat {
const typeCounts: Record<string, number> = {};
(data?.typeCounts ?? []).forEach(item => {
typeCounts[String(item.type)] = item.count ?? 0;
});
const statusCounts: Record<string, number> = {};
(data?.statusCounts ?? []).forEach(item => {
statusCounts[String(item.status)] = item.count ?? 0;
});
const total = data?.total ?? 0;
// 契约约定 total == sum(typeCounts) == sum(statusCounts)dev 下做一致性自检,便于尽早发现后端统计口径错位
if (import.meta.env.DEV) {
const typeSum = Object.values(typeCounts).reduce((sum, count) => sum + count, 0);
const statusSum = Object.values(statusCounts).reduce((sum, count) => sum + count, 0);
if (total !== typeSum || total !== statusSum) {
console.warn(
`[feedback-stat] total=${total} 与分类合计=${typeSum}、状态合计=${statusSum} 不一致,后端统计口径可能有误`
);
}
}
return { total, typeCounts, statusCounts };
}

185
src/service/api/feedback.ts Normal file
View File

@@ -0,0 +1,185 @@
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
import { request } from '../request';
import { type ServiceRequestResult, mapServiceResult, normalizeStringId, safeJsonRequestConfig } from './shared';
import { type FeedbackStatResponse, normalizeFeedbackStat } from './feedback-normalize';
const FEEDBACK_PREFIX = `${SYSTEM_SERVICE_PREFIX}/feedback`;
type FeedbackResponse = Omit<Api.Feedback.FeedbackItem, 'id' | 'creator' | 'attachments' | 'contentPreview'> & {
id: string | number;
creator: string | number;
/** 后端原始:附件的 JSON 数组字符串(前端约定存完整附件对象,兼容历史纯 URL 数组) */
attachmentUrls?: string | null;
};
type FeedbackPageResponse = {
total: number;
list: FeedbackResponse[];
};
/** 从代理 URL 末段拆出文件名(兜底历史纯 URL 附件的展示名) */
function deriveAttachmentName(url: string): string {
try {
const path = decodeURI(url.split('?')[0]);
const idx = path.lastIndexOf('/');
return idx >= 0 ? path.slice(idx + 1) : path;
} catch {
return url;
}
}
/**
* 单个附件归一化为业务层 AttachmentItemID 铁律fileId/configId 一律 String
* - 对象形态(新版存储):按字段对齐;
* - 字符串形态(历史仅存 URL补出 url + 派生 namefileId 缺省空串(不可下载/清理,仅展示)。
*/
function normalizeAttachment(raw: unknown): Api.Project.AttachmentItem | null {
if (typeof raw === 'string') {
return { fileId: '', url: raw, name: deriveAttachmentName(raw) };
}
if (raw && typeof raw === 'object') {
const item = raw as Record<string, unknown>;
const url = typeof item.url === 'string' ? item.url : '';
return {
fileId: item.fileId === null || item.fileId === undefined ? '' : String(item.fileId),
url,
name: typeof item.name === 'string' && item.name ? item.name : deriveAttachmentName(url),
size: typeof item.size === 'number' ? item.size : undefined,
contentType: typeof item.contentType === 'string' ? item.contentType : undefined,
configId: item.configId === null || item.configId === undefined ? undefined : String(item.configId),
path: typeof item.path === 'string' ? item.path : undefined
};
}
return null;
}
/** 把后端 attachmentUrls(JSON 字符串) 安全解析成附件对象数组;空 / 异常兜底为 [] */
function parseAttachments(raw?: string | null): Api.Project.AttachmentItem[] {
if (!raw) {
return [];
}
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.map(normalizeAttachment).filter((item): item is Api.Project.AttachmentItem => item !== null);
} catch {
return [];
}
}
/** 列表「内容」列去富文本标签取纯文本预览content 为 HTML预算一次免每次渲染重扫 */
function stripHtml(html: string | null | undefined): string {
if (!html) {
return '';
}
return html
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
/** 后端记录 → 业务层口径ID 铁律 String 兜底 + 附件 parse + 内容纯文本预览预算) */
function normalizeFeedback(data: FeedbackResponse): Api.Feedback.FeedbackItem {
const { attachmentUrls, ...rest } = data;
return {
...rest,
id: normalizeStringId(data.id),
creator: normalizeStringId(data.creator),
attachments: parseAttachments(attachmentUrls),
contentPreview: stripHtml(data.content)
};
}
/** 附件对象数组 → attachmentUrls JSON 字符串(无附件传空串) */
function serializeAttachments(attachments: Api.Project.AttachmentItem[]): string {
return attachments.length ? JSON.stringify(attachments) : '';
}
/** 分页(支持按提交人 creator 过滤;默认 createTime 倒序由后端保证) */
export async function fetchGetFeedbackPage(params: Api.Feedback.FeedbackSearchParams) {
const result = await request<FeedbackPageResponse>({
...safeJsonRequestConfig,
url: `${FEEDBACK_PREFIX}/page`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<FeedbackPageResponse>, data => ({
total: data.total,
list: data.list.map(normalizeFeedback)
}));
}
/** 统计聚合(左侧分面面板;全量口径,无业务筛选入参) */
export async function fetchGetFeedbackStat() {
const result = await request<FeedbackStatResponse>({
...safeJsonRequestConfig,
url: `${FEEDBACK_PREFIX}/stat`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<FeedbackStatResponse>, normalizeFeedbackStat);
}
/** 详情 */
export async function fetchGetFeedback(id: string) {
const result = await request<FeedbackResponse>({
...safeJsonRequestConfig,
url: `${FEEDBACK_PREFIX}/get`,
method: 'get',
params: { id }
});
return mapServiceResult(result as ServiceRequestResult<FeedbackResponse>, normalizeFeedback);
}
/** 提交反馈attachments 对象数组在此 stringify 成 attachmentUrls无附件传空串返回新建 idID 铁律 String 化) */
export async function fetchCreateFeedback(params: Api.Feedback.FeedbackSubmitParams) {
const { attachments, ...rest } = params;
const result = await request<string | number>({
...safeJsonRequestConfig,
url: `${FEEDBACK_PREFIX}/create`,
method: 'post',
data: {
...rest,
attachmentUrls: serializeAttachments(attachments)
}
});
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
/** 更新反馈编辑态attachments 对象数组在此 stringify 成 attachmentUrls无附件传空串 */
export function fetchUpdateFeedback(params: Api.Feedback.FeedbackUpdateParams) {
const { attachments, ...rest } = params;
return request<boolean>({
...safeJsonRequestConfig,
url: `${FEEDBACK_PREFIX}/update`,
method: 'put',
data: {
...rest,
attachmentUrls: serializeAttachments(attachments)
}
});
}
/** 修改处理状态(仅超管;状态可任意改,不校验流转) */
export function fetchUpdateFeedbackStatus(id: string, status: number) {
return request<boolean>({
url: `${FEEDBACK_PREFIX}/${id}/status`,
method: 'put',
data: { status }
});
}
/** 删除反馈(软删除;后端按归属二次校验) */
export function fetchDeleteFeedback(id: string) {
return request<boolean>({
url: `${FEEDBACK_PREFIX}/delete`,
method: 'delete',
params: { id }
});
}

View File

@@ -1,12 +1,19 @@
export * from './auth'; export * from './auth';
export * from './dict'; export * from './dict';
export * from './feedback';
export * from './file'; export * from './file';
export * from './infra'; export * from './infra';
export * from './notice';
export * from './notify-message';
export * from './object-context'; export * from './object-context';
export * from './overtime-application'; export * from './overtime-application';
export * from './performance';
export * from './personal-item'; export * from './personal-item';
export * from './product'; export * from './product';
export * from './project'; export * from './project';
export * from './project-group';
export * from './project-shared'; export * from './project-shared';
export * from './route'; export * from './route';
export * from './system-manage'; export * from './system-manage';
export * from './system-log';
export * from './work-report';

28
src/service/api/notice.ts Normal file
View File

@@ -0,0 +1,28 @@
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
import { request } from '../request';
import { type ServiceRequestResult, mapServiceResult, normalizeStringId, safeJsonRequestConfig } from './shared';
const NOTICE_PREFIX = `${SYSTEM_SERVICE_PREFIX}/notice`;
type NoticeResponse = Omit<Api.Notice.Notice, 'id'> & {
id: string | number;
};
function normalizeNotice(data: NoticeResponse): Api.Notice.Notice {
return {
...data,
id: normalizeStringId(data.id)
};
}
/** 获取最近公告status=0按 id 倒序;登录即可,工作台公告卡片用) */
export async function fetchGetRecentNotices(size?: number) {
const result = await request<NoticeResponse[]>({
url: `${NOTICE_PREFIX}/recent`,
method: 'get',
params: { size },
...safeJsonRequestConfig
});
return mapServiceResult(result as ServiceRequestResult<NoticeResponse[]>, data => data.map(normalizeNotice));
}

View File

@@ -0,0 +1,63 @@
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
import { request } from '../request';
import { type ServiceRequestResult, mapServiceResult, normalizeStringId, safeJsonRequestConfig } from './shared';
const NOTIFY_MESSAGE_PREFIX = `${SYSTEM_SERVICE_PREFIX}/notify-message`;
type NotifyMessageResponse = Omit<Api.NotifyMessage.NotifyMessage, 'id' | 'level'> & {
id: string | number;
/** 后端老消息可能不带 level按可空接收normalize 时回落普通(1) */
level?: number | null;
};
type MyNotifyMessagePageResponse = Omit<Api.NotifyMessage.PageResult<Api.NotifyMessage.NotifyMessage>, 'list'> & {
list: NotifyMessageResponse[];
};
function normalizeNotifyMessage(data: NotifyMessageResponse): Api.NotifyMessage.NotifyMessage {
return {
...data,
id: normalizeStringId(data.id),
level: data.level ?? 1
};
}
/** 获取当前用户未读站内信数量(铃铛红点轮询用) */
export function fetchGetUnreadNotifyCount() {
return request<number>({
url: `${NOTIFY_MESSAGE_PREFIX}/get-unread-count`,
method: 'get'
});
}
/** 分页获取我的站内信(消息列表唯一数据源;未读传 readStatus=false、已读传 true */
export async function fetchGetMyNotifyMessagePage(params: Api.NotifyMessage.MyPageParams) {
const result = await request<MyNotifyMessagePageResponse>({
url: `${NOTIFY_MESSAGE_PREFIX}/my-page`,
method: 'get',
params,
...safeJsonRequestConfig
});
return mapServiceResult(result as ServiceRequestResult<MyNotifyMessagePageResponse>, data => ({
...data,
list: data.list.map(normalizeNotifyMessage)
}));
}
/** 批量标记站内信已读(后端幂等:重复提交、非本人条目均安全) */
export function fetchUpdateNotifyMessageRead(ids: string[]) {
// 后端约定 ids 逗号分隔
return request<boolean>({
url: `${NOTIFY_MESSAGE_PREFIX}/update-read?ids=${ids.join(',')}`,
method: 'put'
});
}
/** 当前用户全部站内信标记已读 */
export function fetchUpdateAllNotifyMessageRead() {
return request<boolean>({
url: `${NOTIFY_MESSAGE_PREFIX}/update-all-read`,
method: 'put'
});
}

View File

@@ -1,13 +1,7 @@
import { WEB_SERVICE_PREFIX } from '@/constants/service'; import { WEB_SERVICE_PREFIX } from '@/constants/service';
import { request } from '../request'; import { request } from '../request';
import { type ProjectLocalDateValue, normalizeProjectLocalDate } from './project-shared'; import { type ProjectLocalDateValue, normalizeProjectLocalDate } from './project-shared';
import { import { type ServiceRequestResult, mapServiceResult, normalizeStringId, safeJsonRequestConfig } from './shared';
type ServiceRequestResult,
mapServiceResult,
normalizeNullableStringId,
normalizeStringId,
safeJsonRequestConfig
} from './shared';
const OVERTIME_APPLICATION_PREFIX = `${WEB_SERVICE_PREFIX}/project/overtime-applications`; const OVERTIME_APPLICATION_PREFIX = `${WEB_SERVICE_PREFIX}/project/overtime-applications`;
@@ -30,16 +24,37 @@ type OvertimeApplicationPageResponse = Omit<Api.OvertimeApplication.OvertimeAppl
list: OvertimeApplicationResponse[]; list: OvertimeApplicationResponse[];
}; };
type OvertimeApplicationStatusLogResponse = Omit< type OvertimeApplicationApprovalRecordResponse = Omit<
Api.OvertimeApplication.OvertimeApplicationStatusLog, Api.OvertimeApplication.OvertimeApplicationApprovalRecord,
'id' | 'applicationId' | 'operatorUserId' | 'overtimeDateSnapshot' 'id' | 'overtimeApplicationId' | 'statusLogId' | 'auditorUserId'
> & { > & {
id: StringIdResponse; id: StringIdResponse;
applicationId: StringIdResponse; overtimeApplicationId: StringIdResponse;
operatorUserId: StringIdResponse; statusLogId: StringIdResponse;
overtimeDateSnapshot: ProjectLocalDateValue; auditorUserId: StringIdResponse;
}; };
type TeamOvertimeSummaryResponse = Omit<
Api.OvertimeApplication.TeamOvertimeSummary,
'overtimeDateStart' | 'overtimeDateEnd'
> & {
overtimeDateStart?: unknown;
overtimeDateEnd?: unknown;
};
function normalizeDateText(value: unknown) {
if (value === null || value === undefined) return undefined;
const text = String(value).trim();
const commaDateMatch = text.match(/^(\d{4}),(\d{1,2}),(\d{1,2})$/);
if (commaDateMatch) {
const [, year, month, day] = commaDateMatch;
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
}
return text || undefined;
}
function normalizeBooleanFlag(value: boolean | number | string | null | undefined) { function normalizeBooleanFlag(value: boolean | number | string | null | undefined) {
if (typeof value === 'boolean') { if (typeof value === 'boolean') {
return value; return value;
@@ -81,18 +96,16 @@ function normalizeOvertimeApplication(
}; };
} }
function normalizeStatusLog( function normalizeApprovalRecord(
response: OvertimeApplicationStatusLogResponse response: OvertimeApplicationApprovalRecordResponse
): Api.OvertimeApplication.OvertimeApplicationStatusLog { ): Api.OvertimeApplication.OvertimeApplicationApprovalRecord {
return { return {
...response, ...response,
id: normalizeStringId(response.id), id: normalizeStringId(response.id),
applicationId: normalizeStringId(response.applicationId), overtimeApplicationId: normalizeStringId(response.overtimeApplicationId),
operatorUserId: normalizeStringId(response.operatorUserId), statusLogId: normalizeStringId(response.statusLogId),
overtimeDateSnapshot: normalizeProjectLocalDate(response.overtimeDateSnapshot) ?? '', auditorUserId: normalizeStringId(response.auditorUserId),
fromStatus: normalizeNullableStringId(response.fromStatus), opinion: response.opinion ?? null
reason: response.reason ?? null,
remark: response.remark ?? null
}; };
} }
@@ -102,6 +115,18 @@ function createPageQuery(params: Api.OvertimeApplication.OvertimeApplicationSear
query.append('pageNo', String(params.pageNo ?? 1)); query.append('pageNo', String(params.pageNo ?? 1));
query.append('pageSize', String(params.pageSize ?? 10)); query.append('pageSize', String(params.pageSize ?? 10));
if (params.applicantIds !== null && params.applicantIds !== undefined) {
if (params.applicantIds.length) {
params.applicantIds.forEach(item => {
if (item) {
query.append('applicantIds', item);
}
});
} else {
query.append('applicantIds', '');
}
}
if (params.keyword) { if (params.keyword) {
query.append('keyword', params.keyword); query.append('keyword', params.keyword);
} }
@@ -240,12 +265,25 @@ export function fetchRejectOvertimeApplication(id: string, data: Api.OvertimeApp
}); });
} }
export function fetchCancelOvertimeApplication(id: string, data: Api.OvertimeApplication.StatusActionParams) { export function fetchBatchApproveOvertimeApplication(
return request<boolean>({ data: Api.OvertimeApplication.OvertimeApplicationBatchActionParams
) {
return request<Api.OvertimeApplication.OvertimeApplicationBatchActionResult>({
...safeJsonRequestConfig, ...safeJsonRequestConfig,
url: `${OVERTIME_APPLICATION_PREFIX}/${id}/cancel`, url: `${OVERTIME_APPLICATION_PREFIX}/batch-approve`,
method: 'post', method: 'post',
data: toStatusActionRequest(data) data
});
}
export function fetchBatchRejectOvertimeApplication(
data: Api.OvertimeApplication.OvertimeApplicationBatchActionParams
) {
return request<Api.OvertimeApplication.OvertimeApplicationBatchActionResult>({
...safeJsonRequestConfig,
url: `${OVERTIME_APPLICATION_PREFIX}/batch-reject`,
method: 'post',
data
}); });
} }
@@ -257,18 +295,49 @@ export function fetchDeleteOvertimeApplication(id: string) {
}); });
} }
export async function fetchGetOvertimeApplicationStatusLogs(id: string) { export async function fetchGetOvertimeApplicationApprovalRecords(id: string) {
const result = await request<OvertimeApplicationStatusLogResponse[]>({ const result = await request<OvertimeApplicationApprovalRecordResponse[]>({
...safeJsonRequestConfig, ...safeJsonRequestConfig,
url: `${OVERTIME_APPLICATION_PREFIX}/${id}/status-logs`, url: `${OVERTIME_APPLICATION_PREFIX}/${id}/approval-records`,
method: 'get' method: 'get'
}); });
return mapServiceResult(result as ServiceRequestResult<OvertimeApplicationStatusLogResponse[]>, data => return mapServiceResult(result as ServiceRequestResult<OvertimeApplicationApprovalRecordResponse[]>, data =>
data.map(normalizeStatusLog) data.map(normalizeApprovalRecord)
); );
} }
export async function fetchGetOvertimeApplicationStatusDict() {
const result = await request<Api.OvertimeApplication.OvertimeApplicationStatusDict[]>({
...safeJsonRequestConfig,
url: `${OVERTIME_APPLICATION_PREFIX}/status/dict`,
method: 'get'
});
return mapServiceResult(
result as ServiceRequestResult<Api.OvertimeApplication.OvertimeApplicationStatusDict[]>,
data => data
);
}
export async function fetchGetTeamOvertimeSummary(params: Api.OvertimeApplication.TeamOvertimeSummaryParams = {}) {
const result = await request<TeamOvertimeSummaryResponse>({
...safeJsonRequestConfig,
url: `${OVERTIME_APPLICATION_PREFIX}/team/summary`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<TeamOvertimeSummaryResponse>, data => {
if (!data) return data;
return {
...data,
overtimeDateStart: normalizeDateText(data.overtimeDateStart) || '',
overtimeDateEnd: normalizeDateText(data.overtimeDateEnd) || ''
};
});
}
export function fetchExportOvertimeApplications(params: Api.OvertimeApplication.OvertimeApplicationSearchParams = {}) { export function fetchExportOvertimeApplications(params: Api.OvertimeApplication.OvertimeApplicationSearchParams = {}) {
const query = createPageQuery(params); const query = createPageQuery(params);

View File

@@ -0,0 +1,501 @@
import dayjs from 'dayjs';
import { WEB_SERVICE_PREFIX } from '@/constants/service';
import { request } from '../request';
import {
type ServiceRequestResult,
mapServiceResult,
normalizeNullableStringId,
normalizeStringId,
safeJsonRequestConfig
} from './shared';
const TEMPLATE_PREFIX = `${WEB_SERVICE_PREFIX}/project/performance-templates`;
const SHEET_PREFIX = `${WEB_SERVICE_PREFIX}/project/performance-sheets`;
const TEAM_PREFIX = `${SHEET_PREFIX}/team`;
type StringIdResponse = string | number;
type TemplateResponse = Omit<Api.Performance.Template.Template, 'id' | 'fileId' | 'uploadUserId' | 'activeFlag'> & {
id: StringIdResponse;
fileId: StringIdResponse;
uploadUserId: StringIdResponse;
activeFlag?: boolean | number | string | null;
};
type TemplatePageResponse = {
total: number | string;
list: TemplateResponse[];
};
type SheetResponse = Omit<
Api.Performance.Sheet.Sheet,
'id' | 'employeeId' | 'employeeDeptId' | 'managerId' | 'templateId' | 'fileId'
> & {
id: StringIdResponse;
employeeId: StringIdResponse;
employeeDeptId: StringIdResponse;
managerId: StringIdResponse;
templateId: StringIdResponse;
fileId?: StringIdResponse | null;
};
type SheetPageResponse = {
total: number | string;
list: SheetResponse[];
};
type StatusLogResponse = Omit<Api.Performance.Sheet.StatusLog, 'id' | 'sheetId' | 'operatorUserId'> & {
id: StringIdResponse;
sheetId: StringIdResponse;
operatorUserId: StringIdResponse;
};
type ResponseRecordResponse = Omit<
Api.Performance.Sheet.ResponseRecord,
'id' | 'sheetId' | 'statusLogId' | 'responderUserId'
> & {
id: StringIdResponse;
sheetId: StringIdResponse;
statusLogId: StringIdResponse;
responderUserId: StringIdResponse;
};
type MonthlyResultResponse = Omit<Api.Performance.Sheet.MonthlyResult, 'sheetId' | 'employeeId'> & {
sheetId?: StringIdResponse | null;
employeeId: StringIdResponse;
};
type TeamSummaryResponse = Omit<
Api.Performance.Team.Summary,
| 'expectedSheetCount'
| 'pendingSendCount'
| 'pendingConfirmCount'
| 'pendingSendUsers'
| 'pendingConfirmUsers'
| 'deptOrgAverages'
> & {
expectedSheetCount?: number | string | null;
pendingSendCount?: number | string | null;
pendingConfirmCount?: number | string | null;
pendingSendUsers?: Array<
Omit<Api.Performance.Team.PendingSendUser, 'userId' | 'managerUserId' | 'sheetId'> & {
userId: StringIdResponse;
managerUserId?: StringIdResponse | null;
sheetId?: StringIdResponse | null;
}
> | null;
pendingConfirmUsers?: Array<
Omit<Api.Performance.Team.PendingConfirmUser, 'userId' | 'sheetId'> & {
userId: StringIdResponse;
sheetId: StringIdResponse;
}
> | null;
deptOrgAverages?: Array<
Omit<Api.Performance.Team.DeptOrgAverage, 'deptId' | 'confirmedCount'> & {
deptId: StringIdResponse;
confirmedCount?: number | string | null;
}
> | null;
};
function normalizeBooleanFlag(value: boolean | number | string | null | undefined) {
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value === 1;
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
return !['', '0', 'false', 'n', 'no'].includes(normalized);
}
return false;
}
function normalizeTotal(value: number | string | null | undefined) {
const total = Number(value ?? 0);
return Number.isFinite(total) ? Math.max(0, total) : 0;
}
function normalizeTemplate(response: TemplateResponse): Api.Performance.Template.Template {
return {
...response,
id: normalizeStringId(response.id),
fileId: normalizeStringId(response.fileId),
uploadUserId: normalizeStringId(response.uploadUserId),
activeFlag: normalizeBooleanFlag(response.activeFlag),
remark: response.remark ?? null,
scoreCellMapping: response.scoreCellMapping ?? null
};
}
function normalizeSheet(response: SheetResponse): Api.Performance.Sheet.Sheet {
return {
...response,
id: normalizeStringId(response.id),
employeeId: normalizeStringId(response.employeeId),
employeeDeptId: normalizeStringId(response.employeeDeptId),
managerId: normalizeStringId(response.managerId),
templateId: normalizeStringId(response.templateId),
fileId: normalizeNullableStringId(response.fileId),
fileName: response.fileName ?? null,
statusName: response.statusName || response.statusCode,
actualScoreTotal: response.actualScoreTotal ?? null,
baseScoreTotal: response.baseScoreTotal ?? null,
extraScoreTotal: response.extraScoreTotal ?? null,
sentTime: response.sentTime ?? null,
confirmedTime: response.confirmedTime ?? null,
rejectedTime: response.rejectedTime ?? null,
lastStatusReason: response.lastStatusReason ?? null,
createTime: response.createTime ?? null,
updateTime: response.updateTime ?? null
};
}
function normalizeStatusLog(response: StatusLogResponse): Api.Performance.Sheet.StatusLog {
return {
...response,
id: normalizeStringId(response.id),
sheetId: normalizeStringId(response.sheetId),
operatorUserId: normalizeStringId(response.operatorUserId),
reason: response.reason ?? null,
remark: response.remark ?? null
};
}
function normalizeResponseRecord(response: ResponseRecordResponse): Api.Performance.Sheet.ResponseRecord {
return {
...response,
id: normalizeStringId(response.id),
sheetId: normalizeStringId(response.sheetId),
statusLogId: normalizeStringId(response.statusLogId),
responderUserId: normalizeStringId(response.responderUserId),
opinion: response.opinion ?? null
};
}
function normalizeMonthlyResult(response: MonthlyResultResponse): Api.Performance.Sheet.MonthlyResult {
return {
...response,
sheetId: normalizeNullableStringId(response.sheetId),
employeeId: normalizeStringId(response.employeeId),
actualScoreTotal: response.actualScoreTotal ?? null,
baseScoreTotal: response.baseScoreTotal ?? null,
extraScoreTotal: response.extraScoreTotal ?? null,
statusCode: response.statusCode ?? null
};
}
function normalizeTeamSummary(response: TeamSummaryResponse): Api.Performance.Team.Summary {
return {
...response,
expectedSheetCount: normalizeTotal(response.expectedSheetCount),
pendingSendCount: normalizeTotal(response.pendingSendCount),
pendingConfirmCount: normalizeTotal(response.pendingConfirmCount),
pendingSendUsers: (response.pendingSendUsers || []).map(item => ({
...item,
userId: normalizeStringId(item.userId),
managerUserId: normalizeNullableStringId(item.managerUserId),
sheetId: normalizeNullableStringId(item.sheetId),
statusCode: item.statusCode ?? null
})),
pendingConfirmUsers: (response.pendingConfirmUsers || []).map(item => ({
...item,
userId: normalizeStringId(item.userId),
sheetId: normalizeStringId(item.sheetId),
sentTime: item.sentTime ?? null
})),
deptOrgAverages: (response.deptOrgAverages || []).map(item => ({
...item,
deptId: normalizeStringId(item.deptId),
averageScore: item.averageScore ?? null,
confirmedCount: normalizeTotal(item.confirmedCount)
}))
};
}
function appendValue(query: URLSearchParams, key: string, value: unknown) {
if (value === null || value === undefined || value === '') return;
if (Array.isArray(value)) {
if (!value.length) {
query.append(key, '');
return;
}
value.forEach(item => appendValue(query, key, item));
return;
}
query.append(key, String(value));
}
export function formatToYYYYMM(value?: string | null) {
if (!value) return '';
const d = dayjs(value);
return d.isValid() ? d.format('YYYY-MM') : value.slice(0, 7);
}
function createSheetQuery(params: Api.Performance.Sheet.SearchParams = {}) {
const query = new URLSearchParams();
query.append('pageNo', String(params.pageNo ?? 1));
query.append('pageSize', String(params.pageSize ?? 10));
appendValue(query, 'employeeIds', params.employeeIds);
// 将 periodMonthRange 拆为 periodMonthStart / periodMonthEnd
if (params.periodMonthRange?.length === 2) {
appendValue(query, 'periodMonthStart', formatToYYYYMM(params.periodMonthRange[0]));
appendValue(query, 'periodMonthEnd', formatToYYYYMM(params.periodMonthRange[1]));
}
// employeeId 单选追加到 employeeIds
if (params.employeeId) {
query.append('employeeIds', params.employeeId);
}
appendValue(query, 'employeeDeptId', params.employeeDeptId);
appendValue(query, 'managerName', params.managerName);
appendValue(query, 'statusCode', params.statusCode);
return query.toString();
}
function createTemplateQuery(params: Api.Performance.Template.SearchParams = {}) {
const query = new URLSearchParams();
query.append('pageNo', String(params.pageNo ?? 1));
query.append('pageSize', String(params.pageSize ?? 10));
appendValue(query, 'templateName', params.templateName);
appendValue(query, 'activeFlag', params.activeFlag);
return query.toString();
}
export async function fetchPerformanceTemplateCurrent() {
const result = await request<TemplateResponse | null>({
...safeJsonRequestConfig,
url: `${TEMPLATE_PREFIX}/current`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<TemplateResponse | null>, data =>
data ? normalizeTemplate(data) : null
);
}
export async function fetchPerformanceTemplatePage(params: Api.Performance.Template.SearchParams = {}) {
const query = createTemplateQuery(params);
const result = await request<TemplatePageResponse>({
...safeJsonRequestConfig,
url: query ? `${TEMPLATE_PREFIX}/page?${query}` : `${TEMPLATE_PREFIX}/page`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<TemplatePageResponse>, data => ({
total: normalizeTotal(data.total),
list: data.list.map(normalizeTemplate)
}));
}
export async function uploadPerformanceTemplate(data: Api.Performance.Template.UploadParams) {
const result = await request<StringIdResponse>({
...safeJsonRequestConfig,
url: `${TEMPLATE_PREFIX}/upload`,
method: 'post',
data
});
return mapServiceResult(result as ServiceRequestResult<StringIdResponse>, normalizeStringId);
}
export function activatePerformanceTemplate(id: string) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${TEMPLATE_PREFIX}/${id}/activate`,
method: 'post'
});
}
export async function fetchPerformanceSheetPage(params: Api.Performance.Sheet.SearchParams = {}) {
const query = createSheetQuery(params);
const result = await request<SheetPageResponse>({
...safeJsonRequestConfig,
url: query ? `${SHEET_PREFIX}/page?${query}` : `${SHEET_PREFIX}/page`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<SheetPageResponse>, data => ({
total: normalizeTotal(data.total),
list: data.list.map(normalizeSheet)
}));
}
export async function fetchPerformanceSheet(id: string) {
const result = await request<SheetResponse>({
...safeJsonRequestConfig,
url: `${SHEET_PREFIX}/${id}`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<SheetResponse>, normalizeSheet);
}
export async function createPerformanceSheet(data: Api.Performance.Sheet.CreateParams) {
const result = await request<StringIdResponse>({
...safeJsonRequestConfig,
url: SHEET_PREFIX,
method: 'post',
data
});
return mapServiceResult(result as ServiceRequestResult<StringIdResponse>, normalizeStringId);
}
export function updatePerformanceSheetExcel(id: string, data: Api.Performance.Sheet.ExcelUpdateParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${SHEET_PREFIX}/${id}/excel`,
method: 'put',
data
});
}
export function deletePerformanceSheet(id: string) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${SHEET_PREFIX}/${id}`,
method: 'delete'
});
}
export function sendPerformanceSheet(id: string) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${SHEET_PREFIX}/${id}/send`,
method: 'post'
});
}
export function resendPerformanceSheet(id: string) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${SHEET_PREFIX}/${id}/resend`,
method: 'post'
});
}
export function confirmPerformanceSheet(id: string, data: Api.Performance.Sheet.StatusActionParams = {}) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${SHEET_PREFIX}/${id}/confirm`,
method: 'post',
data
});
}
export function rejectPerformanceSheet(id: string, data: Api.Performance.Sheet.StatusActionParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${SHEET_PREFIX}/${id}/reject`,
method: 'post',
data
});
}
export function downloadPerformanceSheet(id: string) {
return request<Blob, 'blob'>({
url: `${SHEET_PREFIX}/${id}/download`,
method: 'get',
responseType: 'blob'
});
}
export function batchDownloadPerformanceSheets(data: Api.Performance.Sheet.BatchDownloadParams) {
return request<Blob, 'blob'>({
...safeJsonRequestConfig,
url: `${SHEET_PREFIX}/batch-download`,
method: 'post',
data,
responseType: 'blob'
});
}
export function exportPerformanceSheets(params: Api.Performance.Sheet.SearchParams = {}) {
const query = createSheetQuery(params);
return request<Blob, 'blob'>({
url: query ? `${SHEET_PREFIX}/export?${query}` : `${SHEET_PREFIX}/export`,
method: 'get',
responseType: 'blob'
});
}
export async function fetchPerformanceSheetStatusLogs(id: string) {
const result = await request<StatusLogResponse[]>({
...safeJsonRequestConfig,
url: `${SHEET_PREFIX}/${id}/status-logs`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<StatusLogResponse[]>, data => data.map(normalizeStatusLog));
}
export async function fetchPerformanceSheetResponseRecords(id: string) {
const result = await request<ResponseRecordResponse[]>({
...safeJsonRequestConfig,
url: `${SHEET_PREFIX}/${id}/response-records`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<ResponseRecordResponse[]>, data =>
data.map(normalizeResponseRecord)
);
}
export async function fetchPerformanceMonthlyResult(employeeId: string, periodMonth: string) {
const result = await request<MonthlyResultResponse | null>({
...safeJsonRequestConfig,
url: `${SHEET_PREFIX}/monthly-result`,
method: 'get',
params: { employeeId, periodMonth }
});
return mapServiceResult(result as ServiceRequestResult<MonthlyResultResponse | null>, data =>
data ? normalizeMonthlyResult(data) : null
);
}
export function fetchPerformanceSheetStatusDict() {
return request<Api.Performance.Sheet.StatusDict[]>({
...safeJsonRequestConfig,
url: `${SHEET_PREFIX}/status-dict`,
method: 'get'
});
}
export function fetchPerformanceSheetStatusTransitions() {
return request<Api.Performance.Sheet.StatusTransition[]>({
...safeJsonRequestConfig,
url: `${SHEET_PREFIX}/status-transitions`,
method: 'get'
});
}
export async function fetchTeamPerformanceSummary(params: Api.Performance.Team.SummaryParams = {}) {
const result = await request<TeamSummaryResponse>({
...safeJsonRequestConfig,
url: `${TEAM_PREFIX}/summary`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<TeamSummaryResponse>, normalizeTeamSummary);
}
export function remindTeamPerformance(data: Api.Performance.Team.RemindParams) {
return request<Api.Performance.Team.RemindResult>({
...safeJsonRequestConfig,
url: `${TEAM_PREFIX}/remind`,
method: 'post',
data
});
}

View File

@@ -11,9 +11,15 @@ import { normalizeProductMember, normalizeProductSettings } from './product-shar
const PRODUCT_PREFIX = `${WEB_SERVICE_PREFIX}/project/product`; const PRODUCT_PREFIX = `${WEB_SERVICE_PREFIX}/project/product`;
type ProductResponse = Omit<Api.Product.Product, 'id' | 'managerUserId'> & { type ProductResponse = Omit<Api.Product.Product, 'id' | 'managerUserId' | 'currentUserRoles'> & {
id: string | number; id: string | number;
managerUserId?: string | number | null; managerUserId?: string | number | null;
/** 灰度/兼容期后端可能缺省,适配层兜底为 [] */
currentUserRoles?: Api.Common.CurrentUserRole[] | null;
};
type ProductOptionResponse = Omit<Api.Product.ProductOption, 'id'> & {
id: string | number;
}; };
type ProductPageResponse = Api.Product.PageResult<ProductResponse>; type ProductPageResponse = Api.Product.PageResult<ProductResponse>;
@@ -39,7 +45,15 @@ function normalizeProduct(product: ProductResponse): Api.Product.Product {
return { return {
...product, ...product,
id: normalizeStringId(product.id), id: normalizeStringId(product.id),
managerUserId: normalizeNullableStringId(product.managerUserId) ?? '' managerUserId: normalizeNullableStringId(product.managerUserId) ?? '',
currentUserRoles: product.currentUserRoles ?? []
};
}
function normalizeProductOption(option: ProductOptionResponse): Api.Product.ProductOption {
return {
...option,
id: normalizeStringId(option.id)
}; };
} }
@@ -106,13 +120,47 @@ export async function fetchGetProductPage(params?: Api.Product.ProductSearchPara
})); }));
} }
/** 获取可绑定产品下拉选项 */
export async function fetchGetProductOptions() {
const result = await request<ProductOptionResponse[]>({
...safeJsonRequestConfig,
url: `${PRODUCT_PREFIX}/options`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<ProductOptionResponse[]>, data =>
(data ?? []).map(normalizeProductOption)
);
}
type ProductOverviewSummaryResponse = Omit<Api.Product.ProductOverviewSummary, 'total' | 'items'> & {
/** 后端 overview-summary 升级total/items灰度期间可能缺省适配层兜底 */
total?: number | null;
items?: Api.Product.OverviewStatusItem[] | null;
};
/** 归一化产品概览统计total/items 兜底,保证业务层拿到完整结构 */
function normalizeProductOverviewSummary(data: ProductOverviewSummaryResponse): Api.Product.ProductOverviewSummary {
return {
...data,
statusCounts: data.statusCounts ?? {},
total: data.total ?? 0,
items: data.items ?? []
};
}
/** 获取产品入口页概览统计 */ /** 获取产品入口页概览统计 */
export function fetchGetProductOverviewSummary() { export async function fetchGetProductOverviewSummary() {
return request<Api.Product.ProductOverviewSummary>({ const result = await request<ProductOverviewSummaryResponse>({
...safeJsonRequestConfig, ...safeJsonRequestConfig,
url: `${PRODUCT_PREFIX}/overview-summary`, url: `${PRODUCT_PREFIX}/overview-summary`,
method: 'get' method: 'get'
}); });
return mapServiceResult(
result as ServiceRequestResult<ProductOverviewSummaryResponse>,
normalizeProductOverviewSummary
);
} }
/** 获取产品详情 */ /** 获取产品详情 */
@@ -189,7 +237,7 @@ type RequirementResponse = Omit<
| 'proposerId' | 'proposerId'
| 'currentHandlerUserId' | 'currentHandlerUserId'
| 'implementProjectId' | 'implementProjectId'
| 'sourceBizId' | 'sourceBizCode'
| 'attachments' | 'attachments'
> & { > & {
id: string | number; id: string | number;
@@ -199,7 +247,7 @@ type RequirementResponse = Omit<
currentHandlerUserId?: string | number | null; currentHandlerUserId?: string | number | null;
implementProjectId?: string | number | null; implementProjectId?: string | number | null;
implementProjectName?: string | null; implementProjectName?: string | null;
sourceBizId?: string | number | null; sourceBizCode?: string | null;
attachments?: AttachmentItemResponse[] | null; attachments?: AttachmentItemResponse[] | null;
children?: RequirementResponse[]; children?: RequirementResponse[];
}; };
@@ -271,7 +319,7 @@ function normalizeRequirement(requirement: RequirementResponse): Api.Product.Req
currentHandlerUserId: normalizeNullableStringId(requirement.currentHandlerUserId), currentHandlerUserId: normalizeNullableStringId(requirement.currentHandlerUserId),
implementProjectId: normalizeNullableStringId(requirement.implementProjectId), implementProjectId: normalizeNullableStringId(requirement.implementProjectId),
implementProjectName: requirement.implementProjectName ?? null, implementProjectName: requirement.implementProjectName ?? null,
sourceBizId: normalizeNullableStringId(requirement.sourceBizId), sourceBizCode: requirement.sourceBizCode ?? null,
attachments: normalizeAttachments(requirement.attachments), attachments: normalizeAttachments(requirement.attachments),
children: requirement.children?.map(normalizeRequirement) children: requirement.children?.map(normalizeRequirement)
}; };

View File

@@ -0,0 +1,62 @@
import { WEB_SERVICE_PREFIX } from '@/constants/service';
import { request } from '../request';
import {
type ServiceRequestResult,
mapServiceResult,
normalizeNullableStringId,
safeJsonRequestConfig
} from './shared';
import { type ProjectResponse, normalizeProject } from './project';
const PROJECT_PREFIX = `${WEB_SERVICE_PREFIX}/project/project`;
/**
* group-page 原始响应。
* 组级 managerUserId、productId后端对小数值 Long如 1001仍按数字返回需 String() 归一;
* projects 字段与 page 接口项目行完全一致,复用 ProjectResponse / normalizeProject。
*/
type ProjectGroupResponse = Omit<Api.Project.ProjectGroup, 'productId' | 'managerUserId' | 'projects'> & {
productId?: string | number | null;
managerUserId?: string | number | null;
projects: ProjectResponse[];
};
type ProjectGroupPageResponse = Omit<Api.Project.ProjectGroupPageResult, 'list'> & {
list: ProjectGroupResponse[];
};
/** 归一化分组:组级 ID String 化,组内项目复用 normalizeProjectid/managerUserId/productId/日期统一口径) */
function normalizeProjectGroup(group: ProjectGroupResponse): Api.Project.ProjectGroup {
return {
...group,
productId: normalizeNullableStringId(group.productId),
managerUserId: normalizeNullableStringId(group.managerUserId),
projects: Array.isArray(group.projects) ? group.projects.map(normalizeProject) : []
};
}
/**
* 项目列表「按产品分组」分页。
*
* 后端契约见《项目列表产品分组-前端API-2026-06-10》
* - pageNo/pageSize 为产品组维度分页statusCode 不传 = 「全部」口径(后端从状态机推导,
* 当前等价 pending/active/paused/completed不含 cancelled/archived
* - 组内 projects 仅返前 topN 条(默认 5projectTotal 为该口径组内全量计数;
* 剩余项目由页面按 productId / orphanOnly + statusCodes 走 page 接口展开拉取。
* - typeCounts / hasBaseline 现状恒按「全部」口径统计,不随 statusCode 变化;其中 typeCounts 已提需求
* 改为与 projectTotal 同口径见《2026-06-11-项目分组接口typeCounts口径-后端接口需求》),后端落地后更新本注释;
* hasBaseline = 存在非已取消的主线项目(已归档/完成也算占坑),前端直接消费、不自行推导。
*/
export async function fetchGetProjectGroupPage(params?: Api.Project.ProjectGroupSearchParams) {
const result = await request<ProjectGroupPageResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/group-page`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<ProjectGroupPageResponse>, data => ({
...data,
list: Array.isArray(data.list) ? data.list.map(normalizeProjectGroup) : []
}));
}

View File

@@ -1,3 +1,4 @@
import dayjs from 'dayjs';
import { normalizeNullableStringId, normalizeStringId } from './shared'; import { normalizeNullableStringId, normalizeStringId } from './shared';
type ProjectStatusCode = Api.Project.ProjectStatusCode; type ProjectStatusCode = Api.Project.ProjectStatusCode;
@@ -40,6 +41,96 @@ export type ProjectExecutionResponse = Omit<
priorityName?: string | null; priorityName?: string | null;
}; };
export type MyExecutionResponse = Omit<
Api.Project.MyExecutionItem,
| 'id'
| 'projectId'
| 'projectRequirementId'
| 'priority'
| 'progressRate'
| 'plannedStartDate'
| 'plannedEndDate'
| 'actualStartDate'
| 'actualEndDate'
> & {
id: StringIdResponse;
projectId: StringIdResponse;
projectRequirementId?: StringIdResponse | null;
priority?: string | number | null;
progressRate?: number | null;
plannedStartDate?: ProjectLocalDateValue;
plannedEndDate?: ProjectLocalDateValue;
actualStartDate?: ProjectLocalDateValue;
actualEndDate?: ProjectLocalDateValue;
};
export type MyParticipatedProjectResponse = Omit<Api.Project.MyParticipatedProjectItem, 'id'> & {
id: StringIdResponse;
};
export type MyOwnedProjectMemberResponse = Omit<Api.Project.MyOwnedProjectMember, 'userId'> & {
userId: StringIdResponse;
};
export type MyOwnedProjectResponse = Omit<Api.Project.MyOwnedProjectItem, 'id' | 'members'> & {
id: StringIdResponse;
members?: MyOwnedProjectMemberResponse[] | null;
};
export type MyTaskResponse = Omit<
Api.Project.MyTaskItem,
| 'id'
| 'projectId'
| 'executionId'
| 'priority'
| 'plannedEndDate'
| 'progressRate'
| 'createTime'
| 'parentTaskId'
| 'availableActions'
> & {
id: StringIdResponse;
projectId: StringIdResponse;
executionId?: StringIdResponse | null;
priority?: string | number | null;
plannedEndDate?: ProjectLocalDateValue;
progressRate?: number | string | null;
createTime?: string | number | null;
parentTaskId?: StringIdResponse | null;
availableActions?: LifecycleActionResponse<Api.Project.ProjectTaskActionCode>[] | null;
};
export type TeamLoadDistributionItemResponse = Omit<Api.Project.TeamLoadDistributionItem, 'projectId'> & {
projectId?: StringIdResponse | null;
};
export type TeamLoadMemberResponse = Omit<Api.Project.TeamLoadMember, 'userId' | 'items'> & {
userId: StringIdResponse;
items?: TeamLoadDistributionItemResponse[] | null;
};
export type TeamLoadResponse = {
members?: TeamLoadMemberResponse[] | null;
};
export type WorklogDistributionItemResponse = Omit<Api.Project.WorklogDistributionItem, 'projectId'> & {
projectId?: StringIdResponse | null;
};
export type MyWorklogWeekResponse = Omit<Api.Project.MyWorklogWeekResult, 'dailyHours' | 'distribution'> & {
dailyHours?: number[] | null;
distribution?: WorklogDistributionItemResponse[] | null;
};
export type TeamWorklogWeekMemberResponse = Omit<Api.Project.TeamWorklogWeekMember, 'userId' | 'items'> & {
userId: StringIdResponse;
items?: WorklogDistributionItemResponse[] | null;
};
export type TeamWorklogWeekResponse = Omit<Api.Project.TeamWorklogWeekResult, 'members'> & {
members?: TeamWorklogWeekMemberResponse[] | null;
};
export type ExecutionAssigneeResponse = Omit<Api.Project.ExecutionAssignee, 'id' | 'executionId' | 'userId'> & { export type ExecutionAssigneeResponse = Omit<Api.Project.ExecutionAssignee, 'id' | 'executionId' | 'userId'> & {
id: StringIdResponse; id: StringIdResponse;
executionId: StringIdResponse; executionId: StringIdResponse;
@@ -227,6 +318,28 @@ export function normalizeProjectLocalDate(value: ProjectLocalDateValue | undefin
return String(value); return String(value);
} }
/**
* 后端 LocalDateTime 统一序列化为毫秒时间戳(也可能是数字字符串/格式化字符串),
* 归一为 'YYYY-MM-DD HH:mm:ss' 供展示与 dayjs 解析。
*/
export function normalizeProjectDateTime(value: string | number | null | undefined): string {
if (value === null || value === undefined || value === '') {
return '';
}
let parsed: dayjs.Dayjs;
if (typeof value === 'number') {
parsed = dayjs(value);
} else if (/^\d+$/.test(value)) {
// 字符串形态的毫秒时间戳dayjs 无法直接解析,先转数值(时间值非 ID安全整数范围内
parsed = dayjs(Number(value));
} else {
parsed = dayjs(value);
}
return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm:ss') : '';
}
export function normalizeLifecycleActions<ActionCode extends string>( export function normalizeLifecycleActions<ActionCode extends string>(
actions: LifecycleActionResponse<ActionCode>[] | null | undefined actions: LifecycleActionResponse<ActionCode>[] | null | undefined
): Api.Project.LifecycleAction<ActionCode>[] { ): Api.Project.LifecycleAction<ActionCode>[] {
@@ -260,6 +373,15 @@ function normalizePriority(value: string | number | null | undefined): string {
return String(value); return String(value);
} }
function normalizeProgressRate(value: number | string | null | undefined) {
if (value === null || value === undefined || value === '') {
return null;
}
const numeric = typeof value === 'number' ? value : Number(value ?? 0);
return Number.isFinite(numeric) ? numeric : null;
}
export function normalizeProjectExecution(response: ProjectExecutionResponse): Api.Project.ProjectExecution { export function normalizeProjectExecution(response: ProjectExecutionResponse): Api.Project.ProjectExecution {
return { return {
...response, ...response,
@@ -286,6 +408,119 @@ export function normalizeProjectExecution(response: ProjectExecutionResponse): A
}; };
} }
export function normalizeMyExecution(response: MyExecutionResponse): Api.Project.MyExecutionItem {
return {
...response,
id: normalizeStringId(response.id),
projectId: normalizeStringId(response.projectId),
statusName: response.statusName ?? null,
priority: normalizePriority(response.priority),
progressRate: typeof response.progressRate === 'number' ? response.progressRate : 0,
plannedStartDate: normalizeProjectLocalDate(response.plannedStartDate),
plannedEndDate: normalizeProjectLocalDate(response.plannedEndDate),
actualStartDate: normalizeProjectLocalDate(response.actualStartDate),
actualEndDate: normalizeProjectLocalDate(response.actualEndDate),
projectRequirementId: normalizeNullableStringId(response.projectRequirementId),
projectRequirementName: response.projectRequirementName ?? null
};
}
export function normalizeMyParticipatedProject(
response: MyParticipatedProjectResponse
): Api.Project.MyParticipatedProjectItem {
return {
...response,
id: normalizeStringId(response.id),
code: response.code ?? null,
statusName: response.statusName ?? null,
myRole: response.myRole ?? null
};
}
export function normalizeMyOwnedProject(response: MyOwnedProjectResponse): Api.Project.MyOwnedProjectItem {
return {
...response,
id: normalizeStringId(response.id),
code: response.code ?? null,
myRole: response.myRole ?? null,
plannedEndDate: response.plannedEndDate ?? null,
members: (response.members ?? []).map(member => ({
...member,
userId: normalizeStringId(member.userId),
userName: member.userName ?? null
}))
};
}
export function normalizeMyTask(response: MyTaskResponse): Api.Project.MyTaskItem {
return {
...response,
id: normalizeStringId(response.id),
projectId: normalizeStringId(response.projectId),
executionId: normalizeNullableStringId(response.executionId),
executionName: response.executionName ?? null,
statusName: response.statusName ?? null,
priority: normalizePriority(response.priority),
plannedEndDate: normalizeProjectLocalDate(response.plannedEndDate),
progressRate: normalizeProgressRate(response.progressRate) ?? 0,
createTime: normalizeProjectDateTime(response.createTime),
parentTaskId: normalizeNullableStringId(response.parentTaskId),
terminal: Boolean(response.terminal),
allowEdit: Boolean(response.allowEdit),
availableActions: normalizeLifecycleActions(response.availableActions)
};
}
function normalizeWorklogDistributionItem(
response: WorklogDistributionItemResponse | TeamLoadDistributionItemResponse
): { projectId: string | null; projectName: string | null; kind: 'project' | 'personal' | 'other' } {
return {
projectId: normalizeNullableStringId(response.projectId),
projectName: response.projectName ?? null,
kind: response.kind
};
}
export function normalizeTeamLoad(response: TeamLoadResponse): Api.Project.TeamLoadResult {
return {
members: (response.members ?? []).map(member => ({
userId: normalizeStringId(member.userId),
userNickname: member.userNickname ?? '',
items: (member.items ?? []).map(item => ({
...normalizeWorklogDistributionItem(item),
count: typeof item.count === 'number' ? item.count : 0
})),
dueSoonCount: typeof member.dueSoonCount === 'number' ? member.dueSoonCount : 0,
overdueCount: typeof member.overdueCount === 'number' ? member.overdueCount : 0
}))
};
}
export function normalizeMyWorklogWeek(response: MyWorklogWeekResponse): Api.Project.MyWorklogWeekResult {
return {
weekStart: response.weekStart ?? '',
dailyHours: response.dailyHours ?? [0, 0, 0, 0, 0],
distribution: (response.distribution ?? []).map(item => ({
...normalizeWorklogDistributionItem(item),
hours: typeof item.hours === 'number' ? item.hours : 0
}))
};
}
export function normalizeTeamWorklogWeek(response: TeamWorklogWeekResponse): Api.Project.TeamWorklogWeekResult {
return {
weekStart: response.weekStart ?? '',
members: (response.members ?? []).map(member => ({
userId: normalizeStringId(member.userId),
userNickname: member.userNickname ?? '',
items: (member.items ?? []).map(item => ({
...normalizeWorklogDistributionItem(item),
hours: typeof item.hours === 'number' ? item.hours : 0
}))
}))
};
}
export function normalizeExecutionAssignee(response: ExecutionAssigneeResponse): Api.Project.ExecutionAssignee { export function normalizeExecutionAssignee(response: ExecutionAssigneeResponse): Api.Project.ExecutionAssignee {
return { return {
...response, ...response,

View File

@@ -10,6 +10,11 @@ import {
import { import {
type ExecutionAssigneeLogResponse, type ExecutionAssigneeLogResponse,
type ExecutionAssigneeResponse, type ExecutionAssigneeResponse,
type MyExecutionResponse,
type MyOwnedProjectResponse,
type MyParticipatedProjectResponse,
type MyTaskResponse,
type MyWorklogWeekResponse,
type ProjectExecutionResponse, type ProjectExecutionResponse,
type ProjectLocalDateValue, type ProjectLocalDateValue,
type ProjectMemberResponse, type ProjectMemberResponse,
@@ -17,23 +22,39 @@ import {
type TaskAssigneeFromApiResponse, type TaskAssigneeFromApiResponse,
type TaskAssigneeLogResponse, type TaskAssigneeLogResponse,
type TaskWorklogResponse, type TaskWorklogResponse,
type TeamLoadResponse,
type TeamWorklogWeekResponse,
getProjectLifecycleActions, getProjectLifecycleActions,
normalizeExecutionAssignee, normalizeExecutionAssignee,
normalizeExecutionAssigneeLog, normalizeExecutionAssigneeLog,
normalizeMyExecution,
normalizeMyOwnedProject,
normalizeMyParticipatedProject,
normalizeMyTask,
normalizeMyWorklogWeek,
normalizeProjectExecution, normalizeProjectExecution,
normalizeProjectLocalDate, normalizeProjectLocalDate,
normalizeProjectMember, normalizeProjectMember,
normalizeProjectTask, normalizeProjectTask,
normalizeTaskAssignee, normalizeTaskAssignee,
normalizeTaskAssigneeLog, normalizeTaskAssigneeLog,
normalizeTaskWorklog normalizeTaskWorklog,
normalizeTeamLoad,
normalizeTeamWorklogWeek
} from './project-shared'; } from './project-shared';
const PROJECT_PREFIX = `${WEB_SERVICE_PREFIX}/project/project`; const PROJECT_PREFIX = `${WEB_SERVICE_PREFIX}/project/project`;
type ProjectResponse = Omit< export type ProjectResponse = Omit<
Api.Project.Project, Api.Project.Project,
'id' | 'managerUserId' | 'productId' | 'plannedStartDate' | 'plannedEndDate' | 'actualStartDate' | 'actualEndDate' | 'id'
| 'managerUserId'
| 'productId'
| 'plannedStartDate'
| 'plannedEndDate'
| 'actualStartDate'
| 'actualEndDate'
| 'currentUserRoles'
> & { > & {
id: string | number; id: string | number;
managerUserId?: string | number | null; managerUserId?: string | number | null;
@@ -42,6 +63,8 @@ type ProjectResponse = Omit<
plannedEndDate?: ProjectLocalDateValue; plannedEndDate?: ProjectLocalDateValue;
actualStartDate?: ProjectLocalDateValue; actualStartDate?: ProjectLocalDateValue;
actualEndDate?: ProjectLocalDateValue; actualEndDate?: ProjectLocalDateValue;
/** 灰度/兼容期后端可能缺省,适配层兜底为 [] */
currentUserRoles?: Api.Common.CurrentUserRole[] | null;
}; };
type ProjectPageResponse = Api.Project.PageResult<ProjectResponse>; type ProjectPageResponse = Api.Project.PageResult<ProjectResponse>;
@@ -73,7 +96,7 @@ function getTaskPrefix(projectId: string, executionId: string) {
} }
/** 归一化项目数据 */ /** 归一化项目数据 */
function normalizeProject(project: ProjectResponse): Api.Project.Project { export function normalizeProject(project: ProjectResponse): Api.Project.Project {
return { return {
...project, ...project,
id: normalizeStringId(project.id), id: normalizeStringId(project.id),
@@ -82,7 +105,8 @@ function normalizeProject(project: ProjectResponse): Api.Project.Project {
plannedStartDate: normalizeProjectLocalDate(project.plannedStartDate), plannedStartDate: normalizeProjectLocalDate(project.plannedStartDate),
plannedEndDate: normalizeProjectLocalDate(project.plannedEndDate), plannedEndDate: normalizeProjectLocalDate(project.plannedEndDate),
actualStartDate: normalizeProjectLocalDate(project.actualStartDate), actualStartDate: normalizeProjectLocalDate(project.actualStartDate),
actualEndDate: normalizeProjectLocalDate(project.actualEndDate) actualEndDate: normalizeProjectLocalDate(project.actualEndDate),
currentUserRoles: project.currentUserRoles ?? []
}; };
} }
@@ -130,13 +154,34 @@ export async function fetchGetProjectPage(params?: Api.Project.ProjectSearchPara
})); }));
} }
type ProjectOverviewSummaryResponse = Omit<Api.Project.ProjectOverviewSummary, 'total' | 'items'> & {
/** 后端 overview-summary 升级total/items灰度期间可能缺省适配层兜底 */
total?: number | null;
items?: Api.Project.OverviewStatusItem[] | null;
};
/** 归一化项目概览统计total/items 兜底,保证业务层拿到完整结构 */
function normalizeProjectOverviewSummary(data: ProjectOverviewSummaryResponse): Api.Project.ProjectOverviewSummary {
return {
...data,
statusCounts: data.statusCounts ?? {},
total: data.total ?? 0,
items: data.items ?? []
};
}
/** 获取项目入口页概览统计 */ /** 获取项目入口页概览统计 */
export function fetchGetProjectOverviewSummary() { export async function fetchGetProjectOverviewSummary() {
return request<Api.Project.ProjectOverviewSummary>({ const result = await request<ProjectOverviewSummaryResponse>({
...safeJsonRequestConfig, ...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/overview-summary`, url: `${PROJECT_PREFIX}/overview-summary`,
method: 'get' method: 'get'
}); });
return mapServiceResult(
result as ServiceRequestResult<ProjectOverviewSummaryResponse>,
normalizeProjectOverviewSummary
);
} }
/** 获取项目详情 */ /** 获取项目详情 */
@@ -365,6 +410,105 @@ export async function fetchGetProjectExecutionPage(
})); }));
} }
/** 获取工作台「我负责的执行」跨项目聚合owner 隐式取当前登录用户) */
export async function fetchGetMyExecutionPage(params?: Api.Project.MyExecutionSearchParams) {
type MyExecutionPageResponse = Api.Project.PageResult<MyExecutionResponse>;
const result = await request<MyExecutionPageResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/me/executions/page`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<MyExecutionPageResponse>, data => ({
...data,
list: data.list.map(normalizeMyExecution)
}));
}
/** 获取工作台「我参与的项目」(成员视角,附我的角色与任务量;隐式取当前登录用户) */
export async function fetchGetMyParticipatedProjectPage(params?: Api.Project.MyProjectSearchParams) {
type MyParticipatedProjectPageResponse = Api.Project.PageResult<MyParticipatedProjectResponse>;
const result = await request<MyParticipatedProjectPageResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/me/participated/page`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<MyParticipatedProjectPageResponse>, data => ({
...data,
list: data.list.map(normalizeMyParticipatedProject)
}));
}
/** 获取工作台「我负责的项目」(项目负责人视角,附聚合统计与成员负载;隐式取当前登录用户) */
export async function fetchGetMyOwnedProjectPage(params?: Api.Project.MyProjectSearchParams) {
type MyOwnedProjectPageResponse = Api.Project.PageResult<MyOwnedProjectResponse>;
const result = await request<MyOwnedProjectPageResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/me/owned/page`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<MyOwnedProjectPageResponse>, data => ({
...data,
list: data.list.map(normalizeMyOwnedProject)
}));
}
/** 获取工作台「我的任务」(跨项目聚合,负责人/在岗协办人口径,只返回非终态;隐式取当前登录用户) */
export async function fetchGetMyTaskPage(params?: Api.Project.MyTaskSearchParams) {
type MyTaskPageResponse = Api.Project.PageResult<MyTaskResponse>;
const result = await request<MyTaskPageResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/me/tasks/page`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<MyTaskPageResponse>, data => ({
...data,
list: data.list.map(normalizeMyTask)
}));
}
/** 获取工作台「团队负载」(团队 = 当前用户 + 管理链路直接下级members[0] 恒为当前用户) */
export async function fetchGetMyTeamLoad() {
const result = await request<TeamLoadResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/me/team-load`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<TeamLoadResponse>, normalizeTeamLoad);
}
/** 获取工作台「我的工时周聚合」weekStart 传任意日期,后端归一到所在周周一;逐日工时为均摊推算值) */
export async function fetchGetMyWorklogWeek(params: Api.Project.WorklogWeekParams) {
const result = await request<MyWorklogWeekResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/me/worklog-week`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<MyWorklogWeekResponse>, normalizeMyWorklogWeek);
}
/** 获取工作台「团队工时周聚合」(成员集合与团队负载同口径;周标准工时后端不返回,前端落常量) */
export async function fetchGetTeamWorklogWeek(params: Api.Project.WorklogWeekParams) {
const result = await request<TeamWorklogWeekResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/me/team-worklog-week`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<TeamWorklogWeekResponse>, normalizeTeamWorklogWeek);
}
/** 获取项目执行状态看板 */ /** 获取项目执行状态看板 */
export function fetchGetProjectExecutionStatusBoard( export function fetchGetProjectExecutionStatusBoard(
projectId: string, projectId: string,
@@ -892,7 +1036,7 @@ const PROJECT_REQUIREMENT_PREFIX = `${WEB_SERVICE_PREFIX}/project/project/requir
type ProjectRequirementResponse = Omit< type ProjectRequirementResponse = Omit<
Api.Project.ProjectRequirement, Api.Project.ProjectRequirement,
'id' | 'projectId' | 'parentId' | 'moduleId' | 'proposerId' | 'currentHandlerUserId' | 'sourceBizId' | 'attachments' 'id' | 'projectId' | 'parentId' | 'moduleId' | 'proposerId' | 'currentHandlerUserId' | 'sourceBizCode' | 'attachments'
> & { > & {
id: string | number; id: string | number;
projectId: string | number; projectId: string | number;
@@ -900,7 +1044,7 @@ type ProjectRequirementResponse = Omit<
moduleId: string | number; moduleId: string | number;
proposerId: string | number; proposerId: string | number;
currentHandlerUserId?: string | number | null; currentHandlerUserId?: string | number | null;
sourceBizId?: string | number | null; sourceBizCode?: string | null;
attachments?: AttachmentItemResponse[] | null; attachments?: AttachmentItemResponse[] | null;
children?: ProjectRequirementResponse[]; children?: ProjectRequirementResponse[];
}; };
@@ -956,7 +1100,7 @@ function normalizeProjectRequirement(requirement: ProjectRequirementResponse): A
moduleId: normalizeStringId(requirement.moduleId), moduleId: normalizeStringId(requirement.moduleId),
proposerId: normalizeStringId(requirement.proposerId), proposerId: normalizeStringId(requirement.proposerId),
currentHandlerUserId: normalizeNullableStringId(requirement.currentHandlerUserId), currentHandlerUserId: normalizeNullableStringId(requirement.currentHandlerUserId),
sourceBizId: normalizeNullableStringId(requirement.sourceBizId), sourceBizCode: requirement.sourceBizCode ?? null,
attachments: normalizeAttachments(requirement.attachments), attachments: normalizeAttachments(requirement.attachments),
progressRate: typeof requirement.progressRate === 'number' ? requirement.progressRate : 0, progressRate: typeof requirement.progressRate === 'number' ? requirement.progressRate : 0,
children: requirement.children?.map(normalizeProjectRequirement) children: requirement.children?.map(normalizeProjectRequirement)

View File

@@ -0,0 +1,260 @@
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
import { request } from '../request';
import {
type ServiceRequestResult,
mapServiceResult,
normalizeNullableStringId,
normalizeStringId,
safeJsonRequestConfig
} from './shared';
const LOGIN_LOG_PREFIX = `${SYSTEM_SERVICE_PREFIX}/login-log`;
const OPERATE_LOG_PREFIX = `${SYSTEM_SERVICE_PREFIX}/operate-log`;
const API_ACCESS_LOG_PREFIX = `${SYSTEM_SERVICE_PREFIX}/api-access-log`;
const API_ERROR_LOG_PREFIX = `${SYSTEM_SERVICE_PREFIX}/api-error-log`;
type StringIdResponse = string | number;
type LoginLogResponse = Omit<Api.SystemLog.Login.Log, 'id' | 'userId'> & {
id: StringIdResponse;
userId?: StringIdResponse | null;
};
type OperateLogResponse = Omit<Api.SystemLog.Operate.Log, 'id' | 'userId' | 'bizId'> & {
id: StringIdResponse;
userId: StringIdResponse;
bizId?: StringIdResponse | null;
};
type ApiAccessLogResponse = Omit<Api.SystemLog.ApiAccess.Log, 'id' | 'userId'> & {
id: StringIdResponse;
userId: StringIdResponse;
};
type ApiErrorLogResponse = Omit<Api.SystemLog.ApiError.Log, 'id' | 'userId' | 'processUserId'> & {
id: StringIdResponse;
userId: StringIdResponse;
processUserId?: StringIdResponse | null;
};
type LoginLogPageResponse = Api.SystemLog.Common.PageResult<LoginLogResponse>;
type OperateLogPageResponse = Api.SystemLog.Common.PageResult<OperateLogResponse>;
type ApiAccessLogPageResponse = Api.SystemLog.Common.PageResult<ApiAccessLogResponse>;
type ApiErrorLogPageResponse = Api.SystemLog.Common.PageResult<ApiErrorLogResponse>;
function appendValue(query: URLSearchParams, key: string, value: unknown) {
if (value === null || value === undefined || value === '') return;
if (Array.isArray(value)) {
value.forEach(item => appendValue(query, key, item));
return;
}
query.append(key, String(value));
}
function buildQuery(params: Record<string, unknown> = {}) {
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
appendValue(query, key, value);
});
return query.toString();
}
function normalizeLoginLog(log: LoginLogResponse): Api.SystemLog.Login.Log {
return {
...log,
id: normalizeStringId(log.id),
userId: normalizeNullableStringId(log.userId),
traceId: log.traceId ?? null,
userType: log.userType ?? null,
userAgent: log.userAgent ?? null
};
}
function normalizeOperateLog(log: OperateLogResponse): Api.SystemLog.Operate.Log {
return {
...log,
id: normalizeStringId(log.id),
userId: normalizeStringId(log.userId),
bizId: normalizeNullableStringId(log.bizId),
traceId: log.traceId ?? null,
action: log.action ?? null,
extra: log.extra ?? null
};
}
function normalizeApiAccessLog(log: ApiAccessLogResponse): Api.SystemLog.ApiAccess.Log {
return {
...log,
id: normalizeStringId(log.id),
userId: normalizeStringId(log.userId),
traceId: log.traceId ?? null,
requestParams: log.requestParams ?? null,
responseBody: log.responseBody ?? null,
operateModule: log.operateModule ?? null,
operateName: log.operateName ?? null,
operateType: log.operateType ?? null,
resultMsg: log.resultMsg ?? null
};
}
function normalizeApiErrorLog(log: ApiErrorLogResponse): Api.SystemLog.ApiError.Log {
return {
...log,
id: normalizeStringId(log.id),
userId: normalizeStringId(log.userId),
traceId: log.traceId ?? null,
requestParams: log.requestParams ?? null,
exceptionRootCauseMessage: log.exceptionRootCauseMessage ?? null,
exceptionStackTrace: log.exceptionStackTrace ?? null,
exceptionClassName: log.exceptionClassName ?? null,
exceptionFileName: log.exceptionFileName ?? null,
exceptionMethodName: log.exceptionMethodName ?? null,
exceptionLineNumber: log.exceptionLineNumber ?? null,
processTime: log.processTime ?? null,
processUserId: normalizeNullableStringId(log.processUserId)
};
}
export async function fetchGetLoginLogPage(params?: Api.SystemLog.Login.SearchParams) {
const query = buildQuery((params ?? {}) as Record<string, unknown>);
const result = await request<LoginLogPageResponse>({
...safeJsonRequestConfig,
url: query ? `${LOGIN_LOG_PREFIX}/page?${query}` : `${LOGIN_LOG_PREFIX}/page`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<LoginLogPageResponse>, data => ({
...data,
list: data.list.map(normalizeLoginLog)
}));
}
export async function fetchGetLoginLog(id: string) {
const result = await request<LoginLogResponse>({
...safeJsonRequestConfig,
url: `${LOGIN_LOG_PREFIX}/get`,
method: 'get',
params: { id }
});
return mapServiceResult(result as ServiceRequestResult<LoginLogResponse>, normalizeLoginLog);
}
export function fetchExportLoginLog(params: Api.SystemLog.Login.SearchParams = {}) {
const query = buildQuery(params as Record<string, unknown>);
return request<Blob, 'blob'>({
url: query ? `${LOGIN_LOG_PREFIX}/export-excel?${query}` : `${LOGIN_LOG_PREFIX}/export-excel`,
method: 'get',
responseType: 'blob'
});
}
export async function fetchGetOperateLogPage(params?: Api.SystemLog.Operate.SearchParams) {
const query = buildQuery((params ?? {}) as Record<string, unknown>);
const result = await request<OperateLogPageResponse>({
...safeJsonRequestConfig,
url: query ? `${OPERATE_LOG_PREFIX}/page?${query}` : `${OPERATE_LOG_PREFIX}/page`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<OperateLogPageResponse>, data => ({
...data,
list: data.list.map(normalizeOperateLog)
}));
}
export async function fetchGetOperateLog(id: string) {
const result = await request<OperateLogResponse>({
...safeJsonRequestConfig,
url: `${OPERATE_LOG_PREFIX}/get`,
method: 'get',
params: { id }
});
return mapServiceResult(result as ServiceRequestResult<OperateLogResponse>, normalizeOperateLog);
}
export function fetchExportOperateLog(params: Api.SystemLog.Operate.SearchParams = {}) {
const query = buildQuery(params as Record<string, unknown>);
return request<Blob, 'blob'>({
url: query ? `${OPERATE_LOG_PREFIX}/export-excel?${query}` : `${OPERATE_LOG_PREFIX}/export-excel`,
method: 'get',
responseType: 'blob'
});
}
export async function fetchGetApiAccessLogPage(params?: Api.SystemLog.ApiAccess.SearchParams) {
const query = buildQuery((params ?? {}) as Record<string, unknown>);
const result = await request<ApiAccessLogPageResponse>({
...safeJsonRequestConfig,
url: query ? `${API_ACCESS_LOG_PREFIX}/page?${query}` : `${API_ACCESS_LOG_PREFIX}/page`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<ApiAccessLogPageResponse>, data => ({
...data,
list: data.list.map(normalizeApiAccessLog)
}));
}
export async function fetchGetApiAccessLog(id: string) {
const result = await request<ApiAccessLogResponse>({
...safeJsonRequestConfig,
url: `${API_ACCESS_LOG_PREFIX}/get`,
method: 'get',
params: { id }
});
return mapServiceResult(result as ServiceRequestResult<ApiAccessLogResponse>, normalizeApiAccessLog);
}
export function fetchExportApiAccessLog(params: Api.SystemLog.ApiAccess.SearchParams = {}) {
const query = buildQuery(params as Record<string, unknown>);
return request<Blob, 'blob'>({
url: query ? `${API_ACCESS_LOG_PREFIX}/export-excel?${query}` : `${API_ACCESS_LOG_PREFIX}/export-excel`,
method: 'get',
responseType: 'blob'
});
}
export async function fetchGetApiErrorLogPage(params?: Api.SystemLog.ApiError.SearchParams) {
const query = buildQuery((params ?? {}) as Record<string, unknown>);
const result = await request<ApiErrorLogPageResponse>({
...safeJsonRequestConfig,
url: query ? `${API_ERROR_LOG_PREFIX}/page?${query}` : `${API_ERROR_LOG_PREFIX}/page`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<ApiErrorLogPageResponse>, data => ({
...data,
list: data.list.map(normalizeApiErrorLog)
}));
}
export async function fetchGetApiErrorLog(id: string) {
const result = await request<ApiErrorLogResponse>({
...safeJsonRequestConfig,
url: `${API_ERROR_LOG_PREFIX}/get`,
method: 'get',
params: { id }
});
return mapServiceResult(result as ServiceRequestResult<ApiErrorLogResponse>, normalizeApiErrorLog);
}
export function fetchExportApiErrorLog(params: Api.SystemLog.ApiError.SearchParams = {}) {
const query = buildQuery(params as Record<string, unknown>);
return request<Blob, 'blob'>({
url: query ? `${API_ERROR_LOG_PREFIX}/export-excel?${query}` : `${API_ERROR_LOG_PREFIX}/export-excel`,
method: 'get',
responseType: 'blob'
});
}

View File

@@ -18,6 +18,29 @@ const POST_PREFIX = `${SYSTEM_SERVICE_PREFIX}/post`;
const ORG_LEADER_PREFIX = `${SYSTEM_SERVICE_PREFIX}/org-leader`; const ORG_LEADER_PREFIX = `${SYSTEM_SERVICE_PREFIX}/org-leader`;
const USER_MANAGEMENT_RELATION_PREFIX = `${SYSTEM_SERVICE_PREFIX}/user-management-relation`; const USER_MANAGEMENT_RELATION_PREFIX = `${SYSTEM_SERVICE_PREFIX}/user-management-relation`;
function appendQueryValue(query: URLSearchParams, key: string, value: unknown) {
if (value === null || value === undefined || value === '') {
return;
}
if (Array.isArray(value)) {
value.forEach(item => appendQueryValue(query, key, item));
return;
}
query.append(key, String(value));
}
function buildQuery(params: object = {}) {
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
appendQueryValue(query, key, value);
});
return query.toString();
}
function createRolePageQuery(params?: Api.SystemManage.RoleSearchParams) { function createRolePageQuery(params?: Api.SystemManage.RoleSearchParams) {
const query = new URLSearchParams(); const query = new URLSearchParams();
@@ -118,6 +141,11 @@ type UserManagementRelationTreeResponse = Omit<
children?: UserManagementRelationTreeResponse[] | null; children?: UserManagementRelationTreeResponse[] | null;
}; };
type MySubordinateTreeNodeResponse = Omit<Api.SystemManage.MySubordinateTreeNode, 'userId' | 'children'> & {
userId: string | number;
children?: MySubordinateTreeNodeResponse[] | null;
};
function normalizeUserSimple(user: UserSimpleResponse): Api.SystemManage.UserSimple { function normalizeUserSimple(user: UserSimpleResponse): Api.SystemManage.UserSimple {
return { return {
...user, ...user,
@@ -181,6 +209,14 @@ function normalizeUserManagementRelationTree(
}; };
} }
function normalizeMySubordinateTreeNode(node: MySubordinateTreeNodeResponse): Api.SystemManage.MySubordinateTreeNode {
return {
...node,
userId: normalizeStringId(node.userId),
children: node.children?.map(normalizeMySubordinateTreeNode) ?? null
};
}
/** 获取角色分页 */ /** 获取角色分页 */
export async function fetchGetRolePage(params?: Api.SystemManage.RoleSearchParams) { export async function fetchGetRolePage(params?: Api.SystemManage.RoleSearchParams) {
const query = createRolePageQuery(params); const query = createRolePageQuery(params);
@@ -311,6 +347,18 @@ export function fetchGetDeptSimpleList() {
}); });
} }
/** 获取部门自身及全部子部门 */
export async function fetchGetDeptSelfAndChildren(id: string) {
const result = await request<Api.SystemManage.DeptSelfAndChildrenList>({
...safeJsonRequestConfig,
url: `${DEPT_PREFIX}/list-self-and-children`,
method: 'get',
params: { id }
});
return mapServiceResult(result as ServiceRequestResult<Api.SystemManage.DeptSelfAndChildrenList>, data => data);
}
/** 创建部门 */ /** 创建部门 */
export function fetchCreateDept(data: Api.SystemManage.SaveDeptParams) { export function fetchCreateDept(data: Api.SystemManage.SaveDeptParams) {
return request<number>({ return request<number>({
@@ -444,6 +492,17 @@ export function fetchBatchDeletePost(ids: number[]) {
}); });
} }
/** 导出岗位 */
export function fetchExportPost(params: Api.SystemManage.PostSearchParams) {
const query = buildQuery(params);
return request<Blob, 'blob'>({
url: query ? `${POST_PREFIX}/export-excel?${query}` : `${POST_PREFIX}/export-excel`,
method: 'get',
responseType: 'blob'
});
}
/** 获取用户简单列表(用于用户选择下拉框) */ /** 获取用户简单列表(用于用户选择下拉框) */
export async function fetchGetUserSimpleList() { export async function fetchGetUserSimpleList() {
return request<UserSimpleResponse[]>({ return request<UserSimpleResponse[]>({
@@ -554,6 +613,17 @@ export function fetchBatchDeleteUser(ids: number[]) {
}); });
} }
/** 导出用户 */
export function fetchExportUser(params: Api.SystemManage.UserSearchParams) {
const query = buildQuery(params);
return request<Blob, 'blob'>({
url: query ? `${USER_PREFIX}/export-excel?${query}` : `${USER_PREFIX}/export-excel`,
method: 'get',
responseType: 'blob'
});
}
/** 获取菜单列表 */ /** 获取菜单列表 */
export async function fetchGetMenuList(params?: Api.SystemManage.MenuSearchParams) { export async function fetchGetMenuList(params?: Api.SystemManage.MenuSearchParams) {
const result = await request<MenuResponse[]>({ const result = await request<MenuResponse[]>({
@@ -712,6 +782,29 @@ export async function fetchGetUserManagementRelationQuery(query: UserManagementR
); );
} }
/** 获取当前登录用户下属树 */
export async function fetchGetMySubordinateTree() {
return request<MySubordinateTreeNodeResponse>({
...safeJsonRequestConfig,
url: `${USER_MANAGEMENT_RELATION_PREFIX}/my-subordinate-tree`,
method: 'get'
}).then(result =>
mapServiceResult(result as ServiceRequestResult<MySubordinateTreeNodeResponse>, normalizeMySubordinateTreeNode)
);
}
/** 获取某用户当前生效的直属下级列表 */
export async function fetchGetDirectSubordinates(userId: string) {
const result = await request<UserSimpleResponse[]>({
...safeJsonRequestConfig,
url: `${USER_MANAGEMENT_RELATION_PREFIX}/direct-subordinates`,
method: 'get',
params: { userId }
});
return mapServiceResult(result as ServiceRequestResult<UserSimpleResponse[]>, data => data.map(normalizeUserSimple));
}
/** /**
* 获取用户管理链路详情 * 获取用户管理链路详情
* *

View File

@@ -0,0 +1,997 @@
import { WEB_SERVICE_PREFIX } from '@/constants/service';
import { request } from '../request';
import {
type ServiceRequestResult,
mapServiceResult,
normalizeNullableStringId,
normalizeStringId,
safeJsonRequestConfig
} from './shared';
const WORK_REPORT_PREFIX = `${WEB_SERVICE_PREFIX}/project/work-reports`;
const WEEKLY_PREFIX = `${WORK_REPORT_PREFIX}/weekly`;
const MONTHLY_PREFIX = `${WORK_REPORT_PREFIX}/monthly`;
const PROJECT_PREFIX = `${WORK_REPORT_PREFIX}/project`;
type StringIdResponse = string | number;
type MaybeStringIdResponse = string | number | null | undefined;
type PageResponse<T> = {
total: number | string;
list: T[];
};
type ReviewItemResponse = Omit<Api.WorkReport.Common.PersonalReportReviewItem, 'id'> & {
id?: MaybeStringIdResponse;
};
type PlanItemResponse = Omit<Api.WorkReport.Common.PersonalReportPlanItem, 'id'> & {
id?: MaybeStringIdResponse;
};
type WeeklyTravelSegmentResponse = Omit<Api.WorkReport.Weekly.WeeklyReportTravelSegment, 'id'> & {
id?: MaybeStringIdResponse;
};
type WeeklyReportResponse = Omit<
Api.WorkReport.Weekly.WeeklyReport,
'id' | 'reporterId' | 'supervisorUserId' | 'reviewItems' | 'planItems' | 'travelSegments'
> & {
id: StringIdResponse;
reporterId: StringIdResponse;
supervisorUserId: StringIdResponse;
reviewItems?: ReviewItemResponse[] | null;
planItems?: PlanItemResponse[] | null;
travelSegments?: WeeklyTravelSegmentResponse[] | null;
};
type MonthlyReportResponse = Omit<
Api.WorkReport.Monthly.MonthlyReport,
'id' | 'reporterId' | 'supervisorUserId' | 'reviewItems' | 'planItems'
> & {
id: StringIdResponse;
reporterId: StringIdResponse;
supervisorUserId: StringIdResponse;
reviewItems?: ReviewItemResponse[] | null;
planItems?: PlanItemResponse[] | null;
};
type MemberSnapshotResponse = Omit<Api.WorkReport.Project.WorkReportMemberSnapshot, 'userId'> & {
userId: StringIdResponse;
};
type ProjectReportItemResponse = Omit<Api.WorkReport.Project.ProjectReportItem, 'id'> & {
id?: MaybeStringIdResponse;
};
type ProjectReportResponse = Omit<
Api.WorkReport.Project.ProjectReport,
'id' | 'projectId' | 'projectOwnerId' | 'projectMemberSnapshot' | 'supervisorUserId' | 'currentItems' | 'nextItems'
> & {
id: StringIdResponse;
projectId: StringIdResponse;
projectOwnerId: StringIdResponse;
projectMemberSnapshot?: MemberSnapshotResponse[] | null;
supervisorUserId: StringIdResponse;
currentItems?: ProjectReportItemResponse[] | null;
nextItems?: ProjectReportItemResponse[] | null;
};
type ApprovalRecordResponse = Omit<
Api.WorkReport.Common.WorkReportApprovalRecord,
'id' | 'statusLogId' | 'auditorUserId'
> & {
id: StringIdResponse;
statusLogId: StringIdResponse;
auditorUserId: StringIdResponse;
};
type MonthlyApprovalRecordResponse = Omit<
Api.WorkReport.Monthly.MonthlyReportApprovalRecord,
'id' | 'statusLogId' | 'auditorUserId'
> & {
id: StringIdResponse;
statusLogId: StringIdResponse;
auditorUserId: StringIdResponse;
};
type ProjectOptionResponse = Omit<Api.WorkReport.Project.ProjectReportOwnerProjectOption, 'id'> & {
id: StringIdResponse;
};
type TeamReportUnsubmittedReportResponse = Omit<
Api.WorkReport.Common.TeamReportUnsubmittedReport,
'userId' | 'projectId'
> & {
userId: StringIdResponse;
projectId?: StringIdResponse | null;
};
type TeamReportSummaryResponse = Omit<
Api.WorkReport.Common.TeamReportSummary,
'unsubmittedReports' | 'periodStartDate' | 'periodEndDate'
> & {
unsubmittedReports?: TeamReportUnsubmittedReportResponse[] | null;
periodStartDate?: unknown;
periodEndDate?: unknown;
};
function normalizeBooleanFlag(value: boolean | number | string | null | undefined) {
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value === 1;
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
return !['', '0', 'false', 'n', 'no'].includes(normalized);
}
return false;
}
function normalizeApprovalConclusion(value: unknown) {
const conclusion = String(value || '')
.trim()
.toLowerCase();
if (conclusion === 'approve') return 'approved';
if (conclusion === 'reject') return 'rejected';
return conclusion;
}
function normalizeDateText(value: unknown) {
if (value === null || value === undefined) return undefined;
const text = String(value).trim();
const commaDateMatch = text.match(/^(\d{4}),(\d{1,2}),(\d{1,2})$/);
if (commaDateMatch) {
const [, year, month, day] = commaDateMatch;
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
}
return text || undefined;
}
function normalizeTotal(total: number | string) {
const value = Number(total);
return Number.isFinite(value) ? Math.max(0, value) : 0;
}
function sumWorkHours(items: Array<{ workHours?: number | string | null }> = []) {
return items.reduce((sum, item) => {
const value = Number(item.workHours ?? 0);
return Number.isFinite(value) ? sum + value : sum;
}, 0);
}
function normalizeReportTotalWorkHours(
totalWorkHours: number | string | null | undefined,
fallbackTotalWorkHours: number
) {
const normalizedTotal = Number(totalWorkHours ?? 0);
if (
(totalWorkHours === null ||
totalWorkHours === undefined ||
totalWorkHours === '' ||
(Number.isFinite(normalizedTotal) && normalizedTotal === 0)) &&
fallbackTotalWorkHours > 0
) {
return fallbackTotalWorkHours;
}
return totalWorkHours ?? 0;
}
function appendValue(query: URLSearchParams, key: string, value: unknown) {
if (value === null || value === undefined || value === '') return;
query.append(key, String(value));
}
function appendArray(query: URLSearchParams, key: string, values?: Array<string | null | undefined> | null) {
values?.forEach(value => appendValue(query, key, value));
}
function appendNullableArrayFlag(
query: URLSearchParams,
key: string,
values?: Array<string | null | undefined> | null
) {
if (values === null || values === undefined) return;
if (!values.length) {
query.append(key, '');
return;
}
appendArray(query, key, values);
}
function createBasePageQuery(params: Api.WorkReport.Common.WorkReportBaseSearchParams = {}) {
const query = new URLSearchParams();
appendValue(query, 'pageNo', params.pageNo ?? 1);
appendValue(query, 'pageSize', params.pageSize ?? 10);
appendValue(query, 'keyword', params.keyword);
appendValue(query, 'statusCode', params.statusCode);
appendValue(query, 'supervisorName', params.supervisorName);
appendArray(query, 'periodStartDate', params.periodStartDate);
appendArray(query, 'submitTime', params.submitTime);
return query;
}
function createWeeklyPageQuery(params: Api.WorkReport.Weekly.WeeklyReportSearchParams = {}) {
const query = createBasePageQuery(params);
appendNullableArrayFlag(query, 'reporterIds', params.reporterIds);
appendValue(query, 'isBusinessTrip', params.isBusinessTrip);
return query.toString();
}
function createMonthlyPageQuery(params: Api.WorkReport.Monthly.MonthlyReportSearchParams = {}) {
const query = createBasePageQuery(params);
appendNullableArrayFlag(query, 'reporterIds', params.reporterIds);
return query.toString();
}
function createProjectPageQuery(params: Api.WorkReport.Project.ProjectReportSearchParams = {}) {
const query = createBasePageQuery(params);
appendNullableArrayFlag(query, 'projectOwnerIds', params.projectOwnerIds);
appendValue(query, 'projectId', params.projectId);
appendValue(query, 'flag', params.flag);
return query.toString();
}
function normalizeReviewItem(item: ReviewItemResponse): Api.WorkReport.Common.PersonalReportReviewItem {
return {
...item,
id: normalizeNullableStringId(item.id) ?? undefined
};
}
function normalizePlanItem(item: PlanItemResponse): Api.WorkReport.Common.PersonalReportPlanItem {
return {
...item,
id: normalizeNullableStringId(item.id) ?? undefined
};
}
function normalizeWeeklyTravelSegment(
item: WeeklyTravelSegmentResponse
): Api.WorkReport.Weekly.WeeklyReportTravelSegment {
return {
...item,
id: normalizeNullableStringId(item.id) ?? undefined,
startDate: normalizeDateText(item.startDate),
endDate: normalizeDateText(item.endDate)
};
}
function normalizeWeeklyReport(response: WeeklyReportResponse): Api.WorkReport.Weekly.WeeklyReport {
const fallbackTotalWorkHours = sumWorkHours(response.reviewItems ?? []);
return {
...response,
id: normalizeStringId(response.id),
reporterId: normalizeStringId(response.reporterId),
supervisorUserId: normalizeStringId(response.supervisorUserId),
reporterDeptName: response.reporterDeptName ?? null,
reporterPostName: response.reporterPostName ?? null,
statusName: response.statusName || response.statusCode,
allowEdit: normalizeBooleanFlag(response.allowEdit),
terminal: normalizeBooleanFlag(response.terminal),
isBusinessTrip: normalizeBooleanFlag(response.isBusinessTrip),
totalWorkHours: normalizeReportTotalWorkHours(response.totalWorkHours, fallbackTotalWorkHours),
submitTime: response.submitTime ?? null,
reviewItems: response.reviewItems?.map(normalizeReviewItem) ?? [],
planItems: response.planItems?.map(normalizePlanItem) ?? [],
travelSegments: response.travelSegments?.map(normalizeWeeklyTravelSegment) ?? []
};
}
function normalizeMonthlyReport(response: MonthlyReportResponse): Api.WorkReport.Monthly.MonthlyReport {
const fallbackTotalWorkHours = sumWorkHours(response.reviewItems ?? []);
return {
...response,
id: normalizeStringId(response.id),
reporterId: normalizeStringId(response.reporterId),
supervisorUserId: normalizeStringId(response.supervisorUserId),
reporterDeptName: response.reporterDeptName ?? null,
reporterPostName: response.reporterPostName ?? null,
statusName: response.statusName || response.statusCode,
allowEdit: normalizeBooleanFlag(response.allowEdit),
terminal: normalizeBooleanFlag(response.terminal),
totalWorkHours: normalizeReportTotalWorkHours(response.totalWorkHours, fallbackTotalWorkHours),
submitTime: response.submitTime ?? null,
reviewItems: response.reviewItems?.map(normalizeReviewItem) ?? [],
planItems: response.planItems?.map(normalizePlanItem) ?? []
};
}
function normalizeMemberSnapshot(item: MemberSnapshotResponse): Api.WorkReport.Project.WorkReportMemberSnapshot {
return {
...item,
userId: normalizeStringId(item.userId)
};
}
function normalizeProjectReportItem(item: ProjectReportItemResponse): Api.WorkReport.Project.ProjectReportItem {
return {
...item,
id: normalizeNullableStringId(item.id) ?? undefined
};
}
function normalizeProjectReport(response: ProjectReportResponse): Api.WorkReport.Project.ProjectReport {
const fallbackTotalWorkHours = sumWorkHours(response.currentItems ?? []);
return {
...response,
id: normalizeStringId(response.id),
projectId: normalizeStringId(response.projectId),
projectOwnerId: normalizeStringId(response.projectOwnerId),
projectMemberSnapshot: response.projectMemberSnapshot?.map(normalizeMemberSnapshot) ?? [],
supervisorUserId: normalizeStringId(response.supervisorUserId),
statusName: response.statusName || response.statusCode,
allowEdit: normalizeBooleanFlag(response.allowEdit),
terminal: normalizeBooleanFlag(response.terminal),
totalWorkHours: normalizeReportTotalWorkHours(response.totalWorkHours, fallbackTotalWorkHours),
submitTime: response.submitTime ?? null,
currentItems: response.currentItems?.map(normalizeProjectReportItem) ?? [],
nextItems: response.nextItems?.map(normalizeProjectReportItem) ?? []
};
}
function normalizeApprovalRecord(response: ApprovalRecordResponse): Api.WorkReport.Common.WorkReportApprovalRecord {
return {
...response,
id: normalizeStringId(response.id),
statusLogId: normalizeStringId(response.statusLogId),
auditorUserId: normalizeStringId(response.auditorUserId),
conclusion: normalizeApprovalConclusion(response.conclusion),
opinion: response.opinion ?? null
};
}
function normalizeMonthlyApprovalRecord(
response: MonthlyApprovalRecordResponse
): Api.WorkReport.Monthly.MonthlyReportApprovalRecord {
return {
...response,
id: normalizeStringId(response.id),
statusLogId: normalizeStringId(response.statusLogId),
auditorUserId: normalizeStringId(response.auditorUserId),
conclusion: normalizeApprovalConclusion(response.conclusion),
opinion: response.opinion ?? null
};
}
function normalizeProjectOption(
response: ProjectOptionResponse
): Api.WorkReport.Project.ProjectReportOwnerProjectOption {
return {
...response,
id: normalizeStringId(response.id)
};
}
function normalizeTeamReportSummary(response: TeamReportSummaryResponse): Api.WorkReport.Common.TeamReportSummary {
return {
...response,
periodStartDate: normalizeDateText(response.periodStartDate) ?? undefined,
periodEndDate: normalizeDateText(response.periodEndDate) ?? undefined,
expectedReportCount: response.expectedReportCount ?? 0,
submittedReportCount: response.submittedReportCount ?? 0,
unsubmittedReportCount: response.unsubmittedReportCount ?? 0,
pendingApprovalReportCount: response.pendingApprovalReportCount ?? 0,
unsubmittedReports:
response.unsubmittedReports?.map(item => ({
...item,
userId: normalizeStringId(item.userId),
projectId: normalizeNullableStringId(item.projectId),
projectName: item.projectName ?? null
})) ?? []
};
}
function mapPage<TInput, TOutput>(data: PageResponse<TInput>, mapper: (item: TInput) => TOutput) {
return {
total: normalizeTotal(data.total),
list: data.list.map(mapper)
};
}
function toStatusActionRequest(data: Api.WorkReport.Common.StatusActionParams = {}) {
return {
reason: data.reason?.trim() || undefined
};
}
function toPersonalReviewItems(items: Api.WorkReport.Common.PersonalReportReviewItem[] = []) {
return items.map((item, index) => ({
itemNumber: item.itemNumber ?? index + 1,
itemTitle: item.itemTitle?.trim() || '',
workHours: item.workHours ?? 0,
contentText: item.contentText?.trim() || '',
contentJson: item.contentJson ?? null,
reflectionText: item.reflectionText?.trim() || ''
}));
}
function toPersonalPlanItems(items: Api.WorkReport.Common.PersonalReportPlanItem[] = []) {
return items.map((item, index) => ({
itemNumber: item.itemNumber ?? index + 1,
itemTitle: item.itemTitle?.trim() || '',
targetText: item.targetText?.trim() || '',
targetJson: item.targetJson ?? null,
supportNeed: item.supportNeed?.trim() || ''
}));
}
function toWeeklySaveRequest(data: Api.WorkReport.Weekly.WeeklyReportSaveParams) {
return {
periodKey: data.periodKey,
periodLabel: data.periodLabel,
periodStartDate: data.periodStartDate,
periodEndDate: data.periodEndDate,
isBusinessTrip: data.isBusinessTrip,
reviewItems: toPersonalReviewItems(data.reviewItems),
planItems: toPersonalPlanItems(data.planItems),
travelSegments: data.isBusinessTrip
? data.travelSegments.map((item, index) => ({
sort: item.sort ?? index + 1,
startDate: item.startDate || undefined,
endDate: item.endDate || undefined,
travelDays: item.travelDays ?? 0,
location: item.location?.trim() || ''
}))
: []
};
}
function toMonthlySaveRequest(data: Api.WorkReport.Monthly.MonthlyReportSaveParams) {
return {
periodKey: data.periodKey,
periodLabel: data.periodLabel,
periodStartDate: data.periodStartDate,
periodEndDate: data.periodEndDate,
reviewItems: toPersonalReviewItems(data.reviewItems),
planItems: toPersonalPlanItems(data.planItems)
};
}
function toProjectItems(items: Api.WorkReport.Project.ProjectReportItem[] = []) {
return items.map(item => ({
itemTitle: item.itemTitle?.trim() || '',
workHours: item.workHours ?? 0,
priorityCode: item.priorityCode || undefined,
progressRate: item.progressRate ?? 0
}));
}
function toProjectSaveRequest(data: Api.WorkReport.Project.ProjectReportSaveParams) {
return {
projectId: data.projectId,
periodKey: data.periodKey,
periodLabel: data.periodLabel,
periodStartDate: data.periodStartDate,
periodEndDate: data.periodEndDate,
flag: data.flag,
projectStatusDesc: data.projectStatusDesc?.trim() || '',
projectProgressPlan: data.projectProgressPlan?.trim() || '',
projectKeyPoints: data.projectKeyPoints?.trim() || '',
projectProblems: data.projectProblems?.trim() || '',
currentItems: toProjectItems(data.currentItems),
nextItems: toProjectItems(data.nextItems)
};
}
export async function fetchGetWorkReportStatusDict() {
const result = await request<Api.WorkReport.Common.WorkReportStatusDict[]>({
...safeJsonRequestConfig,
url: `${WORK_REPORT_PREFIX}/status/dict`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<Api.WorkReport.Common.WorkReportStatusDict[]>, data => data);
}
export async function fetchGetTeamReportSummary(params: Api.WorkReport.Common.TeamReportSummaryParams) {
const result = await request<TeamReportSummaryResponse>({
...safeJsonRequestConfig,
url: `${WORK_REPORT_PREFIX}/team/summary`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<TeamReportSummaryResponse>, normalizeTeamReportSummary);
}
export async function fetchRemindTeamReport(data: Api.WorkReport.Common.TeamReportRemindParams) {
const result = await request<Api.WorkReport.Common.TeamReportRemindResult>({
...safeJsonRequestConfig,
url: `${WORK_REPORT_PREFIX}/team/remind`,
method: 'post',
data: {
...data,
userIds: data.userIds && data.userIds.length ? data.userIds : undefined
}
});
return mapServiceResult(
result as ServiceRequestResult<Api.WorkReport.Common.TeamReportRemindResult>,
payload => payload
);
}
export async function fetchGetWeeklyReportPage(params: Api.WorkReport.Weekly.WeeklyReportSearchParams = {}) {
const query = createWeeklyPageQuery(params);
const result = await request<PageResponse<WeeklyReportResponse>>({
...safeJsonRequestConfig,
url: query ? `${WEEKLY_PREFIX}/page?${query}` : `${WEEKLY_PREFIX}/page`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<PageResponse<WeeklyReportResponse>>, data =>
mapPage(data, normalizeWeeklyReport)
);
}
export async function fetchGetWeeklyReportApprovalPage(params: Api.WorkReport.Weekly.WeeklyReportSearchParams = {}) {
const query = createWeeklyPageQuery(params);
const result = await request<PageResponse<WeeklyReportResponse>>({
...safeJsonRequestConfig,
url: query ? `${WEEKLY_PREFIX}/approval-page?${query}` : `${WEEKLY_PREFIX}/approval-page`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<PageResponse<WeeklyReportResponse>>, data =>
mapPage(data, normalizeWeeklyReport)
);
}
export async function fetchGetWeeklyReportDetail(id: string) {
const result = await request<WeeklyReportResponse>({
...safeJsonRequestConfig,
url: `${WEEKLY_PREFIX}/${id}`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<WeeklyReportResponse>, normalizeWeeklyReport);
}
export async function fetchInitWeeklyReport() {
const result = await request<WeeklyReportResponse>({
...safeJsonRequestConfig,
url: `${WEEKLY_PREFIX}/init`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<WeeklyReportResponse>, normalizeWeeklyReport);
}
export async function fetchPreviewWeeklyReportDefaultDraft(
params: Api.WorkReport.Weekly.WeeklyReportDefaultDraftParams
) {
const result = await request<WeeklyReportResponse>({
...safeJsonRequestConfig,
url: `${WEEKLY_PREFIX}/default-draft`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<WeeklyReportResponse>, normalizeWeeklyReport);
}
export async function fetchRefreshWeeklyReportDraft(data: Api.WorkReport.Weekly.WeeklyReportRefreshDraftParams) {
const result = await request<WeeklyReportResponse>({
...safeJsonRequestConfig,
url: `${WEEKLY_PREFIX}/refresh-draft`,
method: 'post',
data: toWeeklySaveRequest(data)
});
return mapServiceResult(result as ServiceRequestResult<WeeklyReportResponse>, normalizeWeeklyReport);
}
export async function fetchCreateWeeklyReport(data: Api.WorkReport.Weekly.WeeklyReportSaveParams) {
const result = await request<StringIdResponse>({
...safeJsonRequestConfig,
url: WEEKLY_PREFIX,
method: 'post',
data: toWeeklySaveRequest(data)
});
return mapServiceResult(result as ServiceRequestResult<StringIdResponse>, normalizeStringId);
}
export function fetchUpdateWeeklyReport(id: string, data: Api.WorkReport.Weekly.WeeklyReportSaveParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${WEEKLY_PREFIX}/${id}`,
method: 'put',
data: toWeeklySaveRequest(data)
});
}
export function fetchSubmitWeeklyReport(id: string) {
return request<boolean>({ ...safeJsonRequestConfig, url: `${WEEKLY_PREFIX}/${id}/submit`, method: 'post' });
}
export function fetchApproveWeeklyReport(id: string, data: Api.WorkReport.Common.StatusActionParams = {}) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${WEEKLY_PREFIX}/${id}/approve`,
method: 'post',
data: toStatusActionRequest(data)
});
}
export function fetchRejectWeeklyReport(id: string, data: Api.WorkReport.Common.StatusActionParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${WEEKLY_PREFIX}/${id}/reject`,
method: 'post',
data: toStatusActionRequest(data)
});
}
export function fetchDeleteWeeklyReport(id: string) {
return request<boolean>({ ...safeJsonRequestConfig, url: `${WEEKLY_PREFIX}/${id}`, method: 'delete' });
}
export async function fetchGetWeeklyReportApprovalRecords(id: string) {
const result = await request<ApprovalRecordResponse[]>({
...safeJsonRequestConfig,
url: `${WEEKLY_PREFIX}/${id}/approval-records`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<ApprovalRecordResponse[]>, data =>
data.map(normalizeApprovalRecord)
);
}
export function fetchExportWeeklyReports(params: Api.WorkReport.Weekly.WeeklyReportSearchParams = {}) {
const query = createWeeklyPageQuery(params);
return request<Blob, 'blob'>({
url: query ? `${WEEKLY_PREFIX}/export?${query}` : `${WEEKLY_PREFIX}/export`,
method: 'get',
responseType: 'blob'
});
}
export function fetchExportWeeklyReportContent(
data: Api.WorkReport.Common.ContentExportParams<Api.WorkReport.Weekly.WeeklyReportSearchParams>
) {
return request<Blob, 'blob'>({
url: `${WEEKLY_PREFIX}/content-export`,
method: 'post',
data,
responseType: 'blob'
});
}
export async function fetchGetMonthlyReportPage(params: Api.WorkReport.Monthly.MonthlyReportSearchParams = {}) {
const query = createMonthlyPageQuery(params);
const result = await request<PageResponse<MonthlyReportResponse>>({
...safeJsonRequestConfig,
url: query ? `${MONTHLY_PREFIX}/page?${query}` : `${MONTHLY_PREFIX}/page`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<PageResponse<MonthlyReportResponse>>, data =>
mapPage(data, normalizeMonthlyReport)
);
}
export async function fetchGetMonthlyReportApprovalPage(params: Api.WorkReport.Monthly.MonthlyReportSearchParams = {}) {
const query = createMonthlyPageQuery(params);
const result = await request<PageResponse<MonthlyReportResponse>>({
...safeJsonRequestConfig,
url: query ? `${MONTHLY_PREFIX}/approval-page?${query}` : `${MONTHLY_PREFIX}/approval-page`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<PageResponse<MonthlyReportResponse>>, data =>
mapPage(data, normalizeMonthlyReport)
);
}
export async function fetchGetMonthlyReportDetail(id: string) {
const result = await request<MonthlyReportResponse>({
...safeJsonRequestConfig,
url: `${MONTHLY_PREFIX}/${id}`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<MonthlyReportResponse>, normalizeMonthlyReport);
}
export async function fetchInitMonthlyReport() {
const result = await request<MonthlyReportResponse>({
...safeJsonRequestConfig,
url: `${MONTHLY_PREFIX}/init`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<MonthlyReportResponse>, normalizeMonthlyReport);
}
export async function fetchPreviewMonthlyReportDefaultDraft(
params: Api.WorkReport.Monthly.MonthlyReportDefaultDraftParams
) {
const result = await request<MonthlyReportResponse>({
...safeJsonRequestConfig,
url: `${MONTHLY_PREFIX}/default-draft`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<MonthlyReportResponse>, normalizeMonthlyReport);
}
export async function fetchRefreshMonthlyReportDraft(data: Api.WorkReport.Monthly.MonthlyReportRefreshDraftParams) {
const result = await request<MonthlyReportResponse>({
...safeJsonRequestConfig,
url: `${MONTHLY_PREFIX}/refresh-draft`,
method: 'post',
data: toMonthlySaveRequest(data)
});
return mapServiceResult(result as ServiceRequestResult<MonthlyReportResponse>, normalizeMonthlyReport);
}
export async function fetchCreateMonthlyReport(data: Api.WorkReport.Monthly.MonthlyReportSaveParams) {
const result = await request<StringIdResponse>({
...safeJsonRequestConfig,
url: MONTHLY_PREFIX,
method: 'post',
data: toMonthlySaveRequest(data)
});
return mapServiceResult(result as ServiceRequestResult<StringIdResponse>, normalizeStringId);
}
export function fetchUpdateMonthlyReport(id: string, data: Api.WorkReport.Monthly.MonthlyReportSaveParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${MONTHLY_PREFIX}/${id}`,
method: 'put',
data: toMonthlySaveRequest(data)
});
}
export function fetchSubmitMonthlyReport(id: string) {
return request<boolean>({ ...safeJsonRequestConfig, url: `${MONTHLY_PREFIX}/${id}/submit`, method: 'post' });
}
export function fetchApproveMonthlyReport(id: string, data: Api.WorkReport.Monthly.MonthlyReportApproveParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${MONTHLY_PREFIX}/${id}/approve`,
method: 'post',
data
});
}
export function fetchRejectMonthlyReport(id: string, data: Api.WorkReport.Common.StatusActionParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${MONTHLY_PREFIX}/${id}/reject`,
method: 'post',
data: toStatusActionRequest(data)
});
}
export function fetchDeleteMonthlyReport(id: string) {
return request<boolean>({ ...safeJsonRequestConfig, url: `${MONTHLY_PREFIX}/${id}`, method: 'delete' });
}
export async function fetchGetMonthlyReportApprovalRecords(id: string) {
const result = await request<MonthlyApprovalRecordResponse[]>({
...safeJsonRequestConfig,
url: `${MONTHLY_PREFIX}/${id}/approval-records`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<MonthlyApprovalRecordResponse[]>, data =>
data.map(normalizeMonthlyApprovalRecord)
);
}
export function fetchExportMonthlyReports(params: Api.WorkReport.Monthly.MonthlyReportSearchParams = {}) {
const query = createMonthlyPageQuery(params);
return request<Blob, 'blob'>({
url: query ? `${MONTHLY_PREFIX}/export?${query}` : `${MONTHLY_PREFIX}/export`,
method: 'get',
responseType: 'blob'
});
}
export function fetchExportMonthlyReportContent(
data: Api.WorkReport.Common.ContentExportParams<Api.WorkReport.Monthly.MonthlyReportSearchParams>
) {
return request<Blob, 'blob'>({
url: `${MONTHLY_PREFIX}/content-export`,
method: 'post',
data,
responseType: 'blob'
});
}
export async function fetchGetProjectReportOwnerProjectOptions() {
const result = await request<ProjectOptionResponse[]>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/owner-project-options`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<ProjectOptionResponse[]>, data =>
data.map(normalizeProjectOption)
);
}
export async function fetchGetProjectReportPage(params: Api.WorkReport.Project.ProjectReportSearchParams = {}) {
const query = createProjectPageQuery(params);
const result = await request<PageResponse<ProjectReportResponse>>({
...safeJsonRequestConfig,
url: query ? `${PROJECT_PREFIX}/page?${query}` : `${PROJECT_PREFIX}/page`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<PageResponse<ProjectReportResponse>>, data =>
mapPage(data, normalizeProjectReport)
);
}
export async function fetchGetProjectReportApprovalPage(params: Api.WorkReport.Project.ProjectReportSearchParams = {}) {
const query = createProjectPageQuery(params);
const result = await request<PageResponse<ProjectReportResponse>>({
...safeJsonRequestConfig,
url: query ? `${PROJECT_PREFIX}/approval-page?${query}` : `${PROJECT_PREFIX}/approval-page`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<PageResponse<ProjectReportResponse>>, data =>
mapPage(data, normalizeProjectReport)
);
}
export async function fetchGetProjectReportDetail(id: string) {
const result = await request<ProjectReportResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/${id}`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<ProjectReportResponse>, normalizeProjectReport);
}
export async function fetchInitProjectReport(projectId: string) {
const result = await request<ProjectReportResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/init`,
method: 'get',
params: { projectId }
});
return mapServiceResult(result as ServiceRequestResult<ProjectReportResponse>, normalizeProjectReport);
}
export async function fetchPreviewProjectReportDefaultDraft(
projectId: string,
params: Api.WorkReport.Project.ProjectReportDefaultDraftParams
) {
const result = await request<ProjectReportResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/${projectId}/default-draft`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<ProjectReportResponse>, normalizeProjectReport);
}
export async function fetchRefreshProjectReportDraft(
projectId: string,
data: Api.WorkReport.Project.ProjectReportRefreshDraftParams
) {
const result = await request<ProjectReportResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/${projectId}/refresh-draft`,
method: 'post',
data: {
periodKey: data.periodKey,
periodLabel: data.periodLabel,
periodStartDate: data.periodStartDate,
periodEndDate: data.periodEndDate,
flag: data.flag,
projectStatusDesc: data.projectStatusDesc?.trim() || '',
projectProgressPlan: data.projectProgressPlan?.trim() || '',
projectKeyPoints: data.projectKeyPoints?.trim() || '',
projectProblems: data.projectProblems?.trim() || '',
currentItems: toProjectItems(data.currentItems),
nextItems: toProjectItems(data.nextItems)
}
});
return mapServiceResult(result as ServiceRequestResult<ProjectReportResponse>, normalizeProjectReport);
}
export async function fetchCreateProjectReport(data: Api.WorkReport.Project.ProjectReportSaveParams) {
const result = await request<StringIdResponse>({
...safeJsonRequestConfig,
url: PROJECT_PREFIX,
method: 'post',
data: toProjectSaveRequest(data)
});
return mapServiceResult(result as ServiceRequestResult<StringIdResponse>, normalizeStringId);
}
export function fetchUpdateProjectReport(id: string, data: Api.WorkReport.Project.ProjectReportSaveParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/${id}`,
method: 'put',
data: toProjectSaveRequest(data)
});
}
export function fetchSubmitProjectReport(id: string) {
return request<boolean>({ ...safeJsonRequestConfig, url: `${PROJECT_PREFIX}/${id}/submit`, method: 'post' });
}
export function fetchApproveProjectReport(id: string, data: Api.WorkReport.Common.StatusActionParams = {}) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/${id}/approve`,
method: 'post',
data: toStatusActionRequest(data)
});
}
export function fetchRejectProjectReport(id: string, data: Api.WorkReport.Common.StatusActionParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/${id}/reject`,
method: 'post',
data: toStatusActionRequest(data)
});
}
export function fetchDeleteProjectReport(id: string) {
return request<boolean>({ ...safeJsonRequestConfig, url: `${PROJECT_PREFIX}/${id}`, method: 'delete' });
}
export async function fetchGetProjectReportApprovalRecords(id: string) {
const result = await request<ApprovalRecordResponse[]>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/${id}/approval-records`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<ApprovalRecordResponse[]>, data =>
data.map(normalizeApprovalRecord)
);
}
export function fetchExportProjectReports(params: Api.WorkReport.Project.ProjectReportSearchParams = {}) {
const query = createProjectPageQuery(params);
return request<Blob, 'blob'>({
url: query ? `${PROJECT_PREFIX}/export?${query}` : `${PROJECT_PREFIX}/export`,
method: 'get',
responseType: 'blob'
});
}
export function fetchExportProjectReportContent(
data: Api.WorkReport.Common.ContentExportParams<Api.WorkReport.Project.ProjectReportSearchParams>
) {
return request<Blob, 'blob'>({
url: `${PROJECT_PREFIX}/content-export`,
method: 'post',
data,
responseType: 'blob'
});
}

View File

@@ -12,11 +12,13 @@ import type { RequestInstanceState } from './type';
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y'; const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y';
const { baseURL, otherBaseURL } = getServiceBaseURL(import.meta.env, isHttpProxy); const { baseURL, otherBaseURL } = getServiceBaseURL(import.meta.env, isHttpProxy);
const REQUEST_TIMEOUT = 15 * 1000;
export const request = withDedupe( export const request = withDedupe(
createFlatRequest( createFlatRequest(
{ {
baseURL, baseURL,
timeout: REQUEST_TIMEOUT,
headers: { headers: {
apifoxToken: 'XL299LiMEDZ0H5h3A29PxwQXdMJqWyY2' apifoxToken: 'XL299LiMEDZ0H5h3A29PxwQXdMJqWyY2'
} }
@@ -126,6 +128,10 @@ export const request = withDedupe(
let message = error.message; let message = error.message;
let backendErrorCode = ''; let backendErrorCode = '';
if (error.code === 'ECONNABORTED' && error.message.includes('timeout')) {
message = '请求超时,请稍后重试';
}
// 获取后端错误信息和错误码 // 获取后端错误信息和错误码
if (error.code === BACKEND_ERROR_CODE) { if (error.code === BACKEND_ERROR_CODE) {
message = error.response?.data?.msg || message; message = error.response?.data?.msg || message;

View File

@@ -67,7 +67,7 @@ export const useAppStore = defineStore(SetupStoreId.App, () => {
const documentTitle = i18nKey ? $t(i18nKey) : title; const documentTitle = i18nKey ? $t(i18nKey) : title;
useTitle(documentTitle); useTitle(`研发管理系统 - ${documentTitle}`);
} }
function init() { function init() {

View File

@@ -131,11 +131,17 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
// If the tab needs to be cleared,it means we don't need to redirect. // If the tab needs to be cleared,it means we don't need to redirect.
needRedirect = false; needRedirect = false;
} }
// 跳首页前先把权限路由建好:菜单/路由/首页 redirect 全部就绪后再导航,
// 否则依赖守卫在"跳首页"那次导航里懒加载,会出现首页先以空 menus 渲染、
// 之后无新导航补灌、菜单一直空到手动刷新才恢复的竞态。
await routeStore.initAuthRoute();
await redirectFromLogin(needRedirect); await redirectFromLogin(needRedirect);
window.$notification?.success({ window.$notification?.success({
title: $t('page.login.common.loginSuccess'), title: $t('page.login.common.loginSuccess'),
message: $t('page.login.common.welcomeBack', { userName: userInfo.userName }), message: $t('page.login.common.welcomeBack', { userName: userInfo.nickname || userInfo.userName }),
duration: 4500 duration: 4500
}); });
} }

View File

@@ -28,6 +28,15 @@ function normalizeColorType(raw: unknown): string | null {
return HEX_COLOR_PATTERN.test(trimmed) ? trimmed : null; return HEX_COLOR_PATTERN.test(trimmed) ? trimmed : null;
} }
/**
* 解析字典项最终展示色hex
* 精确色 cssClass 优先(覆盖 colorType 落到语义色无法区分黄/橙等场景),其次 colorType
* 两者都不是合法 hex 时回落 null默认渲染
*/
function resolveDisplayColor(colorType: unknown, cssClass: unknown): string | null {
return normalizeColorType(cssClass) ?? normalizeColorType(colorType);
}
function normalizeFrontendDictData( function normalizeFrontendDictData(
dictType: string, dictType: string,
list: Api.Dict.FrontendDictData[], list: Api.Dict.FrontendDictData[],
@@ -40,7 +49,7 @@ function normalizeFrontendDictData(
dictType: item.dictType || dictType, dictType: item.dictType || dictType,
sort: item.sort, sort: item.sort,
status: item.status ?? 0, status: item.status ?? 0,
colorType: normalizeColorType(item.colorType), colorType: resolveDisplayColor(item.colorType, item.cssClass),
remark: item.remark ?? null, remark: item.remark ?? null,
createTime: 0 createTime: 0
})); }));
@@ -54,7 +63,7 @@ function normalizeDictDataItem(item: Api.Dict.DictData, dictType: string): Api.D
value: String(item.value), value: String(item.value),
dictType: item.dictType || dictType, dictType: item.dictType || dictType,
status: item.status ?? 0, status: item.status ?? 0,
colorType: normalizeColorType(item.colorType), colorType: resolveDisplayColor(item.colorType, item.cssClass),
remark: item.remark ?? null remark: item.remark ?? null
}; };
} }

View File

@@ -406,6 +406,7 @@ html .el-collapse {
.business-table-action-cell { .business-table-action-cell {
display: flex; display: flex;
width: 100%; width: 100%;
box-sizing: border-box;
justify-content: center; justify-content: center;
gap: 8px; gap: 8px;
padding: 0 8px; padding: 0 8px;

View File

@@ -89,4 +89,7 @@ export const themeSettings: App.Theme.ThemeSetting = {
* *
* If publish new version, use `overrideThemeSettings` to override certain theme settings * If publish new version, use `overrideThemeSettings` to override certain theme settings
*/ */
export const overrideThemeSettings: Partial<App.Theme.ThemeSetting> = {}; // 系统固定亮色主题:切换入口已全部移除,发新版时把老用户缓存的暗色设置刷回亮色
export const overrideThemeSettings: Partial<App.Theme.ThemeSetting> = {
themeScheme: 'light'
};

View File

@@ -14,6 +14,7 @@ declare namespace Api {
userId: string; userId: string;
userName: string; userName: string;
nickname: string; nickname: string;
deptId?: string | null;
roles: string[]; roles: string[];
buttons: string[]; buttons: string[];
} }

View File

@@ -31,6 +31,22 @@ declare namespace Api {
*/ */
type EnableStatus = '1' | '2'; type EnableStatus = '1' | '2';
/**
* 列表项「当前登录用户在该对象的角色」(产品 / 项目列表共用)。
*
* 后端只读计算字段,随登录身份变化:同一份列表不同账号看到的内容不同;无角色为 []。
* 提交 / 更新接口不需要回传它。
*/
interface CurrentUserRole {
/**
* 角色稳定标识(程序判断用,不随中文名变化)。
* 例product_manager / project_manager / developer / tester / watcher / creator / implicit_observer。
*/
roleKey: string;
/** 角色中文名(直接展示) */
roleName: string;
}
/** common record */ /** common record */
type CommonRecord<T = any> = { type CommonRecord<T = any> = {
/** record id */ /** record id */

View File

@@ -57,6 +57,8 @@ declare namespace Api {
status: DictStatus; status: DictStatus;
/** 颜色hex#xxxxxxnullable无值时前端按默认渲染 */ /** 颜色hex#xxxxxxnullable无值时前端按默认渲染 */
colorType?: string | null; colorType?: string | null;
/** 精确颜色hex#xxxxxx存在时优先于 colorType用于 colorType 落到语义色无法区分的场景 */
cssClass?: string | null;
/** remark */ /** remark */
remark?: string | null; remark?: string | null;
/** create time */ /** create time */
@@ -77,6 +79,8 @@ declare namespace Api {
status?: DictStatus; status?: DictStatus;
/** 颜色hex#xxxxxxnullable无值时前端按默认渲染 */ /** 颜色hex#xxxxxxnullable无值时前端按默认渲染 */
colorType?: string | null; colorType?: string | null;
/** 精确颜色hex#xxxxxx存在时优先于 colorType */
cssClass?: string | null;
/** 备注,可用于下拉中文释义展示 */ /** 备注,可用于下拉中文释义展示 */
remark?: string | null; remark?: string | null;
} }

84
src/typings/api/feedback.d.ts vendored Normal file
View File

@@ -0,0 +1,84 @@
declare namespace Api {
/**
* namespace Feedback
*
* backend api module: "feedback"(用户意见反馈)
*/
namespace Feedback {
/** 反馈分页查询参数GET querytype/status 传字符串即可,后端按 Integer 解析) */
interface FeedbackSearchParams {
pageNo: number;
pageSize: number;
/** 反馈分类,字典 feedback_type */
type?: string | number | null;
/** 处理状态,字典 feedback_status */
status?: string | number | null;
/** 标题关键词,模糊匹配 */
title?: string;
/** 提交人用户 id按提交人过滤ID 铁律 string */
creator?: string;
}
/**
* 反馈记录。
* - id/creator 在 API 适配层已统一为 string
* - content 为富文本 HTML 字符串(详细描述支持图文);
* - attachments 由后端 attachmentUrls(JSON 字符串) 解析为完整附件对象数组,与任务附件同构。
*/
interface FeedbackItem {
id: string;
/** 反馈分类,字典 feedback_type */
type: number;
title: string;
/** 详细描述,富文本 HTML */
content: string;
/** 内容纯文本预览API 适配层预算,列表列直接取,免每次渲染重跑去标签正则) */
contentPreview: string;
/** 附件对象列表(含 fileId/url/name 等,支持下载与会话级清理) */
attachments: Api.Project.AttachmentItem[];
/** 联系方式(选填) */
contact?: string;
/** 处理状态,字典 feedback_status */
status: number;
/** 提交人用户 id字符串 */
creator: string;
/** 提交人姓名(后端回填,缺失时前端回退展示 creator */
creatorName?: string;
createTime: string;
}
/** 提交反馈参数(页面层传 attachments 对象数组api 层 stringify 成 attachmentUrls */
interface FeedbackSubmitParams {
type: number;
title: string;
content: string;
attachments: Api.Project.AttachmentItem[];
contact?: string;
}
/** 更新反馈参数(编辑态;后端更新接口待提供,见根目录后端诉求文档) */
interface FeedbackUpdateParams extends FeedbackSubmitParams {
id: string;
}
/** 分页返回(后端 { list, total } 形态) */
interface FeedbackListResult {
total: number;
list: FeedbackItem[];
}
/**
* 反馈统计聚合(左侧分面面板用;全量口径,不随筛选变化)。
* typeCounts / statusCounts 已在 API 适配层归一化为 Record<码值字符串, 数量>
* 由后端覆盖各自字典的全部码值(无数据为 0
*/
interface FeedbackStat {
/** 全部反馈总数 */
total: number;
/** 各分类计数key=分类码值字符串feedback_type */
typeCounts: Record<string, number>;
/** 各状态计数key=状态码值字符串feedback_status */
statusCounts: Record<string, number>;
}
}
}

24
src/typings/api/notice.d.ts vendored Normal file
View File

@@ -0,0 +1,24 @@
declare namespace Api {
/**
* namespace Notice
*
* backend api module: "notice"(通知公告)
*/
namespace Notice {
/** 公告ID 在 API 适配层已统一为 string */
interface Notice {
/** 公告编号 */
id: string;
/** 公告标题 */
title: string;
/** 公告类型,字典 system_notice_type */
type: number;
/** 公告内容(富文本 / 纯文本,由录入决定) */
content: string;
/** 状态0 开启 / 1 关闭 */
status: number;
/** 创建时间 */
createTime: string | number;
}
}
}

46
src/typings/api/notify-message.d.ts vendored Normal file
View File

@@ -0,0 +1,46 @@
declare namespace Api {
/**
* namespace NotifyMessage
*
* backend api module: "notify-message"(站内信 · 我的收件箱)
*/
namespace NotifyMessage {
interface PageParams {
pageNo: number;
pageSize: number;
}
interface PageResult<T = any> {
total: number;
list: T[];
}
/** 站内信(铃铛 / 收件箱展示用ID 在 API 适配层已统一为 string */
interface NotifyMessage {
/** 站内信编号(雪花 Long按 string 接收) */
id: string;
/** 发送人名称(模板配置的发件人显示名) */
templateNickname: string;
/** 最终消息正文(占位符已渲染,直接展示) */
templateContent: string;
/** 消息类型,字典 system_notify_template_type */
templateType: number;
/** 消息等级(字典 notify_message_level1=普通 2=提醒 3=警告 4=严重,数字越大越紧急);老消息缺省为普通(1) */
level: number;
/** 是否已读 */
readStatus: boolean;
/** 阅读时间;未读为 null */
readTime: string | number | null;
/** 收到时间 */
createTime: string | number;
}
/** 我的站内信分页查询参数 */
interface MyPageParams extends PageParams {
/** true 只看已读 / false 只看未读 / 不传 = 全部 */
readStatus?: boolean;
/** 关键字,后端对消息正文模糊匹配;不传或空串 = 不过滤 */
keyword?: string;
}
}
}

View File

@@ -5,9 +5,9 @@ declare namespace Api {
pageSize: number; pageSize: number;
} }
type OvertimeApplicationStatusCode = 'pending' | 'approved' | 'rejected' | 'cancelled'; type OvertimeApplicationStatusCode = 'pending' | 'approved' | 'rejected';
type OvertimeApplicationActionType = 'submit' | 'resubmit' | 'approve' | 'reject' | 'cancel'; type OvertimeApplicationActionType = 'submit' | 'resubmit' | 'approve' | 'reject';
interface OvertimeApplication { interface OvertimeApplication {
id: string; id: string;
@@ -32,6 +32,7 @@ declare namespace Api {
type OvertimeApplicationSearchParams = CommonType.RecordNullable< type OvertimeApplicationSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & { Pick<PageParams, 'pageNo' | 'pageSize'> & {
applicantIds: string[] | null;
keyword: string; keyword: string;
applicantName: string; applicantName: string;
approverId: string; approverId: string;
@@ -59,20 +60,55 @@ declare namespace Api {
reason?: string | null; reason?: string | null;
} }
interface OvertimeApplicationStatusLog { interface OvertimeApplicationBatchActionParams {
id: string; ids: string[];
applicationId: string;
actionType: OvertimeApplicationActionType;
fromStatus?: string | null;
toStatus: string;
reason?: string | null; reason?: string | null;
operatorUserId: string; }
operatorName: string;
applicantNameSnapshot: string; interface OvertimeApplicationBatchFailItem {
overtimeDateSnapshot: string; id: string;
overtimeDurationSnapshot: string; reason: string;
remark?: string | null; }
interface OvertimeApplicationBatchActionResult {
successCount: number;
failCount: number;
failItems: OvertimeApplicationBatchFailItem[];
}
interface OvertimeApplicationApprovalRecord {
id: string;
overtimeApplicationId: string;
statusLogId: string;
approvalRound: number;
conclusion: string;
opinion?: string | null;
auditorUserId: string;
auditorName: string;
createTime: string; createTime: string;
} }
interface OvertimeApplicationStatusDict {
statusCode: string;
statusName: string;
sort: number;
initialFlag: boolean;
terminalFlag: boolean;
allowEdit: boolean;
}
interface TeamOvertimeSummaryParams {
overtimeDateStart?: string | null;
overtimeDateEnd?: string | null;
}
interface TeamOvertimeSummary {
overtimeDateStart: string;
overtimeDateEnd: string;
totalApplicationCount: number;
pendingCount: number;
approvedCount: number;
rejectedCount: number;
}
} }
} }

233
src/typings/api/performance.d.ts vendored Normal file
View File

@@ -0,0 +1,233 @@
declare namespace Api {
namespace Performance {
namespace Common {
interface PageParams {
pageNo: number;
pageSize: number;
}
interface PageResult<T> {
total: number;
list: T[];
}
type SheetStatusCode = 'draft' | 'sent' | 'confirmed' | 'rejected' | string;
type SheetActionCode = 'send' | 'resend' | 'confirm' | 'reject' | string;
type RemindType = 'pending_confirm' | 'pending_send';
}
namespace Template {
interface ScoreCellMapping {
actualScoreTotalCell?: string | null;
baseScoreTotalCell?: string | null;
extraScoreTotalCell?: string | null;
}
interface Template {
id: string;
templateName: string;
fileId: string;
fileName: string;
versionNo: number;
activeFlag: boolean;
uploadUserId: string;
uploadUserName: string;
uploadTime: string;
remark?: string | null;
scoreCellMapping?: ScoreCellMapping | null;
}
type SearchParams = CommonType.RecordNullable<
Common.PageParams & {
templateName: string;
activeFlag: boolean;
}
>;
interface UploadParams {
templateName: string;
fileId: string;
fileName: string;
activeFlag?: boolean | null;
remark?: string | null;
}
}
namespace Sheet {
interface Sheet {
id: string;
periodMonth: string;
employeeId: string;
employeeName: string;
employeeDeptId: string;
employeeDeptName: string;
deptOrgType: string;
managerId: string;
managerName: string;
templateId: string;
fileId?: string | null;
fileName?: string | null;
fileVersion: number;
statusCode: Common.SheetStatusCode;
statusName: string;
actualScoreTotal?: string | number | null;
baseScoreTotal?: string | number | null;
extraScoreTotal?: string | number | null;
sentTime?: string | null;
confirmedTime?: string | null;
rejectedTime?: string | null;
lastStatusReason?: string | null;
createTime?: string | null;
updateTime?: string | null;
}
type SearchParams = CommonType.RecordNullable<
Common.PageParams & {
employeeIds: string[];
periodMonthRange: string[];
employeeId: string;
employeeName: string;
employeeDeptId: string;
employeeDeptName: string;
managerId: string;
managerName: string;
statusCode: Common.SheetStatusCode;
}
>;
interface CreateParams {
periodMonth: string;
employeeId: string;
}
interface ExcelUpdateParams {
fileId: string;
fileName: string;
fileVersion: number;
actualScoreTotal: string | number;
baseScoreTotal: string | number;
extraScoreTotal: string | number;
}
interface StatusActionParams {
reason?: string | null;
}
interface BatchDownloadParams {
ids: string[];
}
interface StatusDict {
statusCode: Common.SheetStatusCode;
statusName: string;
sort: number;
initialFlag: boolean;
terminalFlag: boolean;
allowEdit: boolean;
}
interface StatusTransition {
actionCode: Common.SheetActionCode;
actionName: string;
fromStatusCode: Common.SheetStatusCode;
toStatusCode: Common.SheetStatusCode;
needReason: boolean;
}
interface StatusLog {
id: string;
sheetId: string;
actionType: Common.SheetActionCode;
fromStatus?: Common.SheetStatusCode | null;
toStatus?: Common.SheetStatusCode | null;
reason?: string | null;
operatorUserId: string;
operatorName: string;
periodMonthSnapshot: string;
employeeNameSnapshot: string;
remark?: string | null;
createTime: string;
}
interface ResponseRecord {
id: string;
sheetId: string;
statusLogId: string;
roundNo: number;
actionType: Common.SheetActionCode;
fromStatus: Common.SheetStatusCode;
toStatus: Common.SheetStatusCode;
opinion?: string | null;
responderUserId: string;
responderName: string;
createTime: string;
}
interface MonthlyResult {
sheetId?: string | null;
periodMonth: string;
employeeId: string;
actualScoreTotal?: string | number | null;
baseScoreTotal?: string | number | null;
extraScoreTotal?: string | number | null;
statusCode?: Common.SheetStatusCode | null;
}
}
namespace Team {
interface SummaryParams {
periodMonthStart?: string | null;
periodMonthEnd?: string | null;
}
interface PendingSendUser {
periodMonth: string;
userId: string;
userNickname: string;
managerUserId?: string | null;
managerName: string;
sheetId?: string | null;
statusCode?: Common.SheetStatusCode | null;
}
interface PendingConfirmUser {
periodMonth: string;
userId: string;
userNickname: string;
sheetId: string;
sentTime?: string | null;
}
interface DeptOrgAverage {
deptId: string;
deptName: string;
deptOrgType: string;
averageScore?: string | number | null;
confirmedCount: number;
}
interface Summary {
periodMonthStart: string;
periodMonthEnd: string;
expectedSheetCount: number;
pendingSendCount: number;
pendingConfirmCount: number;
confirmedRate: string | number;
pendingSendUsers: PendingSendUser[];
pendingConfirmUsers: PendingConfirmUser[];
deptOrgAverages: DeptOrgAverage[];
}
interface RemindParams {
periodMonthStart?: string | null;
periodMonthEnd?: string | null;
remindType: Common.RemindType;
userIds?: string[] | null;
}
interface RemindResult {
remindedCount: number;
}
}
}
}

View File

@@ -21,10 +21,27 @@ declare namespace Api {
list: T[]; list: T[];
} }
/** 入口页概览统计状态看板项(状态机全部启用状态,按 sort 升序,计数为 0 也返回;与项目域契约同构) */
interface OverviewStatusItem {
statusCode: string;
/** 状态展示名(状态机配置中文名,前端直接渲染,不做本地名称映射) */
statusName: string;
count: number;
sort: number;
/** 是否终态(状态机 terminal_flag */
terminal: boolean;
/** 是否计入"全部";当前口径无排除项恒为 true产品列表暂无"全部"视图,按同构契约返回) */
includeInAll: boolean;
}
/** 产品入口页概览统计 */ /** 产品入口页概览统计 */
interface ProductOverviewSummary { interface ProductOverviewSummary {
/** 产品状态数量映射key 为后端状态编码 */ /** 产品状态数量映射key 为后端状态编码(过渡兼容字段,前端迁移完成后由后端删除) */
statusCounts: Record<string, number>; statusCounts: Record<string, number>;
/** "全部"口径总数 = items 各状态 count 之和 */
total: number;
/** 状态看板项,覆盖状态机全部启用状态,按 sort 升序 */
items: OverviewStatusItem[];
} }
interface Product { interface Product {
@@ -50,6 +67,8 @@ declare namespace Api {
createTime: string; createTime: string;
/** 更新时间 */ /** 更新时间 */
updateTime: string; updateTime: string;
/** 当前登录用户在该产品的角色(后端只读计算字段,随登录身份变化;无角色为 [] */
currentUserRoles: Api.Common.CurrentUserRole[];
} }
interface ProductSettingBaseInfo { interface ProductSettingBaseInfo {
@@ -73,6 +92,17 @@ declare namespace Api {
lastStatusReason?: string | null; lastStatusReason?: string | null;
} }
interface ProductOption {
/** 产品 ID */
id: string;
/** 产品编码 */
code: string;
/** 产品名称 */
name: string;
/** 产品方向字典值 */
directionCode: string;
}
interface ProductLifecycleAction { interface ProductLifecycleAction {
actionCode: ProductStatusActionCode; actionCode: ProductStatusActionCode;
actionName: string; actionName: string;
@@ -172,8 +202,10 @@ declare namespace Api {
type ProductSearchParams = CommonType.RecordNullable< type ProductSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & Pick<PageParams, 'pageNo' | 'pageSize'> &
Pick<Product, 'directionCode' | 'managerUserId' | 'statusCode'> & { Pick<Product, 'directionCode' | 'managerUserId'> & {
keyword: string; keyword: string;
/** 状态编码来自状态机overview-summary items 动态下发),不再用前端字面量联合约束 */
statusCode: string;
updateTime: string[]; updateTime: string[];
} }
>; >;
@@ -195,6 +227,7 @@ declare namespace Api {
interface DeleteProductParams { interface DeleteProductParams {
id: string; id: string;
productName: string; productName: string;
confirmText: string;
reason: string; reason: string;
} }
@@ -313,8 +346,8 @@ declare namespace Api {
categoryName?: string | null; categoryName?: string | null;
/** 需求来源类型 */ /** 需求来源类型 */
sourceType: RequirementSourceType; sourceType: RequirementSourceType;
/** 需求来源业务ID */ /** 来源业务编号 */
sourceBizId?: string | null; sourceBizCode?: string | null;
/** 优先级0低 1中 2高 3紧急 */ /** 优先级0低 1中 2高 3紧急 */
priority: RequirementPriority; priority: RequirementPriority;
/** 优先级名称 */ /** 优先级名称 */
@@ -489,7 +522,7 @@ declare namespace Api {
Pick<PageParams, 'pageNo' | 'pageSize'> & Pick<PageParams, 'pageNo' | 'pageSize'> &
Pick< Pick<
Requirement, Requirement,
'moduleId' | 'category' | 'priority' | 'statusCode' | 'currentHandlerUserId' | 'sourceType' 'moduleId' | 'category' | 'priority' | 'statusCode' | 'currentHandlerUserId' | 'sourceBizCode'
> & { > & {
productId: string; productId: string;
title?: string; title?: string;
@@ -507,6 +540,7 @@ declare namespace Api {
| 'attachments' | 'attachments'
| 'category' | 'category'
| 'priority' | 'priority'
| 'sourceBizCode'
| 'proposerId' | 'proposerId'
| 'proposerNickname' | 'proposerNickname'
| 'currentHandlerUserId' | 'currentHandlerUserId'

View File

@@ -170,6 +170,10 @@ declare namespace Api {
name: string; name: string;
size?: number; size?: number;
contentType?: string; contentType?: string;
/** 对象存储配置编号(上传返回),与 path 一起拼永久代理 URL */
configId?: string;
/** 文件相对路径(上传返回),与 configId 一起拼永久代理 URL */
path?: string;
} }
/** 任务详情 / 分页响应里嵌入的活跃协办人引用(按加入时间正序) */ /** 任务详情 / 分页响应里嵌入的活跃协办人引用(按加入时间正序) */
@@ -304,6 +308,213 @@ declare namespace Api {
updateTime: string[]; updateTime: string[];
}>; }>;
/** 工作台「我负责的执行」(跨项目)查询入参 */
type MyExecutionSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & {
/** 预留:单状态精确过滤,不传走后端默认口径 */
statusCode: string;
/** 预留:执行名称模糊匹配 */
keyword: string;
}
>;
/** 工作台「我负责的执行」单项跨项目聚合owner 恒为当前登录用户) */
interface MyExecutionItem {
/** 执行 ID雪花 ID字符串 */
id: string;
executionName: string;
/** 所属项目 */
projectId: string;
projectName: string;
/** 执行状态编码pending / active / paused */
statusCode: string;
/** 执行状态名称 */
statusName: string | null;
/** 优先级字典 valuerdms_req_priority"0"~"3" */
priority: string;
/** 计划起止YYYY-MM-DD */
plannedStartDate: string | null;
plannedEndDate: string | null;
/** 实际起止YYYY-MM-DD */
actualStartDate: string | null;
actualEndDate: string | null;
/** 进度0-100 整数) */
progressRate: number;
/** 关联项目需求 */
projectRequirementId: string | null;
projectRequirementName: string | null;
}
/** 工作台「我的项目」查询入参(我参与的 / 我负责的 共用) */
type MyProjectSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & {
/** 预留:项目名称/编码模糊关键字,后端本期不过滤 */
keyword: string;
}
>;
/** 工作台「我参与的项目」单项(成员视角,附带我的角色与任务量) */
interface MyParticipatedProjectItem {
/** 项目 ID字符串 */
id: string;
name: string;
/** 项目编码,可空 */
code: string | null;
/** 项目状态编码(如 active */
statusCode: string;
/** 项目状态名称,可空 */
statusName: string | null;
/** 项目整体进度 0-100 */
progress: number;
/** 我在该项目中的角色名(多角色拼接),可空 */
myRole: string | null;
/** 我负责的任务总数(按负责人,含已完成) */
myTaskCount: number;
/** 我负责的未完成任务数 */
myPendingTaskCount: number;
}
/** 工作台「我负责的项目」成员负载子项 */
interface MyOwnedProjectMember {
/** 成员用户 ID字符串 */
userId: string;
/** 成员姓名/昵称,可空 */
userName: string | null;
/** 该成员在本项目下进行中任务数(按负责人) */
activeTaskCount: number;
}
/** 工作台「我负责的项目」单项(项目负责人视角,附聚合统计与成员负载) */
interface MyOwnedProjectItem {
/** 项目 ID字符串 */
id: string;
name: string;
/** 项目编码,可空 */
code: string | null;
/** 项目整体进度 0-100 */
progress: number;
/** 我在该项目中的角色名,可空 */
myRole: string | null;
/** 项目计划结束日期 YYYY-MM-DD可空 */
plannedEndDate: string | null;
/** 项目下进行中执行数 */
executionCount: number;
/** 项目下进行中任务数 */
taskCount: number;
/** 项目下逾期任务数 */
overdueCount: number;
/** 项目当前有效成员数(多角色去重) */
memberCount: number;
/** 成员负载列表(无成员为 [] */
members: MyOwnedProjectMember[];
}
/** 工作台「我的任务」(跨项目)查询入参 */
type MyTaskSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & {
/** 身份过滤owner 我负责 / collaborator 我协办;缺省 = 两者并集 */
involveType: 'owner' | 'collaborator';
}
>;
/** 工作台「我的任务」单项(跨项目;当前用户为负责人或在岗协办人,接口只返回非终态任务) */
interface MyTaskItem {
/** 任务 ID雪花 ID字符串 */
id: string;
taskTitle: string;
/** 所属项目 */
projectId: string;
projectName: string;
/** 所属执行,未挂执行为 null */
executionId: string | null;
executionName: string | null;
/** 任务状态pending / active / paused非终态 */
statusCode: ProjectTaskStatusCode;
statusName: string | null;
/** 优先级字典 valuerdms_req_priority"0"~"3",数字越小越高) */
priority: string;
/** 计划结束日期YYYY-MM-DD可空 */
plannedEndDate: string | null;
/** 任务进度0-100后端定稿直接返回无进度明确返 0 */
progressRate: number;
/** 创建时间YYYY-MM-DD HH:mm:ss后端返毫秒时间戳适配层归一 */
createTime: string;
/** 我的角色owner 负责人 / collaborator 协办人;双重身份只返 owner */
myRole: 'owner' | 'collaborator';
/** 父任务 ID字符串一级任务为 null */
parentTaskId: string | null;
/** 是否终态;本接口只返非终态任务,正常恒为 false */
terminal: boolean;
/** 当前状态是否允许编辑任务 */
allowEdit: boolean;
/** 当前登录用户可执行的生命周期动作与任务详情同口径auto_start 不返回),无动作为 [] */
availableActions: LifecycleAction<ProjectTaskActionCode>[];
}
/** 工作台「团队负载」分布子项kind != project 时 projectId / projectName 为 null */
interface TeamLoadDistributionItem {
projectId: string | null;
projectName: string | null;
/** project 项目任务 / personal 个人事项 / other 无法归类的残留 */
kind: 'project' | 'personal' | 'other';
/** 未完成任务数(含待开始/已暂停) */
count: number;
}
/** 工作台「团队负载」成员members[0] 恒为当前用户) */
interface TeamLoadMember {
/** 用户 ID字符串 */
userId: string;
userNickname: string;
/** 未完成任务按归属分布,无任务为 [] */
items: TeamLoadDistributionItem[];
/** 临期:今天 ≤ 计划结束 ≤ 今天+3 天,且未完成(与逾期互斥) */
dueSoonCount: number;
/** 逾期:计划结束 < 今天,且未完成 */
overdueCount: number;
}
/** 工作台「团队负载」响应GET /project/project/me/team-load团队 = 自己 + 管理链路直接下级) */
interface TeamLoadResult {
members: TeamLoadMember[];
}
/** 工作台工时分布子项kind != project 时 projectId / projectName 为 nullhours=0 的行后端不输出) */
interface WorklogDistributionItem {
projectId: string | null;
projectName: string | null;
kind: 'project' | 'personal' | 'other';
hours: number;
}
/** 工作台「我的工时周聚合」响应GET /project/project/me/worklog-week */
interface MyWorklogWeekResult {
/** 归一后的周一日期 YYYY-MM-DD */
weekStart: string;
/** 周一~周五逐日工时(固定 5 元素;均摊推算值,周末份额归周五) */
dailyHours: number[];
/** 本周工时按归属分布hours 降序 */
distribution: WorklogDistributionItem[];
}
/** 工作台「团队工时周聚合」成员members[0] 恒为当前用户;该周未填报成员 items 为 [] */
interface TeamWorklogWeekMember {
userId: string;
userNickname: string;
items: WorklogDistributionItem[];
}
/** 工作台「团队工时周聚合」响应GET /project/project/me/team-worklog-week周标准工时后端不返回前端落常量 35 */
interface TeamWorklogWeekResult {
weekStart: string;
members: TeamWorklogWeekMember[];
}
/** 工作台工时周聚合查询入参weekStart 传任意日期,后端归一到所在周周一) */
interface WorklogWeekParams {
weekStart: string;
}
/** 创建执行入参(含 ownerId + assigneeUserIds */ /** 创建执行入参(含 ownerId + assigneeUserIds */
interface CreateProjectExecutionParams { interface CreateProjectExecutionParams {
executionName: string; executionName: string;
@@ -580,10 +791,29 @@ declare namespace Api {
list: T[]; list: T[];
} }
/** 入口页概览统计状态看板项(状态机全部启用状态,按 sort 升序,计数为 0 也返回) */
interface OverviewStatusItem {
statusCode: string;
/** 状态展示名(状态机配置中文名,前端直接渲染,不做本地名称映射) */
statusName: string;
count: number;
sort: number;
/** 是否终态(状态机 terminal_flag不能用于"全部"排除或左栏分区completed 也可能是终态) */
terminal: boolean;
/** 是否计入"全部";当前口径无排除项恒为 true将来恢复排除项由该字段表达 */
includeInAll: boolean;
}
/** 项目入口页概览统计 */ /** 项目入口页概览统计 */
interface ProjectOverviewSummary { interface ProjectOverviewSummary {
/** 项目状态数量映射key 为后端状态编码 */ /** 项目状态数量映射key 为后端状态编码(过渡兼容字段,前端迁移完成后由后端删除) */
statusCounts: Record<string, number>; statusCounts: Record<string, number>;
/** "全部"口径总数 = items 各状态 count 之和(作废/归档计入) */
total: number;
/** 状态看板项,覆盖状态机全部启用状态,按 sort 升序 */
items: OverviewStatusItem[];
/** 游离项目计数 = 所有未挂产品的项目(不按状态过滤),左栏游离入口据此显隐 */
orphanCount?: number;
} }
interface Project { interface Project {
@@ -625,6 +855,8 @@ declare namespace Api {
createTime: string; createTime: string;
/** 更新时间 */ /** 更新时间 */
updateTime: string; updateTime: string;
/** 当前登录用户在该项目的角色(后端只读计算字段,随登录身份变化;无角色为 [] */
currentUserRoles: Api.Common.CurrentUserRole[];
} }
interface ProjectContext { interface ProjectContext {
@@ -686,11 +918,75 @@ declare namespace Api {
projectType: string; projectType: string;
productId: string; productId: string;
managerUserId: string; managerUserId: string;
statusCode: ProjectStatusCode; /** 状态编码来自状态机overview-summary items 动态下发),不再用前端字面量联合约束 */
statusCode: string;
/** 多值状态筛选(存在时后端优先于单值 statusCode分组页"展开剩余"按"全部"口径传 items 派生的全量编码 */
statusCodes: string[];
/** 仅查游离项目productId 为空);与 productId 互斥,分组页展开游离组剩余时用 */
orphanOnly: boolean;
updateTime: string[]; updateTime: string[];
} }
>; >;
/**
* 项目列表"按产品分组"查询入参GET /project/project/group-page
*
* - pageNo / pageSize 为**产品组维度**分页(一页 M 个产品组),不是项目行分页。
* - statusCode 不传 = "全部"视图后端从状态机推导2026-06-11 口径变更后无排除项,作废/归档计入)。
* - orphanOnly = true 仅返回游离组productId 为空的项目);不可与 productId 同传。
* - topN每组返回项目条数上限后端默认 5范围 1~50超出由页面展开拉取。
*/
type ProjectGroupSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & {
keyword: string;
productId: string;
projectType: string;
/** 状态编码来自状态机overview-summary items 动态下发),不再用前端字面量联合约束 */
statusCode: string;
orphanOnly: boolean;
topN: number;
}
>;
/** 按产品聚合的项目分组 */
interface ProjectGroup {
/** 产品 ID游离组为 null */
productId: string | null;
/** 产品名称;游离组固定为"游离项目" */
productName: string;
/** 产品编码;游离组为 null */
productCode: string | null;
/** 产品方向字典值;游离组为空串 */
directionCode: string;
/** 产品经理用户 ID */
managerUserId: string | null;
/** 产品经理昵称(后端回填;游离组为 null前端 managerLabelMap 兜底) */
managerUserNickname: string | null;
/** 当前筛选口径下组内项目总数 */
projectTotal: number;
/** 组内项目前 topN 条,按最近更新倒序;剩余由页面按 productId/orphanOnly + statusCodes 走 page 接口展开拉取 */
projects: Project[];
/** 组内按项目类型字典 value 的计数(现状按"全部口径"统计;已提需求改为跟随 statusCode 与 projectTotal 同口径,后端落地后更新本注释) */
typeCounts: Record<string, number>;
/** 是否已有主线项目(口径=存在非已取消 cancelled 的主线,已归档/完成也算占坑);前端直接消费、不用 typeCounts 推导 */
hasBaseline: boolean;
/** 是否游离组(未挂产品) */
orphan: boolean;
}
/** 产品分组分页结果 */
interface ProjectGroupPageResult {
/** 当前筛选口径下产品组总数(分页 total含游离组 */
total: number;
/** 当前筛选口径下项目总数(标题 meta 用) */
projectTotal: number;
/** 当前筛选口径下可见产品跨方向数≥2 时前端渲染方向层) */
directionCount: number;
/** 当前筛选口径下游离项目数(标题/分页用);左栏常驻游离计数改用 overview-summary 的 orphanCount 全口径 */
orphanTotal: number;
list: ProjectGroup[];
}
/** 创建/保存项目参数 */ /** 创建/保存项目参数 */
type SaveProjectParams = Pick<Project, 'projectName' | 'directionCode' | 'projectType' | 'projectDesc'> & { type SaveProjectParams = Pick<Project, 'projectName' | 'directionCode' | 'projectType' | 'projectDesc'> & {
projectCode: string | null; projectCode: string | null;
@@ -870,8 +1166,8 @@ declare namespace Api {
categoryName?: string | null; categoryName?: string | null;
/** 需求来源类型 */ /** 需求来源类型 */
sourceType: ProjectRequirementSourceType; sourceType: ProjectRequirementSourceType;
/** 来源业务 ID */ /** 来源业务编号 */
sourceBizId?: string | null; sourceBizCode?: string | null;
/** 优先级 */ /** 优先级 */
priority: ProjectRequirementPriority; priority: ProjectRequirementPriority;
/** 优先级名称 */ /** 优先级名称 */
@@ -995,7 +1291,7 @@ declare namespace Api {
Pick<PageParams, 'pageNo' | 'pageSize'> & Pick<PageParams, 'pageNo' | 'pageSize'> &
Pick< Pick<
ProjectRequirement, ProjectRequirement,
'moduleId' | 'parentId' | 'category' | 'priority' | 'statusCode' | 'currentHandlerUserId' | 'sourceType' 'moduleId' | 'parentId' | 'category' | 'priority' | 'statusCode' | 'currentHandlerUserId' | 'sourceBizCode'
> & { > & {
projectId: string; projectId: string;
title: string; title: string;
@@ -1013,6 +1309,7 @@ declare namespace Api {
| 'attachments' | 'attachments'
| 'category' | 'category'
| 'priority' | 'priority'
| 'sourceBizCode'
| 'proposerId' | 'proposerId'
| 'proposerNickname' | 'proposerNickname'
| 'currentHandlerUserId' | 'currentHandlerUserId'

153
src/typings/api/system-log.d.ts vendored Normal file
View File

@@ -0,0 +1,153 @@
declare namespace Api {
/**
* namespace SystemLog
*
* backend api module: "system/*-log"
*/
namespace SystemLog {
namespace Common {
interface PageParams {
pageNo: number;
pageSize: number;
}
interface PageResult<T = any> {
total: number;
list: T[];
}
}
namespace Login {
interface Log {
id: string;
logType: number;
userId?: string | null;
userType?: number | null;
traceId?: string | null;
username: string;
result: number;
userIp: string;
userAgent?: string | null;
createTime: string;
}
type SearchParams = CommonType.RecordNullable<
Common.PageParams & {
userIp: string;
username: string;
status: boolean;
createTime: string[];
}
>;
}
namespace Operate {
interface Log {
id: string;
traceId?: string | null;
userId: string;
userName: string;
userType: number;
type: string;
subType: string;
bizId?: string | null;
action?: string | null;
extra?: string | null;
requestMethod: string;
requestUrl: string;
userIp: string;
userAgent: string;
createTime: string;
}
type SearchParams = CommonType.RecordNullable<
Common.PageParams & {
userId: string;
type: string;
requestMethod: string;
subType: string;
action: string;
createTime: string[];
}
>;
}
namespace ApiAccess {
interface Log {
id: string;
traceId?: string | null;
userId: string;
userNickname?: string | null;
userType: number;
applicationName: string;
requestMethod: string;
requestUrl: string;
requestParams?: string | null;
responseBody?: string | null;
userIp: string;
userAgent: string;
operateModule?: string | null;
operateName?: string | null;
operateType?: number | null;
beginTime: string;
endTime: string;
duration: number;
resultCode: number;
resultMsg?: string | null;
createTime: string;
}
type SearchParams = CommonType.RecordNullable<
Common.PageParams & {
userId: string;
userType: number;
applicationName: string;
requestUrl: string;
beginTime: string[];
duration: number;
resultCode: number;
}
>;
}
namespace ApiError {
interface Log {
id: string;
traceId?: string | null;
userId: string;
userNickname?: string | null;
userType: number;
applicationName: string;
requestMethod: string;
requestUrl: string;
requestParams?: string | null;
userIp: string;
userAgent: string;
exceptionTime: string;
exceptionName: string;
exceptionMessage: string;
exceptionRootCauseMessage?: string | null;
exceptionStackTrace?: string | null;
exceptionClassName?: string | null;
exceptionFileName?: string | null;
exceptionMethodName?: string | null;
exceptionLineNumber?: number | null;
processStatus: number;
processTime?: string | null;
processUserId?: string | null;
createTime: string;
}
type SearchParams = CommonType.RecordNullable<
Common.PageParams & {
userId: string;
userType: number;
applicationName: string;
requestUrl: string;
exceptionTime: string[];
processStatus: number;
}
>;
}
}
}

View File

@@ -98,6 +98,8 @@ declare namespace Api {
type DeptSimpleList = DeptSimple[]; type DeptSimpleList = DeptSimple[];
type DeptSelfAndChildrenList = DeptSimple[];
type DeptSearchParams = CommonType.RecordNullable<Pick<Dept, 'name' | 'orgType' | 'status'>>; type DeptSearchParams = CommonType.RecordNullable<Pick<Dept, 'name' | 'orgType' | 'status'>>;
type SaveDeptParams = Pick<Dept, 'name' | 'parentId' | 'orgType' | 'code' | 'sort' | 'status'>; type SaveDeptParams = Pick<Dept, 'name' | 'parentId' | 'orgType' | 'code' | 'sort' | 'status'>;
@@ -386,6 +388,24 @@ declare namespace Api {
children?: UserManagementRelationTreeRespVO[] | null; children?: UserManagementRelationTreeRespVO[] | null;
} }
/**
* 当前登录用户的下属树
*
* 用于团队视角选择器;根节点代表“全部下属范围”
*/
interface MySubordinateTreeNode {
/** 用户 ID */
userId: string;
/** 用户昵称 */
userNickname: string;
/** 是否为当前登录用户根节点 */
isRoot: boolean;
/** 全链路下属人数 */
subordinateCount: number;
/** 下级用户列表 */
children?: MySubordinateTreeNode[] | null;
}
/** /**
* 用户管理链路保存参数 * 用户管理链路保存参数
* *
@@ -439,5 +459,7 @@ declare namespace Api {
/** 部门名称 */ /** 部门名称 */
deptName?: string | null; deptName?: string | null;
} }
type UserSimpleList = UserSimple[];
} }
} }

337
src/typings/api/work-report.d.ts vendored Normal file
View File

@@ -0,0 +1,337 @@
declare namespace Api {
namespace WorkReport {
namespace Common {
interface PageParams {
pageNo: number;
pageSize: number;
}
type ReportType = 'weekly' | 'monthly' | 'project';
type WorkReportStatusCode = 'draft' | 'pending_approval' | 'approved' | 'rejected';
interface WorkReportStatusDict {
statusCode: WorkReportStatusCode | string;
statusName: string;
sort: number;
initialFlag: boolean;
terminalFlag: boolean;
allowEdit: boolean;
}
interface WorkReportApprovalRecord {
id: string;
statusLogId: string;
approvalRound: number;
conclusion: string;
opinion?: string | null;
auditorUserId: string;
auditorName: string;
createTime: string;
}
interface PersonalReportReviewItem {
id?: string;
itemNumber?: number | null;
itemTitle: string;
workHours?: number | null;
contentText?: string | null;
contentJson?: unknown;
reflectionText?: string | null;
}
interface PersonalReportPlanItem {
id?: string;
itemNumber?: number | null;
itemTitle: string;
targetText?: string | null;
targetJson?: unknown;
supportNeed?: string | null;
}
type WorkReportBaseSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & {
keyword: string;
statusCode: WorkReportStatusCode | string;
periodStartDate: string[];
submitTime: string[];
supervisorName: string;
}
>;
type ContentExportParams<TSearch> = Partial<TSearch> & {
exportAll?: boolean;
ids?: string[];
};
interface StatusActionParams {
reason?: string | null;
}
interface PageResult<T> {
total: number;
list: T[];
}
interface TeamReportUnsubmittedReport {
userId: string;
userNickname: string;
periodKey: string;
periodLabel: string;
projectId?: string | null;
projectName?: string | null;
}
interface TeamReportSummary {
periodStartDate?: string | null;
periodEndDate?: string | null;
expectedReportCount: number;
submittedReportCount: number;
unsubmittedReportCount: number;
pendingApprovalReportCount: number;
unsubmittedReports: TeamReportUnsubmittedReport[];
}
interface TeamReportSummaryParams {
reportType: ReportType;
periodKey?: string | null;
periodStartDate?: string | null;
periodEndDate?: string | null;
}
interface TeamReportRemindParams {
reportType: ReportType;
periodKey: string;
userIds?: string[] | null;
projectId?: string | null;
}
interface TeamReportRemindResult {
remindedCount: number;
}
}
namespace Weekly {
interface WeeklyReportTravelSegment {
id?: string;
sort?: number | null;
startDate?: string | null;
endDate?: string | null;
travelDays?: number | null;
location?: string | null;
}
interface WeeklyReport {
id: string;
reporterId: string;
reporterName: string;
reporterDeptName?: string | null;
reporterPostName?: string | null;
supervisorUserId: string;
supervisorName: string;
periodKey: string;
periodLabel: string;
periodStartDate: string;
periodEndDate: string;
statusCode: Common.WorkReportStatusCode | string;
statusName: string;
allowEdit: boolean;
terminal: boolean;
isBusinessTrip: boolean;
totalTravelDays?: number | string | null;
totalWorkHours?: number | string | null;
approvalComment?: string | null;
lastStatusReason?: string | null;
submitTime?: string | null;
approvalTime?: string | null;
createTime?: string | null;
updateTime?: string | null;
reviewItems: Common.PersonalReportReviewItem[];
planItems: Common.PersonalReportPlanItem[];
travelSegments: WeeklyReportTravelSegment[];
}
type WeeklyReportSearchParams = Common.WorkReportBaseSearchParams & {
reporterIds?: string[] | null;
isBusinessTrip?: boolean | string | null;
};
interface WeeklyReportSaveParams {
periodKey: string;
periodLabel: string;
periodStartDate: string;
periodEndDate: string;
isBusinessTrip: boolean;
reviewItems: Common.PersonalReportReviewItem[];
planItems: Common.PersonalReportPlanItem[];
travelSegments: WeeklyReportTravelSegment[];
}
type WeeklyReportDefaultDraftParams = Pick<
WeeklyReportSaveParams,
'periodKey' | 'periodLabel' | 'periodStartDate' | 'periodEndDate'
>;
type WeeklyReportRefreshDraftParams = WeeklyReportSaveParams;
}
namespace Monthly {
interface MonthlyReport {
id: string;
reporterId: string;
reporterName: string;
reporterDeptName?: string | null;
reporterPostName?: string | null;
supervisorUserId: string;
supervisorName: string;
periodKey: string;
periodLabel: string;
periodStartDate: string;
periodEndDate: string;
statusCode: Common.WorkReportStatusCode | string;
statusName: string;
allowEdit: boolean;
terminal: boolean;
totalWorkHours?: number | string | null;
approvalComment?: string | null;
lastStatusReason?: string | null;
submitTime?: string | null;
approvalTime?: string | null;
createTime?: string | null;
updateTime?: string | null;
reviewItems: Common.PersonalReportReviewItem[];
planItems: Common.PersonalReportPlanItem[];
}
type MonthlyReportSearchParams = Common.WorkReportBaseSearchParams & {
reporterIds?: string[] | null;
};
interface MonthlyReportSaveParams {
periodKey: string;
periodLabel: string;
periodStartDate: string;
periodEndDate: string;
reviewItems: Common.PersonalReportReviewItem[];
planItems: Common.PersonalReportPlanItem[];
}
type MonthlyReportDefaultDraftParams = Pick<
MonthlyReportSaveParams,
'periodKey' | 'periodLabel' | 'periodStartDate' | 'periodEndDate'
>;
type MonthlyReportRefreshDraftParams = MonthlyReportSaveParams;
interface MonthlyReportApproveParams extends Common.StatusActionParams {
meetingDate?: string | null;
strengthDesc?: string | null;
strengthExample?: string | null;
weaknessDesc?: string | null;
weaknessExample?: string | null;
improvementSuggestion?: string | null;
performanceResult?: string | null;
employeeSignName?: string | null;
employeeSignedDate?: string | null;
supervisorSignName?: string | null;
supervisorSignedDate?: string | null;
}
interface MonthlyReportApprovalRecord extends Common.WorkReportApprovalRecord {
meetingDate?: string | null;
strengthDesc?: string | null;
strengthExample?: string | null;
weaknessDesc?: string | null;
weaknessExample?: string | null;
improvementSuggestion?: string | null;
performanceResult?: string | null;
employeeSignName?: string | null;
employeeSignedDate?: string | null;
supervisorSignName?: string | null;
supervisorSignedDate?: string | null;
}
}
namespace Project {
interface WorkReportMemberSnapshot {
userId: string;
userName: string;
}
interface ProjectReportItem {
id?: string;
itemTitle: string;
workHours?: number | null;
priorityCode?: string | null;
progressRate?: number | null;
}
interface ProjectReportOwnerProjectOption {
id: string;
projectCode: string;
projectName: string;
}
interface ProjectReport {
id: string;
projectId: string;
projectName: string;
projectOwnerId: string;
projectOwnerName: string;
technicalOwnerName?: string | null;
projectMemberSnapshot: WorkReportMemberSnapshot[];
supervisorUserId: string;
supervisorName: string;
periodKey: string;
periodLabel: string;
periodStartDate: string;
periodEndDate: string;
flag: number;
statusCode: Common.WorkReportStatusCode | string;
statusName: string;
allowEdit: boolean;
terminal: boolean;
projectStatusDesc?: string | null;
projectProgressPlan?: string | null;
projectKeyPoints?: string | null;
projectProblems?: string | null;
totalWorkHours?: number | string | null;
approvalComment?: string | null;
lastStatusReason?: string | null;
submitTime?: string | null;
approvalTime?: string | null;
createTime?: string | null;
updateTime?: string | null;
currentItems: ProjectReportItem[];
nextItems: ProjectReportItem[];
}
type ProjectReportSearchParams = Common.WorkReportBaseSearchParams & {
projectOwnerIds?: string[] | null;
projectId?: string | null;
flag?: number | null;
};
interface ProjectReportSaveParams {
projectId: string;
periodKey: string;
periodLabel: string;
periodStartDate: string;
periodEndDate: string;
flag: number;
projectStatusDesc?: string | null;
projectProgressPlan?: string | null;
projectKeyPoints?: string | null;
projectProblems?: string | null;
currentItems: ProjectReportItem[];
nextItems: ProjectReportItem[];
}
type ProjectReportDefaultDraftParams = Pick<
ProjectReportSaveParams,
'periodKey' | 'periodLabel' | 'periodStartDate' | 'periodEndDate' | 'flag'
>;
type ProjectReportRefreshDraftParams = Omit<ProjectReportSaveParams, 'projectId'>;
}
}
}

39
src/typings/app.d.ts vendored
View File

@@ -504,45 +504,6 @@ declare namespace App {
}; };
creativity: string; creativity: string;
}; };
function: {
tab: {
tabOperate: {
title: string;
addTab: string;
addTabDesc: string;
closeTab: string;
closeCurrentTab: string;
closeAboutTab: string;
addMultiTab: string;
addMultiTabDesc1: string;
addMultiTabDesc2: string;
};
tabTitle: {
title: string;
changeTitle: string;
change: string;
resetTitle: string;
reset: string;
};
};
multiTab: {
routeParam: string;
backTab: string;
};
toggleAuth: {
toggleAccount: string;
authHook: string;
superAdminVisible: string;
adminVisible: string;
adminOrUserVisible: string;
};
request: {
repeatedErrorOccurOnce: string;
repeatedError: string;
repeatedErrorMsg1: string;
repeatedErrorMsg2: string;
};
};
system: { system: {
common: { common: {
status: { status: {

View File

@@ -16,13 +16,13 @@ declare module 'vue' {
BusinessFormDialog: typeof import('./../components/custom/business-form-dialog.vue')['default'] BusinessFormDialog: typeof import('./../components/custom/business-form-dialog.vue')['default']
BusinessFormDrawer: typeof import('./../components/custom/business-form-drawer.vue')['default'] BusinessFormDrawer: typeof import('./../components/custom/business-form-drawer.vue')['default']
BusinessFormSection: typeof import('./../components/custom/business-form-section.vue')['default'] BusinessFormSection: typeof import('./../components/custom/business-form-section.vue')['default']
BusinessFormSimpleDialog: typeof import('./../components/custom/business-form-simple-dialog.vue')['default']
BusinessRichTextEditor: typeof import('./../components/custom/business-rich-text-editor.vue')['default'] BusinessRichTextEditor: typeof import('./../components/custom/business-rich-text-editor.vue')['default']
BusinessRichTextView: typeof import('./../components/custom/business-rich-text-view.vue')['default'] BusinessRichTextView: typeof import('./../components/custom/business-rich-text-view.vue')['default']
BusinessUserPicker: typeof import('./../components/custom/business-user-picker.vue')['default'] BusinessUserPicker: typeof import('./../components/custom/business-user-picker.vue')['default']
BusinessUserSelect: typeof import('./../components/custom/business-user-select.vue')['default'] BusinessUserSelect: typeof import('./../components/custom/business-user-select.vue')['default']
ButtonIcon: typeof import('./../components/custom/button-icon.vue')['default'] ButtonIcon: typeof import('./../components/custom/button-icon.vue')['default']
CountTo: typeof import('./../components/custom/count-to.vue')['default'] CountTo: typeof import('./../components/custom/count-to.vue')['default']
CurrentUserRoleTags: typeof import('./../components/custom/current-user-role-tags.vue')['default']
CustomIconSelect: typeof import('./../components/custom/custom-icon-select.vue')['default'] CustomIconSelect: typeof import('./../components/custom/custom-icon-select.vue')['default']
DarkModeContainer: typeof import('./../components/common/dark-mode-container.vue')['default'] DarkModeContainer: typeof import('./../components/common/dark-mode-container.vue')['default']
DictSelect: typeof import('./../components/custom/dict-select.vue')['default'] DictSelect: typeof import('./../components/custom/dict-select.vue')['default']
@@ -34,17 +34,13 @@ declare module 'vue' {
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb'] ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem'] ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
ElButton: typeof import('element-plus/es')['ElButton'] ElButton: typeof import('element-plus/es')['ElButton']
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
ElCard: typeof import('element-plus/es')['ElCard'] ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup'] ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCol: typeof import('element-plus/es')['ElCol'] ElCol: typeof import('element-plus/es')['ElCol']
ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElColorPicker: typeof import('element-plus/es')['ElColorPicker'] ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider'] ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDatePickerPanel: typeof import('element-plus/es')['ElDatePickerPanel']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog'] ElDialog: typeof import('element-plus/es')['ElDialog']
@@ -77,7 +73,6 @@ declare module 'vue' {
ElSelect: typeof import('element-plus/es')['ElSelect'] ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton'] ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElSpace: typeof import('element-plus/es')['ElSpace'] ElSpace: typeof import('element-plus/es')['ElSpace']
ElStatistic: typeof import('element-plus/es')['ElStatistic']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable'] ElTable: typeof import('element-plus/es')['ElTable']
@@ -85,11 +80,10 @@ declare module 'vue' {
ElTabPane: typeof import('element-plus/es')['ElTabPane'] ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs'] ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag'] ElTag: typeof import('element-plus/es')['ElTag']
ElTimeline: typeof import('element-plus/es')['ElTimeline']
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElTooltip: typeof import('element-plus/es')['ElTooltip'] ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElTree: typeof import('element-plus/es')['ElTree'] ElTree: typeof import('element-plus/es')['ElTree']
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect'] ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
ElUpload: typeof import('element-plus/es')['ElUpload']
ElWatermark: typeof import('element-plus/es')['ElWatermark'] ElWatermark: typeof import('element-plus/es')['ElWatermark']
ExceptionBase: typeof import('./../components/common/exception-base.vue')['default'] ExceptionBase: typeof import('./../components/common/exception-base.vue')['default']
FullScreen: typeof import('./../components/common/full-screen.vue')['default'] FullScreen: typeof import('./../components/common/full-screen.vue')['default']
@@ -97,10 +91,6 @@ declare module 'vue' {
IconAntDesignEnterOutlined: typeof import('~icons/ant-design/enter-outlined')['default'] IconAntDesignEnterOutlined: typeof import('~icons/ant-design/enter-outlined')['default']
IconAntDesignReloadOutlined: typeof import('~icons/ant-design/reload-outlined')['default'] IconAntDesignReloadOutlined: typeof import('~icons/ant-design/reload-outlined')['default']
IconAntDesignSettingOutlined: typeof import('~icons/ant-design/setting-outlined')['default'] IconAntDesignSettingOutlined: typeof import('~icons/ant-design/setting-outlined')['default']
IconCarbonAdd: typeof import('~icons/carbon/add')['default']
IconCarbonPlay: typeof import('~icons/carbon/play')['default']
IconCarbonStop: typeof import('~icons/carbon/stop')['default']
'IconCharm:download': typeof import('~icons/charm/download')['default']
'IconEp:arrowDown': typeof import('~icons/ep/arrow-down')['default'] 'IconEp:arrowDown': typeof import('~icons/ep/arrow-down')['default']
'IconEp:arrowRight': typeof import('~icons/ep/arrow-right')['default'] 'IconEp:arrowRight': typeof import('~icons/ep/arrow-right')['default']
'IconEp:box': typeof import('~icons/ep/box')['default'] 'IconEp:box': typeof import('~icons/ep/box')['default']
@@ -112,76 +102,61 @@ declare module 'vue' {
'IconEp:sort': typeof import('~icons/ep/sort')['default'] 'IconEp:sort': typeof import('~icons/ep/sort')['default']
IconEpRemoveFilled: typeof import('~icons/ep/remove-filled')['default'] IconEpRemoveFilled: typeof import('~icons/ep/remove-filled')['default']
IconEpSuccessFilled: typeof import('~icons/ep/success-filled')['default'] IconEpSuccessFilled: typeof import('~icons/ep/success-filled')['default']
'IconF7:circleFill': typeof import('~icons/f7/circle-fill')['default']
'IconF7:flagCircleFill': typeof import('~icons/f7/flag-circle-fill')['default']
'IconFe:eye': typeof import('~icons/fe/eye')['default']
'IconFe:question': typeof import('~icons/fe/question')['default'] 'IconFe:question': typeof import('~icons/fe/question')['default']
'IconFileIcons:microsoftExcel': typeof import('~icons/file-icons/microsoft-excel')['default']
'IconGg:ratio': typeof import('~icons/gg/ratio')['default']
IconGridiconsFullscreen: typeof import('~icons/gridicons/fullscreen')['default'] IconGridiconsFullscreen: typeof import('~icons/gridicons/fullscreen')['default']
IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default'] IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
'IconIc:roundPlus': typeof import('~icons/ic/round-plus')['default']
'IconIconParkOutline:equalRatio': typeof import('~icons/icon-park-outline/equal-ratio')['default']
IconIcRoundChevronRight: typeof import('~icons/ic/round-chevron-right')['default'] IconIcRoundChevronRight: typeof import('~icons/ic/round-chevron-right')['default']
IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default'] IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default']
IconIcRoundEdit: typeof import('~icons/ic/round-edit')['default'] IconIcRoundEdit: typeof import('~icons/ic/round-edit')['default']
IconIcRoundFolder: typeof import('~icons/ic/round-folder')['default']
IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default'] IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default']
IconIcRoundRefresh: typeof import('~icons/ic/round-refresh')['default'] IconIcRoundRefresh: typeof import('~icons/ic/round-refresh')['default']
IconIcRoundRemove: typeof import('~icons/ic/round-remove')['default']
IconIcRoundSearch: typeof import('~icons/ic/round-search')['default'] IconIcRoundSearch: typeof import('~icons/ic/round-search')['default']
IconLocalActivity: typeof import('~icons/local/activity')['default'] IconIcRoundUnfoldLess: typeof import('~icons/ic/round-unfold-less')['default']
IconLocalBanner: typeof import('~icons/local/banner')['default'] IconIcRoundUnfoldMore: typeof import('~icons/ic/round-unfold-more')['default']
IconLocalCast: typeof import('~icons/local/cast')['default'] IconMaterialSymbolsPackage2: typeof import('~icons/material-symbols/package2')['default']
IconLocalLogo: typeof import('~icons/local/logo')['default']
'IconMaterialSymbolsLight:rotate90DegreesCcwOutlineRounded': typeof import('~icons/material-symbols-light/rotate90-degrees-ccw-outline-rounded')['default']
IconMaterialSymbolsLightCheckCircleRounded: typeof import('~icons/material-symbols-light/check-circle-rounded')['default']
'IconMdi:paperclip': typeof import('~icons/mdi/paperclip')['default']
'IconMdi:printer': typeof import('~icons/mdi/printer')['default']
IconMdiAccountTieOutline: typeof import('~icons/mdi/account-tie-outline')['default'] IconMdiAccountTieOutline: typeof import('~icons/mdi/account-tie-outline')['default']
IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default'] IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default']
IconMdiArrowUpThin: typeof import('~icons/mdi/arrow-up-thin')['default'] IconMdiArrowUpThin: typeof import('~icons/mdi/arrow-up-thin')['default']
IconMdiCheck: typeof import('~icons/mdi/check')['default'] IconMdiCheck: typeof import('~icons/mdi/check')['default']
IconMdiChevronDoubleDown: typeof import('~icons/mdi/chevron-double-down')['default'] IconMdiChevronDoubleDown: typeof import('~icons/mdi/chevron-double-down')['default']
IconMdiChevronDoubleUp: typeof import('~icons/mdi/chevron-double-up')['default'] IconMdiChevronDoubleUp: typeof import('~icons/mdi/chevron-double-up')['default']
IconMdiChevronDown: typeof import('~icons/mdi/chevron-down')['default']
IconMdiChevronRight: typeof import('~icons/mdi/chevron-right')['default']
IconMdiClose: typeof import('~icons/mdi/close')['default']
IconMdiCloseCircle: typeof import('~icons/mdi/close-circle')['default'] IconMdiCloseCircle: typeof import('~icons/mdi/close-circle')['default']
IconMdiCrown: typeof import('~icons/mdi/crown')['default']
IconMdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default'] IconMdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default']
IconMdiDotsHorizontal: typeof import('~icons/mdi/dots-horizontal')['default']
IconMdiDownload: typeof import('~icons/mdi/download')['default'] IconMdiDownload: typeof import('~icons/mdi/download')['default']
IconMdiDrag: typeof import('~icons/mdi/drag')['default'] IconMdiDrag: typeof import('~icons/mdi/drag')['default']
IconMdiFileCogOutline: typeof import('~icons/mdi/file-cog-outline')['default']
IconMdiFilterVariant: typeof import('~icons/mdi/filter-variant')['default'] IconMdiFilterVariant: typeof import('~icons/mdi/filter-variant')['default']
IconMdiFolderOpen: typeof import('~icons/mdi/folder-open')['default'] IconMdiFolderOpen: typeof import('~icons/mdi/folder-open')['default']
IconMdiFolderOutline: typeof import('~icons/mdi/folder-outline')['default'] IconMdiFolderOutline: typeof import('~icons/mdi/folder-outline')['default']
IconMdiFolderPlusOutline: typeof import('~icons/mdi/folder-plus-outline')['default'] IconMdiFolderPlusOutline: typeof import('~icons/mdi/folder-plus-outline')['default']
IconMdiInboxMultipleOutline: typeof import('~icons/mdi/inbox-multiple-outline')['default']
IconMdiInformationOutline: typeof import('~icons/mdi/information-outline')['default']
IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default'] IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default']
IconMdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default'] IconMdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
IconMdiLinkVariant: typeof import('~icons/mdi/link-variant')['default'] IconMdiLinkVariant: typeof import('~icons/mdi/link-variant')['default']
IconMdiMenuDown: typeof import('~icons/mdi/menu-down')['default']
IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline')['default'] IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline')['default']
IconMdiPlus: typeof import('~icons/mdi/plus')['default'] IconMdiPlus: typeof import('~icons/mdi/plus')['default']
IconMdiRefresh: typeof import('~icons/mdi/refresh')['default'] IconMdiRefresh: typeof import('~icons/mdi/refresh')['default']
'IconMingcute:zoomInLine': typeof import('~icons/mingcute/zoom-in-line')['default'] IconMdiUpload: typeof import('~icons/mdi/upload')['default']
'IconMingcute:zoomOutLine': typeof import('~icons/mingcute/zoom-out-line')['default']
IconUilSearch: typeof import('~icons/uil/search')['default'] IconUilSearch: typeof import('~icons/uil/search')['default']
LangSwitch: typeof import('./../components/common/lang-switch.vue')['default'] LangSwitch: typeof import('./../components/common/lang-switch.vue')['default']
LookForward: typeof import('./../components/custom/look-forward.vue')['default'] LookForward: typeof import('./../components/custom/look-forward.vue')['default']
MenuToggler: typeof import('./../components/common/menu-toggler.vue')['default'] MenuToggler: typeof import('./../components/common/menu-toggler.vue')['default']
PinToggler: typeof import('./../components/common/pin-toggler.vue')['default'] PinToggler: typeof import('./../components/common/pin-toggler.vue')['default']
PrioritySelect: typeof import('../views/product/requirement/modules/priority-select.vue')['default']
ReadonlyField: typeof import('./../components/custom/readonly-field.vue')['default'] ReadonlyField: typeof import('./../components/custom/readonly-field.vue')['default']
ReloadButton: typeof import('./../components/common/reload-button.vue')['default'] ReloadButton: typeof import('./../components/common/reload-button.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
SoybeanAvatar: typeof import('./../components/custom/soybean-avatar.vue')['default'] SoybeanAvatar: typeof import('./../components/custom/soybean-avatar.vue')['default']
SubordinateSelector: typeof import('./../components/custom/subordinate-selector.vue')['default']
SvgIcon: typeof import('./../components/custom/svg-icon.vue')['default'] SvgIcon: typeof import('./../components/custom/svg-icon.vue')['default']
SystemLogo: typeof import('./../components/common/system-logo.vue')['default'] SystemLogo: typeof import('./../components/common/system-logo.vue')['default']
TableColumnSetting: typeof import('./../components/advanced/table-column-setting.vue')['default'] TableColumnSetting: typeof import('./../components/advanced/table-column-setting.vue')['default']
TableHeaderOperation: typeof import('./../components/advanced/table-header-operation.vue')['default'] TableHeaderOperation: typeof import('./../components/advanced/table-header-operation.vue')['default']
TableSearchFields: typeof import('./../components/custom/table-search-fields.vue')['default'] TableSearchFields: typeof import('./../components/custom/table-search-fields.vue')['default']
TableSearchPanel: typeof import('./../components/custom/table-search-panel.vue')['default'] TableSearchPanel: typeof import('./../components/custom/table-search-panel.vue')['default']
TeamContextPanel: typeof import('./../components/custom/team-context-panel.vue')['default']
ThemeSchemaSwitch: typeof import('./../components/common/theme-schema-switch.vue')['default'] ThemeSchemaSwitch: typeof import('./../components/common/theme-schema-switch.vue')['default']
UserPickerTrigger: typeof import('./../components/custom/business-user-picker/components/user-picker-trigger.vue')['default'] UserPickerTrigger: typeof import('./../components/custom/business-user-picker/components/user-picker-trigger.vue')['default']
WaveBg: typeof import('./../components/custom/wave-bg.vue')['default'] WaveBg: typeof import('./../components/custom/wave-bg.vue')['default']

View File

@@ -24,18 +24,14 @@ declare module "@elegant-router/types" {
"403": "/403"; "403": "/403";
"404": "/404"; "404": "/404";
"500": "/500"; "500": "/500";
"function": "/function"; "feedback": "/feedback";
"function_hide-child": "/function/hide-child";
"function_hide-child_one": "/function/hide-child/one";
"function_hide-child_three": "/function/hide-child/three";
"function_hide-child_two": "/function/hide-child/two";
"function_multi-tab": "/function/multi-tab";
"function_request": "/function/request";
"function_super-page": "/function/super-page";
"function_tab": "/function/tab";
"function_toggle-auth": "/function/toggle-auth";
"iframe-page": "/iframe-page/:url"; "iframe-page": "/iframe-page/:url";
"infra": "/infra"; "infra": "/infra";
"infra_log-management": "/infra/log-management";
"infra_log-management_api-access-log": "/infra/log-management/api-access-log";
"infra_log-management_api-error-log": "/infra/log-management/api-error-log";
"infra_log-management_login-log": "/infra/log-management/login-log";
"infra_log-management_operate-log": "/infra/log-management/operate-log";
"infra_rd-code": "/infra/rd-code"; "infra_rd-code": "/infra/rd-code";
"infra_state-machine": "/infra/state-machine"; "infra_state-machine": "/infra/state-machine";
"login": "/login/:module(pwd-login|reset-pwd)?"; "login": "/login/:module(pwd-login|reset-pwd)?";
@@ -46,33 +42,14 @@ declare module "@elegant-router/types" {
"personal-center": "/personal-center"; "personal-center": "/personal-center";
"personal-center_my-application": "/personal-center/my-application"; "personal-center_my-application": "/personal-center/my-application";
"personal-center_my-item": "/personal-center/my-item"; "personal-center_my-item": "/personal-center/my-item";
"personal-center_my-monthly": "/personal-center/my-monthly";
"personal-center_my-performance": "/personal-center/my-performance"; "personal-center_my-performance": "/personal-center/my-performance";
"personal-center_my-profile": "/personal-center/my-profile"; "personal-center_my-profile": "/personal-center/my-profile";
"personal-center_my-weekly": "/personal-center/my-weekly";
"personal-center_overtime-application": "/personal-center/overtime-application"; "personal-center_overtime-application": "/personal-center/overtime-application";
"personal-center_pending-approval": "/personal-center/pending-approval"; "personal-center_pending-approval": "/personal-center/pending-approval";
"plugin": "/plugin"; "personal-center_work-report": "/personal-center/work-report";
"plugin_barcode": "/plugin/barcode"; "personal-center_work-report_monthly": "/personal-center/work-report/monthly";
"plugin_charts": "/plugin/charts"; "personal-center_work-report_project": "/personal-center/work-report/project";
"plugin_charts_antv": "/plugin/charts/antv"; "personal-center_work-report_weekly": "/personal-center/work-report/weekly";
"plugin_charts_echarts": "/plugin/charts/echarts";
"plugin_charts_vchart": "/plugin/charts/vchart";
"plugin_copy": "/plugin/copy";
"plugin_excel": "/plugin/excel";
"plugin_gantt": "/plugin/gantt";
"plugin_gantt_dhtmlx": "/plugin/gantt/dhtmlx";
"plugin_gantt_vtable": "/plugin/gantt/vtable";
"plugin_icon": "/plugin/icon";
"plugin_map": "/plugin/map";
"plugin_pdf": "/plugin/pdf";
"plugin_pinyin": "/plugin/pinyin";
"plugin_print": "/plugin/print";
"plugin_swiper": "/plugin/swiper";
"plugin_tables": "/plugin/tables";
"plugin_tables_vtable": "/plugin/tables/vtable";
"plugin_typeit": "/plugin/typeit";
"plugin_video": "/plugin/video";
"product": "/product"; "product": "/product";
"product_dashboard": "/product/dashboard"; "product_dashboard": "/product/dashboard";
"product_list": "/product/list"; "product_list": "/product/list";
@@ -135,13 +112,12 @@ declare module "@elegant-router/types" {
| "403" | "403"
| "404" | "404"
| "500" | "500"
| "function" | "feedback"
| "iframe-page" | "iframe-page"
| "infra" | "infra"
| "login" | "login"
| "metrics" | "metrics"
| "personal-center" | "personal-center"
| "plugin"
| "product" | "product"
| "project" | "project"
| "system" | "system"
@@ -169,14 +145,12 @@ declare module "@elegant-router/types" {
| "500" | "500"
| "iframe-page" | "iframe-page"
| "login" | "login"
| "function_hide-child_one" | "feedback"
| "function_hide-child_three" | "infra_log-management_api-access-log"
| "function_hide-child_two" | "infra_log-management_api-error-log"
| "function_multi-tab" | "infra_log-management"
| "function_request" | "infra_log-management_login-log"
| "function_super-page" | "infra_log-management_operate-log"
| "function_tab"
| "function_toggle-auth"
| "infra_rd-code" | "infra_rd-code"
| "infra_state-machine" | "infra_state-machine"
| "metrics_member-efficiency" | "metrics_member-efficiency"
@@ -184,29 +158,14 @@ declare module "@elegant-router/types" {
| "metrics_worktime" | "metrics_worktime"
| "personal-center_my-application" | "personal-center_my-application"
| "personal-center_my-item" | "personal-center_my-item"
| "personal-center_my-monthly"
| "personal-center_my-performance" | "personal-center_my-performance"
| "personal-center_my-profile" | "personal-center_my-profile"
| "personal-center_my-weekly"
| "personal-center_overtime-application" | "personal-center_overtime-application"
| "personal-center_pending-approval" | "personal-center_pending-approval"
| "plugin_barcode" | "personal-center_work-report"
| "plugin_charts_antv" | "personal-center_work-report_monthly"
| "plugin_charts_echarts" | "personal-center_work-report_project"
| "plugin_charts_vchart" | "personal-center_work-report_weekly"
| "plugin_copy"
| "plugin_excel"
| "plugin_gantt_dhtmlx"
| "plugin_gantt_vtable"
| "plugin_icon"
| "plugin_map"
| "plugin_pdf"
| "plugin_pinyin"
| "plugin_print"
| "plugin_swiper"
| "plugin_tables_vtable"
| "plugin_typeit"
| "plugin_video"
| "product_dashboard" | "product_dashboard"
| "product_list" | "product_list"
| "product_requirement" | "product_requirement"

View File

@@ -1,20 +0,0 @@
/// <reference types="@amap/amap-jsapi-types" />
/// <reference types="bmapgl" />
declare namespace BMap {
class Map extends BMapGL.Map {}
class Point extends BMapGL.Point {}
}
declare const TMap: any;
interface Window {
/**
* make baidu map request under https protocol
*
* - 0: http
* - 1: https
* - 2: https
*/
HOST_TYPE: '0' | '1' | '2';
}

27
src/utils/datetime.ts Normal file
View File

@@ -0,0 +1,27 @@
import dayjs from 'dayjs';
/** 相对时间展示:刚刚 / N 分钟前 / N 小时前 / N 天前,超过 7 天回退完整日期 */
export function formatRelativeTime(value: string | number) {
const time = dayjs(value);
if (!time.isValid()) return '';
const now = dayjs();
const diffMinutes = now.diff(time, 'minute');
if (diffMinutes < 1) return '刚刚';
if (diffMinutes < 60) return `${diffMinutes} 分钟前`;
const diffHours = now.diff(time, 'hour');
if (diffHours < 24) return `${diffHours} 小时前`;
const diffDays = now.diff(time, 'day');
if (diffDays < 7) return `${diffDays} 天前`;
return time.format('YYYY-MM-DD HH:mm');
}
/** 绝对时间展示YYYY-MM-DD HH:mm空值或非法值回空串 */
export function formatDateTime(value: string | number | null | undefined) {
if (value === null || value === undefined || value === '') return '';
const time = dayjs(value);
return time.isValid() ? time.format('YYYY-MM-DD HH:mm') : '';
}

View File

@@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, reactive } from 'vue';
import type { Component } from 'vue'; import type { CSSProperties, Component } from 'vue';
import { getPaletteColorByNumber, mixColor } from '@sa/color';
import { loginModuleRecord } from '@/constants/app'; import { loginModuleRecord } from '@/constants/app';
import { useThemeStore } from '@/store/modules/theme'; import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales'; import { $t } from '@/locales';
@@ -31,46 +30,791 @@ const moduleMap: Record<UnionKey.LoginModule, LoginModule> = {
const activeModule = computed(() => moduleMap[props.module || 'pwd-login']); const activeModule = computed(() => moduleMap[props.module || 'pwd-login']);
const bgThemeColor = computed(() => const currentYear = new Date().getFullYear();
themeStore.darkMode ? getPaletteColorByNumber(themeStore.themeColor, 600) : themeStore.themeColor
);
const bgColor = computed(() => { /** 登录页品牌色:取自公司 logo 的湛蓝,不跟随系统主题色(主题色偏紫,与企业蓝不符) */
const COLOR_WHITE = '#ffffff'; const LOGIN_BRAND = '#1e80df';
const ratio = themeStore.darkMode ? 0.5 : 0.2; /** 鼠标视差:归一化指针位置,不同景深的层按系数反向位移 */
const pointer = reactive({ x: 0, y: 0 });
return mixColor(COLOR_WHITE, themeStore.themeColor, ratio); function onPointerMove(event: MouseEvent) {
}); pointer.x = (event.clientX / window.innerWidth - 0.5) * 2;
pointer.y = (event.clientY / window.innerHeight - 0.5) * 2;
}
function layerStyle(depth: number) {
return {
transform: `translate3d(${(-pointer.x * depth).toFixed(1)}px, ${(-pointer.y * depth).toFixed(1)}px, 0)`
};
}
/** 协作分支:角色 → 颜色 → 汇入主干的路径git 分支汇流的意象),曲线两端均水平相切,过渡柔和 */
const branches = [
{
key: 'demand',
label: '需求',
color: '#f59e0b',
y0: 195,
mergeX: 660,
path: 'M -80,195 C 320,195 440,470 660,470',
dur: '7.5s',
begin: '0s'
},
{
key: 'design',
label: '设计',
color: '#ec4899',
y0: 330,
mergeX: 780,
path: 'M -80,330 C 360,330 540,470 780,470',
dur: '6.5s',
begin: '1.2s'
},
{
key: 'dev',
label: '开发',
color: '#0ea5e9',
y0: 615,
mergeX: 880,
path: 'M -80,615 C 380,615 560,470 880,470',
dur: '7s',
begin: '2.1s'
},
{
key: 'test',
label: '测试',
color: '#22c55e',
y0: 745,
mergeX: 970,
path: 'M -80,745 C 420,745 620,470 970,470',
dur: '8s',
begin: '0.6s'
}
];
/** 分支汇入主干的节点位置 */
const mergePoints = [
{ x: 660, color: '#f59e0b' },
{ x: 780, color: '#ec4899' },
{ x: 880, color: '#0ea5e9' },
{ x: 970, color: '#22c55e' }
];
/** 角色徽章在场景中的落位(跟随分支起始段) */
const roleChips: { label: string; color: string; style: CSSProperties }[] = [
{ label: '需求', color: '#f59e0b', style: { left: '5%', top: '20%', '--float-d': '0s' } },
{ label: '设计', color: '#ec4899', style: { left: '11%', top: '35%', '--float-d': '0.8s' } },
{ label: '开发', color: '#0ea5e9', style: { left: '8%', top: '66%', '--float-d': '1.6s' } },
{ label: '测试', color: '#22c55e', style: { left: '13%', top: '80%', '--float-d': '2.4s' } }
];
/**
* 电能质量波形(公司主营:电能质量监测)
*
* 主干汇流完成后,尾段"输出"为基波 + 谐波叠加的正弦波组,寓意协作成果守护电能质量。
* 用二次贝塞尔 Q/T 拼接出周期波形CSS 平移一个整周期实现无缝流动。
*/
interface WaveShape {
/** 波形中线 y */
mid: number;
/** 振幅 */
amp: number;
/** 半周期x 方向) */
half: number;
}
function buildWavePath(shape: WaveShape, from: number, to: number) {
const { mid, amp, half } = shape;
let d = `M ${from} ${mid} Q ${from + half / 2} ${mid - amp} ${from + half} ${mid}`;
for (let x = from + 2 * half; x <= to; x += half) {
d += ` T ${x} ${mid}`;
}
return d;
}
const waves = [
// 基波:主题色,振幅最大
{ key: 'fundamental', mid: 470, amp: 26, half: 110, color: 'var(--brand)', width: 2, opacity: 0.5, dur: '7s' },
// 高次谐波:短周期小振幅
{ key: 'harmonic', mid: 470, amp: 10, half: 55, color: '#0ea5e9', width: 1.5, opacity: 0.45, dur: '4.5s' },
// 低频包络:慢速衬底
{ key: 'flux', mid: 474, amp: 40, half: 220, color: '#60a5fa', width: 2, opacity: 0.22, dur: '14s' }
].map(wave => ({
...wave,
path: buildWavePath(wave, 900 - wave.half * 2, 2000 + wave.half * 2),
shift: `${-2 * wave.half}px`
}));
/** 电力场景剪影:输电铁塔(底部接地,局部坐标基点为塔脚中心) */
const towers = [
{ x: 150, s: 1.42 },
{ x: 540, s: 1.2 },
{ x: 1280, s: 1.0 }
];
/** 塔间悬垂导线(悬链线意象),坐标对应各塔最宽横担端点(随铁塔 scale 同步重算) */
const powerLines = [
{ path: 'M -60,712 Q 4,756 68,700' },
{ path: 'M 232,700 Q 350,777 470,726' },
{ path: 'M 610,726 Q 915,831 1222,750' },
{ path: 'M 1338,750 Q 1429,788 1520,768' }
];
/** 导线上滑过的电流光点(端点对齐 powerLines 的中间两段) */
const lineSparks = [
{ key: 'spark-1', path: 'M 232,700 Q 350,777 470,726', dur: '5s', begin: '0s' },
{ key: 'spark-2', path: 'M 610,726 Q 915,831 1222,750', dur: '7s', begin: '2s' }
];
/** 风机新能源应用场景dur 为叶轮旋转周期 */
const turbines = [
{ key: 'turbine-1', x: 715, s: 1.28, dur: '9s' },
{ key: 'turbine-2', x: 828, s: 0.88, dur: '13s' }
];
</script> </script>
<template> <template>
<div class="relative size-full flex-center overflow-hidden" :style="{ backgroundColor: bgColor }"> <div class="login-scene" :style="{ '--brand': LOGIN_BRAND }" @mousemove="onPointerMove">
<WaveBg :theme-color="bgThemeColor" /> <!-- 远景浮尘微粒 -->
<ElCard class="relative z-4 w-auto rd-12px"> <div class="scene-motes" :style="layerStyle(6)"></div>
<div class="w-400px lt-sm:w-300px">
<header class="flex-y-center justify-between"> <!-- 中景协作汇流图需求/设计/开发/测试 主干 登录入口 -->
<SystemLogo class="text-64px text-primary lt-sm:text-48px" /> <div class="scene-graph" :style="layerStyle(14)">
<h3 class="text-28px text-primary font-500 lt-sm:text-22px">{{ $t('system.title') }}</h3> <svg class="scene-graph__svg" viewBox="0 0 1440 900" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<div class="i-flex-col"> <defs>
<ThemeSchemaSwitch <linearGradient id="trunk-grad" x1="0" y1="0" x2="1" y2="0">
:theme-schema="themeStore.themeScheme" <!-- presentation attribute 不解析 CSS var必须用 style -->
:show-tooltip="false" <stop offset="0" style="stop-color: var(--brand); stop-opacity: 0" />
class="text-20px lt-sm:text-18px" <stop offset="0.45" style="stop-color: var(--brand); stop-opacity: 0.85" />
@switch="themeStore.toggleThemeScheme" <stop offset="1" style="stop-color: #0ea5e9; stop-opacity: 0.9" />
</linearGradient>
<!-- 每条分支一个渐变起点透明临近汇入处渐显模拟光流自然汇聚 -->
<linearGradient
v-for="branch in branches"
:id="`branch-grad-${branch.key}`"
:key="`grad-${branch.key}`"
gradientUnits="userSpaceOnUse"
:x1="-80"
:y1="branch.y0"
:x2="branch.mergeX"
:y2="470"
>
<stop offset="0" :stop-color="branch.color" stop-opacity="0" />
<stop offset="0.45" :stop-color="branch.color" stop-opacity="0.2" />
<stop offset="1" :stop-color="branch.color" stop-opacity="0.65" />
</linearGradient>
<filter id="trunk-glow" x="-30%" y="-300%" width="160%" height="700%">
<feGaussianBlur stdDeviation="5" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<!-- 波形渐显遮罩汇流完成后波形才"长出来"遮罩静止波形在其下平移 -->
<linearGradient id="wave-fade" gradientUnits="userSpaceOnUse" x1="920" y1="0" x2="1980" y2="0">
<stop offset="0" stop-color="#fff" stop-opacity="0" />
<stop offset="0.3" stop-color="#fff" stop-opacity="0.9" />
<stop offset="1" stop-color="#fff" stop-opacity="1" />
</linearGradient>
<mask id="wave-mask">
<rect x="920" y="330" width="1100" height="300" fill="url(#wave-fade)" />
</mask>
</defs>
<!-- 主干 -->
<path class="trunk" d="M -60,470 L 1520,470" stroke="url(#trunk-grad)" filter="url(#trunk-glow)" />
<!-- 四条角色分支 -->
<path
v-for="(branch, index) in branches"
:key="branch.key"
class="branch"
:d="branch.path"
:stroke="`url(#branch-grad-${branch.key})`"
:style="{ '--breathe-d': `${index * 1.4}s` }"
/>
<!-- 分支上行进的光点 -->
<circle
v-for="branch in branches"
:key="`dot-${branch.key}`"
r="3.5"
:fill="branch.color"
class="travel-dot"
:style="{ color: branch.color }"
>
<animateMotion :dur="branch.dur" :begin="branch.begin" repeatCount="indefinite" :path="branch.path" />
</circle>
<!-- 主干上行进的光点 -->
<circle r="3" fill="#5db1f5" class="travel-dot" style="color: #5db1f5">
<animateMotion dur="4.5s" begin="0.5s" repeatCount="indefinite" path="M 660,470 L 1520,470" />
</circle>
<circle r="2.5" fill="#5db1f5" class="travel-dot" style="color: #5db1f5">
<animateMotion dur="5.5s" begin="2.6s" repeatCount="indefinite" path="M 660,470 L 1520,470" />
</circle>
<!-- 电能质量波形基波 + 谐波 + 低频包络沿主干尾段流出 -->
<g mask="url(#wave-mask)">
<path
v-for="wave in waves"
:key="wave.key"
class="wave"
:d="wave.path"
:stroke-width="wave.width"
:style="{
stroke: wave.color,
opacity: wave.opacity,
'--wave-shift': wave.shift,
'--wave-dur': wave.dur
}"
/>
</g>
<!-- 电力场景剪影输电铁塔 + 悬垂导线 + 风机 -->
<g class="industry">
<!-- 输电铁塔 -->
<g
v-for="tower in towers"
:key="`tower-${tower.x}`"
:transform="`translate(${tower.x}, 870) scale(${tower.s})`"
>
<path d="M -32 0 L -10 -150 L 0 -178 L 10 -150 L 32 0" />
<path
d="M -28 -25 L 28 -45 M 28 -25 L -28 -45 M -24 -70 L 24 -88 M 24 -70 L -24 -88 M -19 -112 L 19 -126 M 19 -112 L -19 -126"
/> />
</div> <path d="M -58 -120 L 58 -120 M -46 -150 L 46 -150" />
</header> <path d="M -58 -120 L -58 -110 M 58 -120 L 58 -110 M -46 -150 L -46 -140 M 46 -150 L 46 -140" />
<main class="pt-15px"> </g>
<div class="pt-15px">
<Transition :name="themeStore.page.animateMode" mode="out-in" appear> <!-- 塔间导线 -->
<component :is="activeModule.component" /> <path v-for="line in powerLines" :key="line.path" :d="line.path" />
</Transition>
</div> <!-- 风机 -->
</main> <g
</div> v-for="turbine in turbines"
</ElCard> :key="turbine.key"
:transform="`translate(${turbine.x}, 870) scale(${turbine.s})`"
>
<path d="M -3 0 L 0 -120 M 3 0 L 0 -120" />
<g transform="translate(0, -120)">
<g>
<path d="M 0 0 L 0 -52" />
<path d="M 0 0 L 0 -52" transform="rotate(120)" />
<path d="M 0 0 L 0 -52" transform="rotate(240)" />
<animateTransform
attributeName="transform"
type="rotate"
from="0 0 0"
to="360 0 0"
:dur="turbine.dur"
repeatCount="indefinite"
/>
</g>
</g>
<circle cx="0" cy="-120" r="3" class="industry__hub" />
</g>
</g>
<!-- 导线上的电流光点 -->
<circle v-for="spark in lineSparks" :key="spark.key" r="2.5" class="travel-dot industry__spark">
<animateMotion :dur="spark.dur" :begin="spark.begin" repeatCount="indefinite" :path="spark.path" />
</circle>
<!-- 汇入节点脉冲 -->
<g v-for="point in mergePoints" :key="`merge-${point.x}`">
<circle :cx="point.x" cy="470" r="5" :fill="point.color" />
<circle :cx="point.x" cy="470" r="5" :stroke="point.color" class="merge-pulse" />
</g>
</svg>
<!-- 角色徽章 -->
<span v-for="chip in roleChips" :key="chip.label" class="role-chip" :style="chip.style">
<i class="role-chip__dot" :style="{ backgroundColor: chip.color }"></i>
{{ chip.label }}
</span>
</div>
<!-- 顶部品牌 -->
<header class="scene-header reveal" style="--d: 0s">
<SystemLogo class="text-36px" />
<span class="scene-header__name">{{ $t('system.title') }}</span>
</header>
<!-- 主文案 -->
<div class="scene-hero" :style="layerStyle(10)">
<p class="scene-hero__eyebrow reveal" style="--d: 0.15s">BUILD TOGETHER · GUARD POWER QUALITY</p>
<h1 class="scene-hero__slogan reveal" style="--d: 0.25s">
独行快
<span class="scene-hero__comma"></span>
<br />
众行
<em></em>
</h1>
<p class="scene-hero__sub reveal" style="--d: 0.4s">每一次提交都让电能质量的守护更进一步</p>
</div>
<!-- 登录卡片 -->
<div class="login-card">
<header class="login-card__header reveal" style="--d: 0.3s">
<SystemLogo class="login-card__logo text-52px" />
<h2 class="login-card__title">{{ $t('system.title') }}</h2>
<p class="login-card__subtitle">欢迎回来开始今天的协作</p>
</header>
<main class="reveal" style="--d: 0.45s">
<Transition :name="themeStore.page.animateMode" mode="out-in" appear>
<component :is="activeModule.component" />
</Transition>
</main>
</div>
<footer class="scene-footer reveal" style="--d: 0.6s">
© {{ currentYear }} 南京灿能电力自动化股份有限公司 · {{ $t('system.title') }}
</footer>
</div> </div>
</template> </template>
<style scoped></style> <style scoped lang="scss">
.login-scene {
position: relative;
display: flex;
align-items: center;
justify-content: flex-end;
width: 100%;
height: 100%;
padding-right: 9vw;
overflow: hidden;
background:
radial-gradient(90% 70% at 80% 42%, color-mix(in srgb, var(--brand) 10%, transparent) 0%, transparent 60%),
radial-gradient(80% 60% at 6% 92%, rgb(56 189 248 / 10%) 0%, transparent 60%),
radial-gradient(70% 50% at 18% 8%, rgb(14 165 233 / 6%) 0%, transparent 55%),
linear-gradient(160deg, #f5f9ff 0%, #ecf2fb 50%, #fafcff 100%);
@media (max-width: 1023px) {
justify-content: center;
padding-right: 0;
}
}
/* ---------- 远景浮尘微粒 ---------- */
.scene-motes {
position: absolute;
inset: -40px;
background-image:
radial-gradient(2px 2px at 12% 22%, rgb(30 128 223 / 30%) 50%, transparent 51%),
radial-gradient(1.5px 1.5px at 28% 68%, rgb(23 44 84 / 16%) 50%, transparent 51%),
radial-gradient(2.5px 2.5px at 44% 12%, rgb(30 128 223 / 22%) 50%, transparent 51%),
radial-gradient(1.5px 1.5px at 58% 44%, rgb(23 44 84 / 12%) 50%, transparent 51%),
radial-gradient(2px 2px at 72% 78%, rgb(56 189 248 / 25%) 50%, transparent 51%),
radial-gradient(1.5px 1.5px at 86% 28%, rgb(23 44 84 / 14%) 50%, transparent 51%),
radial-gradient(2px 2px at 94% 62%, rgb(30 128 223 / 20%) 50%, transparent 51%),
radial-gradient(1.5px 1.5px at 6% 86%, rgb(56 189 248 / 18%) 50%, transparent 51%);
background-size: 520px 520px;
background-repeat: repeat;
animation: motes-breathe 6s ease-in-out infinite alternate;
transition: transform 0.25s ease-out;
pointer-events: none;
}
@keyframes motes-breathe {
from {
opacity: 0.5;
}
to {
opacity: 1;
}
}
/* ---------- 协作汇流图 ---------- */
.scene-graph {
position: absolute;
inset: 0;
transition: transform 0.25s ease-out;
pointer-events: none;
}
.scene-graph__svg {
width: 100%;
height: 100%;
}
.trunk {
fill: none;
stroke-width: 2.5;
stroke-linecap: round;
}
.branch {
fill: none;
stroke-width: 2;
stroke-linecap: round;
animation: branch-breathe 6s ease-in-out infinite alternate;
animation-delay: var(--breathe-d, 0s);
}
@keyframes branch-breathe {
from {
opacity: 0.55;
}
to {
opacity: 1;
}
}
.travel-dot {
filter: drop-shadow(0 0 6px currentColor);
}
.wave {
fill: none;
stroke-linecap: round;
animation: wave-drift var(--wave-dur) linear infinite;
}
@keyframes wave-drift {
to {
transform: translateX(var(--wave-shift));
}
}
/* 电力场景剪影 */
.industry {
fill: none;
stroke: #424a8c;
stroke-width: 1.5;
stroke-linecap: round;
opacity: 0.22;
}
.industry__hub {
fill: #424a8c;
stroke: none;
}
.industry__spark {
fill: var(--brand);
color: var(--brand);
opacity: 0.65;
}
.merge-pulse {
fill: none;
stroke-width: 1.5;
transform-box: fill-box;
transform-origin: center;
animation: merge-pulse 2.6s ease-out infinite;
}
@keyframes merge-pulse {
0% {
opacity: 0.8;
transform: scale(1);
}
70%,
100% {
opacity: 0;
transform: scale(3.2);
}
}
/* 角色徽章 */
.role-chip {
position: absolute;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 16px;
border: 1px solid rgb(30 35 80 / 10%);
border-radius: 999px;
font-size: 13px;
letter-spacing: 0.14em;
color: rgb(30 35 80 / 72%);
background: rgb(255 255 255 / 65%);
box-shadow: 0 6px 18px -8px rgb(23 92 171 / 22%);
backdrop-filter: blur(8px);
animation: chip-float 5.5s ease-in-out infinite alternate;
animation-delay: var(--float-d, 0s);
}
.role-chip__dot {
width: 7px;
height: 7px;
border-radius: 50%;
box-shadow: 0 0 8px 1px currentcolor;
}
@keyframes chip-float {
from {
transform: translateY(-6px);
}
to {
transform: translateY(8px);
}
}
/* ---------- 品牌与文案 ---------- */
.scene-header {
position: absolute;
top: 40px;
left: 56px;
z-index: 2;
display: flex;
align-items: center;
gap: 12px;
color: #232850;
}
.scene-header__name {
font-size: 17px;
font-weight: 600;
letter-spacing: 0.08em;
}
.scene-hero {
position: absolute;
top: 24%;
left: 6.5%;
z-index: 2;
color: #1b2050;
transition: transform 0.25s ease-out;
pointer-events: none;
@media (max-width: 1023px) {
display: none;
}
}
.scene-hero__eyebrow {
margin-bottom: 26px;
font-family: Georgia, 'Times New Roman', serif;
font-size: 13px;
letter-spacing: 0.46em;
color: rgb(30 35 80 / 45%);
}
.scene-hero__slogan {
font-size: 64px;
font-weight: 600;
line-height: 1.3;
letter-spacing: 0.14em;
text-shadow: 0 8px 32px rgb(255 255 255 / 70%);
em {
font-style: normal;
background: linear-gradient(120deg, var(--brand) 0%, #0b66c3 55%, #38bdf8 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
}
.scene-hero__comma {
color: rgb(30 35 80 / 28%);
}
.scene-hero__sub {
margin-top: 26px;
font-size: 16px;
letter-spacing: 0.22em;
color: rgb(30 35 80 / 52%);
}
.scene-footer {
position: absolute;
bottom: 26px;
left: 56px;
z-index: 2;
font-size: 12px;
letter-spacing: 0.1em;
color: rgb(30 35 80 / 32%);
}
/* ---------- 登录卡片(白玻璃质感) ---------- */
.login-card {
position: relative;
z-index: 3;
width: 420px;
padding: 44px 40px 40px;
border: 1px solid rgb(255 255 255 / 75%);
border-radius: 20px;
background: linear-gradient(168deg, rgb(255 255 255 / 82%) 0%, rgb(248 250 255 / 88%) 100%);
backdrop-filter: blur(20px) saturate(140%);
box-shadow:
0 30px 70px -24px rgb(23 92 171 / 26%),
0 0 0 1px rgb(30 35 80 / 5%),
0 1px 0 rgb(255 255 255 / 90%) inset;
}
.login-card__header {
margin-bottom: 32px;
text-align: center;
}
.login-card__logo {
display: block;
margin: 0 auto 16px;
filter: drop-shadow(0 10px 26px color-mix(in srgb, var(--brand) 35%, transparent));
}
.login-card__title {
font-size: 24px;
font-weight: 600;
letter-spacing: 0.1em;
color: #20254d;
}
.login-card__subtitle {
margin-top: 10px;
font-size: 13.5px;
letter-spacing: 0.06em;
color: rgb(30 35 80 / 48%);
}
/* 卡片内表单:浅色质感统一覆盖(作用于子模块) */
.login-card :deep(.el-input__wrapper) {
height: 48px;
padding: 0 14px;
border-radius: 10px;
background-color: rgb(30 128 223 / 5%);
box-shadow: 0 0 0 1px rgb(30 35 80 / 12%) inset;
transition:
box-shadow 0.2s ease,
background-color 0.2s ease;
&:hover {
box-shadow: 0 0 0 1px color-mix(in srgb, var(--brand) 55%, rgb(30 35 80 / 20%)) inset;
}
&.is-focus {
background-color: #fff;
box-shadow:
0 0 0 1.5px var(--brand) inset,
0 0 0 4px color-mix(in srgb, var(--brand) 14%, transparent);
}
.el-input__inner {
color: #1f244a;
caret-color: var(--brand);
&::placeholder {
color: rgb(30 35 80 / 34%);
}
}
.el-input__prefix,
.el-input__suffix {
font-size: 18px;
color: rgb(30 35 80 / 35%);
}
.el-input__prefix {
margin-right: 6px;
}
}
.login-card :deep(.el-form-item) {
margin-bottom: 22px;
}
.login-card :deep(.el-checkbox__label) {
letter-spacing: 0.04em;
color: rgb(30 35 80 / 58%);
}
/* 选中态跟随登录页品牌蓝,而非系统主题色 */
.login-card :deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
border-color: var(--brand);
background-color: var(--brand);
}
.login-card :deep(.el-checkbox__input.is-checked + .el-checkbox__label) {
color: var(--brand);
}
.login-card :deep(.login-submit-button) {
position: relative;
width: 100%;
height: 48px;
margin-top: 4px;
border: none;
border-radius: 10px;
overflow: hidden;
font-size: 16px;
font-weight: 600;
letter-spacing: 0.32em;
text-indent: 0.32em;
background: linear-gradient(135deg, var(--brand) 0%, color-mix(in srgb, var(--brand) 68%, #0a3f8f) 100%);
box-shadow: 0 12px 26px -10px color-mix(in srgb, var(--brand) 60%, transparent);
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
filter 0.2s ease;
/* 流光扫过 */
&::after {
content: '';
position: absolute;
top: 0;
left: -60%;
width: 40%;
height: 100%;
background: linear-gradient(100deg, transparent 0%, rgb(255 255 255 / 35%) 50%, transparent 100%);
transform: skewX(-20deg);
transition: left 0.55s ease;
}
&:hover {
transform: translateY(-1px);
filter: brightness(1.06);
box-shadow: 0 16px 32px -10px color-mix(in srgb, var(--brand) 70%, transparent);
&::after {
left: 130%;
}
}
&:active {
transform: translateY(0);
}
}
.login-card :deep(.login-back-button) {
width: 100%;
height: 44px;
margin-top: 14px;
margin-left: 0;
border: 1px solid rgb(30 35 80 / 15%);
border-radius: 10px;
color: rgb(30 35 80 / 70%);
background: transparent;
&:hover {
border-color: color-mix(in srgb, var(--brand) 60%, transparent);
color: var(--brand);
background: color-mix(in srgb, var(--brand) 6%, transparent);
}
}
/* ---------- 入场动效 ---------- */
.reveal {
animation: reveal-up 0.7s cubic-bezier(0.22, 0.61, 0.36, 1) both;
animation-delay: var(--d, 0s);
}
@keyframes reveal-up {
from {
opacity: 0;
transform: translateY(18px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -1,85 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { Component } from 'vue';
import { getPaletteColorByNumber, mixColor } from '@sa/color';
import { loginModuleRecord } from '@/constants/app';
import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales';
import PwdLogin from './modules/pwd-login.vue';
import ResetPwd from './modules/reset-pwd.vue';
defineOptions({ name: 'LoginPage' });
interface Props {
/** The login module */
module?: UnionKey.LoginModule;
}
const props = defineProps<Props>();
const appStore = useAppStore();
const themeStore = useThemeStore();
interface LoginModule {
label: App.I18n.I18nKey;
component: Component;
}
const moduleMap: Record<UnionKey.LoginModule, LoginModule> = {
'pwd-login': { label: loginModuleRecord['pwd-login'], component: PwdLogin },
'reset-pwd': { label: loginModuleRecord['reset-pwd'], component: ResetPwd }
};
const activeModule = computed(() => moduleMap[props.module || 'pwd-login']);
const bgThemeColor = computed(() =>
themeStore.darkMode ? getPaletteColorByNumber(themeStore.themeColor, 600) : themeStore.themeColor
);
const bgColor = computed(() => {
const COLOR_WHITE = '#ffffff';
const ratio = themeStore.darkMode ? 0.5 : 0.2;
return mixColor(COLOR_WHITE, themeStore.themeColor, ratio);
});
</script>
<template>
<div class="relative size-full flex-center overflow-hidden" :style="{ backgroundColor: bgColor }">
<WaveBg :theme-color="bgThemeColor" />
<ElCard class="relative z-4 w-auto rd-12px">
<div class="w-400px lt-sm:w-300px">
<header class="flex-y-center justify-between">
<SystemLogo class="text-64px text-primary lt-sm:text-48px" />
<h3 class="text-28px text-primary font-500 lt-sm:text-22px">{{ $t('system.title') }}</h3>
<div class="i-flex-col">
<ThemeSchemaSwitch
:theme-schema="themeStore.themeScheme"
:show-tooltip="false"
class="text-20px lt-sm:text-18px"
@switch="themeStore.toggleThemeScheme"
/>
<LangSwitch
v-if="themeStore.header.multilingual.visible"
:lang="appStore.locale"
:lang-options="appStore.localeOptions"
:show-tooltip="false"
@change-lang="appStore.changeLocale"
/>
</div>
</header>
<main class="pt-15px">
<div class="pt-15px">
<Transition :name="themeStore.page.animateMode" mode="out-in" appear>
<component :is="activeModule.component" />
</Transition>
</div>
</main>
</div>
</ElCard>
</div>
</template>
<style scoped></style>

View File

@@ -15,8 +15,8 @@ interface FormModel {
} }
const model = ref<FormModel>({ const model = ref<FormModel>({
userName: 'admin', userName: '',
password: 'admin123' password: ''
}); });
const rules = computed<Record<keyof FormModel, App.Global.FormRule[]>>(() => { const rules = computed<Record<keyof FormModel, App.Global.FormRule[]>>(() => {
@@ -36,24 +36,35 @@ async function handleSubmit() {
</script> </script>
<template> <template>
<ElForm ref="formRef" :model="model" :rules="rules" size="large" :show-label="false" @keyup.enter="handleSubmit"> <ElForm ref="formRef" :model="model" :rules="rules" size="large" @keyup.enter="handleSubmit">
<ElFormItem prop="userName"> <ElFormItem prop="userName">
<ElInput v-model="model.userName" :placeholder="$t('page.login.common.userNamePlaceholder')" /> <ElInput v-model="model.userName" :placeholder="$t('page.login.common.userNamePlaceholder')">
<template #prefix>
<SvgIcon icon="mdi:account-outline" />
</template>
</ElInput>
</ElFormItem> </ElFormItem>
<ElFormItem prop="password"> <ElFormItem prop="password">
<ElInput <ElInput
v-model="model.password" v-model="model.password"
type="password" type="password"
show-password-on="click" show-password
:placeholder="$t('page.login.common.passwordPlaceholder')" :placeholder="$t('page.login.common.passwordPlaceholder')"
/> >
<template #prefix>
<SvgIcon icon="mdi:lock-outline" />
</template>
</ElInput>
</ElFormItem> </ElFormItem>
<ElSpace direction="vertical" :size="24" class="w-full" fill> <ElButton
<ElCheckbox>{{ $t('page.login.pwdLogin.rememberMe') }}</ElCheckbox> type="primary"
<ElButton type="primary" size="large" round block :loading="authStore.loginLoading" @click="handleSubmit"> size="large"
{{ $t('common.confirm') }} class="login-submit-button"
</ElButton> :loading="authStore.loginLoading"
</ElSpace> @click="handleSubmit"
>
{{ $t('route.login') }}
</ElButton>
</ElForm> </ElForm>
</template> </template>

View File

@@ -43,38 +43,60 @@ async function handleSubmit() {
</script> </script>
<template> <template>
<ElForm ref="formRef" :model="model" :rules="rules" size="large" :show-label="false" @keyup.enter="handleSubmit"> <ElForm ref="formRef" :model="model" :rules="rules" size="large" @keyup.enter="handleSubmit">
<ElFormItem prop="phone"> <ElFormItem prop="phone">
<ElInput v-model="model.phone" :placeholder="$t('page.login.common.phonePlaceholder')" /> <ElInput v-model="model.phone" :placeholder="$t('page.login.common.phonePlaceholder')">
<template #prefix>
<SvgIcon icon="mdi:cellphone" />
</template>
</ElInput>
</ElFormItem> </ElFormItem>
<ElFormItem prop="code"> <ElFormItem prop="code">
<ElInput v-model="model.code" :placeholder="$t('page.login.common.codePlaceholder')" /> <ElInput v-model="model.code" :placeholder="$t('page.login.common.codePlaceholder')">
<template #prefix>
<SvgIcon icon="mdi:shield-check-outline" />
</template>
</ElInput>
</ElFormItem> </ElFormItem>
<ElFormItem prop="password"> <ElFormItem prop="password">
<ElInput <ElInput
v-model="model.password" v-model="model.password"
type="password" type="password"
show-password-on="click" show-password
:placeholder="$t('page.login.common.passwordPlaceholder')" :placeholder="$t('page.login.common.passwordPlaceholder')"
/> >
<template #prefix>
<SvgIcon icon="mdi:lock-outline" />
</template>
</ElInput>
</ElFormItem> </ElFormItem>
<ElFormItem prop="confirmPassword"> <ElFormItem prop="confirmPassword">
<ElInput <ElInput
v-model="model.confirmPassword" v-model="model.confirmPassword"
type="password" type="password"
show-password-on="click" show-password
:placeholder="$t('page.login.common.confirmPasswordPlaceholder')" :placeholder="$t('page.login.common.confirmPasswordPlaceholder')"
/> >
<template #prefix>
<SvgIcon icon="mdi:lock-check-outline" />
</template>
</ElInput>
</ElFormItem> </ElFormItem>
<ElSpace direction="vertical" fill :size="18" class="w-full"> <ElButton type="primary" size="large" class="login-submit-button" @click="handleSubmit">
<ElButton type="primary" size="large" round @click="handleSubmit"> {{ $t('common.confirm') }}
{{ $t('common.confirm') }} </ElButton>
</ElButton> <ElButton size="large" class="login-back-button" @click="toggleLoginModule('pwd-login')">
<ElButton size="large" round @click="toggleLoginModule('pwd-login')"> {{ $t('page.login.common.back') }}
{{ $t('page.login.common.back') }} </ElButton>
</ElButton>
</ElSpace>
</ElForm> </ElForm>
</template> </template>
<style scoped></style> <style scoped>
.login-back-button {
width: 100%;
height: 44px;
margin-top: 14px;
margin-left: 0;
border-radius: 10px;
}
</style>

View File

@@ -0,0 +1,329 @@
<script setup lang="tsx">
import { computed, onMounted, reactive, ref } from 'vue';
import { ElMessageBox } from 'element-plus';
import { FEEDBACK_STATUS_DICT_CODE, FEEDBACK_TYPE_DICT_CODE } from '@/constants/dict';
import { getFeedbackStatusTagType } from '@/constants/status-tag';
import { fetchDeleteFeedback, fetchGetFeedbackPage, fetchGetFeedbackStat } from '@/service/api';
import { useAuthStore } from '@/store/modules/auth';
import { useUIPaginatedTable } from '@/hooks/common/table';
import DictTag from '@/components/custom/dict-tag.vue';
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
import FeedbackFacet from './modules/feedback-facet.vue';
import FeedbackSearch from './modules/feedback-search.vue';
import FeedbackOperateDialog from './modules/feedback-operate-dialog.vue';
import FeedbackDetailDialog from './modules/feedback-detail-dialog.vue';
import FeedbackStatusDialog from './modules/feedback-status-dialog.vue';
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
import IconMdiProgressCheck from '~icons/mdi/progress-check';
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
defineOptions({ name: 'Feedback' });
const authStore = useAuthStore();
// 动态路由模式下后端不返回 R_SUPER超管按固定登录名 admin 识别(写操作放开依据)
const isSuper = computed(() => authStore.userInfo.userName === 'admin');
// 后端已移除反馈相关权限码:提交对所有登录用户开放;写操作纯前端按「归属 + 超管」控制
/** 是否本人提交 */
function isOwn(row: Api.Feedback.FeedbackItem) {
return authStore.userInfo.userId === row.creator;
}
/** 修改状态:仅超管 */
const canUpdateStatus = computed(() => isSuper.value);
/** 删除按钮逐行显隐:自己提交的,或超管 */
function canDeleteRow(row: Api.Feedback.FeedbackItem) {
return isOwn(row) || isSuper.value;
}
/** 编辑按钮逐行显隐:自己提交的,或超管 */
function canEditRow(row: Api.Feedback.FeedbackItem) {
return isOwn(row) || isSuper.value;
}
function getInitSearchParams(): Api.Feedback.FeedbackSearchParams {
return { pageNo: 1, pageSize: 20, type: undefined, status: 1, title: undefined, creator: undefined };
}
function transformPageResult(
response: Awaited<ReturnType<typeof fetchGetFeedbackPage>>,
pageNo: number,
pageSize: number
) {
if (!response.error) {
return { data: response.data.list, pageNum: pageNo, pageSize, total: response.data.total };
}
return { data: [], pageNum: pageNo, pageSize, total: 0 };
}
const searchParams = reactive(getInitSearchParams());
// 弹层 / 抽屉开关与上下文
const operateVisible = ref(false);
const operateMode = ref<'create' | 'edit'>('create');
const operateRow = ref<Api.Feedback.FeedbackItem | null>(null);
const detailVisible = ref(false);
const detailId = ref<string | null>(null);
const statusVisible = ref(false);
const statusId = ref<string | null>(null);
const statusCurrent = ref<number | null>(null);
function openCreate() {
operateMode.value = 'create';
operateRow.value = null;
operateVisible.value = true;
}
function openEdit(row: Api.Feedback.FeedbackItem) {
operateMode.value = 'edit';
operateRow.value = row;
operateVisible.value = true;
}
function openDetail(row: Api.Feedback.FeedbackItem) {
detailId.value = row.id;
detailVisible.value = true;
}
function openStatus(row: Api.Feedback.FeedbackItem) {
statusId.value = row.id;
statusCurrent.value = row.status;
statusVisible.value = true;
}
const { columns, data, loading, getData, getDataByPage, mobilePagination } = useUIPaginatedTable({
paginationProps: {
currentPage: searchParams.pageNo,
pageSize: searchParams.pageSize
},
api: () => fetchGetFeedbackPage(searchParams),
transform: response => transformPageResult(response, searchParams.pageNo, searchParams.pageSize),
onPaginationParamsChange: params => {
searchParams.pageNo = params.currentPage ?? 1;
searchParams.pageSize = params.pageSize ?? 20;
},
columns: () => [
{ prop: 'index', type: 'index', label: '序号', width: 64, align: 'center' },
{ prop: 'title', label: '标题', minWidth: 180, showOverflowTooltip: true },
{
prop: 'content',
label: '内容',
minWidth: 260,
showOverflowTooltip: true,
formatter: row => <span>{row.contentPreview}</span>
},
{
prop: 'type',
label: '分类',
width: 110,
align: 'center',
formatter: row => <DictTag dictCode={FEEDBACK_TYPE_DICT_CODE} value={row.type} />
},
{
prop: 'status',
label: '状态',
width: 110,
align: 'center',
formatter: row => (
<DictTag dictCode={FEEDBACK_STATUS_DICT_CODE} value={row.status} type={getFeedbackStatusTagType(row.status)} />
)
},
{
prop: 'creatorName',
label: '提交人',
width: 140,
formatter: row => <span>{row.creatorName || row.creator}</span>
},
{ prop: 'createTime', label: '提交时间', width: 180 },
{
prop: 'operate',
label: '操作',
width: 140,
align: 'center',
fixed: 'right',
formatter: row => <BusinessTableActionCell variant="icon" actions={getRowActions(row)} />
}
]
});
// 左侧分面统计(全量口径,不随筛选变化;仅在进入页面与写操作后刷新)
const facetStat = ref<Api.Feedback.FeedbackStat>({ total: 0, typeCounts: {}, statusCounts: {} });
const facetLoading = ref(false);
async function loadFacet() {
facetLoading.value = true;
try {
const { data: stat, error } = await fetchGetFeedbackStat();
if (!error && stat) {
facetStat.value = stat;
}
} finally {
// 统计接口异常(含 normalize 解引用空数据抛错)也要复位,避免分面面板 v-loading 永久转圈
facetLoading.value = false;
}
}
async function handleDelete(row: Api.Feedback.FeedbackItem) {
const confirmed = await ElMessageBox.confirm('确认删除该意见反馈?', '提示', { type: 'warning' })
.then(() => true)
.catch(() => false);
if (!confirmed) {
return;
}
const { error } = await fetchDeleteFeedback(row.id);
if (error) {
return;
}
window.$message?.success('删除成功');
await Promise.all([getData(), loadFacet()]);
}
function getRowActions(row: Api.Feedback.FeedbackItem): BusinessTableAction[] {
const actions: BusinessTableAction[] = [
{ key: 'detail', label: '查看详情', buttonType: 'primary', icon: IconMdiEyeOutline, onClick: () => openDetail(row) }
];
if (canEditRow(row)) {
actions.push({
key: 'edit',
label: '编辑',
buttonType: 'primary',
icon: IconMdiPencilOutline,
onClick: () => openEdit(row)
});
}
if (canUpdateStatus.value) {
actions.push({
key: 'status',
label: '处理',
buttonType: 'warning',
icon: IconMdiProgressCheck,
onClick: () => openStatus(row)
});
}
if (canDeleteRow(row)) {
actions.push({
key: 'delete',
label: '删除',
buttonType: 'danger',
icon: IconMdiDeleteOutline,
onClick: () => handleDelete(row)
});
}
return actions;
}
function handleSearch() {
searchParams.pageNo = 1;
getDataByPage(1);
}
// 搜索区「重置」清搜索区自有字段(标题、提交人);左侧分面选中的 type/status 由分面单独控制,不在此连带清除
function resetSearchParams() {
searchParams.title = undefined;
searchParams.creator = undefined;
searchParams.pageNo = 1;
getDataByPage(1);
}
function handleSubmitted() {
// 新增回到首页看最新;编辑只刷新当前页,避免跳页
if (operateMode.value === 'edit') {
getData();
} else {
getDataByPage(1);
}
loadFacet();
}
function handleStatusUpdated() {
getData();
loadFacet();
}
// 左侧分面点击:单选切换 toggle 由面板内部处理,这里只接收最终值并刷新列表
function handleSelectType(value?: string | number) {
searchParams.type = value ?? undefined;
handleSearch();
}
function handleSelectStatus(value?: string | number) {
searchParams.status = value ?? undefined;
handleSearch();
}
function handleSelectAll() {
searchParams.type = undefined;
searchParams.status = undefined;
handleSearch();
}
onMounted(loadFacet);
</script>
<template>
<div class="h-full flex gap-16px overflow-hidden">
<FeedbackFacet
class="w-200px shrink-0"
:total="facetStat.total"
:type-counts="facetStat.typeCounts"
:status-counts="facetStat.statusCounts"
:selected-type="searchParams.type"
:selected-status="searchParams.status"
:loading="facetLoading"
@select-type="handleSelectType"
@select-status="handleSelectStatus"
@select-all="handleSelectAll"
/>
<div class="flex-col-stretch flex-1-hidden gap-16px">
<FeedbackSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">
<template #header>
<div class="flex items-center justify-between gap-12px">
<div class="flex items-center gap-10px">
<p>意见反馈</p>
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag>
</div>
<ElButton plain type="primary" @click="openCreate">提交反馈</ElButton>
</div>
</template>
<div class="flex-1">
<ElTable v-loading="loading" height="100%" border row-key="id" :data="data">
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
</ElTable>
</div>
<div class="mt-20px flex justify-end">
<ElPagination
v-if="mobilePagination.total"
layout="total,prev,pager,next,sizes"
v-bind="mobilePagination"
@current-change="mobilePagination['current-change']"
@size-change="mobilePagination['size-change']"
/>
</div>
</ElCard>
</div>
<FeedbackOperateDialog
v-model:visible="operateVisible"
:mode="operateMode"
:row-data="operateRow"
@submitted="handleSubmitted"
/>
<FeedbackDetailDialog :id="detailId" v-model:visible="detailVisible" />
<FeedbackStatusDialog
:id="statusId"
v-model:visible="statusVisible"
:current-status="statusCurrent"
@submitted="handleStatusUpdated"
/>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { FEEDBACK_STATUS_DICT_CODE, FEEDBACK_TYPE_DICT_CODE } from '@/constants/dict';
import { getFeedbackStatusTagType } from '@/constants/status-tag';
import { fetchGetFeedback } from '@/service/api';
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessFormSection from '@/components/custom/business-form-section.vue';
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
import DictTag from '@/components/custom/dict-tag.vue';
defineOptions({ name: 'FeedbackDetailDialog' });
const visible = defineModel<boolean>('visible', { default: false });
interface Props {
id?: string | null;
}
const props = defineProps<Props>();
const loading = ref(false);
const detail = ref<Api.Feedback.FeedbackItem | null>(null);
async function loadDetail() {
if (!props.id) {
return;
}
loading.value = true;
const { error, data } = await fetchGetFeedback(props.id);
loading.value = false;
if (!error) {
detail.value = data;
}
}
watch(visible, value => {
if (value) {
detail.value = null;
loadDetail();
}
});
</script>
<template>
<BusinessFormDialog
v-model="visible"
title="反馈详情"
width="980px"
max-body-height="76vh"
:loading="loading"
:show-footer="false"
>
<div v-if="detail" class="feedback-detail__grid">
<div class="feedback-detail__col-left">
<BusinessFormSection title="反馈信息">
<ElDescriptions :column="1" border>
<ElDescriptionsItem label="分类">
<DictTag :dict-code="FEEDBACK_TYPE_DICT_CODE" :value="detail.type" />
</ElDescriptionsItem>
<ElDescriptionsItem label="状态">
<DictTag
:dict-code="FEEDBACK_STATUS_DICT_CODE"
:value="detail.status"
:type="getFeedbackStatusTagType(detail.status)"
/>
</ElDescriptionsItem>
<ElDescriptionsItem label="标题">{{ detail.title }}</ElDescriptionsItem>
<ElDescriptionsItem label="联系方式">{{ detail.contact || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="提交人">{{ detail.creatorName || detail.creator }}</ElDescriptionsItem>
<ElDescriptionsItem label="提交时间">{{ detail.createTime }}</ElDescriptionsItem>
</ElDescriptions>
</BusinessFormSection>
<BusinessFormSection title="附件">
<BusinessAttachmentUploader :model-value="detail.attachments" disabled directory="feedback" />
</BusinessFormSection>
</div>
<div class="feedback-detail__col-right">
<BusinessFormSection title="详细描述">
<BusinessRichTextEditor
:model-value="detail.content"
disabled
:height="380"
upload-directory="feedback"
placeholder="--"
/>
</BusinessFormSection>
</div>
</div>
</BusinessFormDialog>
</template>
<style scoped>
.feedback-detail__grid {
display: grid;
grid-template-columns: 320px 1fr;
gap: 24px;
align-items: start;
}
.feedback-detail__col-left,
.feedback-detail__col-right {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
@media (width <= 1024px) {
.feedback-detail__grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,303 @@
<script setup lang="ts">
import { type Component, computed } from 'vue';
import { FEEDBACK_STATUS_DICT_CODE, FEEDBACK_TYPE_DICT_CODE } from '@/constants/dict';
import { getFeedbackStatusTagType } from '@/constants/status-tag';
import { useDict } from '@/hooks/business/dict';
import IconBugOutline from '~icons/mdi/bug-outline';
import IconEmoticonSadOutline from '~icons/mdi/emoticon-sad-outline';
import IconLightbulbOnOutline from '~icons/mdi/lightbulb-on-outline';
import IconTagOutline from '~icons/mdi/tag-outline';
defineOptions({ name: 'FeedbackFacet' });
interface Props {
/** 全部反馈总数 */
total: number;
/** 各分类计数key=分类码值字符串 */
typeCounts: Record<string, number>;
/** 各状态计数key=状态码值字符串 */
statusCounts: Record<string, number>;
/** 当前选中的分类码值(来自列表搜索参数) */
selectedType?: string | number | null;
/** 当前选中的状态码值 */
selectedStatus?: string | number | null;
/** 统计加载态 */
loading?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
selectedType: undefined,
selectedStatus: undefined,
loading: false
});
const emit = defineEmits<{
/** 切换分类筛选(已处理单选 toggle传最终值或 undefined */
selectType: [value: string | number | undefined];
/** 切换状态筛选 */
selectStatus: [value: string | number | undefined];
/** 清空分类 + 状态筛选 */
selectAll: [];
}>();
const { dictData: typeDictData } = useDict(FEEDBACK_TYPE_DICT_CODE);
const { dictData: statusDictData } = useDict(FEEDBACK_STATUS_DICT_CODE);
// 统计接口按字典全集返回计数,分面也使用全集,避免停用码值的历史数据被隐藏。
const typeOptions = computed(() => typeDictData.value.map(item => ({ label: item.label, value: item.value })));
const statusOptions = computed(() => statusDictData.value.map(item => ({ label: item.label, value: item.value })));
// ElTag type → 主题色变量,用于状态色点(颜色语义来源仍是 status-tag.ts
// 注意:本主题把 --el-color-info 设成了蓝色settings.ts otherColor.info=#2080f0
// 与「处理中」的 primary 蓝撞色;故分面里 info已忽略/兜底)单独落真实灰,仅作用于左侧圆点,
// 右侧表格状态标签仍走主题色不受影响。
const TAG_TYPE_COLOR_VAR: Record<string, string> = {
primary: 'var(--el-color-primary)',
success: 'var(--el-color-success)',
warning: 'var(--el-color-warning)',
danger: 'var(--el-color-danger)',
info: 'var(--el-text-color-secondary)'
};
function statusDotColor(value: string | number) {
return TAG_TYPE_COLOR_VAR[getFeedbackStatusTagType(value)] ?? TAG_TYPE_COLOR_VAR.info;
}
// 分类图标按字典码值映射feedback_type1 缺陷 / 2 体验问题 / 3 功能建议,口径见 constants/dict.ts
const TYPE_ICON_MAP: Record<string, Component> = {
'1': IconBugOutline,
'2': IconEmoticonSadOutline,
'3': IconLightbulbOnOutline
};
/** 分类前导图标,未匹配码值回退通用标签图标 */
function typeIcon(value: string | number): Component {
return TYPE_ICON_MAP[String(value)] ?? IconTagOutline;
}
/** 未选任何分类 / 状态时,「全部」高亮 */
const isAllActive = computed(
() =>
(props.selectedType === undefined || props.selectedType === null) &&
(props.selectedStatus === undefined || props.selectedStatus === null)
);
/** 选中值与候选项是否同一码值(统一 String 比较,兼容字典 value 的 number/string 形态) */
function isSame(selected: string | number | null | undefined, value: string | number) {
return selected !== undefined && selected !== null && String(selected) === String(value);
}
function handleType(value: string | number) {
emit('selectType', isSame(props.selectedType, value) ? undefined : value);
}
function handleStatus(value: string | number) {
emit('selectStatus', isSame(props.selectedStatus, value) ? undefined : value);
}
function handleAll() {
emit('selectAll');
}
</script>
<template>
<ElCard class="feedback-facet card-wrapper">
<div v-loading="loading" class="feedback-facet__scroll">
<button
type="button"
class="facet-item facet-item--all"
:class="{ 'is-active': isAllActive }"
:aria-pressed="isAllActive"
@click="handleAll"
>
<span class="facet-item__lead" aria-hidden="true"><icon-mdi-inbox-multiple-outline /></span>
<span class="facet-item__label">全部</span>
<span class="facet-item__count" :class="{ 'is-zero': total === 0 }">{{ total }}</span>
</button>
<div class="facet-group">
<p class="facet-group__title">分类</p>
<button
v-for="opt in typeOptions"
:key="opt.value"
type="button"
class="facet-item"
:class="{ 'is-active': isSame(selectedType, opt.value) }"
:aria-pressed="isSame(selectedType, opt.value)"
@click="handleType(opt.value)"
>
<span class="facet-item__lead" aria-hidden="true"><component :is="typeIcon(opt.value)" /></span>
<span class="facet-item__label">{{ opt.label }}</span>
<span class="facet-item__count" :class="{ 'is-zero': (typeCounts[String(opt.value)] ?? 0) === 0 }">
{{ typeCounts[String(opt.value)] ?? 0 }}
</span>
</button>
<p v-if="!typeOptions.length" class="facet-empty">分类字典未加载</p>
</div>
<div class="facet-group">
<p class="facet-group__title">状态</p>
<button
v-for="opt in statusOptions"
:key="opt.value"
type="button"
class="facet-item"
:class="{ 'is-active': isSame(selectedStatus, opt.value) }"
:aria-pressed="isSame(selectedStatus, opt.value)"
@click="handleStatus(opt.value)"
>
<span class="facet-item__lead" aria-hidden="true">
<span class="facet-item__dot" :style="{ backgroundColor: statusDotColor(opt.value) }" />
</span>
<span class="facet-item__label">{{ opt.label }}</span>
<span class="facet-item__count" :class="{ 'is-zero': (statusCounts[String(opt.value)] ?? 0) === 0 }">
{{ statusCounts[String(opt.value)] ?? 0 }}
</span>
</button>
<p v-if="!statusOptions.length" class="facet-empty">状态字典未加载</p>
</div>
</div>
</ElCard>
</template>
<style scoped lang="scss">
.feedback-facet {
display: flex;
flex-direction: column;
overflow: hidden;
:deep(.el-card__body) {
flex: 1;
min-height: 0;
padding: 8px;
}
}
.feedback-facet__scroll {
height: 100%;
overflow-y: auto;
padding-right: 2px;
}
.facet-item--all {
margin-bottom: 2px;
}
.facet-group + .facet-group {
margin-top: 2px;
}
.facet-group__title {
margin: 14px 10px 4px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--el-text-color-secondary);
}
.facet-empty {
margin: 4px 10px;
font-size: 12px;
color: var(--el-text-color-placeholder);
}
.facet-item {
position: relative;
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 10px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--el-text-color-regular);
font-size: 13px;
line-height: 1.4;
text-align: left;
cursor: pointer;
transition: background-color 0.18s ease-out;
}
.facet-item:hover {
background: var(--el-fill-color-light);
}
// 键盘聚焦可见环offset 内缩,避免被圆角裁切)
.facet-item:focus-visible {
outline: 2px solid var(--el-color-primary);
outline-offset: -2px;
}
.facet-item.is-active {
font-weight: 600;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
// 选中项左侧主题色强调条
.facet-item.is-active::before {
content: '';
position: absolute;
top: 50%;
left: 3px;
width: 3px;
height: 14px;
border-radius: 2px;
background: var(--el-color-primary);
transform: translateY(-50%);
}
// 前导标记列:全部=收件箱图标 / 状态=语义色点,对齐同一宽度
.facet-item__lead {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 18px;
font-size: 16px;
color: var(--el-text-color-secondary);
}
.facet-item:hover .facet-item__lead,
.facet-item.is-active .facet-item__lead {
color: var(--el-color-primary);
}
.facet-item__dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.facet-item__label {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.facet-item__count {
flex-shrink: 0;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--el-text-color-secondary);
}
// 计数为 0 的项弱化,避免一排 0 抢视线
.facet-item__count.is-zero {
color: var(--el-text-color-placeholder);
}
.facet-item.is-active .facet-item__count {
color: var(--el-color-primary);
}
// 尊重系统「减少动态效果」偏好
@media (prefers-reduced-motion: reduce) {
.facet-item {
transition: none;
}
}
</style>

View File

@@ -0,0 +1,252 @@
<script setup lang="ts">
import { computed, nextTick, reactive, ref, watch } from 'vue';
import { FEEDBACK_TYPE_DICT_CODE } from '@/constants/dict';
import { fetchCreateFeedback, fetchUpdateFeedback } from '@/service/api';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessFormSection from '@/components/custom/business-form-section.vue';
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
import DictSelect from '@/components/custom/dict-select.vue';
defineOptions({ name: 'FeedbackOperateDialog' });
type OperateMode = 'create' | 'edit';
interface Props {
mode: OperateMode;
rowData?: Api.Feedback.FeedbackItem | null;
}
const props = withDefaults(defineProps<Props>(), {
rowData: null
});
const emit = defineEmits<{
submitted: [];
}>();
const visible = defineModel<boolean>('visible', { default: false });
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const submitting = ref(false);
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
const richTextEditorRef = ref<InstanceType<typeof BusinessRichTextEditor> | null>(null);
interface FormModel {
/** DictSelect 选中后为字符串;提交时 Number() 转字典 Integer非 ID允许转 number */
type: string | number | null;
title: string;
content: string;
contact: string;
attachments: Api.Project.AttachmentItem[];
}
const model = reactive<FormModel>({
type: null,
title: '',
content: '',
contact: '',
attachments: []
});
const dialogTitle = computed(() => (props.mode === 'create' ? '提交意见反馈' : '编辑意见反馈'));
/** 富文本是否为空:去标签去 &nbsp; 后无文本,且无图片 */
function isEmptyRichText(html: string | null | undefined) {
if (!html) {
return true;
}
const text = html
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, '')
.trim();
if (text) {
return false;
}
return !/<img\b/i.test(html);
}
const rules = computed(
() =>
({
type: [createRequiredRule('请选择反馈分类')],
title: [createRequiredRule('请输入标题')],
content: [
{
validator: (_rule, _value, callback) => {
if (isEmptyRichText(model.content)) {
callback(new Error('请输入详细描述'));
return;
}
callback();
},
trigger: 'blur'
}
]
}) satisfies Record<string, App.Global.FormRule[]>
);
function applyRowDataToModel() {
const row = props.rowData;
if (props.mode === 'edit' && row) {
// 字典选项 value 为字符串,回显需转字符串才能与下拉项严格匹配(否则显示原始数字)
model.type = row.type === null || row.type === undefined ? null : String(row.type);
model.title = row.title ?? '';
model.content = row.content ?? '';
model.contact = row.contact ?? '';
model.attachments = row.attachments ? [...row.attachments] : [];
return;
}
model.type = null;
model.title = '';
model.content = '';
model.contact = '';
model.attachments = [];
}
async function handleConfirm() {
await validate();
if (attachmentUploaderRef.value?.hasUploading) {
window.$message?.warning('附件还在上传中,请稍候');
return;
}
submitting.value = true;
const basePayload: Api.Feedback.FeedbackSubmitParams = {
type: Number(model.type),
title: model.title.trim(),
content: model.content,
contact: model.contact.trim() || undefined,
attachments: [...model.attachments]
};
const { error } =
props.mode === 'edit' && props.rowData
? await fetchUpdateFeedback({ id: props.rowData.id, ...basePayload })
: await fetchCreateFeedback(basePayload);
submitting.value = false;
if (error) {
return;
}
// 成功后先 commit置 committed=true避免 destroy-on-close 触发 rollback 误删已存文件/图片
await Promise.all([attachmentUploaderRef.value?.commit(), richTextEditorRef.value?.commit()]);
window.$message?.success(props.mode === 'edit' ? '保存成功' : '提交成功');
visible.value = false;
emit('submitted');
}
async function handleCancel() {
await Promise.all([attachmentUploaderRef.value?.rollback(), richTextEditorRef.value?.rollback()]);
}
watch(visible, async value => {
if (!value) {
return;
}
applyRowDataToModel();
await nextTick();
// 让附件 / 富文本组件把当前 model 视作 original必须在 model 填充之后
attachmentUploaderRef.value?.initSession();
richTextEditorRef.value?.initSession();
formRef.value?.clearValidate();
});
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="dialogTitle"
width="980px"
max-body-height="76vh"
:confirm-loading="submitting"
@confirm="handleConfirm"
@cancel="handleCancel"
>
<ElForm
ref="formRef"
:model="model"
:rules="rules"
label-position="top"
:validate-on-rule-change="false"
class="feedback-operate-dialog__form"
>
<div class="feedback-operate-dialog__grid">
<div class="feedback-operate-dialog__col-left">
<BusinessFormSection title="反馈信息">
<ElFormItem label="反馈分类" prop="type">
<DictSelect v-model="model.type" :dict-code="FEEDBACK_TYPE_DICT_CODE" placeholder="请选择反馈分类" />
</ElFormItem>
<ElFormItem label="标题" prop="title">
<ElInput v-model="model.title" maxlength="100" show-word-limit placeholder="请输入标题" />
</ElFormItem>
<ElFormItem label="联系方式">
<ElInput v-model="model.contact" maxlength="100" placeholder="选填,方便我们联系你" />
</ElFormItem>
</BusinessFormSection>
<BusinessFormSection title="附件">
<ElFormItem class="feedback-operate-dialog__attachment-item">
<BusinessAttachmentUploader
ref="attachmentUploaderRef"
v-model="model.attachments"
directory="feedback"
/>
</ElFormItem>
</BusinessFormSection>
</div>
<div class="feedback-operate-dialog__col-right">
<BusinessFormSection title="详细描述">
<ElFormItem prop="content" class="feedback-operate-dialog__desc-item">
<BusinessRichTextEditor
ref="richTextEditorRef"
v-model="model.content"
:height="380"
upload-directory="feedback"
placeholder="请输入详细描述"
/>
</ElFormItem>
</BusinessFormSection>
</div>
</div>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped>
.feedback-operate-dialog__grid {
display: grid;
grid-template-columns: 320px 1fr;
gap: 24px;
align-items: start;
}
.feedback-operate-dialog__col-left,
.feedback-operate-dialog__col-right {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
.feedback-operate-dialog__desc-item,
.feedback-operate-dialog__attachment-item {
margin-bottom: 0;
}
@media (width <= 1024px) {
.feedback-operate-dialog__grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { FEEDBACK_STATUS_DICT_CODE } from '@/constants/dict';
import { fetchGetUserSimpleList } from '@/service/api';
import { useDict } from '@/hooks/business/dict';
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
defineOptions({ name: 'FeedbackSearch' });
const emit = defineEmits<{
reset: [];
search: [];
}>();
const model = defineModel<Api.Feedback.FeedbackSearchParams>('model', { required: true });
// 提交人下拉选项全员简易列表value 取用户 idID 铁律 string
const userOptions = ref<{ label: string; value: string }[]>([]);
const { dictOptions: statusOptions } = useDict(FEEDBACK_STATUS_DICT_CODE);
// 分类保留在左侧分面;搜索区补充标题关键词、提交人、状态
const fields = computed<SearchField[]>(() => [
{ key: 'title', label: '标题', type: 'input', placeholder: '请输入标题关键词' },
{
key: 'creator',
label: '提交人',
type: 'select',
placeholder: '请选择提交人',
filterable: true,
options: userOptions.value
},
{
key: 'status',
label: '状态',
type: 'select',
placeholder: '请选择状态',
options: statusOptions.value
}
]);
async function loadUserOptions() {
const { data, error } = await fetchGetUserSimpleList();
if (!error && data) {
userOptions.value = data.map(item => ({ label: item.nickname, value: item.id }));
}
}
function reset() {
emit('reset');
}
function search() {
// 输入期不实时 trim避免受控 input 吃掉词间空格);提交前规整一次,空串归一为 undefined
model.value.title = model.value.title?.trim() || undefined;
emit('search');
}
onMounted(loadUserOptions);
</script>
<template>
<TableSearchFields v-model="model" :fields="fields" :columns="4" @reset="reset" @search="search" />
</template>
<style scoped></style>

View File

@@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { FEEDBACK_STATUS_DICT_CODE } from '@/constants/dict';
import { fetchUpdateFeedbackStatus } from '@/service/api';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import DictSelect from '@/components/custom/dict-select.vue';
defineOptions({ name: 'FeedbackStatusDialog' });
const visible = defineModel<boolean>('visible', { default: false });
interface Props {
id?: string | null;
currentStatus?: number | null;
}
const props = defineProps<Props>();
const emit = defineEmits<{
submitted: [];
}>();
const submitting = ref(false);
const status = ref<string | number | null>(null);
const confirmDisabled = computed(() => status.value === null || status.value === undefined || status.value === '');
async function handleConfirm() {
if (!props.id || confirmDisabled.value) {
return;
}
submitting.value = true;
// status 是字典编码(非 IDDictSelect 选中为字符串,转 Integer 提交
const { error } = await fetchUpdateFeedbackStatus(props.id, Number(status.value));
submitting.value = false;
if (error) {
return;
}
window.$message?.success('状态已更新');
visible.value = false;
emit('submitted');
}
watch(visible, value => {
if (value) {
// 字典选项 value 为字符串,回显需转字符串才能与下拉项严格匹配(否则显示原始数字)
const current = props.currentStatus;
status.value = current === null || current === undefined ? null : String(current);
}
});
</script>
<template>
<BusinessFormDialog
v-model="visible"
title="修改处理状态"
preset="sm"
:confirm-loading="submitting"
:confirm-disabled="confirmDisabled"
@confirm="handleConfirm"
>
<ElForm label-position="top">
<ElFormItem label="处理状态">
<DictSelect
v-model="status"
:dict-code="FEEDBACK_STATUS_DICT_CODE"
:clearable="false"
placeholder="请选择处理状态"
/>
</ElFormItem>
</ElForm>
</BusinessFormDialog>
</template>

View File

@@ -1,7 +0,0 @@
<script setup lang="ts"></script>
<template>
<LookForward />
</template>
<style scoped></style>

View File

@@ -1,7 +0,0 @@
<script setup lang="ts"></script>
<template>
<LookForward />
</template>
<style scoped></style>

View File

@@ -1,7 +0,0 @@
<script setup lang="ts"></script>
<template>
<LookForward />
</template>
<style scoped></style>

View File

@@ -1,24 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useRouterPush } from '@/hooks/common/router';
import { $t } from '@/locales';
const route = useRoute();
const { routerPushByKey } = useRouterPush();
const routeQuery = computed(() => JSON.stringify(route.query));
</script>
<template>
<div>
<LookForward>
<div>
<ElButton @click="routerPushByKey('function_tab')">{{ $t('page.function.multiTab.backTab') }}</ElButton>
<div class="py-24px">{{ $t('page.function.multiTab.routeParam') }}: {{ routeQuery }}</div>
</div>
</LookForward>
</div>
</template>
<style scoped></style>

View File

@@ -1,57 +0,0 @@
<script setup lang="ts">
import { fetchCustomBackendError } from '@/service/api';
import { $t } from '@/locales';
async function logout() {
await fetchCustomBackendError('8888', $t('request.logoutMsg'));
}
async function logoutWithModal() {
await fetchCustomBackendError('7777', $t('request.logoutWithModalMsg'));
}
async function refreshToken() {
await fetchCustomBackendError('9999', $t('request.tokenExpired'));
}
async function handleRepeatedMessageError() {
await Promise.all([
fetchCustomBackendError('2222', $t('page.function.request.repeatedErrorMsg1')),
fetchCustomBackendError('2222', $t('page.function.request.repeatedErrorMsg1')),
fetchCustomBackendError('2222', $t('page.function.request.repeatedErrorMsg1')),
fetchCustomBackendError('3333', $t('page.function.request.repeatedErrorMsg2')),
fetchCustomBackendError('3333', $t('page.function.request.repeatedErrorMsg2')),
fetchCustomBackendError('3333', $t('page.function.request.repeatedErrorMsg2'))
]);
}
async function handleRepeatedModalError() {
await Promise.all([
fetchCustomBackendError('7777', $t('request.logoutWithModalMsg')),
fetchCustomBackendError('7777', $t('request.logoutWithModalMsg')),
fetchCustomBackendError('7777', $t('request.logoutWithModalMsg'))
]);
}
</script>
<template>
<ElSpace direction="vertical" fill :size="16">
<ElCard :header="$t('request.logout')" class="card-wrapper">
<ElButton @click="logout">{{ $t('common.trigger') }}</ElButton>
</ElCard>
<ElCard :header="$t('request.logoutWithModal')" class="card-wrapper">
<ElButton @click="logoutWithModal">{{ $t('common.trigger') }}</ElButton>
</ElCard>
<ElCard :header="$t('request.refreshToken')" class="card-wrapper">
<ElButton @click="refreshToken">{{ $t('common.trigger') }}</ElButton>
</ElCard>
<ElCard :header="$t('page.function.request.repeatedErrorOccurOnce')" class="card-wrapper">
<ElButton @click="handleRepeatedMessageError">{{ $t('page.function.request.repeatedError') }}(Message)</ElButton>
<ElButton class="ml-12px" @click="handleRepeatedModalError">
{{ $t('page.function.request.repeatedError') }}(Modal)
</ElButton>
</ElCard>
</ElSpace>
</template>
<style scoped></style>

View File

@@ -1,7 +0,0 @@
<script setup lang="ts"></script>
<template>
<LookForward />
</template>
<style scoped></style>

View File

@@ -1,62 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useTabStore } from '@/store/modules/tab';
import { useRouterPush } from '@/hooks/common/router';
import { $t } from '@/locales';
defineOptions({ name: 'TabPage' });
const tabStore = useTabStore();
const { routerPushByKey } = useRouterPush();
const tabLabel = ref('');
function changeTabLabel() {
tabStore.setTabLabel(tabLabel.value);
}
function resetTabLabel() {
tabStore.resetTabLabel();
}
</script>
<template>
<ElSpace direction="vertical" fill :size="16">
<ElCard :header="$t('page.function.tab.tabOperate.title')" class="card-wrapper">
<ElDivider content-position="left">{{ $t('page.function.tab.tabOperate.addTab') }}</ElDivider>
<ElButton @click="routerPushByKey('system_user')">{{ $t('page.function.tab.tabOperate.addTabDesc') }}</ElButton>
<ElDivider content-position="left">{{ $t('page.function.tab.tabOperate.closeTab') }}</ElDivider>
<ElSpace>
<ElButton @click="tabStore.removeActiveTab">
{{ $t('page.function.tab.tabOperate.closeCurrentTab') }}
</ElButton>
<ElButton @click="tabStore.removeTabByRouteName('system_user')">
{{ $t('page.function.tab.tabOperate.closeAboutTab') }}
</ElButton>
</ElSpace>
<ElDivider content-position="left">{{ $t('page.function.tab.tabOperate.addMultiTab') }}</ElDivider>
<ElSpace>
<ElButton @click="routerPushByKey('function_multi-tab')">
{{ $t('page.function.tab.tabOperate.addMultiTabDesc1') }}
</ElButton>
<ElButton @click="routerPushByKey('function_multi-tab', { query: { a: '1' } })">
{{ $t('page.function.tab.tabOperate.addMultiTabDesc2') }}
</ElButton>
</ElSpace>
</ElCard>
<ElCard :header="$t('page.function.tab.tabTitle.title')" class="card-wrapper">
<ElDivider content-position="left">{{ $t('page.function.tab.tabTitle.changeTitle') }}</ElDivider>
<ElInput v-model="tabLabel" class="max-w-240px">
<template #append>
<ElButton type="primary" @click="changeTabLabel">{{ $t('page.function.tab.tabTitle.change') }}</ElButton>
</template>
</ElInput>
<ElDivider content-position="left">{{ $t('page.function.tab.tabTitle.resetTitle') }}</ElDivider>
<ElButton type="danger" plain class="w-80px" @click="resetTabLabel">
{{ $t('page.function.tab.tabTitle.reset') }}
</ElButton>
</ElCard>
</ElSpace>
</template>
<style scoped></style>

View File

@@ -1,99 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useLoading } from '@sa/hooks';
import { useAppStore } from '@/store/modules/app';
import { useAuthStore } from '@/store/modules/auth';
import { useTabStore } from '@/store/modules/tab';
import { useAuth } from '@/hooks/business/auth';
import { $t } from '@/locales';
defineOptions({ name: 'ToggleAuth' });
const route = useRoute();
const appStore = useAppStore();
const authStore = useAuthStore();
const tabStore = useTabStore();
const { hasAuth } = useAuth();
const { loading, startLoading, endLoading } = useLoading();
type AccountKey = 'super' | 'admin' | 'user';
interface Account {
key: AccountKey;
label: string;
userName: string;
password: string;
}
const accounts = computed<Account[]>(() => [
{
key: 'super',
label: $t('page.login.pwdLogin.superAdmin'),
userName: 'Super',
password: '123456'
},
{
key: 'admin',
label: $t('page.login.pwdLogin.admin'),
userName: 'Admin',
password: '123456'
},
{
key: 'user',
label: $t('page.login.pwdLogin.user'),
userName: 'User',
password: '123456'
}
]);
const loginAccount = ref<AccountKey>('super');
async function handleToggleAccount(account: Account) {
loginAccount.value = account.key;
startLoading();
await authStore.login(account.userName, account.password, false);
tabStore.initTabStore(route);
endLoading();
appStore.reloadPage();
}
</script>
<template>
<ElSpace direction="vertical" fill :size="16">
<ElCard :header="$t('route.function_toggle-auth')" class="card-wrapper">
<ElDescriptions direction="vertical" border :column="1">
<ElDescriptionsItem :label="$t('page.system.user.userRole')">
<ElSpace>
<ElTag v-for="role in authStore.userInfo.roles" :key="role">{{ role }}</ElTag>
</ElSpace>
</ElDescriptionsItem>
<ElDescriptionsItem ions-item :label="$t('page.function.toggleAuth.toggleAccount')">
<ElSpace>
<ElButton
v-for="account in accounts"
:key="account.key"
:loading="loading && loginAccount === account.key"
:disabled="loading && loginAccount !== account.key"
@click="handleToggleAccount(account)"
>
{{ account.label }}
</ElButton>
</ElSpace>
</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
<ElCard :header="$t('page.function.toggleAuth.authHook')" class="card-wrapper">
<ElSpace>
<ElButton v-if="hasAuth('B_CODE1')">{{ $t('page.function.toggleAuth.superAdminVisible') }}</ElButton>
<ElButton v-if="hasAuth('B_CODE2')">{{ $t('page.function.toggleAuth.adminVisible') }}</ElButton>
<ElButton v-if="hasAuth('B_CODE3')">
{{ $t('page.function.toggleAuth.adminOrUserVisible') }}
</ElButton>
</ElSpace>
</ElCard>
</ElSpace>
</template>
<style scoped></style>

View File

@@ -0,0 +1,246 @@
<script setup lang="tsx">
import { computed, reactive, ref } from 'vue';
import { INFRA_OPERATE_TYPE_DICT_CODE } from '@/constants/dict';
import { fetchExportApiAccessLog, fetchGetApiAccessLog, fetchGetApiAccessLogPage } from '@/service/api';
import { useAuth } from '@/hooks/business/auth';
import { useUIPaginatedTable } from '@/hooks/common/table';
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
import DictText from '@/components/custom/dict-text.vue';
import LogDetailDialog from '../shared/log-detail-dialog.vue';
import {
type LogDetailSection,
LogPermission,
downloadBlob,
formatDateTime,
formatDuration,
getLogExportFileName
} from '../shared';
import ApiAccessLogSearch from './modules/search.vue';
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
defineOptions({ name: 'ApiAccessLogTab' });
type ApiAccessLogPageResponse = Awaited<ReturnType<typeof fetchGetApiAccessLogPage>>;
function createSearchParams(): Api.SystemLog.ApiAccess.SearchParams {
return {
pageNo: 1,
pageSize: 10,
userId: undefined,
userType: undefined,
applicationName: undefined,
requestUrl: undefined,
beginTime: undefined,
duration: undefined,
resultCode: undefined
};
}
function transformPageResult(response: ApiAccessLogPageResponse, pageNo: number, pageSize: number) {
if (!response.error && response.data) {
return {
data: response.data.list,
pageNum: pageNo,
pageSize,
total: response.data.total
};
}
return {
data: [],
pageNum: 1,
pageSize,
total: 0
};
}
const { hasAuth } = useAuth();
const searchParams = reactive(createSearchParams());
const detailVisible = ref(false);
const currentRow = ref<Api.SystemLog.ApiAccess.Log | null>(null);
const exporting = ref(false);
const canExport = computed(() => hasAuth(LogPermission.ApiAccessExport));
const detailSections: LogDetailSection[] = [
{
title: '请求信息',
fields: [
{ label: '日志编号', key: 'id' },
{ label: '链路追踪编号', key: 'traceId' },
{ label: '应用名', key: 'applicationName' },
{ label: '请求方式', key: 'requestMethod' },
{ label: '请求地址', key: 'requestUrl', span: 2 },
{ label: '开始时间', key: 'beginTime', type: 'datetime' },
{ label: '结束时间', key: 'endTime', type: 'datetime' },
{ label: '执行时长', formatter: detail => formatDuration(detail.duration) },
{ label: '结果码', key: 'resultCode' },
{ label: '结果提示', key: 'resultMsg', span: 2 }
]
},
{
title: '业务上下文',
fields: [
{ label: '用户姓名', key: 'userNickname' },
{ label: '用户编号', key: 'userId' },
{ label: '操作模块', key: 'operateModule' },
{ label: '操作名', key: 'operateName' },
{ label: '操作分类', key: 'operateType', type: 'dict', dictCode: INFRA_OPERATE_TYPE_DICT_CODE },
{ label: '用户IP', key: 'userIp' },
{ label: '浏览器UA', key: 'userAgent', type: 'multiline' }
]
},
{
title: '报文内容',
fields: [
{ label: '请求参数', key: 'requestParams', type: 'multiline' },
{ label: '响应结果', key: 'responseBody', type: 'multiline' }
]
}
];
const { columns, columnChecks, data, loading, getData, getDataByPage, mobilePagination } = useUIPaginatedTable<
ApiAccessLogPageResponse,
Api.SystemLog.ApiAccess.Log
>({
paginationProps: {
currentPage: searchParams.pageNo,
pageSize: searchParams.pageSize
},
api: () => fetchGetApiAccessLogPage(searchParams),
transform: response => transformPageResult(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
onPaginationParamsChange: params => {
searchParams.pageNo = params.currentPage ?? 1;
searchParams.pageSize = params.pageSize ?? 10;
},
columns: () => [
{ prop: 'index', type: 'index', label: '序号', width: 64 },
{ prop: 'applicationName', label: '应用名', minWidth: 140, showOverflowTooltip: true },
{
prop: 'userNickname',
label: '用户姓名',
minWidth: 120,
showOverflowTooltip: true,
formatter: row => row.userNickname || '--'
},
{ prop: 'requestMethod', label: '请求方式', width: 100, align: 'center' },
{ prop: 'requestUrl', label: '请求地址', minWidth: 220, showOverflowTooltip: true },
{ prop: 'operateModule', label: '操作模块', minWidth: 140, showOverflowTooltip: true },
{ prop: 'operateName', label: '操作名', minWidth: 140, showOverflowTooltip: true },
{
prop: 'operateType',
label: '操作分类',
minWidth: 120,
formatter: row => <DictText dictCode={INFRA_OPERATE_TYPE_DICT_CODE} value={row.operateType} />
},
{ prop: 'resultCode', label: '结果码', width: 100, align: 'center' },
{ prop: 'duration', label: '执行时长', width: 120, formatter: row => formatDuration(row.duration) },
{ prop: 'beginTime', label: '请求时间', minWidth: 180, formatter: row => formatDateTime(row.beginTime) },
{
prop: 'operate',
label: '操作',
width: 90,
align: 'center',
fixed: 'right',
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
}
]
});
function getRowActions(row: Api.SystemLog.ApiAccess.Log): BusinessTableAction[] {
return [
{
key: 'view',
label: '查看',
buttonType: 'primary',
icon: IconMdiEyeOutline,
onClick: () => openDetail(row)
}
];
}
function openDetail(row: Api.SystemLog.ApiAccess.Log) {
currentRow.value = row;
detailVisible.value = true;
}
async function reloadTable(page = searchParams.pageNo) {
await getDataByPage(page);
}
function resetSearchParams() {
Object.assign(searchParams, createSearchParams());
reloadTable(1);
}
function handleSearch() {
reloadTable(1);
}
async function handleExport() {
exporting.value = true;
const { error, data: blob } = await fetchExportApiAccessLog(searchParams);
exporting.value = false;
if (error || !blob) {
return;
}
downloadBlob(blob, getLogExportFileName('API访问日志'));
}
</script>
<template>
<div class="h-full min-h-0 flex-col-stretch gap-16px overflow-hidden">
<ApiAccessLogSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">
<template #header>
<div class="flex items-center justify-between gap-12px">
<div class="flex items-center gap-10px">
<p>API访问日志列表</p>
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag>
</div>
<TableHeaderOperation
v-model:columns="columnChecks"
:disabled-delete="true"
:loading="loading"
@refresh="getData"
>
<template #default>
<ElButton v-if="canExport" plain type="primary" :loading="exporting" @click="handleExport">
<template #icon>
<icon-mdi-download class="text-icon" />
</template>
导出
</ElButton>
</template>
</TableHeaderOperation>
</div>
</template>
<div class="flex-1">
<ElTable v-loading="loading" height="100%" border row-key="id" :data="data">
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
</ElTable>
</div>
<div class="mt-20px flex justify-end">
<ElPagination
v-if="mobilePagination.total"
layout="total,prev,pager,next,sizes"
v-bind="mobilePagination"
@current-change="mobilePagination['current-change']"
@size-change="mobilePagination['size-change']"
/>
</div>
</ElCard>
<LogDetailDialog
v-model:visible="detailVisible"
title="API访问日志详情"
:row-data="currentRow"
:sections="detailSections"
:fetch-detail="fetchGetApiAccessLog"
/>
</div>
</template>

View File

@@ -0,0 +1,81 @@
<script setup lang="ts">
import { computed } from 'vue';
import { SYSTEM_USER_TYPE_DICT_CODE } from '@/constants/dict';
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
defineOptions({ name: 'ApiAccessLogSearch' });
const emit = defineEmits<{
reset: [];
search: [];
}>();
const model = defineModel<Api.SystemLog.ApiAccess.SearchParams>('model', { required: true });
const fields = computed<SearchField[]>(() => [
{
key: 'applicationName',
label: '应用名',
type: 'input',
placeholder: '请输入应用名'
},
{
key: 'requestUrl',
label: '请求地址',
type: 'input',
placeholder: '请输入请求地址'
},
{
key: 'resultCode',
label: '结果码',
type: 'input',
placeholder: '请输入结果码',
transformValue: value => {
const text = String(value ?? '').trim();
if (!text) return undefined;
const resultCode = Number(text);
return Number.isFinite(resultCode) ? resultCode : undefined;
},
resolveValue: value => (value === null || value === undefined ? '' : String(value))
},
{
key: 'duration',
label: '最小时长(ms)',
type: 'input',
placeholder: '请输入执行时长下限',
transformValue: value => {
const text = String(value ?? '').trim();
if (!text) return undefined;
const duration = Number(text);
return Number.isFinite(duration) ? duration : undefined;
},
resolveValue: value => (value === null || value === undefined ? '' : String(value))
},
{
key: 'userId',
label: '用户编号',
type: 'input',
placeholder: '请输入用户编号'
},
{
key: 'userType',
label: '用户类型',
type: 'dict',
placeholder: '请选择用户类型',
dictCode: SYSTEM_USER_TYPE_DICT_CODE,
filterable: true
},
{
key: 'beginTime',
label: '请求时间',
type: 'dateRange',
placeholder: '请选择请求时间',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
format: 'YYYY-MM-DD HH:mm:ss'
}
]);
</script>
<template>
<TableSearchFields v-model="model" :fields="fields" :columns="4" @reset="emit('reset')" @search="emit('search')" />
</template>

View File

@@ -0,0 +1,238 @@
<script setup lang="tsx">
import { computed, reactive, ref } from 'vue';
import { INFRA_API_ERROR_LOG_PROCESS_STATUS_DICT_CODE } from '@/constants/dict';
import { fetchExportApiErrorLog, fetchGetApiErrorLog, fetchGetApiErrorLogPage } from '@/service/api';
import { useAuth } from '@/hooks/business/auth';
import { useUIPaginatedTable } from '@/hooks/common/table';
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
import DictText from '@/components/custom/dict-text.vue';
import LogDetailDialog from '../shared/log-detail-dialog.vue';
import { type LogDetailSection, LogPermission, downloadBlob, formatDateTime, getLogExportFileName } from '../shared';
import ApiErrorLogSearch from './modules/search.vue';
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
defineOptions({ name: 'ApiErrorLogTab' });
type ApiErrorLogPageResponse = Awaited<ReturnType<typeof fetchGetApiErrorLogPage>>;
function createSearchParams(): Api.SystemLog.ApiError.SearchParams {
return {
pageNo: 1,
pageSize: 10,
userId: undefined,
userType: undefined,
applicationName: undefined,
requestUrl: undefined,
exceptionTime: undefined,
processStatus: undefined
};
}
function transformPageResult(response: ApiErrorLogPageResponse, pageNo: number, pageSize: number) {
if (!response.error && response.data) {
return {
data: response.data.list,
pageNum: pageNo,
pageSize,
total: response.data.total
};
}
return {
data: [],
pageNum: 1,
pageSize,
total: 0
};
}
const { hasAuth } = useAuth();
const searchParams = reactive(createSearchParams());
const detailVisible = ref(false);
const currentRow = ref<Api.SystemLog.ApiError.Log | null>(null);
const exporting = ref(false);
const canExport = computed(() => hasAuth(LogPermission.ApiErrorExport));
const detailSections: LogDetailSection[] = [
{
title: '异常信息',
fields: [
{ label: '编号', key: 'id' },
{ label: '链路追踪编号', key: 'traceId' },
{ label: '应用名', key: 'applicationName' },
{ label: '请求方式', key: 'requestMethod' },
{ label: '请求地址', key: 'requestUrl', span: 2 },
{ label: '异常时间', key: 'exceptionTime', type: 'datetime' },
{ label: '异常名', key: 'exceptionName' },
{ label: '异常消息', key: 'exceptionMessage', type: 'multiline' },
{ label: '根因消息', key: 'exceptionRootCauseMessage', type: 'multiline' }
]
},
{
title: '上下文',
fields: [
{ label: '用户姓名', key: 'userNickname' },
{ label: '用户编号', key: 'userId' },
{ label: '用户IP', key: 'userIp' },
{ label: '浏览器UA', key: 'userAgent', type: 'multiline' },
{ label: '请求参数', key: 'requestParams', type: 'multiline' }
]
},
{
title: '堆栈与处理',
fields: [
{ label: '异常类名', key: 'exceptionClassName' },
{ label: '异常文件', key: 'exceptionFileName' },
{ label: '异常方法', key: 'exceptionMethodName' },
{ label: '异常行号', key: 'exceptionLineNumber' },
{ label: '处理状态', key: 'processStatus', type: 'dict', dictCode: INFRA_API_ERROR_LOG_PROCESS_STATUS_DICT_CODE },
{ label: '处理时间', key: 'processTime', type: 'datetime' },
{ label: '处理用户编号', key: 'processUserId', span: 2 },
{ label: '异常栈轨迹', key: 'exceptionStackTrace', type: 'multiline' }
]
}
];
const { columns, columnChecks, data, loading, getData, getDataByPage, mobilePagination } = useUIPaginatedTable<
ApiErrorLogPageResponse,
Api.SystemLog.ApiError.Log
>({
paginationProps: {
currentPage: searchParams.pageNo,
pageSize: searchParams.pageSize
},
api: () => fetchGetApiErrorLogPage(searchParams),
transform: response => transformPageResult(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
onPaginationParamsChange: params => {
searchParams.pageNo = params.currentPage ?? 1;
searchParams.pageSize = params.pageSize ?? 10;
},
columns: () => [
{ prop: 'index', type: 'index', label: '序号', width: 64 },
{ prop: 'applicationName', label: '应用名', minWidth: 140, showOverflowTooltip: true },
{
prop: 'userNickname',
label: '用户姓名',
minWidth: 120,
showOverflowTooltip: true,
formatter: row => row.userNickname || '--'
},
{ prop: 'requestMethod', label: '请求方式', width: 100, align: 'center' },
{ prop: 'requestUrl', label: '请求地址', minWidth: 220, showOverflowTooltip: true },
{ prop: 'exceptionName', label: '异常名', minWidth: 180, showOverflowTooltip: true },
{
prop: 'processStatus',
label: '处理状态',
minWidth: 120,
formatter: row => <DictText dictCode={INFRA_API_ERROR_LOG_PROCESS_STATUS_DICT_CODE} value={row.processStatus} />
},
{ prop: 'exceptionTime', label: '异常时间', minWidth: 180, formatter: row => formatDateTime(row.exceptionTime) },
{
prop: 'operate',
label: '操作',
width: 90,
align: 'center',
fixed: 'right',
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
}
]
});
function getRowActions(row: Api.SystemLog.ApiError.Log): BusinessTableAction[] {
return [
{
key: 'view',
label: '查看',
buttonType: 'primary',
icon: IconMdiEyeOutline,
onClick: () => openDetail(row)
}
];
}
function openDetail(row: Api.SystemLog.ApiError.Log) {
currentRow.value = row;
detailVisible.value = true;
}
async function reloadTable(page = searchParams.pageNo) {
await getDataByPage(page);
}
function resetSearchParams() {
Object.assign(searchParams, createSearchParams());
reloadTable(1);
}
function handleSearch() {
reloadTable(1);
}
async function handleExport() {
exporting.value = true;
const { error, data: blob } = await fetchExportApiErrorLog(searchParams);
exporting.value = false;
if (error || !blob) {
return;
}
downloadBlob(blob, getLogExportFileName('API错误日志'));
}
</script>
<template>
<div class="h-full min-h-0 flex-col-stretch gap-16px overflow-hidden">
<ApiErrorLogSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">
<template #header>
<div class="flex items-center justify-between gap-12px">
<div class="flex items-center gap-10px">
<p>API错误日志列表</p>
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag>
</div>
<TableHeaderOperation
v-model:columns="columnChecks"
:disabled-delete="true"
:loading="loading"
@refresh="getData"
>
<template #default>
<ElButton v-if="canExport" plain type="primary" :loading="exporting" @click="handleExport">
<template #icon>
<icon-mdi-download class="text-icon" />
</template>
导出
</ElButton>
</template>
</TableHeaderOperation>
</div>
</template>
<div class="flex-1">
<ElTable v-loading="loading" height="100%" border row-key="id" :data="data">
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
</ElTable>
</div>
<div class="mt-20px flex justify-end">
<ElPagination
v-if="mobilePagination.total"
layout="total,prev,pager,next,sizes"
v-bind="mobilePagination"
@current-change="mobilePagination['current-change']"
@size-change="mobilePagination['size-change']"
/>
</div>
</ElCard>
<LogDetailDialog
v-model:visible="detailVisible"
title="API错误日志详情"
:row-data="currentRow"
:sections="detailSections"
:fetch-detail="fetchGetApiErrorLog"
/>
</div>
</template>

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
import { computed } from 'vue';
import { INFRA_API_ERROR_LOG_PROCESS_STATUS_DICT_CODE, SYSTEM_USER_TYPE_DICT_CODE } from '@/constants/dict';
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
defineOptions({ name: 'ApiErrorLogSearch' });
const emit = defineEmits<{
reset: [];
search: [];
}>();
const model = defineModel<Api.SystemLog.ApiError.SearchParams>('model', { required: true });
const fields = computed<SearchField[]>(() => [
{
key: 'applicationName',
label: '应用名',
type: 'input',
placeholder: '请输入应用名'
},
{
key: 'requestUrl',
label: '请求地址',
type: 'input',
placeholder: '请输入请求地址'
},
{
key: 'processStatus',
label: '处理状态',
type: 'dict',
placeholder: '请选择处理状态',
dictCode: INFRA_API_ERROR_LOG_PROCESS_STATUS_DICT_CODE,
filterable: true
},
{
key: 'userId',
label: '用户编号',
type: 'input',
placeholder: '请输入用户编号'
},
{
key: 'userType',
label: '用户类型',
type: 'dict',
placeholder: '请选择用户类型',
dictCode: SYSTEM_USER_TYPE_DICT_CODE,
filterable: true
},
{
key: 'exceptionTime',
label: '异常时间',
type: 'dateRange',
placeholder: '请选择异常时间',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
format: 'YYYY-MM-DD HH:mm:ss'
}
]);
</script>
<template>
<TableSearchFields v-model="model" :fields="fields" :columns="4" @reset="emit('reset')" @search="emit('search')" />
</template>

View File

@@ -0,0 +1,197 @@
<script setup lang="ts">
import { computed, markRaw, ref, watch } from 'vue';
import { useAuth } from '@/hooks/business/auth';
import LoginLogTab from './login-log/index.vue';
import OperateLogTab from './operate-log/index.vue';
import ApiAccessLogTab from './api-access-log/index.vue';
import ApiErrorLogTab from './api-error-log/index.vue';
import { LOG_TABS, type LogTabKey } from './shared';
defineOptions({ name: 'LogManagement' });
const { hasAuth } = useAuth();
const activeTab = ref<LogTabKey>('login-log');
const visibleTabs = computed(() => LOG_TABS.filter(tab => hasAuth(tab.queryPermission)));
const activeTabMeta = computed(() => visibleTabs.value.find(tab => tab.name === activeTab.value) || null);
const scopeOptions = computed(() =>
visibleTabs.value.map(tab => ({
label: tab.label,
value: tab.name
}))
);
const componentMap: Record<LogTabKey, object> = {
'login-log': markRaw(LoginLogTab),
'operate-log': markRaw(OperateLogTab),
'api-access-log': markRaw(ApiAccessLogTab),
'api-error-log': markRaw(ApiErrorLogTab)
};
watch(
visibleTabs,
tabs => {
if (!tabs.length) return;
if (!tabs.some(tab => tab.name === activeTab.value)) {
activeTab.value = tabs[0].name;
}
},
{ immediate: true }
);
</script>
<template>
<div class="log-management-page">
<ElCard class="log-management-page__context" body-class="log-management-page__context-body">
<div v-if="visibleTabs.length" class="log-management-page__context-layout">
<div class="log-management-page__context-controls">
<ElSegmented v-model="activeTab" :options="scopeOptions" class="log-management-page__segmented" />
</div>
<div class="log-management-page__context-info">
<div class="log-management-page__context-main">
<div class="log-management-page__context-item">
<span class="log-management-page__context-label">当前日志</span>
<strong class="log-management-page__context-value">{{ activeTabMeta?.label || '--' }}</strong>
</div>
</div>
<p class="log-management-page__context-desc">
{{ activeTabMeta?.description || '当前查看系统日志数据。' }}
</p>
</div>
</div>
<ElEmpty v-else description="暂无可查看的日志权限" />
</ElCard>
<div v-if="activeTabMeta" class="log-management-page__content">
<div class="log-management-page__pane">
<KeepAlive>
<component :is="componentMap[activeTab]" class="log-management-page__tab" />
</KeepAlive>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.log-management-page {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
overflow: hidden;
}
.log-management-page__context {
border: 1px solid var(--el-border-color-light);
background: var(--el-fill-color-blank);
box-shadow: none;
}
:deep(.log-management-page__context-body) {
padding: 16px 18px;
}
.log-management-page__context-layout {
display: flex;
align-items: flex-start;
gap: 20px;
}
.log-management-page__context-controls {
flex-shrink: 0;
}
:deep(.log-management-page__segmented) {
padding: 6px;
background: var(--el-fill-color-light);
border-radius: 12px;
}
:deep(.log-management-page__segmented .el-segmented__item) {
min-width: 128px;
min-height: 40px;
padding: 0 24px;
font-size: 14px;
}
.log-management-page__context-info {
flex: 1;
min-width: 0;
padding-left: 20px;
border-left: 1px solid var(--el-border-color-lighter);
}
.log-management-page__context-main {
display: flex;
flex-wrap: wrap;
gap: 24px;
}
.log-management-page__context-item {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 180px;
}
.log-management-page__context-label {
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.5;
}
.log-management-page__context-value {
color: var(--el-text-color-primary);
font-size: 15px;
font-weight: 600;
line-height: 1.5;
}
.log-management-page__context-desc {
margin: 10px 0 0;
color: var(--el-text-color-regular);
font-size: 13px;
line-height: 1.6;
}
.log-management-page__content {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
.log-management-page__pane {
display: flex;
flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
}
:deep(.log-management-page__tab) {
display: flex;
width: 100%;
flex: 1;
min-width: 0;
min-height: 0;
}
@media (width <= 1200px) {
.log-management-page__context-layout {
flex-direction: column;
align-items: stretch;
}
.log-management-page__context-info {
padding-left: 0;
padding-top: 14px;
border-left: none;
border-top: 1px solid var(--el-border-color-lighter);
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More