Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d147ee263 | |||
| a8e2bac65a | |||
| a650f6e96d | |||
| 4f357a35a9 | |||
| 499f2115b2 | |||
| a4884035cd | |||
| 570f284230 | |||
| ea6a816d58 | |||
| 3ffdad142d | |||
| b26a9c8a39 | |||
| 10418fea0a | |||
| 632c123112 | |||
| b1d52b852f | |||
| 4a7f54b0ed | |||
| 61fe9ef143 | |||
| 9a5845708d | |||
| cd64cf42cc | |||
| 31344f1d58 | |||
| 1543bf76a9 | |||
| 622d8d5a4d | |||
| 3c1cf6c7fa | |||
| 17690283f6 | |||
| 030dc737fc | |||
| 609a01dc8a | |||
| 80f028bcb9 | |||
| 5061eced32 | |||
| 6896a86130 | |||
| 0652a24c5e | |||
| d53a8dfae5 | |||
| 2e369b23a9 | |||
| b72ad00912 | |||
| 7cc29e0a35 | |||
| 39458386ae | |||
| acef4418d8 |
4
.env
4
.env
@@ -2,9 +2,9 @@
|
||||
# 如果部署在子目录下,结尾必须带 "/",例如 "/admin/",不能写成 "/admin"
|
||||
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
|
||||
|
||||
@@ -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= `{
|
||||
|
||||
2
.trae/rules/vue-need.md
Normal file
2
.trae/rules/vue-need.md
Normal file
@@ -0,0 +1,2 @@
|
||||
1. 每次开发新功能、编写代码时都添加好相应的注释。
|
||||
2. 所有的vue文件编码必须是UTF-8的。
|
||||
@@ -27,7 +27,7 @@ export function setupElegantRouter() {
|
||||
onRouteMetaGen(routeName) {
|
||||
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>>> = {
|
||||
workbench: {
|
||||
icon: 'mdi:view-dashboard-outline',
|
||||
@@ -131,16 +131,23 @@ export function setupElegantRouter() {
|
||||
order: 1,
|
||||
keepAlive: true
|
||||
},
|
||||
'personal-center_my-weekly': {
|
||||
icon: 'mdi:calendar-week-outline',
|
||||
order: 2,
|
||||
keepAlive: true
|
||||
},
|
||||
'personal-center_my-monthly': {
|
||||
icon: 'mdi:calendar-month-outline',
|
||||
'personal-center_work-report': {
|
||||
icon: 'mdi:file-chart-outline',
|
||||
order: 3,
|
||||
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': {
|
||||
icon: 'mdi:trophy-outline',
|
||||
order: 4,
|
||||
@@ -193,6 +200,11 @@ export function setupElegantRouter() {
|
||||
roles: ['R_ADMIN'],
|
||||
activeMenu: 'system_user'
|
||||
},
|
||||
feedback: {
|
||||
icon: 'mdi:message-alert-outline',
|
||||
order: 10,
|
||||
keepAlive: true
|
||||
},
|
||||
infra: {
|
||||
icon: 'ep:monitor',
|
||||
order: 20
|
||||
@@ -202,9 +214,30 @@ export function setupElegantRouter() {
|
||||
order: 1,
|
||||
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': {
|
||||
icon: 'mdi:identifier',
|
||||
order: 2,
|
||||
order: 3,
|
||||
keepAlive: true
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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.",
|
||||
"rules": {
|
||||
"directoryComponent": "layout.base",
|
||||
@@ -306,79 +306,13 @@
|
||||
"source": "generated"
|
||||
},
|
||||
{
|
||||
"name": "personal-center_my-weekly",
|
||||
"path": "/personal-center/my-weekly",
|
||||
"component": "view.personal-center_my-weekly",
|
||||
"title": "我的周报",
|
||||
"routeTitle": "personal-center_my-weekly",
|
||||
"i18nKey": "route.personal-center_my-weekly",
|
||||
"icon": "mdi:calendar-week-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",
|
||||
"name": "personal-center_work-report",
|
||||
"path": "/personal-center/work-report",
|
||||
"component": "view.personal-center_work-report",
|
||||
"title": "工作报告",
|
||||
"routeTitle": "personal-center_work-report",
|
||||
"i18nKey": "route.personal-center_work-report",
|
||||
"icon": "mdi:file-chart-outline",
|
||||
"localIcon": null,
|
||||
"order": 3,
|
||||
"hideInMenu": false,
|
||||
@@ -389,9 +323,9 @@
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "我的绩效",
|
||||
"i18nKey": "route.personal-center_my-performance",
|
||||
"icon": "mdi:trophy-outline",
|
||||
"title": "工作报告",
|
||||
"i18nKey": "route.personal-center_work-report",
|
||||
"icon": "mdi:file-chart-outline",
|
||||
"localIcon": null,
|
||||
"order": 3,
|
||||
"keepAlive": true,
|
||||
@@ -438,15 +372,15 @@
|
||||
"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",
|
||||
"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,
|
||||
"order": 5,
|
||||
"order": 4,
|
||||
"hideInMenu": false,
|
||||
"keepAlive": true,
|
||||
"activeMenu": null,
|
||||
@@ -455,11 +389,11 @@
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "待我审批",
|
||||
"i18nKey": "route.personal-center_pending-approval",
|
||||
"icon": "mdi:check-decagram-outline",
|
||||
"title": "我的绩效",
|
||||
"i18nKey": "route.personal-center_my-performance",
|
||||
"icon": "mdi:trophy-outline",
|
||||
"localIcon": null,
|
||||
"order": 5,
|
||||
"order": 4,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
@@ -503,6 +437,39 @@
|
||||
"pageType": "leaf",
|
||||
"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",
|
||||
"path": "/system/user",
|
||||
@@ -734,6 +701,39 @@
|
||||
"pageType": "leaf",
|
||||
"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",
|
||||
"path": "/infra/rd-code",
|
||||
@@ -743,7 +743,7 @@
|
||||
"i18nKey": "route.infra_rd-code",
|
||||
"icon": "mdi:identifier",
|
||||
"localIcon": null,
|
||||
"order": 2,
|
||||
"order": 3,
|
||||
"hideInMenu": false,
|
||||
"keepAlive": true,
|
||||
"activeMenu": null,
|
||||
@@ -756,7 +756,7 @@
|
||||
"i18nKey": "route.infra_rd-code",
|
||||
"icon": "mdi:identifier",
|
||||
"localIcon": null,
|
||||
"order": 2,
|
||||
"order": 3,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
|
||||
28
package.json
28
package.json
@@ -37,60 +37,42 @@
|
||||
"update-pkg": "sa update-pkg"
|
||||
},
|
||||
"dependencies": {
|
||||
"@antv/data-set": "0.11.8",
|
||||
"@antv/g2": "5.4.0",
|
||||
"@antv/g6": "5.0.49",
|
||||
"@better-scroll/core": "2.5.1",
|
||||
"@iconify-vue/mingcute": "^1.0.5",
|
||||
"@iconify/vue": "5.0.0",
|
||||
"@sa/axios": "workspace:*",
|
||||
"@sa/color": "workspace:*",
|
||||
"@sa/hooks": "workspace:*",
|
||||
"@sa/materials": "workspace:*",
|
||||
"@sa/utils": "workspace:*",
|
||||
"@visactor/vchart": "2.0.4",
|
||||
"@visactor/vchart-theme": "1.12.2",
|
||||
"@visactor/vtable-editors": "1.19.8",
|
||||
"@visactor/vtable-gantt": "1.19.8",
|
||||
"@visactor/vue-vtable": "1.19.8",
|
||||
"@vueuse/components": "13.9.0",
|
||||
"@univerjs/preset-sheets-core": "^0.25.0",
|
||||
"@univerjs/presets": "^0.25.0",
|
||||
"@vueuse/core": "13.9.0",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||
"@zwight/luckyexcel": "^1.1.6",
|
||||
"clipboard": "2.0.11",
|
||||
"dayjs": "1.11.18",
|
||||
"defu": "^6.1.4",
|
||||
"dhtmlx-gantt": "9.0.14",
|
||||
"dompurify": "3.2.6",
|
||||
"echarts": "6.0.0",
|
||||
"element-plus": "^2.11.1",
|
||||
"jsbarcode": "3.12.1",
|
||||
"grid-layout-plus": "^1.1.1",
|
||||
"jsencrypt": "^3.5.4",
|
||||
"json5": "2.2.3",
|
||||
"nprogress": "0.2.0",
|
||||
"pinia": "3.0.3",
|
||||
"pinyin-pro": "3.27.0",
|
||||
"print-js": "1.6.0",
|
||||
"swiper": "11.2.10",
|
||||
"tailwind-merge": "3.3.1",
|
||||
"typeit": "8.8.7",
|
||||
"vditor": "3.11.2",
|
||||
"vue": "3.5.20",
|
||||
"vue-draggable-plus": "0.6.0",
|
||||
"vue-i18n": "11.1.11",
|
||||
"vue-pdf-embed": "2.1.3",
|
||||
"vue-router": "4.5.1",
|
||||
"xgplayer": "3.0.23",
|
||||
"xlsx": "0.18.5"
|
||||
"vue-router": "4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@amap/amap-jsapi-types": "0.0.15",
|
||||
"@elegant-router/vue": "0.3.8",
|
||||
"@iconify/json": "2.2.380",
|
||||
"@sa/scripts": "workspace:*",
|
||||
"@sa/uno-preset": "workspace:*",
|
||||
"@soybeanjs/eslint-config": "1.7.1",
|
||||
"@types/bmapgl": "0.0.7",
|
||||
"@types/node": "24.3.0",
|
||||
"@types/nprogress": "0.2.3",
|
||||
"@unocss/eslint-config": "66.5.0",
|
||||
|
||||
5971
pnpm-lock.yaml
generated
5971
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, reactive, ref } from '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' });
|
||||
|
||||
@@ -31,7 +31,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
const model = defineModel<Api.Project.AttachmentItem[]>({ default: () => [] });
|
||||
|
||||
/** 给用户看的简短分类(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)
|
||||
const ALLOWED_EXTENSIONS = new Set([
|
||||
@@ -55,7 +55,10 @@ const ALLOWED_EXTENSIONS = new Set([
|
||||
'rar',
|
||||
'7z',
|
||||
'mp4',
|
||||
'mp3'
|
||||
'mp3',
|
||||
'sql',
|
||||
'xml',
|
||||
'json'
|
||||
]);
|
||||
|
||||
const FORBIDDEN_EXTENSIONS = new Set([
|
||||
@@ -230,7 +233,7 @@ async function uploadOne(file: File) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { id, url } = result.data;
|
||||
const { id, configId, path, url } = result.data;
|
||||
|
||||
// 组件已卸载(用户上传过程中关弹层):onBeforeUnmount 已跑过且看不到这个 id,
|
||||
// 这里立刻调删除,避免孤儿文件
|
||||
@@ -248,7 +251,9 @@ async function uploadOne(file: File) {
|
||||
url,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
contentType: file.type || undefined
|
||||
contentType: file.type || undefined,
|
||||
configId,
|
||||
path
|
||||
}
|
||||
];
|
||||
session.addedIds.add(id);
|
||||
@@ -419,6 +424,16 @@ defineExpose({
|
||||
/** 父组件在提交前可读此值判断是否还有 pending 上传 */
|
||||
get hasUploading() {
|
||||
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
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -82,6 +82,12 @@ function handleConfirm() {
|
||||
footer-class="business-form-dialog__footer"
|
||||
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
|
||||
v-if="props.scrollbar"
|
||||
:max-height="props.maxBodyHeight"
|
||||
|
||||
@@ -12,7 +12,9 @@ defineProps<Props>();
|
||||
|
||||
<template>
|
||||
<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 />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -178,7 +178,9 @@ const toolbarConfig: Partial<IToolbarConfig> = {
|
||||
'insertLink',
|
||||
'editLink',
|
||||
'unLink',
|
||||
'viewLink'
|
||||
'viewLink',
|
||||
// 全屏:弹层内全屏体验割裂,隐藏
|
||||
'fullScreen'
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import { computed, defineComponent, h, ref } from 'vue';
|
||||
import type { Component, PropType } from 'vue';
|
||||
import { ElButton, ElPopover, ElTooltip } from 'element-plus';
|
||||
import { $t } from '@/locales';
|
||||
import IconMdiDotsHorizontal from '~icons/mdi/dots-horizontal';
|
||||
import IconMdiChevronDown from '~icons/mdi/chevron-down';
|
||||
|
||||
export type BusinessTableAction = {
|
||||
key: string;
|
||||
@@ -162,11 +164,11 @@ export default defineComponent({
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
{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">
|
||||
{$t('common.more')}
|
||||
<icon-mdi-chevron-down class="text-14px" />
|
||||
<IconMdiChevronDown class="text-14px" />
|
||||
</span>
|
||||
)}
|
||||
</ElButton>
|
||||
|
||||
84
src/components/custom/current-user-role-tags.vue
Normal file
84
src/components/custom/current-user-role-tags.vue
Normal 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>
|
||||
145
src/components/custom/subordinate-selector.vue
Normal file
145
src/components/custom/subordinate-selector.vue
Normal 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>
|
||||
@@ -18,6 +18,16 @@ export interface SearchField {
|
||||
label: string;
|
||||
/** 字段类型 */
|
||||
type: 'input' | 'select' | 'date' | 'dateRange' | 'dict';
|
||||
/** date 字段的日期粒度 */
|
||||
dateType?: 'date' | 'month';
|
||||
/** dateRange 字段的日期范围粒度 */
|
||||
dateRangeType?: 'daterange' | 'monthrange';
|
||||
/** 日期面板展示格式 */
|
||||
format?: string;
|
||||
/** 自定义范围分隔文案 */
|
||||
rangeSeparator?: string;
|
||||
/** 日期字段提交格式 */
|
||||
valueFormat?: string;
|
||||
/** 占位列数,默认 1 */
|
||||
span?: number;
|
||||
/** select 类型的选项 */
|
||||
@@ -30,6 +40,12 @@ export interface SearchField {
|
||||
placeholder?: string;
|
||||
/** select 类型的自定义选项渲染函数 */
|
||||
renderOption?: (option: Option) => VNode | VNode[] | string;
|
||||
/** select/dict 类型是否支持搜索 */
|
||||
filterable?: boolean;
|
||||
/** 值写回模型前的转换函数 */
|
||||
transformValue?: (value: any) => any;
|
||||
/** 从模型值解析展示值 */
|
||||
resolveValue?: (value: any) => any;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -116,6 +132,16 @@ function handleReset() {
|
||||
function handleSearch() {
|
||||
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>
|
||||
|
||||
<!-- eslint-disable vue/no-mutating-props -->
|
||||
@@ -133,19 +159,20 @@ function handleSearch() {
|
||||
<ElFormItem :label="field.label">
|
||||
<ElInput
|
||||
v-if="field.type === 'input'"
|
||||
:model-value="props.modelValue[field.key]"
|
||||
:model-value="getFieldValue(field)"
|
||||
:placeholder="field.placeholder"
|
||||
clearable
|
||||
:disabled="props.disabled"
|
||||
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||
@update:model-value="val => updateFieldValue(field, val)"
|
||||
/>
|
||||
<ElSelect
|
||||
v-else-if="field.type === 'select'"
|
||||
:model-value="props.modelValue[field.key]"
|
||||
:model-value="getFieldValue(field)"
|
||||
:placeholder="field.placeholder"
|
||||
clearable
|
||||
:filterable="field.filterable"
|
||||
: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">
|
||||
<template v-if="field.renderOption" #default>
|
||||
@@ -155,34 +182,38 @@ function handleSearch() {
|
||||
</ElSelect>
|
||||
<ElDatePicker
|
||||
v-else-if="field.type === 'date'"
|
||||
:model-value="props.modelValue[field.key]"
|
||||
type="date"
|
||||
:model-value="getFieldValue(field)"
|
||||
:type="field.dateType || 'date'"
|
||||
:placeholder="field.placeholder"
|
||||
clearable
|
||||
:disabled="props.disabled"
|
||||
value-format="YYYY-MM-DD"
|
||||
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||
:format="field.format"
|
||||
:value-format="field.valueFormat || 'YYYY-MM-DD'"
|
||||
@update:model-value="val => updateFieldValue(field, val)"
|
||||
/>
|
||||
<ElDatePicker
|
||||
v-else-if="field.type === 'dateRange'"
|
||||
:model-value="props.modelValue[field.key]"
|
||||
type="daterange"
|
||||
:model-value="getFieldValue(field)"
|
||||
:type="field.dateRangeType || 'daterange'"
|
||||
:placeholder="field.placeholder"
|
||||
clearable
|
||||
:disabled="props.disabled"
|
||||
value-format="YYYY-MM-DD"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||
:format="field.format"
|
||||
:value-format="field.valueFormat || 'YYYY-MM-DD'"
|
||||
:range-separator="field.rangeSeparator || '至'"
|
||||
:start-placeholder="field.dateRangeType === 'monthrange' ? '开始月份' : '开始日期'"
|
||||
:end-placeholder="field.dateRangeType === 'monthrange' ? '结束月份' : '结束日期'"
|
||||
@update:model-value="val => updateFieldValue(field, val)"
|
||||
/>
|
||||
<DictSelect
|
||||
v-else-if="field.type === 'dict'"
|
||||
:model-value="props.modelValue[field.key]"
|
||||
:model-value="getFieldValue(field)"
|
||||
:dict-code="field.dictCode!"
|
||||
:placeholder="field.placeholder"
|
||||
:filterable="field.filterable"
|
||||
:disabled="props.disabled"
|
||||
:show-remark="field.showRemark"
|
||||
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||
@update:model-value="val => updateFieldValue(field, val)"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
@@ -230,19 +261,20 @@ function handleSearch() {
|
||||
<ElFormItem :label="field.label">
|
||||
<ElInput
|
||||
v-if="field.type === 'input'"
|
||||
:model-value="props.modelValue[field.key]"
|
||||
:model-value="getFieldValue(field)"
|
||||
:placeholder="field.placeholder"
|
||||
clearable
|
||||
:disabled="props.disabled"
|
||||
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||
@update:model-value="val => updateFieldValue(field, val)"
|
||||
/>
|
||||
<ElSelect
|
||||
v-else-if="field.type === 'select'"
|
||||
:model-value="props.modelValue[field.key]"
|
||||
:model-value="getFieldValue(field)"
|
||||
:placeholder="field.placeholder"
|
||||
clearable
|
||||
:filterable="field.filterable"
|
||||
: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">
|
||||
<template v-if="field.renderOption" #default>
|
||||
@@ -252,34 +284,38 @@ function handleSearch() {
|
||||
</ElSelect>
|
||||
<ElDatePicker
|
||||
v-else-if="field.type === 'date'"
|
||||
:model-value="props.modelValue[field.key]"
|
||||
type="date"
|
||||
:model-value="getFieldValue(field)"
|
||||
:type="field.dateType || 'date'"
|
||||
:placeholder="field.placeholder"
|
||||
clearable
|
||||
:disabled="props.disabled"
|
||||
value-format="YYYY-MM-DD"
|
||||
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||
:format="field.format"
|
||||
:value-format="field.valueFormat || 'YYYY-MM-DD'"
|
||||
@update:model-value="val => updateFieldValue(field, val)"
|
||||
/>
|
||||
<ElDatePicker
|
||||
v-else-if="field.type === 'dateRange'"
|
||||
:model-value="props.modelValue[field.key]"
|
||||
type="daterange"
|
||||
:model-value="getFieldValue(field)"
|
||||
:type="field.dateRangeType || 'daterange'"
|
||||
:placeholder="field.placeholder"
|
||||
clearable
|
||||
:disabled="props.disabled"
|
||||
value-format="YYYY-MM-DD"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||
:format="field.format"
|
||||
:value-format="field.valueFormat || 'YYYY-MM-DD'"
|
||||
:range-separator="field.rangeSeparator || '至'"
|
||||
:start-placeholder="field.dateRangeType === 'monthrange' ? '开始月份' : '开始日期'"
|
||||
:end-placeholder="field.dateRangeType === 'monthrange' ? '结束月份' : '结束日期'"
|
||||
@update:model-value="val => updateFieldValue(field, val)"
|
||||
/>
|
||||
<DictSelect
|
||||
v-else-if="field.type === 'dict'"
|
||||
:model-value="props.modelValue[field.key]"
|
||||
:model-value="getFieldValue(field)"
|
||||
:dict-code="field.dictCode!"
|
||||
:placeholder="field.placeholder"
|
||||
:filterable="field.filterable"
|
||||
:disabled="props.disabled"
|
||||
:show-remark="field.showRemark"
|
||||
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||
@update:model-value="val => updateFieldValue(field, val)"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
|
||||
163
src/components/custom/team-context-panel.vue
Normal file
163
src/components/custom/team-context-panel.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
* 加班申请状态字典编码
|
||||
*
|
||||
* 对应业务字段:加班申请中的 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
|
||||
*/
|
||||
export const RDMS_OVERTIME_DURATION_DICT_CODE = 'rdms_overtime_duration';
|
||||
|
||||
/**
|
||||
* 站内信消息等级字典编码
|
||||
*
|
||||
* 对应业务字段:站内信 NotifyMessage.level(1=普通 2=提醒 3=警告 4=严重,数字越大越紧急)
|
||||
* 来源口径:`2026-06-13-站内信消息等级-前端对接.html` 明确等级字典为 notify_message_level,
|
||||
* 显示名与颜色(hex)均走字典,前端按 level 取色不硬编码。
|
||||
*/
|
||||
export const NOTIFY_MESSAGE_LEVEL_DICT_CODE = 'notify_message_level';
|
||||
|
||||
/**
|
||||
* 意见反馈分类字典编码
|
||||
*
|
||||
* 对应业务字段:意见反馈 type(1 缺陷 / 2 体验问题 / 3 功能建议)
|
||||
* 来源口径:`2026-06-25-意见反馈-前端API.html` 明确分类字典为 feedback_type,后端已落库。
|
||||
*/
|
||||
export const FEEDBACK_TYPE_DICT_CODE = 'feedback_type';
|
||||
|
||||
/**
|
||||
* 意见反馈处理状态字典编码
|
||||
*
|
||||
* 对应业务字段:意见反馈 status(1 待处理 / 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';
|
||||
|
||||
@@ -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';
|
||||
@@ -14,10 +14,14 @@ export type StatusDomain =
|
||||
| 'taskAssigneeMember'
|
||||
| 'project'
|
||||
| 'product'
|
||||
| 'requirement'
|
||||
| 'productRequirement'
|
||||
| 'projectRequirement'
|
||||
| 'workOrder'
|
||||
| 'workReport'
|
||||
| 'performanceSheet'
|
||||
| 'personalItem'
|
||||
| 'overtimeApplication';
|
||||
| 'overtimeApplication'
|
||||
| 'feedback';
|
||||
|
||||
const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>> = {
|
||||
// 项目-执行
|
||||
@@ -52,10 +56,47 @@ const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>>
|
||||
project: {},
|
||||
// 产品(待补全)
|
||||
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: {},
|
||||
// 工作报告
|
||||
workReport: {
|
||||
draft: 'info',
|
||||
pending_approval: 'warning',
|
||||
approved: 'success',
|
||||
rejected: 'danger'
|
||||
},
|
||||
// 绩效表
|
||||
performanceSheet: {
|
||||
draft: 'info',
|
||||
sent: 'warning',
|
||||
confirmed: 'success',
|
||||
rejected: 'danger'
|
||||
},
|
||||
// 个人事项
|
||||
personalItem: {
|
||||
pending: 'info',
|
||||
@@ -67,8 +108,14 @@ const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>>
|
||||
overtimeApplication: {
|
||||
pending: 'warning',
|
||||
approved: 'success',
|
||||
rejected: 'danger',
|
||||
cancelled: 'info'
|
||||
rejected: 'danger'
|
||||
},
|
||||
// 意见反馈
|
||||
feedback: {
|
||||
'1': 'warning', // 待处理
|
||||
'2': 'primary', // 处理中
|
||||
'3': 'success', // 已处理
|
||||
'4': 'info' // 已忽略
|
||||
}
|
||||
};
|
||||
|
||||
@@ -84,6 +131,9 @@ export function getPersonalItemStatusTagType(statusCode: string | null | undefin
|
||||
return getStatusTagType('personalItem', statusCode);
|
||||
}
|
||||
|
||||
export function getOvertimeApplicationStatusTagType(statusCode: string | null | undefined) {
|
||||
return getStatusTagType('overtimeApplication', statusCode);
|
||||
export function getFeedbackStatusTagType(statusCode: string | number | null | undefined) {
|
||||
return getStatusTagType(
|
||||
'feedback',
|
||||
statusCode === null || statusCode === undefined ? statusCode : String(statusCode)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -131,12 +131,14 @@ export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: C
|
||||
* @param callback callback function
|
||||
*/
|
||||
async function updateOptions(callback: (opts: T, optsFactory: () => T) => ECOption = () => chartOptions) {
|
||||
if (!isRendered()) return;
|
||||
|
||||
const updatedOpts = callback(chartOptions, optionsFactory);
|
||||
|
||||
Object.assign(chartOptions, updatedOpts);
|
||||
|
||||
// 图表未初始化(容器尺寸未就绪)时只缓存最新 options,待 render() 初始化时一并应用;
|
||||
// 否则数据先于初始化到达会被静默丢弃,首屏永远停留在空数据
|
||||
if (!isRendered()) return;
|
||||
|
||||
if (isRendered()) {
|
||||
chart?.clear();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -1,76 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useInfiniteScroll } from '@vueuse/core';
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
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' });
|
||||
|
||||
interface NotificationItem {
|
||||
id: string;
|
||||
title: string;
|
||||
timeLabel: string;
|
||||
unread: boolean;
|
||||
}
|
||||
const dictStore = useDictStore();
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
const UNREAD_COUNT_POLL_INTERVAL = 15 * 1000;
|
||||
|
||||
// 通知 mock:扩到 60 条以演示分页 / 搜索;等真接口落地后整体迁移
|
||||
function buildMockNotifications(): NotificationItem[] {
|
||||
const titles = [
|
||||
'你被指派为执行「迭代 24.06」负责人',
|
||||
'任务「SSO 改造」状态变更:开发中 → 待验收',
|
||||
'需求「多币种支持」评审通过',
|
||||
'工单 #1042 已分派给你',
|
||||
'需求「订单导出」被退回,请补充材料',
|
||||
'@ 你的评论已被回复',
|
||||
'项目「客户中心 2.0」周报已生成',
|
||||
'工单 #1098 客户回复待处理',
|
||||
'执行「迭代 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
|
||||
}));
|
||||
type TabKey = 'unread' | 'read';
|
||||
|
||||
interface MessageListState {
|
||||
items: Api.NotifyMessage.NotifyMessage[];
|
||||
pageNo: number;
|
||||
total: number;
|
||||
loading: boolean;
|
||||
/** 是否已按当前关键字拉过第一页(tab 懒加载 / 失效重拉用) */
|
||||
loaded: boolean;
|
||||
/** 竞态令牌:重置后递增,过期响应直接丢弃 */
|
||||
token: number;
|
||||
}
|
||||
|
||||
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 readAll = computed(() => notifications.value.filter(n => !n.unread));
|
||||
const unreadCount = computed(() => unreadAll.value.length);
|
||||
const listStates = reactive<Record<TabKey, MessageListState>>({
|
||||
unread: createListState(),
|
||||
read: createListState()
|
||||
});
|
||||
|
||||
const unreadCount = ref(0);
|
||||
const badgeLabel = computed(() => (unreadCount.value > 99 ? '99+' : String(unreadCount.value)));
|
||||
|
||||
const drawerOpen = ref(false);
|
||||
const activeTab = ref<'unread' | 'read'>('unread');
|
||||
const activeTab = ref<TabKey>('unread');
|
||||
const searchKeyword = ref('');
|
||||
|
||||
function matchesKeyword(item: NotificationItem) {
|
||||
const kw = searchKeyword.value.trim();
|
||||
if (!kw) return true;
|
||||
return item.title.toLowerCase().includes(kw.toLowerCase());
|
||||
const detailVisible = ref(false);
|
||||
const detailMessage = ref<Api.NotifyMessage.NotifyMessage | null>(null);
|
||||
|
||||
function keywordParam() {
|
||||
return searchKeyword.value.trim() || undefined;
|
||||
}
|
||||
|
||||
const filteredUnread = computed(() => unreadAll.value.filter(matchesKeyword));
|
||||
const filteredRead = computed(() => readAll.value.filter(matchesKeyword));
|
||||
/** 列表圆点颜色:跟随消息等级(与等级徽标同一字典色源);取不到时回 undefined,由 CSS 兜底 */
|
||||
function levelDotColor(level: number) {
|
||||
return dictStore.getDictItem(NOTIFY_MESSAGE_LEVEL_DICT_CODE, level)?.colorType ?? undefined;
|
||||
}
|
||||
|
||||
const unreadPageSize = ref(PAGE_SIZE);
|
||||
const readPageSize = ref(PAGE_SIZE);
|
||||
async function refreshUnreadCount() {
|
||||
const { data, error } = await fetchGetUnreadNotifyCount();
|
||||
if (!error && typeof data === 'number') {
|
||||
unreadCount.value = data;
|
||||
}
|
||||
}
|
||||
|
||||
const visibleUnread = computed(() => filteredUnread.value.slice(0, unreadPageSize.value));
|
||||
const visibleRead = computed(() => filteredRead.value.slice(0, readPageSize.value));
|
||||
function resetList(tab: TabKey) {
|
||||
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);
|
||||
const hasMoreRead = computed(() => readPageSize.value < filteredRead.value.length);
|
||||
async function loadPage(tab: TabKey) {
|
||||
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, () => {
|
||||
unreadPageSize.value = PAGE_SIZE;
|
||||
readPageSize.value = PAGE_SIZE;
|
||||
applyKeywordSearch();
|
||||
});
|
||||
|
||||
// 已读列表数量会因"标已读"动态增长 / 未读会缩小;切换 tab 不重置已展示页数,体感更自然
|
||||
watch(activeTab, tab => {
|
||||
ensureLoaded(tab);
|
||||
});
|
||||
|
||||
type ScrollbarRefValue = { wrapRef?: HTMLElement } | null;
|
||||
const unreadScrollbar = ref<ScrollbarRefValue>(null);
|
||||
@@ -79,7 +136,9 @@ const readScrollbar = ref<ScrollbarRefValue>(null);
|
||||
useInfiniteScroll(
|
||||
() => unreadScrollbar.value?.wrapRef,
|
||||
() => {
|
||||
if (hasMoreUnread.value) unreadPageSize.value += PAGE_SIZE;
|
||||
if (drawerOpen.value && hasMore('unread') && !listStates.unread.loading) {
|
||||
loadPage('unread');
|
||||
}
|
||||
},
|
||||
{ distance: 48 }
|
||||
);
|
||||
@@ -87,43 +146,90 @@ useInfiniteScroll(
|
||||
useInfiniteScroll(
|
||||
() => readScrollbar.value?.wrapRef,
|
||||
() => {
|
||||
if (hasMoreRead.value) readPageSize.value += PAGE_SIZE;
|
||||
if (drawerOpen.value && hasMore('read') && !listStates.read.loading) {
|
||||
loadPage('read');
|
||||
}
|
||||
},
|
||||
{ distance: 48 }
|
||||
);
|
||||
|
||||
function openDrawer() {
|
||||
drawerOpen.value = true;
|
||||
// 每次打开面板都从第 1 页重拉(与后端对齐的消费口径)
|
||||
resetList('unread');
|
||||
resetList('read');
|
||||
loadPage(activeTab.value);
|
||||
refreshUnreadCount();
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
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() {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -137,21 +243,18 @@ function onDrawerClosed() {
|
||||
<span v-if="unreadCount > 0" class="notification-bell__badge">{{ badgeLabel }}</span>
|
||||
</button>
|
||||
|
||||
<ElDrawer v-model="drawerOpen" size="480px" :with-header="false" @closed="onDrawerClosed">
|
||||
<div class="notification-bell__panel">
|
||||
<header class="notification-bell__header">
|
||||
<ElDrawer v-model="drawerOpen" size="480px" @closed="onDrawerClosed">
|
||||
<template #header>
|
||||
<div class="notification-bell__header-main">
|
||||
<span class="notification-bell__title">
|
||||
通知
|
||||
<span v-if="unreadCount > 0" class="notification-bell__title-count">未读 {{ unreadCount }}</span>
|
||||
</span>
|
||||
<span class="notification-bell__header-actions">
|
||||
<ElButton v-if="unreadCount > 0" link size="small" @click="markAllRead">全部已读</ElButton>
|
||||
<button class="notification-bell__close" type="button" aria-label="关闭" @click="closeDrawer">
|
||||
<SvgIcon icon="mdi:close" />
|
||||
</button>
|
||||
</span>
|
||||
</header>
|
||||
<ElButton v-if="unreadCount > 0" link size="small" @click="markAllRead">全部已读</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="notification-bell__panel">
|
||||
<div class="notification-bell__search">
|
||||
<ElInput v-model="searchKeyword" placeholder="搜索通知" clearable>
|
||||
<template #prefix>
|
||||
@@ -165,61 +268,95 @@ function onDrawerClosed() {
|
||||
<template #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>
|
||||
</template>
|
||||
<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
|
||||
v-for="row in visibleUnread"
|
||||
v-for="row in listStates.unread.items"
|
||||
:key="row.id"
|
||||
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-title">{{ row.title }}</div>
|
||||
<div class="notification-bell__row-time">{{ row.timeLabel }}</div>
|
||||
<div class="notification-bell__row-title">{{ row.templateContent }}</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>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="notification-bell__empty">
|
||||
{{ searchKeyword ? '没有匹配的通知' : '暂无未读通知' }}
|
||||
{{ listStates.unread.loading ? '加载中…' : searchKeyword ? '没有匹配的通知' : '暂无未读通知' }}
|
||||
</div>
|
||||
<div v-if="visibleUnread.length > 0" class="notification-bell__footer-hint">
|
||||
{{ hasMoreUnread ? '滚动加载更多…' : '— 已经到底了 —' }}
|
||||
<div v-if="listStates.unread.items.length > 0" class="notification-bell__footer-hint">
|
||||
{{ listStates.unread.loading ? '加载中…' : hasMore('unread') ? '滚动加载更多…' : '— 已经到底了 —' }}
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
</ElTabPane>
|
||||
|
||||
<ElTabPane name="read">
|
||||
<template #label>
|
||||
<span class="notification-bell__tab-label">
|
||||
已读
|
||||
<span class="notification-bell__tab-count">{{ filteredRead.length }}</span>
|
||||
</span>
|
||||
<span class="notification-bell__tab-label">已读</span>
|
||||
</template>
|
||||
<ElScrollbar ref="readScrollbar" class="notification-bell__scroll">
|
||||
<ul v-if="visibleRead.length > 0" class="notification-bell__list">
|
||||
<li v-for="row in visibleRead" :key="row.id" class="notification-bell__row" @click="openItem(row)">
|
||||
<span class="notification-bell__row-dot" />
|
||||
<ul v-if="listStates.read.items.length > 0" class="notification-bell__list">
|
||||
<li
|
||||
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-title">{{ row.title }}</div>
|
||||
<div class="notification-bell__row-time">{{ row.timeLabel }}</div>
|
||||
<div class="notification-bell__row-title">{{ row.templateContent }}</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>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="notification-bell__empty">
|
||||
{{ searchKeyword ? '没有匹配的通知' : '暂无已读通知' }}
|
||||
{{ listStates.read.loading ? '加载中…' : searchKeyword ? '没有匹配的通知' : '暂无已读通知' }}
|
||||
</div>
|
||||
<div v-if="visibleRead.length > 0" class="notification-bell__footer-hint">
|
||||
{{ hasMoreRead ? '滚动加载更多…' : '— 已经到底了 —' }}
|
||||
<div v-if="listStates.read.items.length > 0" class="notification-bell__footer-hint">
|
||||
{{ listStates.read.loading ? '加载中…' : hasMore('read') ? '滚动加载更多…' : '— 已经到底了 —' }}
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<ElButton @click="closeDrawer">关闭</ElButton>
|
||||
</template>
|
||||
</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>
|
||||
|
||||
<style scoped>
|
||||
@@ -258,18 +395,53 @@ function onDrawerClosed() {
|
||||
|
||||
.notification-bell__badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 999px;
|
||||
background-color: var(--el-color-danger);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 16px;
|
||||
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 {
|
||||
@@ -278,13 +450,14 @@ function onDrawerClosed() {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.notification-bell__header {
|
||||
.notification-bell__header-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
min-width: 0;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.notification-bell__title {
|
||||
@@ -305,37 +478,8 @@ function onDrawerClosed() {
|
||||
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 {
|
||||
padding: 12px 0 4px;
|
||||
padding: 0 0 4px;
|
||||
}
|
||||
|
||||
.notification-bell__tabs {
|
||||
@@ -393,8 +537,8 @@ function onDrawerClosed() {
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 4px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease;
|
||||
}
|
||||
|
||||
@@ -434,8 +578,14 @@ function onDrawerClosed() {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.notification-bell__row-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.notification-bell__row-time {
|
||||
margin-top: 4px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -454,4 +604,41 @@ function onDrawerClosed() {
|
||||
font-size: 12px;
|
||||
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>
|
||||
|
||||
@@ -41,11 +41,6 @@ const { isFullscreen, toggle } = useFullscreen();
|
||||
<div>
|
||||
<FullScreen v-if="!appStore.isMobile" :full="isFullscreen" @click="toggle" />
|
||||
</div>
|
||||
<ThemeSchemaSwitch
|
||||
:theme-schema="themeStore.themeScheme"
|
||||
:is-dark="themeStore.darkMode"
|
||||
@switch="themeStore.toggleThemeScheme"
|
||||
/>
|
||||
<div>
|
||||
<ThemeButton />
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@ const { selectedKeyDummy, handleSelect } = useMenu();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
|
||||
<Teleport defer :to="`#${GLOBAL_HEADER_MENU_ID}`">
|
||||
<ElMenu
|
||||
ellipsis
|
||||
class="w-full"
|
||||
|
||||
@@ -93,7 +93,8 @@ function isMenuActive(menu: App.Global.Menu | App.ObjectContext.Menu): boolean {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
|
||||
<!-- defer:BaseLayout 二次挂载时 GlobalMenu 已缓存为同步挂载,目标 div 还未插入 document,不延迟解析会静默失败且不重试 -->
|
||||
<Teleport defer :to="`#${GLOBAL_HEADER_MENU_ID}`">
|
||||
<div class="mix-header-nav size-full min-w-0 flex-y-center">
|
||||
<button
|
||||
v-if="activeFirstLevelMenu"
|
||||
@@ -161,7 +162,7 @@ function isMenuActive(menu: App.Global.Menu | App.ObjectContext.Menu): boolean {
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
||||
<Teleport defer :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
||||
<FirstLevelMenu
|
||||
:menus="allMenus"
|
||||
:active-menu-key="activeFirstLevelMenuKey"
|
||||
|
||||
@@ -55,7 +55,7 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
|
||||
<Teleport defer :to="`#${GLOBAL_HEADER_MENU_ID}`">
|
||||
<ElMenu
|
||||
ellipsis
|
||||
class="w-full"
|
||||
@@ -66,7 +66,7 @@ watch(
|
||||
<MenuItem v-for="item in firstLevelMenus" :key="item.key" :item="item" :index="item.key" />
|
||||
</ElMenu>
|
||||
</Teleport>
|
||||
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
||||
<Teleport defer :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
||||
<SimpleScrollbar>
|
||||
<ElMenu
|
||||
mode="vertical"
|
||||
|
||||
@@ -38,7 +38,7 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
||||
<Teleport defer :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
||||
<SimpleScrollbar>
|
||||
<ElMenu
|
||||
mode="vertical"
|
||||
|
||||
@@ -90,7 +90,7 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
||||
<Teleport defer :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
||||
<div class="h-full flex" @mouseleave="handleResetActiveMenu">
|
||||
<FirstLevelMenu
|
||||
:menus="allMenus"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { themeSchemaRecord } from '@/constants/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { $t } from '@/locales';
|
||||
import SettingItem from '../components/setting-item.vue';
|
||||
@@ -9,16 +8,6 @@ defineOptions({ name: 'DarkMode' });
|
||||
|
||||
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) {
|
||||
themeStore.setGrayscale(value);
|
||||
}
|
||||
@@ -33,15 +22,6 @@ const showSiderInverted = computed(() => !themeStore.darkMode && themeStore.layo
|
||||
<template>
|
||||
<ElDivider>{{ $t('theme.themeSchema.title') }}</ElDivider>
|
||||
<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">
|
||||
<SettingItem v-if="showSiderInverted" :label="$t('theme.sider.inverted')">
|
||||
<ElSwitch v-model="themeStore.sider.inverted" />
|
||||
|
||||
@@ -169,25 +169,23 @@ const local: App.I18n.Schema = {
|
||||
'personal-center': 'Personal Center',
|
||||
'personal-center_my-profile': 'My Profile',
|
||||
'personal-center_my-item': 'My Items',
|
||||
'personal-center_my-weekly': 'My Weekly Report',
|
||||
'personal-center_my-monthly': 'My Monthly Report',
|
||||
'personal-center_work-report': 'Work 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-application': 'My Application',
|
||||
'personal-center_overtime-application': 'Overtime Application',
|
||||
'personal-center_pending-approval': 'Pending Approval',
|
||||
feedback: 'Feedback',
|
||||
infra: 'Infra',
|
||||
'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',
|
||||
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_list: 'Product List',
|
||||
product_dashboard: 'Dashboard',
|
||||
@@ -211,28 +209,7 @@ const local: App.I18n.Schema = {
|
||||
exception: 'Exception',
|
||||
exception_403: '403',
|
||||
exception_404: '404',
|
||||
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'
|
||||
exception_500: '500'
|
||||
},
|
||||
page: {
|
||||
login: {
|
||||
@@ -328,45 +305,6 @@ const local: App.I18n.Schema = {
|
||||
},
|
||||
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: {
|
||||
common: {
|
||||
status: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const local: App.I18n.Schema = {
|
||||
system: {
|
||||
title: '研发内部管理系统'
|
||||
title: '研发管理系统'
|
||||
},
|
||||
common: {
|
||||
action: '操作',
|
||||
@@ -169,25 +169,23 @@ const local: App.I18n.Schema = {
|
||||
'personal-center': '个人中心',
|
||||
'personal-center_my-profile': '个人信息',
|
||||
'personal-center_my-item': '我的事项',
|
||||
'personal-center_my-weekly': '我的周报',
|
||||
'personal-center_my-monthly': '我的月报',
|
||||
'personal-center_work-report': '工作报告',
|
||||
'personal-center_work-report_weekly': '个人周报',
|
||||
'personal-center_work-report_monthly': '个人月报',
|
||||
'personal-center_work-report_project': '项目半月报',
|
||||
'personal-center_my-performance': '我的绩效',
|
||||
'personal-center_my-application': '我的申请',
|
||||
'personal-center_overtime-application': '加班申请',
|
||||
'personal-center_pending-approval': '待我审批',
|
||||
feedback: '意见反馈',
|
||||
infra: '基础设施',
|
||||
'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': '研发令号',
|
||||
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_list: '产品列表',
|
||||
product_dashboard: '产品仪表盘',
|
||||
@@ -211,28 +209,7 @@ const local: App.I18n.Schema = {
|
||||
exception: '异常页',
|
||||
exception_403: '403',
|
||||
exception_404: '404',
|
||||
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'
|
||||
exception_500: '500'
|
||||
},
|
||||
page: {
|
||||
login: {
|
||||
@@ -284,7 +261,7 @@ const local: App.I18n.Schema = {
|
||||
about: {
|
||||
title: '关于',
|
||||
introduction:
|
||||
'灿能研发内部管理系统是灿能电力内部使用的研发管理前端系统,用于承载内部业务模块、工程协作流程和日常管理能力。',
|
||||
'灿能研发管理系统是灿能电力内部使用的研发管理前端系统,用于承载内部业务模块、工程协作流程和日常管理能力。',
|
||||
projectInfo: {
|
||||
title: '项目信息',
|
||||
version: '版本',
|
||||
@@ -327,45 +304,6 @@ const local: App.I18n.Schema = {
|
||||
},
|
||||
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: {
|
||||
common: {
|
||||
status: {
|
||||
@@ -729,7 +667,7 @@ const local: App.I18n.Schema = {
|
||||
},
|
||||
pwd: {
|
||||
required: '请输入密码',
|
||||
invalid: '密码格式不正确,6-18位字符,包含字母、数字、下划线'
|
||||
invalid: '密码格式不正确,4-30位字符,包含字母、数字、下划线'
|
||||
},
|
||||
confirmPwd: {
|
||||
required: '请输入确认密码',
|
||||
|
||||
@@ -3,6 +3,3 @@ import 'element-plus/dist/index.css';
|
||||
import 'element-plus/theme-chalk/dark/css-vars.css';
|
||||
import 'uno.css';
|
||||
import '../styles/css/global.css';
|
||||
import 'swiper/css';
|
||||
import 'swiper/css/navigation';
|
||||
import 'swiper/css/pagination';
|
||||
|
||||
@@ -20,14 +20,12 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
|
||||
500: () => import("@/views/_builtin/500/index.vue"),
|
||||
"iframe-page": () => import("@/views/_builtin/iframe-page/[url].vue"),
|
||||
login: () => import("@/views/_builtin/login/index.vue"),
|
||||
"function_hide-child_one": () => import("@/views/function/hide-child/one/index.vue"),
|
||||
"function_hide-child_three": () => import("@/views/function/hide-child/three/index.vue"),
|
||||
"function_hide-child_two": () => import("@/views/function/hide-child/two/index.vue"),
|
||||
"function_multi-tab": () => import("@/views/function/multi-tab/index.vue"),
|
||||
function_request: () => import("@/views/function/request/index.vue"),
|
||||
"function_super-page": () => import("@/views/function/super-page/index.vue"),
|
||||
function_tab: () => import("@/views/function/tab/index.vue"),
|
||||
"function_toggle-auth": () => import("@/views/function/toggle-auth/index.vue"),
|
||||
feedback: () => import("@/views/feedback/index.vue"),
|
||||
"infra_log-management_api-access-log": () => import("@/views/infra/log-management/api-access-log/index.vue"),
|
||||
"infra_log-management_api-error-log": () => import("@/views/infra/log-management/api-error-log/index.vue"),
|
||||
"infra_log-management": () => import("@/views/infra/log-management/index.vue"),
|
||||
"infra_log-management_login-log": () => import("@/views/infra/log-management/login-log/index.vue"),
|
||||
"infra_log-management_operate-log": () => import("@/views/infra/log-management/operate-log/index.vue"),
|
||||
"infra_rd-code": () => import("@/views/infra/rd-code/index.vue"),
|
||||
"infra_state-machine": () => import("@/views/infra/state-machine/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"),
|
||||
"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-monthly": () => import("@/views/personal-center/my-monthly/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-weekly": () => import("@/views/personal-center/my-weekly/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"),
|
||||
plugin_barcode: () => import("@/views/plugin/barcode/index.vue"),
|
||||
plugin_charts_antv: () => import("@/views/plugin/charts/antv/index.vue"),
|
||||
plugin_charts_echarts: () => import("@/views/plugin/charts/echarts/index.vue"),
|
||||
plugin_charts_vchart: () => import("@/views/plugin/charts/vchart/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"),
|
||||
"personal-center_work-report": () => import("@/views/personal-center/work-report/index.vue"),
|
||||
"personal-center_work-report_monthly": () => import("@/views/personal-center/work-report/monthly/index.vue"),
|
||||
"personal-center_work-report_project": () => import("@/views/personal-center/work-report/project/index.vue"),
|
||||
"personal-center_work-report_weekly": () => import("@/views/personal-center/work-report/weekly/index.vue"),
|
||||
product_dashboard: () => import("@/views/product/dashboard/index.vue"),
|
||||
product_list: () => import("@/views/product/list/index.vue"),
|
||||
product_requirement: () => import("@/views/product/requirement/index.vue"),
|
||||
|
||||
@@ -40,122 +40,17 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'function',
|
||||
path: '/function',
|
||||
component: 'layout.base',
|
||||
name: 'feedback',
|
||||
path: '/feedback',
|
||||
component: 'layout.base$view.feedback',
|
||||
meta: {
|
||||
title: 'function',
|
||||
i18nKey: 'route.function',
|
||||
icon: 'icon-park-outline:all-application',
|
||||
order: 6
|
||||
},
|
||||
children: [
|
||||
{
|
||||
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
|
||||
}
|
||||
}
|
||||
]
|
||||
title: 'feedback',
|
||||
i18nKey: 'route.feedback',
|
||||
icon: 'mdi:message-alert-outline',
|
||||
order: 10,
|
||||
keepAlive: true,
|
||||
constant: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'iframe-page',
|
||||
@@ -181,6 +76,64 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
order: 20
|
||||
},
|
||||
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',
|
||||
path: '/infra/rd-code',
|
||||
@@ -189,7 +142,7 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
title: 'infra_rd-code',
|
||||
i18nKey: 'route.infra_rd-code',
|
||||
icon: 'mdi:identifier',
|
||||
order: 2,
|
||||
order: 3,
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
@@ -303,18 +256,6 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
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',
|
||||
path: '/personal-center/my-performance',
|
||||
@@ -323,7 +264,7 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
title: 'personal-center_my-performance',
|
||||
i18nKey: 'route.personal-center_my-performance',
|
||||
icon: 'mdi:trophy-outline',
|
||||
order: 3,
|
||||
order: 4,
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
@@ -339,18 +280,6 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
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',
|
||||
path: '/personal-center/overtime-application',
|
||||
@@ -374,223 +303,53 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
order: 7,
|
||||
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',
|
||||
path: '/plugin/charts',
|
||||
name: 'personal-center_work-report',
|
||||
path: '/personal-center/work-report',
|
||||
component: 'view.personal-center_work-report',
|
||||
meta: {
|
||||
title: 'plugin_charts',
|
||||
i18nKey: 'route.plugin_charts',
|
||||
icon: 'mdi:chart-areaspline'
|
||||
},
|
||||
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',
|
||||
title: 'personal-center_work-report',
|
||||
i18nKey: 'route.personal-center_work-report',
|
||||
icon: 'mdi:file-chart-outline',
|
||||
order: 3,
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'plugin_gantt',
|
||||
path: '/plugin/gantt',
|
||||
meta: {
|
||||
title: 'plugin_gantt',
|
||||
i18nKey: 'route.plugin_gantt',
|
||||
icon: 'ant-design:bar-chart-outlined'
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'plugin_gantt_dhtmlx',
|
||||
path: '/plugin/gantt/dhtmlx',
|
||||
component: 'view.plugin_gantt_dhtmlx',
|
||||
name: 'personal-center_work-report_monthly',
|
||||
path: '/personal-center/work-report/monthly',
|
||||
component: 'view.personal-center_work-report_monthly',
|
||||
meta: {
|
||||
title: 'plugin_gantt_dhtmlx',
|
||||
i18nKey: 'route.plugin_gantt_dhtmlx',
|
||||
icon: 'gridicons:posts'
|
||||
title: 'personal-center_work-report_monthly',
|
||||
i18nKey: 'route.personal-center_work-report_monthly',
|
||||
hideInMenu: true,
|
||||
activeMenu: 'personal-center_work-report'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'plugin_gantt_vtable',
|
||||
path: '/plugin/gantt/vtable',
|
||||
component: 'view.plugin_gantt_vtable',
|
||||
name: 'personal-center_work-report_project',
|
||||
path: '/personal-center/work-report/project',
|
||||
component: 'view.personal-center_work-report_project',
|
||||
meta: {
|
||||
title: 'plugin_gantt_vtable',
|
||||
i18nKey: 'route.plugin_gantt_vtable',
|
||||
localIcon: 'visactor'
|
||||
title: 'personal-center_work-report_project',
|
||||
i18nKey: 'route.personal-center_work-report_project',
|
||||
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',
|
||||
path: '/plugin/tables/vtable',
|
||||
component: 'view.plugin_tables_vtable',
|
||||
name: 'personal-center_work-report_weekly',
|
||||
path: '/personal-center/work-report/weekly',
|
||||
component: 'view.personal-center_work-report_weekly',
|
||||
meta: {
|
||||
title: 'plugin_tables_vtable',
|
||||
i18nKey: 'route.plugin_tables_vtable',
|
||||
localIcon: 'visactor'
|
||||
title: 'personal-center_work-report_weekly',
|
||||
i18nKey: 'route.personal-center_work-report_weekly',
|
||||
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'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -170,18 +170,14 @@ const routeMap: RouteMap = {
|
||||
"403": "/403",
|
||||
"404": "/404",
|
||||
"500": "/500",
|
||||
"function": "/function",
|
||||
"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",
|
||||
"feedback": "/feedback",
|
||||
"iframe-page": "/iframe-page/:url",
|
||||
"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_state-machine": "/infra/state-machine",
|
||||
"login": "/login/:module(pwd-login|reset-pwd)?",
|
||||
@@ -192,33 +188,14 @@ const routeMap: RouteMap = {
|
||||
"personal-center": "/personal-center",
|
||||
"personal-center_my-application": "/personal-center/my-application",
|
||||
"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-profile": "/personal-center/my-profile",
|
||||
"personal-center_my-weekly": "/personal-center/my-weekly",
|
||||
"personal-center_overtime-application": "/personal-center/overtime-application",
|
||||
"personal-center_pending-approval": "/personal-center/pending-approval",
|
||||
"plugin": "/plugin",
|
||||
"plugin_barcode": "/plugin/barcode",
|
||||
"plugin_charts": "/plugin/charts",
|
||||
"plugin_charts_antv": "/plugin/charts/antv",
|
||||
"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",
|
||||
"personal-center_work-report": "/personal-center/work-report",
|
||||
"personal-center_work-report_monthly": "/personal-center/work-report/monthly",
|
||||
"personal-center_work-report_project": "/personal-center/work-report/project",
|
||||
"personal-center_work-report_weekly": "/personal-center/work-report/weekly",
|
||||
"product": "/product",
|
||||
"product_dashboard": "/product/dashboard",
|
||||
"product_list": "/product/list",
|
||||
|
||||
@@ -8,6 +8,6 @@ export function createDocumentTitleGuard(router: Router) {
|
||||
|
||||
const documentTitle = i18nKey ? $t(i18nKey) : title;
|
||||
|
||||
useTitle(documentTitle);
|
||||
useTitle(`研发管理系统 - ${documentTitle}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ interface BackendUserInfoDTO {
|
||||
userId: string | number;
|
||||
userName?: string | null;
|
||||
nickname?: string | null;
|
||||
deptId?: string | number | null;
|
||||
roles?: string[] | null;
|
||||
buttons?: string[] | null;
|
||||
}
|
||||
@@ -61,6 +62,7 @@ function mapUserInfo(data: BackendUserInfoDTO): Api.Auth.UserInfo {
|
||||
userId: String(data.userId ?? ''),
|
||||
userName: data.userName ?? '',
|
||||
nickname: data.nickname ?? '',
|
||||
deptId: safeStringId(data.deptId),
|
||||
roles: data.roles ?? [],
|
||||
buttons: data.buttons ?? []
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ function createBatchDeleteQuery(ids: number[]) {
|
||||
type DictDataResponse = Omit<Api.Dict.DictData, 'colorType'> & {
|
||||
colorType?: string | null;
|
||||
color_type?: string | null;
|
||||
css_class?: string | null;
|
||||
};
|
||||
|
||||
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'> & {
|
||||
colorType?: string | null;
|
||||
color_type?: string | null;
|
||||
css_class?: string | null;
|
||||
};
|
||||
|
||||
type FrontendDictCacheResponse = Record<string, FrontendDictDataResponse[]>;
|
||||
@@ -37,20 +39,22 @@ function normalizeColorType(value?: string | null) {
|
||||
}
|
||||
|
||||
function normalizeDictData(data: DictDataResponse): Api.Dict.DictData {
|
||||
const { color_type: colorTypeFromSnakeCase, ...rest } = data;
|
||||
const { color_type: colorTypeFromSnakeCase, css_class: cssClassFromSnakeCase, ...rest } = data;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
colorType: normalizeColorType(data.colorType ?? colorTypeFromSnakeCase)
|
||||
colorType: normalizeColorType(data.colorType ?? colorTypeFromSnakeCase),
|
||||
cssClass: normalizeColorType(data.cssClass ?? cssClassFromSnakeCase)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFrontendDictData(data: FrontendDictDataResponse): Api.Dict.FrontendDictData {
|
||||
const { color_type: colorTypeFromSnakeCase, ...rest } = data;
|
||||
const { color_type: colorTypeFromSnakeCase, css_class: cssClassFromSnakeCase, ...rest } = data;
|
||||
|
||||
return {
|
||||
...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 => ({
|
||||
...data,
|
||||
list: data.list.map(normalizeDictData)
|
||||
list: (data.list ?? []).map(normalizeDictData)
|
||||
}));
|
||||
}
|
||||
|
||||
34
src/service/api/feedback-normalize.ts
Normal file
34
src/service/api/feedback-normalize.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/** 后端统计原始返回(type/status 可能为 number 或 string,count 为整数) */
|
||||
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 缺省兜底 0;data 为空全兜底 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
185
src/service/api/feedback.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个附件归一化为业务层 AttachmentItem(ID 铁律:fileId/configId 一律 String)。
|
||||
* - 对象形态(新版存储):按字段对齐;
|
||||
* - 字符串形态(历史仅存 URL):补出 url + 派生 name,fileId 缺省空串(不可下载/清理,仅展示)。
|
||||
*/
|
||||
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(/ /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;无附件传空串;返回新建 id,ID 铁律 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 }
|
||||
});
|
||||
}
|
||||
@@ -1,12 +1,19 @@
|
||||
export * from './auth';
|
||||
export * from './dict';
|
||||
export * from './feedback';
|
||||
export * from './file';
|
||||
export * from './infra';
|
||||
export * from './notice';
|
||||
export * from './notify-message';
|
||||
export * from './object-context';
|
||||
export * from './overtime-application';
|
||||
export * from './performance';
|
||||
export * from './personal-item';
|
||||
export * from './product';
|
||||
export * from './project';
|
||||
export * from './project-group';
|
||||
export * from './project-shared';
|
||||
export * from './route';
|
||||
export * from './system-manage';
|
||||
export * from './system-log';
|
||||
export * from './work-report';
|
||||
|
||||
28
src/service/api/notice.ts
Normal file
28
src/service/api/notice.ts
Normal 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));
|
||||
}
|
||||
63
src/service/api/notify-message.ts
Normal file
63
src/service/api/notify-message.ts
Normal 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'
|
||||
});
|
||||
}
|
||||
@@ -1,13 +1,7 @@
|
||||
import { WEB_SERVICE_PREFIX } from '@/constants/service';
|
||||
import { request } from '../request';
|
||||
import { type ProjectLocalDateValue, normalizeProjectLocalDate } from './project-shared';
|
||||
import {
|
||||
type ServiceRequestResult,
|
||||
mapServiceResult,
|
||||
normalizeNullableStringId,
|
||||
normalizeStringId,
|
||||
safeJsonRequestConfig
|
||||
} from './shared';
|
||||
import { type ServiceRequestResult, mapServiceResult, normalizeStringId, safeJsonRequestConfig } from './shared';
|
||||
|
||||
const OVERTIME_APPLICATION_PREFIX = `${WEB_SERVICE_PREFIX}/project/overtime-applications`;
|
||||
|
||||
@@ -30,16 +24,37 @@ type OvertimeApplicationPageResponse = Omit<Api.OvertimeApplication.OvertimeAppl
|
||||
list: OvertimeApplicationResponse[];
|
||||
};
|
||||
|
||||
type OvertimeApplicationStatusLogResponse = Omit<
|
||||
Api.OvertimeApplication.OvertimeApplicationStatusLog,
|
||||
'id' | 'applicationId' | 'operatorUserId' | 'overtimeDateSnapshot'
|
||||
type OvertimeApplicationApprovalRecordResponse = Omit<
|
||||
Api.OvertimeApplication.OvertimeApplicationApprovalRecord,
|
||||
'id' | 'overtimeApplicationId' | 'statusLogId' | 'auditorUserId'
|
||||
> & {
|
||||
id: StringIdResponse;
|
||||
applicationId: StringIdResponse;
|
||||
operatorUserId: StringIdResponse;
|
||||
overtimeDateSnapshot: ProjectLocalDateValue;
|
||||
overtimeApplicationId: StringIdResponse;
|
||||
statusLogId: StringIdResponse;
|
||||
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) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
@@ -81,18 +96,16 @@ function normalizeOvertimeApplication(
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStatusLog(
|
||||
response: OvertimeApplicationStatusLogResponse
|
||||
): Api.OvertimeApplication.OvertimeApplicationStatusLog {
|
||||
function normalizeApprovalRecord(
|
||||
response: OvertimeApplicationApprovalRecordResponse
|
||||
): Api.OvertimeApplication.OvertimeApplicationApprovalRecord {
|
||||
return {
|
||||
...response,
|
||||
id: normalizeStringId(response.id),
|
||||
applicationId: normalizeStringId(response.applicationId),
|
||||
operatorUserId: normalizeStringId(response.operatorUserId),
|
||||
overtimeDateSnapshot: normalizeProjectLocalDate(response.overtimeDateSnapshot) ?? '',
|
||||
fromStatus: normalizeNullableStringId(response.fromStatus),
|
||||
reason: response.reason ?? null,
|
||||
remark: response.remark ?? null
|
||||
overtimeApplicationId: normalizeStringId(response.overtimeApplicationId),
|
||||
statusLogId: normalizeStringId(response.statusLogId),
|
||||
auditorUserId: normalizeStringId(response.auditorUserId),
|
||||
opinion: response.opinion ?? null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -102,6 +115,18 @@ function createPageQuery(params: Api.OvertimeApplication.OvertimeApplicationSear
|
||||
query.append('pageNo', String(params.pageNo ?? 1));
|
||||
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) {
|
||||
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) {
|
||||
return request<boolean>({
|
||||
export function fetchBatchApproveOvertimeApplication(
|
||||
data: Api.OvertimeApplication.OvertimeApplicationBatchActionParams
|
||||
) {
|
||||
return request<Api.OvertimeApplication.OvertimeApplicationBatchActionResult>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${OVERTIME_APPLICATION_PREFIX}/${id}/cancel`,
|
||||
url: `${OVERTIME_APPLICATION_PREFIX}/batch-approve`,
|
||||
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) {
|
||||
const result = await request<OvertimeApplicationStatusLogResponse[]>({
|
||||
export async function fetchGetOvertimeApplicationApprovalRecords(id: string) {
|
||||
const result = await request<OvertimeApplicationApprovalRecordResponse[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${OVERTIME_APPLICATION_PREFIX}/${id}/status-logs`,
|
||||
url: `${OVERTIME_APPLICATION_PREFIX}/${id}/approval-records`,
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<OvertimeApplicationStatusLogResponse[]>, data =>
|
||||
data.map(normalizeStatusLog)
|
||||
return mapServiceResult(result as ServiceRequestResult<OvertimeApplicationApprovalRecordResponse[]>, data =>
|
||||
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 = {}) {
|
||||
const query = createPageQuery(params);
|
||||
|
||||
|
||||
501
src/service/api/performance.ts
Normal file
501
src/service/api/performance.ts
Normal 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
|
||||
});
|
||||
}
|
||||
@@ -11,9 +11,15 @@ import { normalizeProductMember, normalizeProductSettings } from './product-shar
|
||||
|
||||
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;
|
||||
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>;
|
||||
@@ -39,7 +45,15 @@ function normalizeProduct(product: ProductResponse): Api.Product.Product {
|
||||
return {
|
||||
...product,
|
||||
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() {
|
||||
return request<Api.Product.ProductOverviewSummary>({
|
||||
export async function fetchGetProductOverviewSummary() {
|
||||
const result = await request<ProductOverviewSummaryResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PRODUCT_PREFIX}/overview-summary`,
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
return mapServiceResult(
|
||||
result as ServiceRequestResult<ProductOverviewSummaryResponse>,
|
||||
normalizeProductOverviewSummary
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取产品详情 */
|
||||
@@ -189,7 +237,7 @@ type RequirementResponse = Omit<
|
||||
| 'proposerId'
|
||||
| 'currentHandlerUserId'
|
||||
| 'implementProjectId'
|
||||
| 'sourceBizId'
|
||||
| 'sourceBizCode'
|
||||
| 'attachments'
|
||||
> & {
|
||||
id: string | number;
|
||||
@@ -199,7 +247,7 @@ type RequirementResponse = Omit<
|
||||
currentHandlerUserId?: string | number | null;
|
||||
implementProjectId?: string | number | null;
|
||||
implementProjectName?: string | null;
|
||||
sourceBizId?: string | number | null;
|
||||
sourceBizCode?: string | null;
|
||||
attachments?: AttachmentItemResponse[] | null;
|
||||
children?: RequirementResponse[];
|
||||
};
|
||||
@@ -271,7 +319,7 @@ function normalizeRequirement(requirement: RequirementResponse): Api.Product.Req
|
||||
currentHandlerUserId: normalizeNullableStringId(requirement.currentHandlerUserId),
|
||||
implementProjectId: normalizeNullableStringId(requirement.implementProjectId),
|
||||
implementProjectName: requirement.implementProjectName ?? null,
|
||||
sourceBizId: normalizeNullableStringId(requirement.sourceBizId),
|
||||
sourceBizCode: requirement.sourceBizCode ?? null,
|
||||
attachments: normalizeAttachments(requirement.attachments),
|
||||
children: requirement.children?.map(normalizeRequirement)
|
||||
};
|
||||
|
||||
62
src/service/api/project-group.ts
Normal file
62
src/service/api/project-group.ts
Normal 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 化,组内项目复用 normalizeProject(id/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 条(默认 5),projectTotal 为该口径组内全量计数;
|
||||
* 剩余项目由页面按 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) : []
|
||||
}));
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { normalizeNullableStringId, normalizeStringId } from './shared';
|
||||
|
||||
type ProjectStatusCode = Api.Project.ProjectStatusCode;
|
||||
@@ -40,6 +41,96 @@ export type ProjectExecutionResponse = Omit<
|
||||
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'> & {
|
||||
id: StringIdResponse;
|
||||
executionId: StringIdResponse;
|
||||
@@ -227,6 +318,28 @@ export function normalizeProjectLocalDate(value: ProjectLocalDateValue | undefin
|
||||
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>(
|
||||
actions: LifecycleActionResponse<ActionCode>[] | null | undefined
|
||||
): Api.Project.LifecycleAction<ActionCode>[] {
|
||||
@@ -260,6 +373,15 @@ function normalizePriority(value: string | number | null | undefined): string {
|
||||
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 {
|
||||
return {
|
||||
...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 {
|
||||
return {
|
||||
...response,
|
||||
|
||||
@@ -10,6 +10,11 @@ import {
|
||||
import {
|
||||
type ExecutionAssigneeLogResponse,
|
||||
type ExecutionAssigneeResponse,
|
||||
type MyExecutionResponse,
|
||||
type MyOwnedProjectResponse,
|
||||
type MyParticipatedProjectResponse,
|
||||
type MyTaskResponse,
|
||||
type MyWorklogWeekResponse,
|
||||
type ProjectExecutionResponse,
|
||||
type ProjectLocalDateValue,
|
||||
type ProjectMemberResponse,
|
||||
@@ -17,23 +22,39 @@ import {
|
||||
type TaskAssigneeFromApiResponse,
|
||||
type TaskAssigneeLogResponse,
|
||||
type TaskWorklogResponse,
|
||||
type TeamLoadResponse,
|
||||
type TeamWorklogWeekResponse,
|
||||
getProjectLifecycleActions,
|
||||
normalizeExecutionAssignee,
|
||||
normalizeExecutionAssigneeLog,
|
||||
normalizeMyExecution,
|
||||
normalizeMyOwnedProject,
|
||||
normalizeMyParticipatedProject,
|
||||
normalizeMyTask,
|
||||
normalizeMyWorklogWeek,
|
||||
normalizeProjectExecution,
|
||||
normalizeProjectLocalDate,
|
||||
normalizeProjectMember,
|
||||
normalizeProjectTask,
|
||||
normalizeTaskAssignee,
|
||||
normalizeTaskAssigneeLog,
|
||||
normalizeTaskWorklog
|
||||
normalizeTaskWorklog,
|
||||
normalizeTeamLoad,
|
||||
normalizeTeamWorklogWeek
|
||||
} from './project-shared';
|
||||
|
||||
const PROJECT_PREFIX = `${WEB_SERVICE_PREFIX}/project/project`;
|
||||
|
||||
type ProjectResponse = Omit<
|
||||
export type ProjectResponse = Omit<
|
||||
Api.Project.Project,
|
||||
'id' | 'managerUserId' | 'productId' | 'plannedStartDate' | 'plannedEndDate' | 'actualStartDate' | 'actualEndDate'
|
||||
| 'id'
|
||||
| 'managerUserId'
|
||||
| 'productId'
|
||||
| 'plannedStartDate'
|
||||
| 'plannedEndDate'
|
||||
| 'actualStartDate'
|
||||
| 'actualEndDate'
|
||||
| 'currentUserRoles'
|
||||
> & {
|
||||
id: string | number;
|
||||
managerUserId?: string | number | null;
|
||||
@@ -42,6 +63,8 @@ type ProjectResponse = Omit<
|
||||
plannedEndDate?: ProjectLocalDateValue;
|
||||
actualStartDate?: ProjectLocalDateValue;
|
||||
actualEndDate?: ProjectLocalDateValue;
|
||||
/** 灰度/兼容期后端可能缺省,适配层兜底为 [] */
|
||||
currentUserRoles?: Api.Common.CurrentUserRole[] | null;
|
||||
};
|
||||
|
||||
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 {
|
||||
...project,
|
||||
id: normalizeStringId(project.id),
|
||||
@@ -82,7 +105,8 @@ function normalizeProject(project: ProjectResponse): Api.Project.Project {
|
||||
plannedStartDate: normalizeProjectLocalDate(project.plannedStartDate),
|
||||
plannedEndDate: normalizeProjectLocalDate(project.plannedEndDate),
|
||||
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() {
|
||||
return request<Api.Project.ProjectOverviewSummary>({
|
||||
export async function fetchGetProjectOverviewSummary() {
|
||||
const result = await request<ProjectOverviewSummaryResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PROJECT_PREFIX}/overview-summary`,
|
||||
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(
|
||||
projectId: string,
|
||||
@@ -892,7 +1036,7 @@ const PROJECT_REQUIREMENT_PREFIX = `${WEB_SERVICE_PREFIX}/project/project/requir
|
||||
|
||||
type ProjectRequirementResponse = Omit<
|
||||
Api.Project.ProjectRequirement,
|
||||
'id' | 'projectId' | 'parentId' | 'moduleId' | 'proposerId' | 'currentHandlerUserId' | 'sourceBizId' | 'attachments'
|
||||
'id' | 'projectId' | 'parentId' | 'moduleId' | 'proposerId' | 'currentHandlerUserId' | 'sourceBizCode' | 'attachments'
|
||||
> & {
|
||||
id: string | number;
|
||||
projectId: string | number;
|
||||
@@ -900,7 +1044,7 @@ type ProjectRequirementResponse = Omit<
|
||||
moduleId: string | number;
|
||||
proposerId: string | number;
|
||||
currentHandlerUserId?: string | number | null;
|
||||
sourceBizId?: string | number | null;
|
||||
sourceBizCode?: string | null;
|
||||
attachments?: AttachmentItemResponse[] | null;
|
||||
children?: ProjectRequirementResponse[];
|
||||
};
|
||||
@@ -956,7 +1100,7 @@ function normalizeProjectRequirement(requirement: ProjectRequirementResponse): A
|
||||
moduleId: normalizeStringId(requirement.moduleId),
|
||||
proposerId: normalizeStringId(requirement.proposerId),
|
||||
currentHandlerUserId: normalizeNullableStringId(requirement.currentHandlerUserId),
|
||||
sourceBizId: normalizeNullableStringId(requirement.sourceBizId),
|
||||
sourceBizCode: requirement.sourceBizCode ?? null,
|
||||
attachments: normalizeAttachments(requirement.attachments),
|
||||
progressRate: typeof requirement.progressRate === 'number' ? requirement.progressRate : 0,
|
||||
children: requirement.children?.map(normalizeProjectRequirement)
|
||||
|
||||
260
src/service/api/system-log.ts
Normal file
260
src/service/api/system-log.ts
Normal 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'
|
||||
});
|
||||
}
|
||||
@@ -18,6 +18,29 @@ const POST_PREFIX = `${SYSTEM_SERVICE_PREFIX}/post`;
|
||||
const ORG_LEADER_PREFIX = `${SYSTEM_SERVICE_PREFIX}/org-leader`;
|
||||
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) {
|
||||
const query = new URLSearchParams();
|
||||
|
||||
@@ -118,6 +141,11 @@ type UserManagementRelationTreeResponse = Omit<
|
||||
children?: UserManagementRelationTreeResponse[] | null;
|
||||
};
|
||||
|
||||
type MySubordinateTreeNodeResponse = Omit<Api.SystemManage.MySubordinateTreeNode, 'userId' | 'children'> & {
|
||||
userId: string | number;
|
||||
children?: MySubordinateTreeNodeResponse[] | null;
|
||||
};
|
||||
|
||||
function normalizeUserSimple(user: UserSimpleResponse): Api.SystemManage.UserSimple {
|
||||
return {
|
||||
...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) {
|
||||
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) {
|
||||
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() {
|
||||
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) {
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户管理链路详情
|
||||
*
|
||||
|
||||
997
src/service/api/work-report.ts
Normal file
997
src/service/api/work-report.ts
Normal 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'
|
||||
});
|
||||
}
|
||||
@@ -12,11 +12,13 @@ import type { RequestInstanceState } from './type';
|
||||
|
||||
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y';
|
||||
const { baseURL, otherBaseURL } = getServiceBaseURL(import.meta.env, isHttpProxy);
|
||||
const REQUEST_TIMEOUT = 15 * 1000;
|
||||
|
||||
export const request = withDedupe(
|
||||
createFlatRequest(
|
||||
{
|
||||
baseURL,
|
||||
timeout: REQUEST_TIMEOUT,
|
||||
headers: {
|
||||
apifoxToken: 'XL299LiMEDZ0H5h3A29PxwQXdMJqWyY2'
|
||||
}
|
||||
@@ -126,6 +128,10 @@ export const request = withDedupe(
|
||||
let message = error.message;
|
||||
let backendErrorCode = '';
|
||||
|
||||
if (error.code === 'ECONNABORTED' && error.message.includes('timeout')) {
|
||||
message = '请求超时,请稍后重试';
|
||||
}
|
||||
|
||||
// 获取后端错误信息和错误码
|
||||
if (error.code === BACKEND_ERROR_CODE) {
|
||||
message = error.response?.data?.msg || message;
|
||||
|
||||
@@ -67,7 +67,7 @@ export const useAppStore = defineStore(SetupStoreId.App, () => {
|
||||
|
||||
const documentTitle = i18nKey ? $t(i18nKey) : title;
|
||||
|
||||
useTitle(documentTitle);
|
||||
useTitle(`研发管理系统 - ${documentTitle}`);
|
||||
}
|
||||
|
||||
function init() {
|
||||
|
||||
@@ -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.
|
||||
needRedirect = false;
|
||||
}
|
||||
|
||||
// 跳首页前先把权限路由建好:菜单/路由/首页 redirect 全部就绪后再导航,
|
||||
// 否则依赖守卫在"跳首页"那次导航里懒加载,会出现首页先以空 menus 渲染、
|
||||
// 之后无新导航补灌、菜单一直空到手动刷新才恢复的竞态。
|
||||
await routeStore.initAuthRoute();
|
||||
|
||||
await redirectFromLogin(needRedirect);
|
||||
|
||||
window.$notification?.success({
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,6 +28,15 @@ function normalizeColorType(raw: unknown): string | 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(
|
||||
dictType: string,
|
||||
list: Api.Dict.FrontendDictData[],
|
||||
@@ -40,7 +49,7 @@ function normalizeFrontendDictData(
|
||||
dictType: item.dictType || dictType,
|
||||
sort: item.sort,
|
||||
status: item.status ?? 0,
|
||||
colorType: normalizeColorType(item.colorType),
|
||||
colorType: resolveDisplayColor(item.colorType, item.cssClass),
|
||||
remark: item.remark ?? null,
|
||||
createTime: 0
|
||||
}));
|
||||
@@ -54,7 +63,7 @@ function normalizeDictDataItem(item: Api.Dict.DictData, dictType: string): Api.D
|
||||
value: String(item.value),
|
||||
dictType: item.dictType || dictType,
|
||||
status: item.status ?? 0,
|
||||
colorType: normalizeColorType(item.colorType),
|
||||
colorType: resolveDisplayColor(item.colorType, item.cssClass),
|
||||
remark: item.remark ?? null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -406,6 +406,7 @@ html .el-collapse {
|
||||
.business-table-action-cell {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 0 8px;
|
||||
|
||||
@@ -89,4 +89,7 @@ export const themeSettings: App.Theme.ThemeSetting = {
|
||||
*
|
||||
* 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'
|
||||
};
|
||||
|
||||
1
src/typings/api/auth.d.ts
vendored
1
src/typings/api/auth.d.ts
vendored
@@ -14,6 +14,7 @@ declare namespace Api {
|
||||
userId: string;
|
||||
userName: string;
|
||||
nickname: string;
|
||||
deptId?: string | null;
|
||||
roles: string[];
|
||||
buttons: string[];
|
||||
}
|
||||
|
||||
16
src/typings/api/common.d.ts
vendored
16
src/typings/api/common.d.ts
vendored
@@ -31,6 +31,22 @@ declare namespace Api {
|
||||
*/
|
||||
type EnableStatus = '1' | '2';
|
||||
|
||||
/**
|
||||
* 列表项「当前登录用户在该对象的角色」(产品 / 项目列表共用)。
|
||||
*
|
||||
* 后端只读计算字段,随登录身份变化:同一份列表不同账号看到的内容不同;无角色为 []。
|
||||
* 提交 / 更新接口不需要回传它。
|
||||
*/
|
||||
interface CurrentUserRole {
|
||||
/**
|
||||
* 角色稳定标识(程序判断用,不随中文名变化)。
|
||||
* 例:product_manager / project_manager / developer / tester / watcher / creator / implicit_observer。
|
||||
*/
|
||||
roleKey: string;
|
||||
/** 角色中文名(直接展示) */
|
||||
roleName: string;
|
||||
}
|
||||
|
||||
/** common record */
|
||||
type CommonRecord<T = any> = {
|
||||
/** record id */
|
||||
|
||||
4
src/typings/api/dict.d.ts
vendored
4
src/typings/api/dict.d.ts
vendored
@@ -57,6 +57,8 @@ declare namespace Api {
|
||||
status: DictStatus;
|
||||
/** 颜色(hex,#xxxxxx);nullable,无值时前端按默认渲染 */
|
||||
colorType?: string | null;
|
||||
/** 精确颜色(hex,#xxxxxx);存在时优先于 colorType,用于 colorType 落到语义色无法区分的场景 */
|
||||
cssClass?: string | null;
|
||||
/** remark */
|
||||
remark?: string | null;
|
||||
/** create time */
|
||||
@@ -77,6 +79,8 @@ declare namespace Api {
|
||||
status?: DictStatus;
|
||||
/** 颜色(hex,#xxxxxx);nullable,无值时前端按默认渲染 */
|
||||
colorType?: string | null;
|
||||
/** 精确颜色(hex,#xxxxxx);存在时优先于 colorType */
|
||||
cssClass?: string | null;
|
||||
/** 备注,可用于下拉中文释义展示 */
|
||||
remark?: string | null;
|
||||
}
|
||||
|
||||
84
src/typings/api/feedback.d.ts
vendored
Normal file
84
src/typings/api/feedback.d.ts
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
declare namespace Api {
|
||||
/**
|
||||
* namespace Feedback
|
||||
*
|
||||
* backend api module: "feedback"(用户意见反馈)
|
||||
*/
|
||||
namespace Feedback {
|
||||
/** 反馈分页查询参数(GET query;type/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
24
src/typings/api/notice.d.ts
vendored
Normal 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
46
src/typings/api/notify-message.d.ts
vendored
Normal 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_level,1=普通 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
64
src/typings/api/overtime-application.d.ts
vendored
64
src/typings/api/overtime-application.d.ts
vendored
@@ -5,9 +5,9 @@ declare namespace Api {
|
||||
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 {
|
||||
id: string;
|
||||
@@ -32,6 +32,7 @@ declare namespace Api {
|
||||
|
||||
type OvertimeApplicationSearchParams = CommonType.RecordNullable<
|
||||
Pick<PageParams, 'pageNo' | 'pageSize'> & {
|
||||
applicantIds: string[] | null;
|
||||
keyword: string;
|
||||
applicantName: string;
|
||||
approverId: string;
|
||||
@@ -59,20 +60,55 @@ declare namespace Api {
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
interface OvertimeApplicationStatusLog {
|
||||
id: string;
|
||||
applicationId: string;
|
||||
actionType: OvertimeApplicationActionType;
|
||||
fromStatus?: string | null;
|
||||
toStatus: string;
|
||||
interface OvertimeApplicationBatchActionParams {
|
||||
ids: string[];
|
||||
reason?: string | null;
|
||||
operatorUserId: string;
|
||||
operatorName: string;
|
||||
applicantNameSnapshot: string;
|
||||
overtimeDateSnapshot: string;
|
||||
overtimeDurationSnapshot: string;
|
||||
remark?: string | null;
|
||||
}
|
||||
|
||||
interface OvertimeApplicationBatchFailItem {
|
||||
id: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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
233
src/typings/api/performance.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
44
src/typings/api/product.d.ts
vendored
44
src/typings/api/product.d.ts
vendored
@@ -21,10 +21,27 @@ declare namespace Api {
|
||||
list: T[];
|
||||
}
|
||||
|
||||
/** 入口页概览统计状态看板项(状态机全部启用状态,按 sort 升序,计数为 0 也返回;与项目域契约同构) */
|
||||
interface OverviewStatusItem {
|
||||
statusCode: string;
|
||||
/** 状态展示名(状态机配置中文名,前端直接渲染,不做本地名称映射) */
|
||||
statusName: string;
|
||||
count: number;
|
||||
sort: number;
|
||||
/** 是否终态(状态机 terminal_flag) */
|
||||
terminal: boolean;
|
||||
/** 是否计入"全部";当前口径无排除项恒为 true(产品列表暂无"全部"视图,按同构契约返回) */
|
||||
includeInAll: boolean;
|
||||
}
|
||||
|
||||
/** 产品入口页概览统计 */
|
||||
interface ProductOverviewSummary {
|
||||
/** 产品状态数量映射,key 为后端状态编码 */
|
||||
/** 产品状态数量映射,key 为后端状态编码(过渡兼容字段,前端迁移完成后由后端删除) */
|
||||
statusCounts: Record<string, number>;
|
||||
/** "全部"口径总数 = items 各状态 count 之和 */
|
||||
total: number;
|
||||
/** 状态看板项,覆盖状态机全部启用状态,按 sort 升序 */
|
||||
items: OverviewStatusItem[];
|
||||
}
|
||||
|
||||
interface Product {
|
||||
@@ -50,6 +67,8 @@ declare namespace Api {
|
||||
createTime: string;
|
||||
/** 更新时间 */
|
||||
updateTime: string;
|
||||
/** 当前登录用户在该产品的角色(后端只读计算字段,随登录身份变化;无角色为 []) */
|
||||
currentUserRoles: Api.Common.CurrentUserRole[];
|
||||
}
|
||||
|
||||
interface ProductSettingBaseInfo {
|
||||
@@ -73,6 +92,17 @@ declare namespace Api {
|
||||
lastStatusReason?: string | null;
|
||||
}
|
||||
|
||||
interface ProductOption {
|
||||
/** 产品 ID */
|
||||
id: string;
|
||||
/** 产品编码 */
|
||||
code: string;
|
||||
/** 产品名称 */
|
||||
name: string;
|
||||
/** 产品方向字典值 */
|
||||
directionCode: string;
|
||||
}
|
||||
|
||||
interface ProductLifecycleAction {
|
||||
actionCode: ProductStatusActionCode;
|
||||
actionName: string;
|
||||
@@ -172,8 +202,10 @@ declare namespace Api {
|
||||
|
||||
type ProductSearchParams = CommonType.RecordNullable<
|
||||
Pick<PageParams, 'pageNo' | 'pageSize'> &
|
||||
Pick<Product, 'directionCode' | 'managerUserId' | 'statusCode'> & {
|
||||
Pick<Product, 'directionCode' | 'managerUserId'> & {
|
||||
keyword: string;
|
||||
/** 状态编码来自状态机(overview-summary items 动态下发),不再用前端字面量联合约束 */
|
||||
statusCode: string;
|
||||
updateTime: string[];
|
||||
}
|
||||
>;
|
||||
@@ -195,6 +227,7 @@ declare namespace Api {
|
||||
interface DeleteProductParams {
|
||||
id: string;
|
||||
productName: string;
|
||||
confirmText: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
@@ -313,8 +346,8 @@ declare namespace Api {
|
||||
categoryName?: string | null;
|
||||
/** 需求来源类型 */
|
||||
sourceType: RequirementSourceType;
|
||||
/** 需求来源业务ID */
|
||||
sourceBizId?: string | null;
|
||||
/** 来源业务编号 */
|
||||
sourceBizCode?: string | null;
|
||||
/** 优先级(0低 1中 2高 3紧急) */
|
||||
priority: RequirementPriority;
|
||||
/** 优先级名称 */
|
||||
@@ -489,7 +522,7 @@ declare namespace Api {
|
||||
Pick<PageParams, 'pageNo' | 'pageSize'> &
|
||||
Pick<
|
||||
Requirement,
|
||||
'moduleId' | 'category' | 'priority' | 'statusCode' | 'currentHandlerUserId' | 'sourceType'
|
||||
'moduleId' | 'category' | 'priority' | 'statusCode' | 'currentHandlerUserId' | 'sourceBizCode'
|
||||
> & {
|
||||
productId: string;
|
||||
title?: string;
|
||||
@@ -507,6 +540,7 @@ declare namespace Api {
|
||||
| 'attachments'
|
||||
| 'category'
|
||||
| 'priority'
|
||||
| 'sourceBizCode'
|
||||
| 'proposerId'
|
||||
| 'proposerNickname'
|
||||
| 'currentHandlerUserId'
|
||||
|
||||
307
src/typings/api/project.d.ts
vendored
307
src/typings/api/project.d.ts
vendored
@@ -170,6 +170,10 @@ declare namespace Api {
|
||||
name: string;
|
||||
size?: number;
|
||||
contentType?: string;
|
||||
/** 对象存储配置编号(上传返回),与 path 一起拼永久代理 URL */
|
||||
configId?: string;
|
||||
/** 文件相对路径(上传返回),与 configId 一起拼永久代理 URL */
|
||||
path?: string;
|
||||
}
|
||||
|
||||
/** 任务详情 / 分页响应里嵌入的活跃协办人引用(按加入时间正序) */
|
||||
@@ -304,6 +308,213 @@ declare namespace Api {
|
||||
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;
|
||||
/** 优先级字典 value(rdms_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;
|
||||
/** 优先级字典 value(rdms_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 为 null;hours=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) */
|
||||
interface CreateProjectExecutionParams {
|
||||
executionName: string;
|
||||
@@ -580,10 +791,29 @@ declare namespace Api {
|
||||
list: T[];
|
||||
}
|
||||
|
||||
/** 入口页概览统计状态看板项(状态机全部启用状态,按 sort 升序,计数为 0 也返回) */
|
||||
interface OverviewStatusItem {
|
||||
statusCode: string;
|
||||
/** 状态展示名(状态机配置中文名,前端直接渲染,不做本地名称映射) */
|
||||
statusName: string;
|
||||
count: number;
|
||||
sort: number;
|
||||
/** 是否终态(状态机 terminal_flag);不能用于"全部"排除或左栏分区(completed 也可能是终态) */
|
||||
terminal: boolean;
|
||||
/** 是否计入"全部";当前口径无排除项恒为 true,将来恢复排除项由该字段表达 */
|
||||
includeInAll: boolean;
|
||||
}
|
||||
|
||||
/** 项目入口页概览统计 */
|
||||
interface ProjectOverviewSummary {
|
||||
/** 项目状态数量映射,key 为后端状态编码 */
|
||||
/** 项目状态数量映射,key 为后端状态编码(过渡兼容字段,前端迁移完成后由后端删除) */
|
||||
statusCounts: Record<string, number>;
|
||||
/** "全部"口径总数 = items 各状态 count 之和(作废/归档计入) */
|
||||
total: number;
|
||||
/** 状态看板项,覆盖状态机全部启用状态,按 sort 升序 */
|
||||
items: OverviewStatusItem[];
|
||||
/** 游离项目计数 = 所有未挂产品的项目(不按状态过滤),左栏游离入口据此显隐 */
|
||||
orphanCount?: number;
|
||||
}
|
||||
|
||||
interface Project {
|
||||
@@ -625,6 +855,8 @@ declare namespace Api {
|
||||
createTime: string;
|
||||
/** 更新时间 */
|
||||
updateTime: string;
|
||||
/** 当前登录用户在该项目的角色(后端只读计算字段,随登录身份变化;无角色为 []) */
|
||||
currentUserRoles: Api.Common.CurrentUserRole[];
|
||||
}
|
||||
|
||||
interface ProjectContext {
|
||||
@@ -686,11 +918,75 @@ declare namespace Api {
|
||||
projectType: string;
|
||||
productId: string;
|
||||
managerUserId: string;
|
||||
statusCode: ProjectStatusCode;
|
||||
/** 状态编码来自状态机(overview-summary items 动态下发),不再用前端字面量联合约束 */
|
||||
statusCode: string;
|
||||
/** 多值状态筛选(存在时后端优先于单值 statusCode);分组页"展开剩余"按"全部"口径传 items 派生的全量编码 */
|
||||
statusCodes: string[];
|
||||
/** 仅查游离项目(productId 为空);与 productId 互斥,分组页展开游离组剩余时用 */
|
||||
orphanOnly: boolean;
|
||||
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'> & {
|
||||
projectCode: string | null;
|
||||
@@ -870,8 +1166,8 @@ declare namespace Api {
|
||||
categoryName?: string | null;
|
||||
/** 需求来源类型 */
|
||||
sourceType: ProjectRequirementSourceType;
|
||||
/** 来源业务 ID */
|
||||
sourceBizId?: string | null;
|
||||
/** 来源业务编号 */
|
||||
sourceBizCode?: string | null;
|
||||
/** 优先级 */
|
||||
priority: ProjectRequirementPriority;
|
||||
/** 优先级名称 */
|
||||
@@ -995,7 +1291,7 @@ declare namespace Api {
|
||||
Pick<PageParams, 'pageNo' | 'pageSize'> &
|
||||
Pick<
|
||||
ProjectRequirement,
|
||||
'moduleId' | 'parentId' | 'category' | 'priority' | 'statusCode' | 'currentHandlerUserId' | 'sourceType'
|
||||
'moduleId' | 'parentId' | 'category' | 'priority' | 'statusCode' | 'currentHandlerUserId' | 'sourceBizCode'
|
||||
> & {
|
||||
projectId: string;
|
||||
title: string;
|
||||
@@ -1013,6 +1309,7 @@ declare namespace Api {
|
||||
| 'attachments'
|
||||
| 'category'
|
||||
| 'priority'
|
||||
| 'sourceBizCode'
|
||||
| 'proposerId'
|
||||
| 'proposerNickname'
|
||||
| 'currentHandlerUserId'
|
||||
|
||||
153
src/typings/api/system-log.d.ts
vendored
Normal file
153
src/typings/api/system-log.d.ts
vendored
Normal 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;
|
||||
}
|
||||
>;
|
||||
}
|
||||
}
|
||||
}
|
||||
22
src/typings/api/system-manage.d.ts
vendored
22
src/typings/api/system-manage.d.ts
vendored
@@ -98,6 +98,8 @@ declare namespace Api {
|
||||
|
||||
type DeptSimpleList = DeptSimple[];
|
||||
|
||||
type DeptSelfAndChildrenList = DeptSimple[];
|
||||
|
||||
type DeptSearchParams = CommonType.RecordNullable<Pick<Dept, 'name' | 'orgType' | 'status'>>;
|
||||
|
||||
type SaveDeptParams = Pick<Dept, 'name' | 'parentId' | 'orgType' | 'code' | 'sort' | 'status'>;
|
||||
@@ -386,6 +388,24 @@ declare namespace Api {
|
||||
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;
|
||||
}
|
||||
|
||||
type UserSimpleList = UserSimple[];
|
||||
}
|
||||
}
|
||||
|
||||
337
src/typings/api/work-report.d.ts
vendored
Normal file
337
src/typings/api/work-report.d.ts
vendored
Normal 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
39
src/typings/app.d.ts
vendored
@@ -504,45 +504,6 @@ declare namespace App {
|
||||
};
|
||||
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: {
|
||||
common: {
|
||||
status: {
|
||||
|
||||
49
src/typings/components.d.ts
vendored
49
src/typings/components.d.ts
vendored
@@ -16,13 +16,13 @@ declare module 'vue' {
|
||||
BusinessFormDialog: typeof import('./../components/custom/business-form-dialog.vue')['default']
|
||||
BusinessFormDrawer: typeof import('./../components/custom/business-form-drawer.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']
|
||||
BusinessRichTextView: typeof import('./../components/custom/business-rich-text-view.vue')['default']
|
||||
BusinessUserPicker: typeof import('./../components/custom/business-user-picker.vue')['default']
|
||||
BusinessUserSelect: typeof import('./../components/custom/business-user-select.vue')['default']
|
||||
ButtonIcon: typeof import('./../components/custom/button-icon.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']
|
||||
DarkModeContainer: typeof import('./../components/common/dark-mode-container.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']
|
||||
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
||||
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']
|
||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
ElDatePickerPanel: typeof import('element-plus/es')['ElDatePickerPanel']
|
||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
@@ -77,7 +73,6 @@ declare module 'vue' {
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||
ElSpace: typeof import('element-plus/es')['ElSpace']
|
||||
ElStatistic: typeof import('element-plus/es')['ElStatistic']
|
||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
@@ -85,11 +80,10 @@ declare module 'vue' {
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
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']
|
||||
ElTree: typeof import('element-plus/es')['ElTree']
|
||||
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
|
||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||
ElWatermark: typeof import('element-plus/es')['ElWatermark']
|
||||
ExceptionBase: typeof import('./../components/common/exception-base.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']
|
||||
IconAntDesignReloadOutlined: typeof import('~icons/ant-design/reload-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:arrowRight': typeof import('~icons/ep/arrow-right')['default']
|
||||
'IconEp:box': typeof import('~icons/ep/box')['default']
|
||||
@@ -112,76 +102,61 @@ declare module 'vue' {
|
||||
'IconEp:sort': typeof import('~icons/ep/sort')['default']
|
||||
IconEpRemoveFilled: typeof import('~icons/ep/remove-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']
|
||||
'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']
|
||||
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']
|
||||
IconIcRoundDelete: typeof import('~icons/ic/round-delete')['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']
|
||||
IconIcRoundRefresh: typeof import('~icons/ic/round-refresh')['default']
|
||||
IconIcRoundRemove: typeof import('~icons/ic/round-remove')['default']
|
||||
IconIcRoundSearch: typeof import('~icons/ic/round-search')['default']
|
||||
IconLocalActivity: typeof import('~icons/local/activity')['default']
|
||||
IconLocalBanner: typeof import('~icons/local/banner')['default']
|
||||
IconLocalCast: typeof import('~icons/local/cast')['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']
|
||||
IconIcRoundUnfoldLess: typeof import('~icons/ic/round-unfold-less')['default']
|
||||
IconIcRoundUnfoldMore: typeof import('~icons/ic/round-unfold-more')['default']
|
||||
IconMaterialSymbolsPackage2: typeof import('~icons/material-symbols/package2')['default']
|
||||
IconMdiAccountTieOutline: typeof import('~icons/mdi/account-tie-outline')['default']
|
||||
IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default']
|
||||
IconMdiArrowUpThin: typeof import('~icons/mdi/arrow-up-thin')['default']
|
||||
IconMdiCheck: typeof import('~icons/mdi/check')['default']
|
||||
IconMdiChevronDoubleDown: typeof import('~icons/mdi/chevron-double-down')['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']
|
||||
IconMdiCrown: typeof import('~icons/mdi/crown')['default']
|
||||
IconMdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default']
|
||||
IconMdiDotsHorizontal: typeof import('~icons/mdi/dots-horizontal')['default']
|
||||
IconMdiDownload: typeof import('~icons/mdi/download')['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']
|
||||
IconMdiFolderOpen: typeof import('~icons/mdi/folder-open')['default']
|
||||
IconMdiFolderOutline: typeof import('~icons/mdi/folder-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']
|
||||
IconMdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['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']
|
||||
IconMdiPlus: typeof import('~icons/mdi/plus')['default']
|
||||
IconMdiRefresh: typeof import('~icons/mdi/refresh')['default']
|
||||
'IconMingcute:zoomInLine': typeof import('~icons/mingcute/zoom-in-line')['default']
|
||||
'IconMingcute:zoomOutLine': typeof import('~icons/mingcute/zoom-out-line')['default']
|
||||
IconMdiUpload: typeof import('~icons/mdi/upload')['default']
|
||||
IconUilSearch: typeof import('~icons/uil/search')['default']
|
||||
LangSwitch: typeof import('./../components/common/lang-switch.vue')['default']
|
||||
LookForward: typeof import('./../components/custom/look-forward.vue')['default']
|
||||
MenuToggler: typeof import('./../components/common/menu-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']
|
||||
ReloadButton: typeof import('./../components/common/reload-button.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
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']
|
||||
SystemLogo: typeof import('./../components/common/system-logo.vue')['default']
|
||||
TableColumnSetting: typeof import('./../components/advanced/table-column-setting.vue')['default']
|
||||
TableHeaderOperation: typeof import('./../components/advanced/table-header-operation.vue')['default']
|
||||
TableSearchFields: typeof import('./../components/custom/table-search-fields.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']
|
||||
UserPickerTrigger: typeof import('./../components/custom/business-user-picker/components/user-picker-trigger.vue')['default']
|
||||
WaveBg: typeof import('./../components/custom/wave-bg.vue')['default']
|
||||
|
||||
83
src/typings/elegant-router.d.ts
vendored
83
src/typings/elegant-router.d.ts
vendored
@@ -24,18 +24,14 @@ declare module "@elegant-router/types" {
|
||||
"403": "/403";
|
||||
"404": "/404";
|
||||
"500": "/500";
|
||||
"function": "/function";
|
||||
"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";
|
||||
"feedback": "/feedback";
|
||||
"iframe-page": "/iframe-page/:url";
|
||||
"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_state-machine": "/infra/state-machine";
|
||||
"login": "/login/:module(pwd-login|reset-pwd)?";
|
||||
@@ -46,33 +42,14 @@ declare module "@elegant-router/types" {
|
||||
"personal-center": "/personal-center";
|
||||
"personal-center_my-application": "/personal-center/my-application";
|
||||
"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-profile": "/personal-center/my-profile";
|
||||
"personal-center_my-weekly": "/personal-center/my-weekly";
|
||||
"personal-center_overtime-application": "/personal-center/overtime-application";
|
||||
"personal-center_pending-approval": "/personal-center/pending-approval";
|
||||
"plugin": "/plugin";
|
||||
"plugin_barcode": "/plugin/barcode";
|
||||
"plugin_charts": "/plugin/charts";
|
||||
"plugin_charts_antv": "/plugin/charts/antv";
|
||||
"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";
|
||||
"personal-center_work-report": "/personal-center/work-report";
|
||||
"personal-center_work-report_monthly": "/personal-center/work-report/monthly";
|
||||
"personal-center_work-report_project": "/personal-center/work-report/project";
|
||||
"personal-center_work-report_weekly": "/personal-center/work-report/weekly";
|
||||
"product": "/product";
|
||||
"product_dashboard": "/product/dashboard";
|
||||
"product_list": "/product/list";
|
||||
@@ -135,13 +112,12 @@ declare module "@elegant-router/types" {
|
||||
| "403"
|
||||
| "404"
|
||||
| "500"
|
||||
| "function"
|
||||
| "feedback"
|
||||
| "iframe-page"
|
||||
| "infra"
|
||||
| "login"
|
||||
| "metrics"
|
||||
| "personal-center"
|
||||
| "plugin"
|
||||
| "product"
|
||||
| "project"
|
||||
| "system"
|
||||
@@ -169,14 +145,12 @@ declare module "@elegant-router/types" {
|
||||
| "500"
|
||||
| "iframe-page"
|
||||
| "login"
|
||||
| "function_hide-child_one"
|
||||
| "function_hide-child_three"
|
||||
| "function_hide-child_two"
|
||||
| "function_multi-tab"
|
||||
| "function_request"
|
||||
| "function_super-page"
|
||||
| "function_tab"
|
||||
| "function_toggle-auth"
|
||||
| "feedback"
|
||||
| "infra_log-management_api-access-log"
|
||||
| "infra_log-management_api-error-log"
|
||||
| "infra_log-management"
|
||||
| "infra_log-management_login-log"
|
||||
| "infra_log-management_operate-log"
|
||||
| "infra_rd-code"
|
||||
| "infra_state-machine"
|
||||
| "metrics_member-efficiency"
|
||||
@@ -184,29 +158,14 @@ declare module "@elegant-router/types" {
|
||||
| "metrics_worktime"
|
||||
| "personal-center_my-application"
|
||||
| "personal-center_my-item"
|
||||
| "personal-center_my-monthly"
|
||||
| "personal-center_my-performance"
|
||||
| "personal-center_my-profile"
|
||||
| "personal-center_my-weekly"
|
||||
| "personal-center_overtime-application"
|
||||
| "personal-center_pending-approval"
|
||||
| "plugin_barcode"
|
||||
| "plugin_charts_antv"
|
||||
| "plugin_charts_echarts"
|
||||
| "plugin_charts_vchart"
|
||||
| "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"
|
||||
| "personal-center_work-report"
|
||||
| "personal-center_work-report_monthly"
|
||||
| "personal-center_work-report_project"
|
||||
| "personal-center_work-report_weekly"
|
||||
| "product_dashboard"
|
||||
| "product_list"
|
||||
| "product_requirement"
|
||||
|
||||
20
src/typings/package.d.ts
vendored
20
src/typings/package.d.ts
vendored
@@ -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
27
src/utils/datetime.ts
Normal 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') : '';
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { Component } from 'vue';
|
||||
import { getPaletteColorByNumber, mixColor } from '@sa/color';
|
||||
import { computed, reactive } from 'vue';
|
||||
import type { CSSProperties, Component } from 'vue';
|
||||
import { loginModuleRecord } from '@/constants/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { $t } from '@/locales';
|
||||
@@ -31,46 +30,791 @@ const moduleMap: Record<UnionKey.LoginModule, LoginModule> = {
|
||||
|
||||
const activeModule = computed(() => moduleMap[props.module || 'pwd-login']);
|
||||
|
||||
const bgThemeColor = computed(() =>
|
||||
themeStore.darkMode ? getPaletteColorByNumber(themeStore.themeColor, 600) : themeStore.themeColor
|
||||
);
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const bgColor = computed(() => {
|
||||
const COLOR_WHITE = '#ffffff';
|
||||
/** 登录页品牌色:取自公司 logo 的湛蓝,不跟随系统主题色(主题色偏紫,与企业蓝不符) */
|
||||
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>
|
||||
|
||||
<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"
|
||||
<div class="login-scene" :style="{ '--brand': LOGIN_BRAND }" @mousemove="onPointerMove">
|
||||
<!-- 远景:浮尘微粒 -->
|
||||
<div class="scene-motes" :style="layerStyle(6)"></div>
|
||||
|
||||
<!-- 中景:协作汇流图(需求/设计/开发/测试 → 主干 → 登录入口) -->
|
||||
<div class="scene-graph" :style="layerStyle(14)">
|
||||
<svg class="scene-graph__svg" viewBox="0 0 1440 900" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="trunk-grad" x1="0" y1="0" x2="1" y2="0">
|
||||
<!-- presentation attribute 不解析 CSS var,必须用 style -->
|
||||
<stop offset="0" style="stop-color: var(--brand); stop-opacity: 0" />
|
||||
<stop offset="0.45" style="stop-color: var(--brand); stop-opacity: 0.85" />
|
||||
<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>
|
||||
</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>
|
||||
<path d="M -58 -120 L 58 -120 M -46 -150 L 46 -150" />
|
||||
<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" />
|
||||
</g>
|
||||
|
||||
<!-- 塔间导线 -->
|
||||
<path v-for="line in powerLines" :key="line.path" :d="line.path" />
|
||||
|
||||
<!-- 风机 -->
|
||||
<g
|
||||
v-for="turbine in turbines"
|
||||
: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>
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
@@ -15,8 +15,8 @@ interface FormModel {
|
||||
}
|
||||
|
||||
const model = ref<FormModel>({
|
||||
userName: 'admin',
|
||||
password: 'admin123'
|
||||
userName: '',
|
||||
password: ''
|
||||
});
|
||||
|
||||
const rules = computed<Record<keyof FormModel, App.Global.FormRule[]>>(() => {
|
||||
@@ -36,24 +36,35 @@ async function handleSubmit() {
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<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 prop="password">
|
||||
<ElInput
|
||||
v-model="model.password"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
show-password
|
||||
:placeholder="$t('page.login.common.passwordPlaceholder')"
|
||||
/>
|
||||
>
|
||||
<template #prefix>
|
||||
<SvgIcon icon="mdi:lock-outline" />
|
||||
</template>
|
||||
</ElInput>
|
||||
</ElFormItem>
|
||||
<ElSpace direction="vertical" :size="24" class="w-full" fill>
|
||||
<ElCheckbox>{{ $t('page.login.pwdLogin.rememberMe') }}</ElCheckbox>
|
||||
<ElButton type="primary" size="large" round block :loading="authStore.loginLoading" @click="handleSubmit">
|
||||
{{ $t('common.confirm') }}
|
||||
</ElButton>
|
||||
</ElSpace>
|
||||
<ElButton
|
||||
type="primary"
|
||||
size="large"
|
||||
class="login-submit-button"
|
||||
:loading="authStore.loginLoading"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{ $t('route.login') }}
|
||||
</ElButton>
|
||||
</ElForm>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -43,38 +43,60 @@ async function handleSubmit() {
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<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 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 prop="password">
|
||||
<ElInput
|
||||
v-model="model.password"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
show-password
|
||||
:placeholder="$t('page.login.common.passwordPlaceholder')"
|
||||
/>
|
||||
>
|
||||
<template #prefix>
|
||||
<SvgIcon icon="mdi:lock-outline" />
|
||||
</template>
|
||||
</ElInput>
|
||||
</ElFormItem>
|
||||
<ElFormItem prop="confirmPassword">
|
||||
<ElInput
|
||||
v-model="model.confirmPassword"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
show-password
|
||||
:placeholder="$t('page.login.common.confirmPasswordPlaceholder')"
|
||||
/>
|
||||
>
|
||||
<template #prefix>
|
||||
<SvgIcon icon="mdi:lock-check-outline" />
|
||||
</template>
|
||||
</ElInput>
|
||||
</ElFormItem>
|
||||
<ElSpace direction="vertical" fill :size="18" class="w-full">
|
||||
<ElButton type="primary" size="large" round @click="handleSubmit">
|
||||
{{ $t('common.confirm') }}
|
||||
</ElButton>
|
||||
<ElButton size="large" round @click="toggleLoginModule('pwd-login')">
|
||||
{{ $t('page.login.common.back') }}
|
||||
</ElButton>
|
||||
</ElSpace>
|
||||
<ElButton type="primary" size="large" class="login-submit-button" @click="handleSubmit">
|
||||
{{ $t('common.confirm') }}
|
||||
</ElButton>
|
||||
<ElButton size="large" class="login-back-button" @click="toggleLoginModule('pwd-login')">
|
||||
{{ $t('page.login.common.back') }}
|
||||
</ElButton>
|
||||
</ElForm>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
<style scoped>
|
||||
.login-back-button {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
margin-top: 14px;
|
||||
margin-left: 0;
|
||||
border-radius: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
329
src/views/feedback/index.vue
Normal file
329
src/views/feedback/index.vue
Normal 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>
|
||||
115
src/views/feedback/modules/feedback-detail-dialog.vue
Normal file
115
src/views/feedback/modules/feedback-detail-dialog.vue
Normal 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>
|
||||
303
src/views/feedback/modules/feedback-facet.vue
Normal file
303
src/views/feedback/modules/feedback-facet.vue
Normal 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_type:1 缺陷 / 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>
|
||||
252
src/views/feedback/modules/feedback-operate-dialog.vue
Normal file
252
src/views/feedback/modules/feedback-operate-dialog.vue
Normal 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' ? '提交意见反馈' : '编辑意见反馈'));
|
||||
|
||||
/** 富文本是否为空:去标签去 后无文本,且无图片 */
|
||||
function isEmptyRichText(html: string | null | undefined) {
|
||||
if (!html) {
|
||||
return true;
|
||||
}
|
||||
const text = html
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/ /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>
|
||||
65
src/views/feedback/modules/feedback-search.vue
Normal file
65
src/views/feedback/modules/feedback-search.vue
Normal 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 取用户 id,ID 铁律 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>
|
||||
74
src/views/feedback/modules/feedback-status-dialog.vue
Normal file
74
src/views/feedback/modules/feedback-status-dialog.vue
Normal 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 是字典编码(非 ID),DictSelect 选中为字符串,转 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>
|
||||
@@ -1,7 +0,0 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<LookForward />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,7 +0,0 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<LookForward />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,7 +0,0 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<LookForward />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,7 +0,0 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<LookForward />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
246
src/views/infra/log-management/api-access-log/index.vue
Normal file
246
src/views/infra/log-management/api-access-log/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
238
src/views/infra/log-management/api-error-log/index.vue
Normal file
238
src/views/infra/log-management/api-error-log/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
197
src/views/infra/log-management/index.vue
Normal file
197
src/views/infra/log-management/index.vue
Normal 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
Reference in New Issue
Block a user