Compare commits
21 Commits
17690283f6
...
2026-06
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d147ee263 | |||
| a8e2bac65a | |||
| a650f6e96d | |||
| 4f357a35a9 | |||
| 499f2115b2 | |||
| a4884035cd | |||
| 570f284230 | |||
| ea6a816d58 | |||
| 3ffdad142d | |||
| b26a9c8a39 | |||
| 10418fea0a | |||
| 632c123112 | |||
| b1d52b852f | |||
| 4a7f54b0ed | |||
| 61fe9ef143 | |||
| 9a5845708d | |||
| cd64cf42cc | |||
| 31344f1d58 | |||
| 1543bf76a9 | |||
| 622d8d5a4d | |||
| 3c1cf6c7fa |
@@ -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= `{
|
||||
|
||||
@@ -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',
|
||||
@@ -200,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
|
||||
@@ -209,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,12 +1,12 @@
|
||||
{
|
||||
"generatedAt": "2026-06-05T03:08:01.803Z",
|
||||
"generatedAt": "2026-06-27T00:49:28.085Z",
|
||||
"description": "Frontend visible page resource whitelist for backend route/menu configuration.",
|
||||
"rules": {
|
||||
"directoryComponent": "layout.base",
|
||||
"pageComponentPattern": "view.<routeName>",
|
||||
"singlePageComponentPattern": "layout.<layoutName>$view.<routeName>"
|
||||
},
|
||||
"total": 22,
|
||||
"total": 23,
|
||||
"items": [
|
||||
{
|
||||
"name": "product_list",
|
||||
@@ -338,6 +338,39 @@
|
||||
"pageType": "leaf",
|
||||
"source": "generated"
|
||||
},
|
||||
{
|
||||
"name": "personal-center_my-application",
|
||||
"path": "/personal-center/my-application",
|
||||
"component": "view.personal-center_my-application",
|
||||
"title": "我的申请",
|
||||
"routeTitle": "personal-center_my-application",
|
||||
"i18nKey": "route.personal-center_my-application",
|
||||
"icon": "mdi:file-document-outline",
|
||||
"localIcon": null,
|
||||
"order": 4,
|
||||
"hideInMenu": false,
|
||||
"keepAlive": true,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null,
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "我的申请",
|
||||
"i18nKey": "route.personal-center_my-application",
|
||||
"icon": "mdi:file-document-outline",
|
||||
"localIcon": null,
|
||||
"order": 4,
|
||||
"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",
|
||||
@@ -372,15 +405,15 @@
|
||||
"source": "generated"
|
||||
},
|
||||
{
|
||||
"name": "personal-center_my-application",
|
||||
"path": "/personal-center/my-application",
|
||||
"component": "view.personal-center_my-application",
|
||||
"title": "我的申请",
|
||||
"routeTitle": "personal-center_my-application",
|
||||
"i18nKey": "route.personal-center_my-application",
|
||||
"icon": "mdi:file-document-outline",
|
||||
"name": "personal-center_overtime-application",
|
||||
"path": "/personal-center/overtime-application",
|
||||
"component": "view.personal-center_overtime-application",
|
||||
"title": "加班申请",
|
||||
"routeTitle": "personal-center_overtime-application",
|
||||
"i18nKey": "route.personal-center_overtime-application",
|
||||
"icon": "mdi:clock-plus-outline",
|
||||
"localIcon": null,
|
||||
"order": 5,
|
||||
"order": 6,
|
||||
"hideInMenu": false,
|
||||
"keepAlive": true,
|
||||
"activeMenu": null,
|
||||
@@ -389,11 +422,11 @@
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "我的申请",
|
||||
"i18nKey": "route.personal-center_my-application",
|
||||
"icon": "mdi:file-document-outline",
|
||||
"title": "加班申请",
|
||||
"i18nKey": "route.personal-center_overtime-application",
|
||||
"icon": "mdi:clock-plus-outline",
|
||||
"localIcon": null,
|
||||
"order": 5,
|
||||
"order": 6,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
@@ -437,39 +470,6 @@
|
||||
"pageType": "leaf",
|
||||
"source": "generated"
|
||||
},
|
||||
{
|
||||
"name": "personal-center_overtime-application",
|
||||
"path": "/personal-center/overtime-application",
|
||||
"component": "view.personal-center_overtime-application",
|
||||
"title": "加班申请",
|
||||
"routeTitle": "personal-center_overtime-application",
|
||||
"i18nKey": "route.personal-center_overtime-application",
|
||||
"icon": "mdi:clock-plus-outline",
|
||||
"localIcon": null,
|
||||
"order": 6,
|
||||
"hideInMenu": false,
|
||||
"keepAlive": true,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null,
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "加班申请",
|
||||
"i18nKey": "route.personal-center_overtime-application",
|
||||
"icon": "mdi:clock-plus-outline",
|
||||
"localIcon": null,
|
||||
"order": 6,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"parentName": "personal-center",
|
||||
"pageType": "leaf",
|
||||
"source": "generated"
|
||||
},
|
||||
{
|
||||
"name": "system_user",
|
||||
"path": "/system/user",
|
||||
@@ -701,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",
|
||||
@@ -710,7 +743,7 @@
|
||||
"i18nKey": "route.infra_rd-code",
|
||||
"icon": "mdi:identifier",
|
||||
"localIcon": null,
|
||||
"order": 2,
|
||||
"order": 3,
|
||||
"hideInMenu": false,
|
||||
"keepAlive": true,
|
||||
"activeMenu": null,
|
||||
@@ -723,7 +756,7 @@
|
||||
"i18nKey": "route.infra_rd-code",
|
||||
"icon": "mdi:identifier",
|
||||
"localIcon": null,
|
||||
"order": 2,
|
||||
"order": 3,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
|
||||
@@ -44,9 +44,12 @@
|
||||
"@sa/hooks": "workspace:*",
|
||||
"@sa/materials": "workspace:*",
|
||||
"@sa/utils": "workspace:*",
|
||||
"@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",
|
||||
|
||||
3681
pnpm-lock.yaml
generated
3681
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>
|
||||
@@ -22,6 +22,10 @@ export interface SearchField {
|
||||
dateType?: 'date' | 'month';
|
||||
/** dateRange 字段的日期范围粒度 */
|
||||
dateRangeType?: 'daterange' | 'monthrange';
|
||||
/** 日期面板展示格式 */
|
||||
format?: string;
|
||||
/** 自定义范围分隔文案 */
|
||||
rangeSeparator?: string;
|
||||
/** 日期字段提交格式 */
|
||||
valueFormat?: string;
|
||||
/** 占位列数,默认 1 */
|
||||
@@ -36,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 {
|
||||
@@ -122,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 -->
|
||||
@@ -139,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>
|
||||
@@ -161,34 +182,38 @@ function handleSearch() {
|
||||
</ElSelect>
|
||||
<ElDatePicker
|
||||
v-else-if="field.type === 'date'"
|
||||
:model-value="props.modelValue[field.key]"
|
||||
:model-value="getFieldValue(field)"
|
||||
:type="field.dateType || 'date'"
|
||||
:placeholder="field.placeholder"
|
||||
clearable
|
||||
:disabled="props.disabled"
|
||||
:format="field.format"
|
||||
:value-format="field.valueFormat || 'YYYY-MM-DD'"
|
||||
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||
@update:model-value="val => updateFieldValue(field, val)"
|
||||
/>
|
||||
<ElDatePicker
|
||||
v-else-if="field.type === 'dateRange'"
|
||||
:model-value="props.modelValue[field.key]"
|
||||
:model-value="getFieldValue(field)"
|
||||
:type="field.dateRangeType || 'daterange'"
|
||||
:placeholder="field.placeholder"
|
||||
clearable
|
||||
:disabled="props.disabled"
|
||||
: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 => (props.modelValue[field.key] = val)"
|
||||
@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>
|
||||
@@ -236,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>
|
||||
@@ -258,34 +284,38 @@ function handleSearch() {
|
||||
</ElSelect>
|
||||
<ElDatePicker
|
||||
v-else-if="field.type === 'date'"
|
||||
:model-value="props.modelValue[field.key]"
|
||||
:model-value="getFieldValue(field)"
|
||||
:type="field.dateType || 'date'"
|
||||
:placeholder="field.placeholder"
|
||||
clearable
|
||||
:disabled="props.disabled"
|
||||
:format="field.format"
|
||||
:value-format="field.valueFormat || 'YYYY-MM-DD'"
|
||||
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||
@update:model-value="val => updateFieldValue(field, val)"
|
||||
/>
|
||||
<ElDatePicker
|
||||
v-else-if="field.type === 'dateRange'"
|
||||
:model-value="props.modelValue[field.key]"
|
||||
:model-value="getFieldValue(field)"
|
||||
:type="field.dateRangeType || 'daterange'"
|
||||
:placeholder="field.placeholder"
|
||||
clearable
|
||||
:disabled="props.disabled"
|
||||
: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 => (props.modelValue[field.key] = val)"
|
||||
@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>
|
||||
@@ -128,3 +128,67 @@ export const RDMS_OVERTIME_DURATION_DICT_CODE = 'rdms_overtime_duration';
|
||||
* 显示名与颜色(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';
|
||||
|
||||
@@ -18,8 +18,10 @@ export type StatusDomain =
|
||||
| 'projectRequirement'
|
||||
| 'workOrder'
|
||||
| 'workReport'
|
||||
| 'performanceSheet'
|
||||
| 'personalItem'
|
||||
| 'overtimeApplication';
|
||||
| 'overtimeApplication'
|
||||
| 'feedback';
|
||||
|
||||
const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>> = {
|
||||
// 项目-执行
|
||||
@@ -88,6 +90,13 @@ const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>>
|
||||
approved: 'success',
|
||||
rejected: 'danger'
|
||||
},
|
||||
// 绩效表
|
||||
performanceSheet: {
|
||||
draft: 'info',
|
||||
sent: 'warning',
|
||||
confirmed: 'success',
|
||||
rejected: 'danger'
|
||||
},
|
||||
// 个人事项
|
||||
personalItem: {
|
||||
pending: 'info',
|
||||
@@ -100,6 +109,13 @@ const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>>
|
||||
pending: 'warning',
|
||||
approved: 'success',
|
||||
rejected: 'danger'
|
||||
},
|
||||
// 意见反馈
|
||||
feedback: {
|
||||
'1': 'warning', // 待处理
|
||||
'2': 'primary', // 处理中
|
||||
'3': 'success', // 已处理
|
||||
'4': 'info' // 已忽略
|
||||
}
|
||||
};
|
||||
|
||||
@@ -114,3 +130,10 @@ export function getStatusTagType(domain: StatusDomain, statusCode: string | null
|
||||
export function getPersonalItemStatusTagType(statusCode: string | null | undefined) {
|
||||
return getStatusTagType('personalItem', statusCode);
|
||||
}
|
||||
|
||||
export function getFeedbackStatusTagType(statusCode: string | number | null | undefined) {
|
||||
return getStatusTagType(
|
||||
'feedback',
|
||||
statusCode === null || statusCode === undefined ? statusCode : String(statusCode)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -177,8 +177,14 @@ const local: App.I18n.Schema = {
|
||||
'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',
|
||||
product: 'Product',
|
||||
product_list: 'Product List',
|
||||
|
||||
@@ -177,8 +177,14 @@ const local: App.I18n.Schema = {
|
||||
'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': '研发令号',
|
||||
product: '产品管理',
|
||||
product_list: '产品列表',
|
||||
@@ -661,7 +667,7 @@ const local: App.I18n.Schema = {
|
||||
},
|
||||
pwd: {
|
||||
required: '请输入密码',
|
||||
invalid: '密码格式不正确,6-18位字符,包含字母、数字、下划线'
|
||||
invalid: '密码格式不正确,4-30位字符,包含字母、数字、下划线'
|
||||
},
|
||||
confirmPwd: {
|
||||
required: '请输入确认密码',
|
||||
|
||||
@@ -20,6 +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"),
|
||||
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"),
|
||||
|
||||
@@ -39,6 +39,19 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
hideInMenu: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'feedback',
|
||||
path: '/feedback',
|
||||
component: 'layout.base$view.feedback',
|
||||
meta: {
|
||||
title: 'feedback',
|
||||
i18nKey: 'route.feedback',
|
||||
icon: 'mdi:message-alert-outline',
|
||||
order: 10,
|
||||
keepAlive: true,
|
||||
constant: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'iframe-page',
|
||||
path: '/iframe-page/:url',
|
||||
@@ -63,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',
|
||||
@@ -71,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
|
||||
}
|
||||
},
|
||||
@@ -193,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
|
||||
}
|
||||
},
|
||||
|
||||
@@ -170,8 +170,14 @@ const routeMap: RouteMap = {
|
||||
"403": "/403",
|
||||
"404": "/404",
|
||||
"500": "/500",
|
||||
"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)?",
|
||||
|
||||
@@ -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 ?? []
|
||||
};
|
||||
|
||||
@@ -191,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,11 +1,13 @@
|
||||
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';
|
||||
@@ -13,4 +15,5 @@ export * from './project-group';
|
||||
export * from './project-shared';
|
||||
export * from './route';
|
||||
export * from './system-manage';
|
||||
export * from './system-log';
|
||||
export * from './work-report';
|
||||
|
||||
@@ -34,6 +34,27 @@ type OvertimeApplicationApprovalRecordResponse = Omit<
|
||||
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;
|
||||
@@ -94,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);
|
||||
}
|
||||
@@ -287,6 +320,24 @@ export async function fetchGetOvertimeApplicationStatusDict() {
|
||||
);
|
||||
}
|
||||
|
||||
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,6 +120,19 @@ 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;
|
||||
@@ -210,7 +237,7 @@ type RequirementResponse = Omit<
|
||||
| 'proposerId'
|
||||
| 'currentHandlerUserId'
|
||||
| 'implementProjectId'
|
||||
| 'sourceBizId'
|
||||
| 'sourceBizCode'
|
||||
| 'attachments'
|
||||
> & {
|
||||
id: string | number;
|
||||
@@ -220,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[];
|
||||
};
|
||||
@@ -292,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)
|
||||
};
|
||||
|
||||
@@ -47,7 +47,14 @@ const PROJECT_PREFIX = `${WEB_SERVICE_PREFIX}/project/project`;
|
||||
|
||||
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;
|
||||
@@ -56,6 +63,8 @@ export type ProjectResponse = Omit<
|
||||
plannedEndDate?: ProjectLocalDateValue;
|
||||
actualStartDate?: ProjectLocalDateValue;
|
||||
actualEndDate?: ProjectLocalDateValue;
|
||||
/** 灰度/兼容期后端可能缺省,适配层兜底为 [] */
|
||||
currentUserRoles?: Api.Common.CurrentUserRole[] | null;
|
||||
};
|
||||
|
||||
type ProjectPageResponse = Api.Project.PageResult<ProjectResponse>;
|
||||
@@ -96,7 +105,8 @@ export 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 ?? []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1026,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;
|
||||
@@ -1034,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[];
|
||||
};
|
||||
@@ -1090,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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户管理链路详情
|
||||
*
|
||||
|
||||
@@ -99,6 +99,23 @@ type ProjectOptionResponse = Omit<Api.WorkReport.Project.ProjectReportOwnerProje
|
||||
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;
|
||||
@@ -173,6 +190,21 @@ function appendArray(query: URLSearchParams, key: string, values?: Array<string
|
||||
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();
|
||||
|
||||
@@ -189,16 +221,20 @@ function createBasePageQuery(params: Api.WorkReport.Common.WorkReportBaseSearchP
|
||||
|
||||
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 = {}) {
|
||||
return createBasePageQuery(params).toString();
|
||||
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();
|
||||
@@ -338,6 +374,25 @@ function normalizeProjectOption(
|
||||
};
|
||||
}
|
||||
|
||||
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),
|
||||
@@ -440,6 +495,34 @@ export async function fetchGetWorkReportStatusDict() {
|
||||
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>>({
|
||||
|
||||
@@ -67,7 +67,7 @@ export const useAppStore = defineStore(SetupStoreId.App, () => {
|
||||
|
||||
const documentTitle = i18nKey ? $t(i18nKey) : title;
|
||||
|
||||
useTitle(documentTitle);
|
||||
useTitle(`研发管理系统 - ${documentTitle}`);
|
||||
}
|
||||
|
||||
function init() {
|
||||
|
||||
@@ -141,7 +141,7 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
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 */
|
||||
|
||||
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>;
|
||||
}
|
||||
}
|
||||
}
|
||||
15
src/typings/api/overtime-application.d.ts
vendored
15
src/typings/api/overtime-application.d.ts
vendored
@@ -32,6 +32,7 @@ declare namespace Api {
|
||||
|
||||
type OvertimeApplicationSearchParams = CommonType.RecordNullable<
|
||||
Pick<PageParams, 'pageNo' | 'pageSize'> & {
|
||||
applicantIds: string[] | null;
|
||||
keyword: string;
|
||||
applicantName: string;
|
||||
approverId: string;
|
||||
@@ -95,5 +96,19 @@ declare namespace Api {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
src/typings/api/product.d.ts
vendored
21
src/typings/api/product.d.ts
vendored
@@ -67,6 +67,8 @@ declare namespace Api {
|
||||
createTime: string;
|
||||
/** 更新时间 */
|
||||
updateTime: string;
|
||||
/** 当前登录用户在该产品的角色(后端只读计算字段,随登录身份变化;无角色为 []) */
|
||||
currentUserRoles: Api.Common.CurrentUserRole[];
|
||||
}
|
||||
|
||||
interface ProductSettingBaseInfo {
|
||||
@@ -90,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;
|
||||
@@ -214,6 +227,7 @@ declare namespace Api {
|
||||
interface DeleteProductParams {
|
||||
id: string;
|
||||
productName: string;
|
||||
confirmText: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
@@ -332,8 +346,8 @@ declare namespace Api {
|
||||
categoryName?: string | null;
|
||||
/** 需求来源类型 */
|
||||
sourceType: RequirementSourceType;
|
||||
/** 需求来源业务ID */
|
||||
sourceBizId?: string | null;
|
||||
/** 来源业务编号 */
|
||||
sourceBizCode?: string | null;
|
||||
/** 优先级(0低 1中 2高 3紧急) */
|
||||
priority: RequirementPriority;
|
||||
/** 优先级名称 */
|
||||
@@ -508,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;
|
||||
@@ -526,6 +540,7 @@ declare namespace Api {
|
||||
| 'attachments'
|
||||
| 'category'
|
||||
| 'priority'
|
||||
| 'sourceBizCode'
|
||||
| 'proposerId'
|
||||
| 'proposerNickname'
|
||||
| 'currentHandlerUserId'
|
||||
|
||||
13
src/typings/api/project.d.ts
vendored
13
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;
|
||||
}
|
||||
|
||||
/** 任务详情 / 分页响应里嵌入的活跃协办人引用(按加入时间正序) */
|
||||
@@ -851,6 +855,8 @@ declare namespace Api {
|
||||
createTime: string;
|
||||
/** 更新时间 */
|
||||
updateTime: string;
|
||||
/** 当前登录用户在该项目的角色(后端只读计算字段,随登录身份变化;无角色为 []) */
|
||||
currentUserRoles: Api.Common.CurrentUserRole[];
|
||||
}
|
||||
|
||||
interface ProjectContext {
|
||||
@@ -1160,8 +1166,8 @@ declare namespace Api {
|
||||
categoryName?: string | null;
|
||||
/** 需求来源类型 */
|
||||
sourceType: ProjectRequirementSourceType;
|
||||
/** 来源业务 ID */
|
||||
sourceBizId?: string | null;
|
||||
/** 来源业务编号 */
|
||||
sourceBizCode?: string | null;
|
||||
/** 优先级 */
|
||||
priority: ProjectRequirementPriority;
|
||||
/** 优先级名称 */
|
||||
@@ -1285,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;
|
||||
@@ -1303,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[];
|
||||
}
|
||||
}
|
||||
|
||||
43
src/typings/api/work-report.d.ts
vendored
43
src/typings/api/work-report.d.ts
vendored
@@ -71,6 +71,43 @@ declare namespace Api {
|
||||
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 {
|
||||
@@ -114,6 +151,7 @@ declare namespace Api {
|
||||
}
|
||||
|
||||
type WeeklyReportSearchParams = Common.WorkReportBaseSearchParams & {
|
||||
reporterIds?: string[] | null;
|
||||
isBusinessTrip?: boolean | string | null;
|
||||
};
|
||||
|
||||
@@ -164,7 +202,9 @@ declare namespace Api {
|
||||
planItems: Common.PersonalReportPlanItem[];
|
||||
}
|
||||
|
||||
type MonthlyReportSearchParams = Common.WorkReportBaseSearchParams;
|
||||
type MonthlyReportSearchParams = Common.WorkReportBaseSearchParams & {
|
||||
reporterIds?: string[] | null;
|
||||
};
|
||||
|
||||
interface MonthlyReportSaveParams {
|
||||
periodKey: string;
|
||||
@@ -266,6 +306,7 @@ declare namespace Api {
|
||||
}
|
||||
|
||||
type ProjectReportSearchParams = Common.WorkReportBaseSearchParams & {
|
||||
projectOwnerIds?: string[] | null;
|
||||
projectId?: string | null;
|
||||
flag?: number | null;
|
||||
};
|
||||
|
||||
47
src/typings/components.d.ts
vendored
47
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,78 +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']
|
||||
IconIcRoundUnfoldLess: typeof import('~icons/ic/round-unfold-less')['default']
|
||||
IconIcRoundUnfoldMore: typeof import('~icons/ic/round-unfold-more')['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']
|
||||
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']
|
||||
|
||||
13
src/typings/elegant-router.d.ts
vendored
13
src/typings/elegant-router.d.ts
vendored
@@ -24,8 +24,14 @@ declare module "@elegant-router/types" {
|
||||
"403": "/403";
|
||||
"404": "/404";
|
||||
"500": "/500";
|
||||
"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)?";
|
||||
@@ -106,6 +112,7 @@ declare module "@elegant-router/types" {
|
||||
| "403"
|
||||
| "404"
|
||||
| "500"
|
||||
| "feedback"
|
||||
| "iframe-page"
|
||||
| "infra"
|
||||
| "login"
|
||||
@@ -138,6 +145,12 @@ declare module "@elegant-router/types" {
|
||||
| "500"
|
||||
| "iframe-page"
|
||||
| "login"
|
||||
| "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"
|
||||
|
||||
@@ -148,29 +148,29 @@ const waves = [
|
||||
|
||||
/** 电力场景剪影:输电铁塔(底部接地,局部坐标基点为塔脚中心) */
|
||||
const towers = [
|
||||
{ x: 150, s: 1 },
|
||||
{ x: 540, s: 0.85 },
|
||||
{ x: 1280, s: 0.7 }
|
||||
{ x: 150, s: 1.42 },
|
||||
{ x: 540, s: 1.2 },
|
||||
{ x: 1280, s: 1.0 }
|
||||
];
|
||||
|
||||
/** 塔间悬垂导线(悬链线意象),坐标对应各塔最宽横担端点 */
|
||||
/** 塔间悬垂导线(悬链线意象),坐标对应各塔最宽横担端点(随铁塔 scale 同步重算) */
|
||||
const powerLines = [
|
||||
{ path: 'M -60,762 Q 30,800 92,750' },
|
||||
{ path: 'M 208,750 Q 350,819 491,768' },
|
||||
{ path: 'M 589,768 Q 914,867 1239,786' },
|
||||
{ path: 'M 1321,786 Q 1430,824 1520,804' }
|
||||
{ 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 208,750 Q 350,819 491,768', dur: '5s', begin: '0s' },
|
||||
{ key: 'spark-2', path: 'M 589,768 Q 914,867 1239,786', dur: '7s', begin: '2s' }
|
||||
{ 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: 0.9, dur: '9s' },
|
||||
{ key: 'turbine-2', x: 828, s: 0.6, dur: '13s' }
|
||||
{ key: 'turbine-1', x: 715, s: 1.28, dur: '9s' },
|
||||
{ key: 'turbine-2', x: 828, s: 0.88, dur: '13s' }
|
||||
];
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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[]>>(() => {
|
||||
@@ -56,9 +56,6 @@ async function handleSubmit() {
|
||||
</template>
|
||||
</ElInput>
|
||||
</ElFormItem>
|
||||
<div class="pb-18px">
|
||||
<ElCheckbox>{{ $t('page.login.pwdLogin.rememberMe') }}</ElCheckbox>
|
||||
</div>
|
||||
<ElButton
|
||||
type="primary"
|
||||
size="large"
|
||||
|
||||
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>
|
||||
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>
|
||||
243
src/views/infra/log-management/login-log/index.vue
Normal file
243
src/views/infra/log-management/login-log/index.vue
Normal file
@@ -0,0 +1,243 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { ElTag } from 'element-plus';
|
||||
import { SYSTEM_LOGIN_RESULT_DICT_CODE, SYSTEM_LOGIN_TYPE_DICT_CODE } from '@/constants/dict';
|
||||
import { fetchExportLoginLog, fetchGetLoginLog, fetchGetLoginLogPage } from '@/service/api';
|
||||
import { useAuth } from '@/hooks/business/auth';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
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 LoginLogSearch from './modules/search.vue';
|
||||
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
|
||||
|
||||
defineOptions({ name: 'LoginLogTab' });
|
||||
|
||||
type LoginLogPageResponse = Awaited<ReturnType<typeof fetchGetLoginLogPage>>;
|
||||
|
||||
function createSearchParams(): Api.SystemLog.Login.SearchParams {
|
||||
return {
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
userIp: undefined,
|
||||
username: undefined,
|
||||
status: undefined,
|
||||
createTime: undefined
|
||||
};
|
||||
}
|
||||
|
||||
function transformPageResult(response: LoginLogPageResponse, 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 { getLabel: getLoginResultLabel } = useDict(SYSTEM_LOGIN_RESULT_DICT_CODE);
|
||||
const searchParams = reactive(createSearchParams());
|
||||
const detailVisible = ref(false);
|
||||
const currentRow = ref<Api.SystemLog.Login.Log | null>(null);
|
||||
const exporting = ref(false);
|
||||
const canExport = computed(() => hasAuth(LogPermission.LoginExport));
|
||||
|
||||
function getLoginResultTagType(result: Api.SystemLog.Login.Log['result']) {
|
||||
const label = getLoginResultLabel(result, '');
|
||||
|
||||
return label.includes('成功') ? 'success' : 'danger';
|
||||
}
|
||||
|
||||
const detailSections: LogDetailSection[] = [
|
||||
{
|
||||
title: '基础信息',
|
||||
fields: [
|
||||
{ label: '日志编号', key: 'id' },
|
||||
{ label: '日志类型', key: 'logType', type: 'dict', dictCode: SYSTEM_LOGIN_TYPE_DICT_CODE },
|
||||
{ label: '用户编号', key: 'userId' },
|
||||
{ label: '账号', key: 'username' },
|
||||
{ label: '登录结果', key: 'result', type: 'dict', dictCode: SYSTEM_LOGIN_RESULT_DICT_CODE },
|
||||
{ label: '登录时间', key: 'createTime', type: 'datetime' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '访问上下文',
|
||||
fields: [
|
||||
{ label: '链路追踪编号', key: 'traceId' },
|
||||
{ label: '登录IP', key: 'userIp' },
|
||||
{ label: '浏览器UA', key: 'userAgent', type: 'multiline' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const { columns, columnChecks, data, loading, getData, getDataByPage, mobilePagination } = useUIPaginatedTable<
|
||||
LoginLogPageResponse,
|
||||
Api.SystemLog.Login.Log
|
||||
>({
|
||||
paginationProps: {
|
||||
currentPage: searchParams.pageNo,
|
||||
pageSize: searchParams.pageSize
|
||||
},
|
||||
api: () => fetchGetLoginLogPage(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: 'username',
|
||||
label: '账号',
|
||||
minWidth: 140,
|
||||
showOverflowTooltip: true
|
||||
},
|
||||
{
|
||||
prop: 'logType',
|
||||
label: '日志类型',
|
||||
minWidth: 120,
|
||||
formatter: row => <DictText dictCode={SYSTEM_LOGIN_TYPE_DICT_CODE} value={row.logType} />
|
||||
},
|
||||
{
|
||||
prop: 'result',
|
||||
label: '登录结果',
|
||||
minWidth: 80,
|
||||
formatter: row => (
|
||||
<ElTag type={getLoginResultTagType(row.result)}>
|
||||
<DictText dictCode={SYSTEM_LOGIN_RESULT_DICT_CODE} value={row.result} />
|
||||
</ElTag>
|
||||
)
|
||||
},
|
||||
{
|
||||
prop: 'userIp',
|
||||
label: '登录IP',
|
||||
minWidth: 140,
|
||||
showOverflowTooltip: true
|
||||
},
|
||||
{
|
||||
prop: 'createTime',
|
||||
label: '登录时间',
|
||||
minWidth: 180,
|
||||
formatter: row => formatDateTime(row.createTime)
|
||||
},
|
||||
{
|
||||
prop: 'operate',
|
||||
label: '操作',
|
||||
width: 90,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
function getRowActions(row: Api.SystemLog.Login.Log): BusinessTableAction[] {
|
||||
return [
|
||||
{
|
||||
key: 'view',
|
||||
label: '查看',
|
||||
buttonType: 'primary',
|
||||
icon: IconMdiEyeOutline,
|
||||
onClick: () => openDetail(row)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function openDetail(row: Api.SystemLog.Login.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 fetchExportLoginLog(searchParams);
|
||||
exporting.value = false;
|
||||
|
||||
if (error || !blob) {
|
||||
return;
|
||||
}
|
||||
|
||||
downloadBlob(blob, getLogExportFileName('登录日志'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full min-h-0 flex-col-stretch gap-16px overflow-hidden">
|
||||
<LoginLogSearch 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>
|
||||
<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="登录日志详情"
|
||||
:row-data="currentRow"
|
||||
:sections="detailSections"
|
||||
:fetch-detail="fetchGetLoginLog"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
60
src/views/infra/log-management/login-log/modules/search.vue
Normal file
60
src/views/infra/log-management/login-log/modules/search.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
|
||||
|
||||
defineOptions({ name: 'LoginLogSearch' });
|
||||
|
||||
const emit = defineEmits<{
|
||||
reset: [];
|
||||
search: [];
|
||||
}>();
|
||||
|
||||
const model = defineModel<Api.SystemLog.Login.SearchParams>('model', { required: true });
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '成功', value: 1 },
|
||||
{ label: '失败', value: 0 }
|
||||
];
|
||||
|
||||
const fields = computed<SearchField[]>(() => [
|
||||
{
|
||||
key: 'username',
|
||||
label: '账号',
|
||||
type: 'input',
|
||||
placeholder: '请输入用户账号'
|
||||
},
|
||||
{
|
||||
key: 'userIp',
|
||||
label: '登录IP',
|
||||
type: 'input',
|
||||
placeholder: '请输入登录 IP'
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '登录结果',
|
||||
type: 'select',
|
||||
placeholder: '请选择登录结果',
|
||||
options: statusOptions,
|
||||
transformValue: value => {
|
||||
if (value === undefined || value === null || value === '') return undefined;
|
||||
return Number(value) === 1;
|
||||
},
|
||||
resolveValue: value => {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
return value ? 1 : 0;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'createTime',
|
||||
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>
|
||||
244
src/views/infra/log-management/operate-log/index.vue
Normal file
244
src/views/infra/log-management/operate-log/index.vue
Normal file
@@ -0,0 +1,244 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import {
|
||||
fetchExportOperateLog,
|
||||
fetchGetOperateLog,
|
||||
fetchGetOperateLogPage,
|
||||
fetchGetUserSimpleList
|
||||
} 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 LogDetailDialog from '../shared/log-detail-dialog.vue';
|
||||
import { type LogDetailSection, LogPermission, downloadBlob, formatDateTime, getLogExportFileName } from '../shared';
|
||||
import OperateLogSearch from './modules/search.vue';
|
||||
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
|
||||
|
||||
defineOptions({ name: 'OperateLogTab' });
|
||||
|
||||
type OperateLogPageResponse = Awaited<ReturnType<typeof fetchGetOperateLogPage>>;
|
||||
|
||||
function createSearchParams(): Api.SystemLog.Operate.SearchParams {
|
||||
return {
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
userId: undefined,
|
||||
type: undefined,
|
||||
requestMethod: undefined,
|
||||
subType: undefined,
|
||||
action: undefined,
|
||||
createTime: undefined
|
||||
};
|
||||
}
|
||||
|
||||
function transformPageResult(response: OperateLogPageResponse, 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.Operate.Log | null>(null);
|
||||
const exporting = ref(false);
|
||||
const userOptions = ref<Array<{ label: string; value: string }>>([]);
|
||||
const canExport = computed(() => hasAuth(LogPermission.OperateExport));
|
||||
|
||||
const detailSections: LogDetailSection[] = [
|
||||
{
|
||||
title: '操作信息',
|
||||
fields: [
|
||||
{ label: '日志编号', key: 'id' },
|
||||
{ label: '操作人', key: 'userName' },
|
||||
{ label: '用户编号', key: 'userId' },
|
||||
{ label: '模块类型', key: 'type' },
|
||||
{ label: '操作名', key: 'subType' },
|
||||
{ label: '业务编号', key: 'bizId' },
|
||||
{ label: '请求方式', key: 'requestMethod' },
|
||||
{ label: '请求地址', key: 'requestUrl', span: 2 },
|
||||
{ label: '操作时间', key: 'createTime', type: 'datetime' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '上下文',
|
||||
fields: [
|
||||
{ label: '链路追踪编号', key: 'traceId' },
|
||||
{ label: '用户IP', key: 'userIp' },
|
||||
{ label: '浏览器UA', key: 'userAgent', type: 'multiline' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '详细内容',
|
||||
fields: [
|
||||
{ label: '操作明细', key: 'action', type: 'multiline' },
|
||||
{ label: '扩展字段', key: 'extra', type: 'multiline' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const { columns, columnChecks, data, loading, getData, getDataByPage, mobilePagination } = useUIPaginatedTable<
|
||||
OperateLogPageResponse,
|
||||
Api.SystemLog.Operate.Log
|
||||
>({
|
||||
paginationProps: {
|
||||
currentPage: searchParams.pageNo,
|
||||
pageSize: searchParams.pageSize
|
||||
},
|
||||
api: () => fetchGetOperateLogPage(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: 'userName', label: '操作人', minWidth: 120, showOverflowTooltip: true },
|
||||
{ prop: 'type', label: '模块类型', minWidth: 140, showOverflowTooltip: true },
|
||||
{ prop: 'subType', label: '操作名', minWidth: 140, showOverflowTooltip: true },
|
||||
// { prop: 'bizId', label: '业务编号', minWidth: 120, showOverflowTooltip: true },
|
||||
{ prop: 'requestMethod', label: '请求方式', width: 150, align: 'center' },
|
||||
{ prop: 'requestUrl', label: '请求地址', minWidth: 220, showOverflowTooltip: true },
|
||||
{ prop: 'createTime', label: '操作时间', minWidth: 180, formatter: row => formatDateTime(row.createTime) },
|
||||
{
|
||||
prop: 'operate',
|
||||
label: '操作',
|
||||
width: 90,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
function getRowActions(row: Api.SystemLog.Operate.Log): BusinessTableAction[] {
|
||||
return [
|
||||
{
|
||||
key: 'view',
|
||||
label: '查看',
|
||||
buttonType: 'primary',
|
||||
icon: IconMdiEyeOutline,
|
||||
onClick: () => openDetail(row)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function openDetail(row: Api.SystemLog.Operate.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 loadUserOptions() {
|
||||
const { error, data: userList } = await fetchGetUserSimpleList();
|
||||
|
||||
if (error || !userList) {
|
||||
userOptions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
userOptions.value = userList.map((item: Api.SystemManage.UserSimple) => ({
|
||||
label: item.username ? `${item.nickname}(${item.username})` : item.nickname,
|
||||
value: item.id
|
||||
}));
|
||||
}
|
||||
|
||||
loadUserOptions();
|
||||
|
||||
async function handleExport() {
|
||||
exporting.value = true;
|
||||
const { error, data: blob } = await fetchExportOperateLog(searchParams);
|
||||
exporting.value = false;
|
||||
|
||||
if (error || !blob) {
|
||||
return;
|
||||
}
|
||||
|
||||
downloadBlob(blob, getLogExportFileName('操作日志'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full min-h-0 flex-col-stretch gap-16px overflow-hidden">
|
||||
<OperateLogSearch
|
||||
v-model:model="searchParams"
|
||||
:user-options="userOptions"
|
||||
@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>
|
||||
<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="操作日志详情"
|
||||
:row-data="currentRow"
|
||||
:sections="detailSections"
|
||||
:fetch-detail="fetchGetOperateLog"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { SYSTEM_REQUEST_METHOD_DICT_CODE } from '@/constants/dict';
|
||||
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
|
||||
|
||||
defineOptions({ name: 'OperateLogSearch' });
|
||||
|
||||
const emit = defineEmits<{
|
||||
reset: [];
|
||||
search: [];
|
||||
}>();
|
||||
|
||||
const model = defineModel<Api.SystemLog.Operate.SearchParams>('model', { required: true });
|
||||
|
||||
const props = defineProps<{
|
||||
userOptions: Array<{ label: string; value: string }>;
|
||||
}>();
|
||||
|
||||
const fields = computed<SearchField[]>(() => [
|
||||
{
|
||||
key: 'userId',
|
||||
label: '操作人',
|
||||
type: 'select',
|
||||
placeholder: '请选择操作人',
|
||||
options: props.userOptions,
|
||||
filterable: true
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
label: '模块类型',
|
||||
type: 'input',
|
||||
placeholder: '请输入操作模块类型'
|
||||
},
|
||||
{
|
||||
key: 'requestMethod',
|
||||
label: '请求方式',
|
||||
type: 'dict',
|
||||
placeholder: '请选择请求方式',
|
||||
dictCode: SYSTEM_REQUEST_METHOD_DICT_CODE,
|
||||
filterable: true
|
||||
},
|
||||
{
|
||||
key: 'subType',
|
||||
label: '操作名',
|
||||
type: 'input',
|
||||
placeholder: '请输入操作名'
|
||||
},
|
||||
{
|
||||
key: 'createTime',
|
||||
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>
|
||||
123
src/views/infra/log-management/shared.ts
Normal file
123
src/views/infra/log-management/shared.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export type LogTabKey = 'login-log' | 'operate-log' | 'api-access-log' | 'api-error-log';
|
||||
|
||||
export interface LogTabOption {
|
||||
name: LogTabKey;
|
||||
label: string;
|
||||
description: string;
|
||||
queryPermission: string;
|
||||
exportPermission: string;
|
||||
}
|
||||
|
||||
export interface LogDetailField {
|
||||
label: string;
|
||||
key?: string;
|
||||
span?: number;
|
||||
type?: 'text' | 'datetime' | 'dict' | 'multiline';
|
||||
dictCode?: string;
|
||||
formatter?: (detail: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
export interface LogDetailSection {
|
||||
title: string;
|
||||
fields: LogDetailField[];
|
||||
}
|
||||
|
||||
export const LogPermission = {
|
||||
LoginQuery: 'system:login-log:query',
|
||||
LoginExport: 'system:login-log:export',
|
||||
OperateQuery: 'system:operate-log:query',
|
||||
OperateExport: 'system:operate-log:export',
|
||||
ApiAccessQuery: 'system:api-access-log:query',
|
||||
ApiAccessExport: 'system:api-access-log:export',
|
||||
ApiErrorQuery: 'system:api-error-log:query',
|
||||
ApiErrorExport: 'system:api-error-log:export'
|
||||
} as const;
|
||||
|
||||
export const LOG_TABS: LogTabOption[] = [
|
||||
{
|
||||
name: 'login-log',
|
||||
label: '登录日志',
|
||||
description: '查看系统登录行为、登录结果与登录时间。',
|
||||
queryPermission: LogPermission.LoginQuery,
|
||||
exportPermission: LogPermission.LoginExport
|
||||
},
|
||||
{
|
||||
name: 'operate-log',
|
||||
label: '操作日志',
|
||||
description: '查看系统操作轨迹、请求地址与业务编号。',
|
||||
queryPermission: LogPermission.OperateQuery,
|
||||
exportPermission: LogPermission.OperateExport
|
||||
},
|
||||
{
|
||||
name: 'api-access-log',
|
||||
label: 'API访问日志',
|
||||
description: '查看接口访问结果、执行时长与请求链路。',
|
||||
queryPermission: LogPermission.ApiAccessQuery,
|
||||
exportPermission: LogPermission.ApiAccessExport
|
||||
},
|
||||
{
|
||||
name: 'api-error-log',
|
||||
label: 'API错误日志',
|
||||
description: '查看接口异常、处理状态与错误上下文。',
|
||||
queryPermission: LogPermission.ApiErrorQuery,
|
||||
exportPermission: LogPermission.ApiErrorExport
|
||||
}
|
||||
];
|
||||
|
||||
export function formatDateTime(value?: string | null) {
|
||||
if (!value) return '--';
|
||||
|
||||
const target = dayjs(value);
|
||||
return target.isValid() ? target.format('YYYY-MM-DD HH:mm:ss') : value;
|
||||
}
|
||||
|
||||
export function formatText(value: unknown) {
|
||||
if (value === null || value === undefined || value === '') return '--';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function formatDuration(value: unknown) {
|
||||
if (value === null || value === undefined || value === '') return '--';
|
||||
|
||||
const duration = Number(value);
|
||||
return Number.isFinite(duration) ? `${duration} ms` : String(value);
|
||||
}
|
||||
|
||||
export function formatMultilineText(value: unknown) {
|
||||
if (value === null || value === undefined || value === '') return '--';
|
||||
|
||||
const text = String(value);
|
||||
const normalized = text.trim();
|
||||
|
||||
if (!normalized) return '--';
|
||||
|
||||
if (
|
||||
(normalized.startsWith('{') && normalized.endsWith('}')) ||
|
||||
(normalized.startsWith('[') && normalized.endsWith(']'))
|
||||
) {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(normalized), null, 2);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
export function downloadBlob(blob: Blob, fileName: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
link.click();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function getLogExportFileName(label: string) {
|
||||
return `${label}_${dayjs().format('YYYY-MM-DD')}.xls`;
|
||||
}
|
||||
137
src/views/infra/log-management/shared/log-detail-dialog.vue
Normal file
137
src/views/infra/log-management/shared/log-detail-dialog.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import BusinessFormSection from '@/components/custom/business-form-section.vue';
|
||||
import DictText from '@/components/custom/dict-text.vue';
|
||||
import { type LogDetailField, type LogDetailSection, formatDateTime, formatMultilineText, formatText } from '../shared';
|
||||
|
||||
defineOptions({ name: 'LogDetailDialog' });
|
||||
|
||||
type DetailRecord = { id: string };
|
||||
|
||||
const visible = defineModel<boolean>('visible', { default: false });
|
||||
|
||||
const props = defineProps<{
|
||||
title: string;
|
||||
rowData?: { id: string } | null;
|
||||
sections: LogDetailSection[];
|
||||
fetchDetail: (id: string) => Promise<{
|
||||
error: unknown;
|
||||
data: DetailRecord | null;
|
||||
}>;
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
const detail = ref<DetailRecord | null>(null);
|
||||
|
||||
watch(
|
||||
() => [visible.value, props.rowData?.id] as const,
|
||||
([isVisible]) => {
|
||||
if (!isVisible) return;
|
||||
loadDetail();
|
||||
}
|
||||
);
|
||||
|
||||
async function loadDetail() {
|
||||
if (!props.rowData?.id) {
|
||||
detail.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
const result = await props.fetchDetail(props.rowData.id);
|
||||
loading.value = false;
|
||||
|
||||
detail.value = !result.error && result.data ? result.data : null;
|
||||
}
|
||||
|
||||
function getFieldValue(field: LogDetailField) {
|
||||
if (!detail.value || !field.key) return undefined;
|
||||
return (detail.value as Record<string, unknown>)[field.key];
|
||||
}
|
||||
|
||||
function getDictFieldValue(field: LogDetailField): string | number | null | undefined {
|
||||
const value = getFieldValue(field);
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return typeof value === 'string' || typeof value === 'number' ? value : String(value);
|
||||
}
|
||||
|
||||
function getFieldText(field: LogDetailField) {
|
||||
if (field.formatter && detail.value) {
|
||||
return field.formatter(detail.value);
|
||||
}
|
||||
|
||||
const value = getFieldValue(field);
|
||||
|
||||
if (field.type === 'datetime') {
|
||||
return formatDateTime(value as string | null | undefined);
|
||||
}
|
||||
|
||||
if (field.type === 'multiline') {
|
||||
return formatMultilineText(value);
|
||||
}
|
||||
|
||||
return formatText(value);
|
||||
}
|
||||
|
||||
function getFieldSpan(field: LogDetailField) {
|
||||
return field.span || (field.type === 'multiline' ? 2 : 1);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog v-model="visible" :title="title" preset="lg" :loading="loading" :show-footer="false">
|
||||
<div v-if="detail" class="log-detail-dialog">
|
||||
<BusinessFormSection v-for="section in sections" :key="section.title" :title="section.title">
|
||||
<ElDescriptions class="log-detail-dialog__descriptions" :column="2" border size="small">
|
||||
<ElDescriptionsItem
|
||||
v-for="field in section.fields"
|
||||
:key="`${section.title}-${field.label}`"
|
||||
:label="field.label"
|
||||
label-class-name="log-detail-dialog__label"
|
||||
:span="getFieldSpan(field)"
|
||||
>
|
||||
<DictText v-if="field.type === 'dict'" :dict-code="field.dictCode!" :value="getDictFieldValue(field)" />
|
||||
<div v-else-if="field.type === 'multiline'" class="log-detail-dialog__multiline">
|
||||
{{ getFieldText(field) }}
|
||||
</div>
|
||||
<span v-else>{{ getFieldText(field) }}</span>
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</BusinessFormSection>
|
||||
</div>
|
||||
<ElEmpty v-else description="暂无日志详情" />
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.log-detail-dialog {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.log-detail-dialog__descriptions .el-descriptions__cell) {
|
||||
line-height: 1.7;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
:deep(.log-detail-dialog__label) {
|
||||
width: 120px;
|
||||
min-width: 120px;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.log-detail-dialog__multiline {
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
background: var(--el-fill-color-light);
|
||||
color: var(--el-text-color-regular);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
@@ -255,9 +255,9 @@ function openView(row: Api.PersonalItem.PersonalItem) {
|
||||
function buildRowActions(row: Api.PersonalItem.PersonalItem): PersonalItemRowAction[] {
|
||||
currentStatusItem.value = row;
|
||||
|
||||
const isCompleted = row.statusCode === 'completed';
|
||||
const rawLifecycleActions = [...(row.availableActions ?? [])];
|
||||
const pauseAction = rawLifecycleActions.find(action => action.actionCode === 'pause') ?? null;
|
||||
const cancelAction = rawLifecycleActions.find(action => action.actionCode === 'cancel') ?? null;
|
||||
const completeAction = rawLifecycleActions.find(action => action.actionCode === 'complete') ?? null;
|
||||
|
||||
const lifecycleActions = rawLifecycleActions
|
||||
@@ -281,37 +281,45 @@ function buildRowActions(row: Api.PersonalItem.PersonalItem): PersonalItemRowAct
|
||||
type: 'primary',
|
||||
onClick: async () => openDetail(row, 'worklog')
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
tooltip: '编辑',
|
||||
icon: markRaw(IconMdiPencilOutline),
|
||||
type: 'primary',
|
||||
onClick: async () => {
|
||||
operateType.value = 'edit';
|
||||
editingData.value = row;
|
||||
openOperateDialog();
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
tooltip: '删除',
|
||||
icon: markRaw(IconMdiDeleteOutline),
|
||||
type: 'danger',
|
||||
onClick: async () => handleDelete(row)
|
||||
},
|
||||
{
|
||||
key: 'status-pause',
|
||||
tooltip: pauseAction?.actionName ?? '暂停',
|
||||
icon: markRaw(IconMdiPause),
|
||||
type: 'warning',
|
||||
disabled: !pauseAction,
|
||||
onClick: async () =>
|
||||
handleStatusAction(row, {
|
||||
actionCode: pauseAction?.actionCode ?? 'pause',
|
||||
actionName: pauseAction?.actionName ?? '暂停',
|
||||
needReason: pauseAction?.needReason ?? false
|
||||
})
|
||||
},
|
||||
...(!isCompleted
|
||||
? ([
|
||||
{
|
||||
key: 'edit',
|
||||
tooltip: '编辑',
|
||||
icon: markRaw(IconMdiPencilOutline),
|
||||
type: 'primary',
|
||||
onClick: async () => {
|
||||
operateType.value = 'edit';
|
||||
editingData.value = row;
|
||||
openOperateDialog();
|
||||
}
|
||||
}
|
||||
] satisfies PersonalItemRowAction[])
|
||||
: []),
|
||||
...(!isCompleted
|
||||
? ([
|
||||
{
|
||||
key: 'delete',
|
||||
tooltip: '删除',
|
||||
icon: markRaw(IconMdiDeleteOutline),
|
||||
type: 'danger',
|
||||
onClick: async () => handleDelete(row)
|
||||
},
|
||||
{
|
||||
key: 'status-pause',
|
||||
tooltip: pauseAction?.actionName ?? '暂停',
|
||||
icon: markRaw(IconMdiPause),
|
||||
type: 'warning',
|
||||
disabled: !pauseAction,
|
||||
onClick: async () =>
|
||||
handleStatusAction(row, {
|
||||
actionCode: pauseAction?.actionCode ?? 'pause',
|
||||
actionName: pauseAction?.actionName ?? '暂停',
|
||||
needReason: pauseAction?.needReason ?? false
|
||||
})
|
||||
}
|
||||
] satisfies PersonalItemRowAction[])
|
||||
: []),
|
||||
// {
|
||||
// key: 'status-cancel',
|
||||
// tooltip: cancelAction?.actionName ?? '取消',
|
||||
@@ -326,19 +334,23 @@ function buildRowActions(row: Api.PersonalItem.PersonalItem): PersonalItemRowAct
|
||||
// })
|
||||
// },
|
||||
...lifecycleActions,
|
||||
{
|
||||
key: 'status-complete',
|
||||
tooltip: completeAction?.actionName ?? '完成',
|
||||
icon: markRaw(IconMdiCheckCircleOutline),
|
||||
type: 'success',
|
||||
disabled: !completeAction,
|
||||
onClick: async () =>
|
||||
handleStatusAction(row, {
|
||||
actionCode: completeAction?.actionCode ?? 'complete',
|
||||
actionName: completeAction?.actionName ?? '完成',
|
||||
needReason: completeAction?.needReason ?? false
|
||||
})
|
||||
}
|
||||
...(!isCompleted
|
||||
? ([
|
||||
{
|
||||
key: 'status-complete',
|
||||
tooltip: completeAction?.actionName ?? '完成',
|
||||
icon: markRaw(IconMdiCheckCircleOutline),
|
||||
type: 'success',
|
||||
disabled: !completeAction,
|
||||
onClick: async () =>
|
||||
handleStatusAction(row, {
|
||||
actionCode: completeAction?.actionCode ?? 'complete',
|
||||
actionName: completeAction?.actionName ?? '完成',
|
||||
needReason: completeAction?.needReason ?? false
|
||||
})
|
||||
}
|
||||
] satisfies PersonalItemRowAction[])
|
||||
: [])
|
||||
];
|
||||
}
|
||||
|
||||
@@ -452,7 +464,7 @@ async function handleStatusActionSubmit(reason: string | null) {
|
||||
|
||||
async function handleDelete(row: Api.PersonalItem.PersonalItem) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除个人事项“${row.taskTitle}”吗?`, '删除确认', {
|
||||
await ElMessageBox.confirm(`确定删除我的事项“${row.taskTitle}”吗?`, '删除确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消'
|
||||
@@ -473,12 +485,12 @@ async function handleDelete(row: Api.PersonalItem.PersonalItem) {
|
||||
|
||||
async function handleBatchDelete() {
|
||||
if (!checkedRowIds.value.length) {
|
||||
window.$message?.warning('请先选择个人事项');
|
||||
window.$message?.warning('请先选择我的事项');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除选中的 ${selectedCount.value} 条个人事项吗?`, '删除确认', {
|
||||
await ElMessageBox.confirm(`确定删除选中的 ${selectedCount.value} 条我的事项吗?`, '删除确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消'
|
||||
@@ -500,7 +512,7 @@ async function handleBatchDelete() {
|
||||
|
||||
function handleOpenBindExecution() {
|
||||
if (!checkedRowIds.value.length) {
|
||||
window.$message?.warning('请先选择个人事项');
|
||||
window.$message?.warning('请先选择我的事项');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -541,7 +553,7 @@ onActivated(() => {
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-12px">
|
||||
<div class="flex items-center gap-10px">
|
||||
<p>个人事项</p>
|
||||
<p>我的事项</p>
|
||||
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag>
|
||||
</div>
|
||||
<TableHeaderOperation v-model:columns="columnChecks" :loading="loading" @refresh="reloadTable">
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { fetchGetPersonalItemExecutionOptions } from '@/service/api';
|
||||
import {
|
||||
fetchGetMyOwnedProjectPage,
|
||||
fetchGetMyParticipatedProjectPage,
|
||||
fetchGetProjectExecutionPage
|
||||
} from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
|
||||
@@ -28,44 +32,113 @@ const visible = defineModel<boolean>('visible', {
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const loading = ref(false);
|
||||
const executionOptions = ref<Api.PersonalItem.PersonalItemExecutionOption[]>([]);
|
||||
interface ProjectOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface ExecutionOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const projectLoading = ref(false);
|
||||
const executionLoading = ref(false);
|
||||
const projectOptions = ref<ProjectOption[]>([]);
|
||||
const executionOptions = ref<ExecutionOption[]>([]);
|
||||
const loading = computed(() => projectLoading.value || executionLoading.value);
|
||||
|
||||
const model = reactive({
|
||||
projectId: '',
|
||||
executionId: ''
|
||||
});
|
||||
|
||||
const rules = computed(
|
||||
() =>
|
||||
({
|
||||
projectId: [createRequiredRule('请选择项目')],
|
||||
executionId: [createRequiredRule('请选择执行')]
|
||||
}) satisfies Record<string, App.Global.FormRule[]>
|
||||
);
|
||||
|
||||
function getExecutionOptionLabel(option: Api.PersonalItem.PersonalItemExecutionOption) {
|
||||
if (option.projectName?.trim()) {
|
||||
return `${option.projectName} / ${option.executionName}`;
|
||||
}
|
||||
function createProjectOptions(
|
||||
participated: Api.Project.MyParticipatedProjectItem[],
|
||||
owned: Api.Project.MyOwnedProjectItem[]
|
||||
): ProjectOption[] {
|
||||
const optionMap = new Map<string, ProjectOption>();
|
||||
|
||||
return option.executionName;
|
||||
owned.forEach(item => {
|
||||
if (!item.id || !item.name?.trim()) return;
|
||||
optionMap.set(item.id, {
|
||||
label: item.name.trim(),
|
||||
value: item.id
|
||||
});
|
||||
});
|
||||
|
||||
participated.forEach(item => {
|
||||
if (!item.id || !item.name?.trim()) return;
|
||||
if (item.statusCode === 'completed' || item.statusCode === 'cancelled' || item.statusCode === 'archived') {
|
||||
return;
|
||||
}
|
||||
if (!optionMap.has(item.id)) {
|
||||
optionMap.set(item.id, {
|
||||
label: item.name.trim(),
|
||||
value: item.id
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(optionMap.values());
|
||||
}
|
||||
|
||||
async function loadExecutionOptions() {
|
||||
loading.value = true;
|
||||
const { error, data } = await fetchGetPersonalItemExecutionOptions();
|
||||
loading.value = false;
|
||||
async function loadProjectOptions() {
|
||||
projectLoading.value = true;
|
||||
const [participatedResult, ownedResult] = await Promise.all([
|
||||
fetchGetMyParticipatedProjectPage({ pageNo: 1, pageSize: -1 }),
|
||||
fetchGetMyOwnedProjectPage({ pageNo: 1, pageSize: -1 })
|
||||
]);
|
||||
projectLoading.value = false;
|
||||
|
||||
if (participatedResult.error || ownedResult.error || !participatedResult.data || !ownedResult.data) {
|
||||
projectOptions.value = [];
|
||||
executionOptions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
projectOptions.value = createProjectOptions(participatedResult.data.list, ownedResult.data.list);
|
||||
}
|
||||
|
||||
async function loadExecutionOptions(projectId: string) {
|
||||
if (!projectId) {
|
||||
executionOptions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
executionLoading.value = true;
|
||||
const { error, data } = await fetchGetProjectExecutionPage(projectId, {
|
||||
pageNo: 1,
|
||||
pageSize: -1
|
||||
});
|
||||
executionLoading.value = false;
|
||||
|
||||
if (error || !data) {
|
||||
executionOptions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
executionOptions.value = data.map(item => ({ ...item }));
|
||||
executionOptions.value = data.list
|
||||
.filter(item => !item.terminal && item.statusCode !== 'completed' && item.statusCode !== 'cancelled')
|
||||
.map(item => ({
|
||||
label: item.executionName,
|
||||
value: item.id
|
||||
}));
|
||||
}
|
||||
|
||||
async function initDialog() {
|
||||
model.projectId = '';
|
||||
model.executionId = '';
|
||||
await loadExecutionOptions();
|
||||
executionOptions.value = [];
|
||||
await loadProjectOptions();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
|
||||
@@ -85,42 +158,84 @@ watch(
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => model.projectId,
|
||||
async (projectId, previousProjectId) => {
|
||||
if (projectId === previousProjectId) return;
|
||||
|
||||
model.executionId = '';
|
||||
|
||||
if (!projectId) {
|
||||
executionOptions.value = [];
|
||||
formRef.value?.clearValidate('executionId');
|
||||
return;
|
||||
}
|
||||
|
||||
await loadExecutionOptions(projectId);
|
||||
formRef.value?.clearValidate('executionId');
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
title="批量关联执行"
|
||||
preset="sm"
|
||||
preset="md"
|
||||
:loading="loading"
|
||||
:confirm-loading="props.submitLoading"
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<ElAlert
|
||||
:title="`已选中 ${props.selectedCount} 条个人事项,关联成功后这些事项会从当前列表移除。`"
|
||||
:title="`已选中 ${props.selectedCount} 条我的事项,关联成功后这些事项会从当前列表移除。`"
|
||||
type="info"
|
||||
:closable="false"
|
||||
class="mb-16px"
|
||||
/>
|
||||
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
|
||||
<ElFormItem label="执行" prop="executionId">
|
||||
<ElSelect
|
||||
v-model="model.executionId"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="请选择执行"
|
||||
class="w-full"
|
||||
:loading="loading"
|
||||
>
|
||||
<ElOption
|
||||
v-for="option in executionOptions"
|
||||
:key="option.executionId"
|
||||
:label="getExecutionOptionLabel(option)"
|
||||
:value="option.executionId"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="项目" prop="projectId">
|
||||
<ElSelect
|
||||
v-model="model.projectId"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="请选择项目"
|
||||
class="w-full"
|
||||
:loading="projectLoading"
|
||||
>
|
||||
<ElOption
|
||||
v-for="option in projectOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="执行" prop="executionId">
|
||||
<ElSelect
|
||||
v-model="model.executionId"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="请选择执行"
|
||||
class="w-full"
|
||||
:loading="executionLoading"
|
||||
:disabled="!model.projectId"
|
||||
>
|
||||
<ElOption
|
||||
v-for="option in executionOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
@@ -81,10 +81,7 @@ const totalHoursText = computed(() => {
|
||||
});
|
||||
const canSubmitWorklog = computed(() =>
|
||||
Boolean(
|
||||
detailData.value?.id &&
|
||||
(detailData.value.statusCode === 'pending' ||
|
||||
detailData.value.statusCode === 'active' ||
|
||||
detailData.value.statusCode === 'completed')
|
||||
detailData.value?.id && (detailData.value.statusCode === 'pending' || detailData.value.statusCode === 'active')
|
||||
)
|
||||
);
|
||||
|
||||
@@ -132,7 +129,7 @@ async function promptCompleteItemIfNeeded() {
|
||||
const { error } = await fetchCompletePersonalItem(detailData.value.id);
|
||||
|
||||
if (!error) {
|
||||
window.$message?.success('个人事项已完成');
|
||||
window.$message?.success('我的事项已完成');
|
||||
await refreshDetail();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,10 +70,10 @@ const model = reactive<Model>(createDefaultModel());
|
||||
|
||||
const title = computed(() => {
|
||||
if (isView.value) {
|
||||
return '个人事项详情';
|
||||
return '我的事项详情';
|
||||
}
|
||||
|
||||
return isEdit.value ? '编辑个人事项' : '新增个人事项';
|
||||
return isEdit.value ? '编辑我的事项' : '新增我的事项';
|
||||
});
|
||||
|
||||
function createDefaultModel(): Model {
|
||||
@@ -195,7 +195,7 @@ async function handleSubmit() {
|
||||
|
||||
await Promise.all([attachmentUploaderRef.value?.commit(), richTextEditorRef.value?.commit()]);
|
||||
|
||||
window.$message?.success(isEdit.value ? '个人事项修改成功' : '个人事项创建成功');
|
||||
window.$message?.success(isEdit.value ? '我的事项修改成功' : '我的事项创建成功');
|
||||
visible.value = false;
|
||||
emit('submitted');
|
||||
}
|
||||
|
||||
@@ -1,3 +1,853 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, markRaw, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { ElButton, ElMessageBox, ElTag } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
batchDownloadPerformanceSheets,
|
||||
deletePerformanceSheet,
|
||||
downloadPerformanceSheet,
|
||||
exportPerformanceSheets,
|
||||
fetchGetDeptSelfAndChildren,
|
||||
fetchGetDirectSubordinates,
|
||||
fetchGetMyProfileDetail,
|
||||
fetchGetMySubordinateTree,
|
||||
fetchPerformanceSheetPage,
|
||||
fetchPerformanceSheetStatusDict,
|
||||
fetchTeamPerformanceSummary,
|
||||
formatToYYYYMM,
|
||||
resendPerformanceSheet,
|
||||
sendPerformanceSheet
|
||||
} from '@/service/api';
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
import { useAuth } from '@/hooks/business/auth';
|
||||
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||
import SubordinateSelector from '@/components/custom/subordinate-selector.vue';
|
||||
import TeamContextPanel from '@/components/custom/team-context-panel.vue';
|
||||
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
|
||||
import {
|
||||
type TeamViewContext,
|
||||
type TeamViewMode,
|
||||
collectSubordinateUserIds,
|
||||
findSubordinateNode
|
||||
} from '../shared/team-dashboard';
|
||||
import PerformanceActionDialog from './modules/performance-action-dialog.vue';
|
||||
import PerformanceExcelEditorDrawer from './modules/performance-excel-editor-drawer.vue';
|
||||
import PerformanceRecordDialog from './modules/performance-record-dialog.vue';
|
||||
import PerformanceSearch from './modules/performance-search.vue';
|
||||
import PerformanceSummary from './modules/performance-summary.vue';
|
||||
import PerformanceTemplateDialog from './modules/performance-template-dialog.vue';
|
||||
import {
|
||||
PerformancePermission,
|
||||
createDefaultPeriodMonth,
|
||||
downloadBlob,
|
||||
formatDateTime,
|
||||
formatScore,
|
||||
getPerformanceStatusLabel,
|
||||
getPerformanceStatusOptions,
|
||||
getSheetExportName,
|
||||
resolvePerformanceStatusTagType
|
||||
} from './modules/performance-shared';
|
||||
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
|
||||
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
|
||||
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
|
||||
import IconMdiDownloadOutline from '~icons/mdi/download-outline';
|
||||
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
|
||||
import IconMdiFileDocumentEditOutline from '~icons/mdi/file-document-edit-outline';
|
||||
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
|
||||
import IconMdiSendOutline from '~icons/mdi/send-outline';
|
||||
|
||||
defineOptions({ name: 'MyPerformance' });
|
||||
|
||||
type PerformanceSheetPageResponse = Awaited<ReturnType<typeof fetchPerformanceSheetPage>>;
|
||||
|
||||
function createSearchParams(): Api.Performance.Sheet.SearchParams {
|
||||
return {
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
employeeIds: undefined,
|
||||
periodMonthRange: undefined,
|
||||
employeeId: undefined,
|
||||
employeeName: undefined,
|
||||
employeeDeptId: undefined,
|
||||
employeeDeptName: undefined,
|
||||
managerId: undefined,
|
||||
managerName: undefined,
|
||||
statusCode: undefined
|
||||
};
|
||||
}
|
||||
|
||||
function transformPageResult(response: PerformanceSheetPageResponse, 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 authStore = useAuthStore();
|
||||
const searchParams = reactive(createSearchParams());
|
||||
const teamViewMode = ref<TeamViewMode>('self');
|
||||
const subordinateTreeLoading = ref(false);
|
||||
const subordinateTree = ref<Api.SystemManage.MySubordinateTreeNode | null>(null);
|
||||
const selectedSubordinateUserId = ref<string | null>(null);
|
||||
const teamSummaryLoading = ref(false);
|
||||
const teamSummary = ref<Api.Performance.Team.Summary | null>(null);
|
||||
const selectedRows = ref<Api.Performance.Sheet.Sheet[]>([]);
|
||||
const currentRow = ref<Api.Performance.Sheet.Sheet | null>(null);
|
||||
const deptOptions = ref<Array<{ label: string; value: string }>>([]);
|
||||
const directSubordinateOptions = ref<Array<{ label: string; value: string }>>([]);
|
||||
const statusOptions = ref<Array<{ label: string; value: string }>>([]);
|
||||
const currentDeptId = ref<string>('');
|
||||
|
||||
const templateVisible = ref(false);
|
||||
const excelVisible = ref(false);
|
||||
const excelMode = ref<'view' | 'edit' | 'create'>('view');
|
||||
const actionVisible = ref(false);
|
||||
const actionType = ref<'confirm' | 'reject'>('confirm');
|
||||
const recordVisible = ref(false);
|
||||
|
||||
const exporting = ref(false);
|
||||
const currentUserId = computed(() => authStore.userInfo.userId || '');
|
||||
const rowActionLoadingKey = ref('');
|
||||
|
||||
const ACTION_ICON_MAP = {
|
||||
view: markRaw(IconMdiEyeOutline),
|
||||
edit: markRaw(IconMdiPencilOutline),
|
||||
send: markRaw(IconMdiSendOutline),
|
||||
confirm: markRaw(IconMdiCheckCircleOutline),
|
||||
reject: markRaw(IconMdiCloseCircleOutline),
|
||||
export: markRaw(IconMdiDownloadOutline),
|
||||
delete: markRaw(IconMdiDeleteOutline),
|
||||
response: markRaw(IconMdiFileDocumentEditOutline)
|
||||
};
|
||||
|
||||
const canUseTeamDashboard = computed(() => hasAuth(PerformancePermission.TeamDashboard));
|
||||
const canCreate = computed(() => hasAuth(PerformancePermission.SheetCreate));
|
||||
const canUpdate = computed(() => hasAuth(PerformancePermission.SheetUpdate));
|
||||
const canDelete = computed(() => hasAuth(PerformancePermission.SheetDelete));
|
||||
const canConfirm = computed(() => hasAuth(PerformancePermission.SheetConfirm));
|
||||
const canReject = computed(() => hasAuth(PerformancePermission.SheetReject));
|
||||
const canExport = computed(() => hasAuth(PerformancePermission.SheetExport));
|
||||
const canManageTemplate = computed(
|
||||
() => hasAuth(PerformancePermission.TemplateQuery) || hasAuth(PerformancePermission.TemplateUpdate)
|
||||
);
|
||||
const isTeamMode = computed(() => teamViewMode.value === 'team');
|
||||
const allSubordinateUserIds = computed(() => collectSubordinateUserIds(subordinateTree.value));
|
||||
const subordinateOptions = computed(() => {
|
||||
const options: Array<{ label: string; value: string }> = [];
|
||||
|
||||
const walk = (nodes?: Api.SystemManage.MySubordinateTreeNode[] | null) => {
|
||||
nodes?.forEach(node => {
|
||||
options.push({ label: node.userNickname, value: node.userId });
|
||||
walk(node.children ?? null);
|
||||
});
|
||||
};
|
||||
|
||||
walk(subordinateTree.value?.children ?? null);
|
||||
return options;
|
||||
});
|
||||
const selectedSubordinateNode = computed(() =>
|
||||
findSubordinateNode(subordinateTree.value, selectedSubordinateUserId.value)
|
||||
);
|
||||
const formattedPeriodMonthStart = computed(
|
||||
() => formatToYYYYMM(searchParams.periodMonthRange?.[0]) || createDefaultPeriodMonth()
|
||||
);
|
||||
const formattedPeriodMonthEnd = computed(
|
||||
() => formatToYYYYMM(searchParams.periodMonthRange?.[1]) || createDefaultPeriodMonth()
|
||||
);
|
||||
const isRootSelected = computed(() => Boolean(isTeamMode.value && selectedSubordinateNode.value?.isRoot));
|
||||
const selectedTeamLabel = computed(() => {
|
||||
if (!isTeamMode.value) return '我自己';
|
||||
if (!selectedSubordinateNode.value) return '--';
|
||||
|
||||
return selectedSubordinateNode.value.isRoot ? '全部下属' : selectedSubordinateNode.value.userNickname;
|
||||
});
|
||||
const teamContext = computed<TeamViewContext | null>(() => {
|
||||
if (!canUseTeamDashboard.value) return null;
|
||||
|
||||
return {
|
||||
mode: teamViewMode.value,
|
||||
selectedUserId: selectedSubordinateUserId.value,
|
||||
selectedUserIds:
|
||||
isTeamMode.value && selectedSubordinateUserId.value && !isRootSelected.value
|
||||
? [selectedSubordinateUserId.value]
|
||||
: [],
|
||||
isRootSelected: isRootSelected.value,
|
||||
allSubordinateUserIds: allSubordinateUserIds.value,
|
||||
selectedLabel: selectedTeamLabel.value
|
||||
};
|
||||
});
|
||||
const currentEmployeeIds = computed(() => {
|
||||
if (!isTeamMode.value) return undefined;
|
||||
if (isRootSelected.value) return [];
|
||||
|
||||
return teamContext.value?.selectedUserIds ?? [];
|
||||
});
|
||||
const resolvedEmployeeIds = computed(() => {
|
||||
if (!isTeamMode.value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (searchParams.employeeId) {
|
||||
return [searchParams.employeeId];
|
||||
}
|
||||
|
||||
return currentEmployeeIds.value;
|
||||
});
|
||||
|
||||
const { columns, columnChecks, data, loading, getDataByPage, mobilePagination, reloadColumns } = useUIPaginatedTable<
|
||||
PerformanceSheetPageResponse,
|
||||
Api.Performance.Sheet.Sheet
|
||||
>({
|
||||
paginationProps: {
|
||||
currentPage: searchParams.pageNo,
|
||||
pageSize: searchParams.pageSize
|
||||
},
|
||||
api: () =>
|
||||
fetchPerformanceSheetPage({
|
||||
...searchParams,
|
||||
employeeIds: resolvedEmployeeIds.value,
|
||||
employeeId: undefined
|
||||
}),
|
||||
transform: response => transformPageResult(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
|
||||
onPaginationParamsChange: params => {
|
||||
searchParams.pageNo = params.currentPage ?? 1;
|
||||
searchParams.pageSize = params.pageSize ?? 10;
|
||||
},
|
||||
columns: () => {
|
||||
const baseColumns: UI.TableColumn<Api.Performance.Sheet.Sheet>[] = [
|
||||
{ prop: 'selection', type: 'selection', width: 48 },
|
||||
{ prop: 'index', type: 'index', label: '序号', width: 64 },
|
||||
{ prop: 'periodMonth', label: '绩效月份', minWidth: 110 }
|
||||
];
|
||||
|
||||
if (isTeamMode.value) {
|
||||
baseColumns.push({ prop: 'employeeName', label: '下属', minWidth: 110, showOverflowTooltip: true });
|
||||
}
|
||||
|
||||
baseColumns.push(
|
||||
{ prop: 'managerName', label: '直属上级', minWidth: 110, showOverflowTooltip: true },
|
||||
{ prop: 'employeeDeptName', label: '部门', minWidth: 110, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'statusCode',
|
||||
label: '状态',
|
||||
minWidth: 100,
|
||||
align: 'center',
|
||||
formatter: row => (
|
||||
<ElTag type={resolvePerformanceStatusTagType(row.statusCode)}>
|
||||
{getPerformanceStatusLabel(row.statusCode, row.statusName)}
|
||||
</ElTag>
|
||||
)
|
||||
},
|
||||
{
|
||||
prop: 'actualScoreTotal',
|
||||
label: '实际得分',
|
||||
minWidth: 100,
|
||||
formatter: row => formatScore(row.actualScoreTotal)
|
||||
},
|
||||
// {
|
||||
// prop: 'baseScoreTotal',
|
||||
// label: '基础得分',
|
||||
// width: 100,
|
||||
// align: 'right',
|
||||
// formatter: row => formatScore(row.baseScoreTotal)
|
||||
// },
|
||||
// {
|
||||
// prop: 'extraScoreTotal',
|
||||
// label: '附加得分',
|
||||
// width: 100,
|
||||
// align: 'right',
|
||||
// formatter: row => formatScore(row.extraScoreTotal)
|
||||
// },
|
||||
{
|
||||
prop: 'sentTime',
|
||||
label: '发送时间',
|
||||
minWidth: 150,
|
||||
formatter: row => formatDateTime(row.sentTime)
|
||||
},
|
||||
{
|
||||
prop: 'confirmedTime',
|
||||
label: '确认时间',
|
||||
minWidth: 150,
|
||||
formatter: row => formatDateTime(row.confirmedTime)
|
||||
},
|
||||
// {
|
||||
// prop: 'updateTime',
|
||||
// label: '更新时间',
|
||||
// width: 150,
|
||||
// formatter: row => formatDateTime(row.updateTime)
|
||||
// },
|
||||
{
|
||||
prop: 'operate',
|
||||
label: '操作',
|
||||
width: 190,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
|
||||
}
|
||||
);
|
||||
|
||||
return baseColumns;
|
||||
}
|
||||
});
|
||||
|
||||
const totalCount = computed(() => mobilePagination.value.total || data.value.length);
|
||||
|
||||
function getRowActions(row: Api.Performance.Sheet.Sheet): BusinessTableAction[] {
|
||||
const actions: BusinessTableAction[] = [
|
||||
{
|
||||
key: 'view',
|
||||
label: '查看',
|
||||
buttonType: 'primary',
|
||||
icon: ACTION_ICON_MAP.view,
|
||||
onClick: () => openExcel(row, 'view')
|
||||
}
|
||||
];
|
||||
|
||||
if (canExport.value && row.fileId) {
|
||||
actions.push({
|
||||
key: 'export',
|
||||
label: '导出',
|
||||
buttonType: 'info',
|
||||
icon: ACTION_ICON_MAP.export,
|
||||
disabled: rowActionLoadingKey.value === `export:${row.id}`,
|
||||
onClick: () => handleDownload(row)
|
||||
});
|
||||
}
|
||||
|
||||
if (isTeamMode.value) {
|
||||
const isDirectManager = Boolean(currentUserId.value) && row.managerId === currentUserId.value;
|
||||
|
||||
if (isDirectManager && canUpdate.value && ['draft', 'rejected'].includes(row.statusCode)) {
|
||||
actions.push({
|
||||
key: 'edit',
|
||||
label: '编辑',
|
||||
buttonType: 'primary',
|
||||
icon: ACTION_ICON_MAP.edit,
|
||||
onClick: () => openExcel(row, 'edit')
|
||||
});
|
||||
actions.push({
|
||||
key: row.statusCode === 'rejected' ? 'resend' : 'send',
|
||||
label: row.statusCode === 'rejected' ? '重新发送' : '发送',
|
||||
buttonType: 'success',
|
||||
icon: ACTION_ICON_MAP.send,
|
||||
disabled: rowActionLoadingKey.value === `send:${row.id}`,
|
||||
onClick: () => handleSend(row)
|
||||
});
|
||||
}
|
||||
|
||||
if (isDirectManager && canDelete.value && row.statusCode === 'draft') {
|
||||
actions.push({
|
||||
key: 'delete',
|
||||
label: '删除',
|
||||
buttonType: 'danger',
|
||||
icon: ACTION_ICON_MAP.delete,
|
||||
disabled: rowActionLoadingKey.value === `delete:${row.id}`,
|
||||
onClick: () => handleDelete(row)
|
||||
});
|
||||
}
|
||||
} else if (row.statusCode === 'sent') {
|
||||
if (canConfirm.value) {
|
||||
actions.push({
|
||||
key: 'confirm',
|
||||
label: '确认',
|
||||
buttonType: 'success',
|
||||
icon: ACTION_ICON_MAP.confirm,
|
||||
onClick: () => openAction(row, 'confirm')
|
||||
});
|
||||
}
|
||||
if (canReject.value) {
|
||||
actions.push({
|
||||
key: 'reject',
|
||||
label: '退回',
|
||||
buttonType: 'danger',
|
||||
icon: ACTION_ICON_MAP.reject,
|
||||
onClick: () => openAction(row, 'reject')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
actions.push({
|
||||
key: 'response-record',
|
||||
label: '反馈历史',
|
||||
buttonType: 'info',
|
||||
icon: ACTION_ICON_MAP.response,
|
||||
onClick: () => openRecord(row)
|
||||
});
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
function resetSearchParams() {
|
||||
const pageSize = searchParams.pageSize ?? 10;
|
||||
Object.assign(searchParams, createSearchParams(), { pageSize });
|
||||
reloadTable(1);
|
||||
loadTeamSummary();
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
reloadTable(1);
|
||||
loadTeamSummary();
|
||||
}
|
||||
|
||||
async function reloadTable(page = searchParams.pageNo ?? 1) {
|
||||
await getDataByPage(page);
|
||||
}
|
||||
|
||||
function createExportParams(): Api.Performance.Sheet.SearchParams {
|
||||
const { pageNo: _pageNo, pageSize: _pageSize, ...params } = searchParams;
|
||||
|
||||
return {
|
||||
...params,
|
||||
employeeIds: resolvedEmployeeIds.value ?? undefined,
|
||||
employeeId: undefined
|
||||
};
|
||||
}
|
||||
|
||||
function handleSelectionChange(rows: Api.Performance.Sheet.Sheet[]) {
|
||||
selectedRows.value = rows;
|
||||
}
|
||||
|
||||
function openExcel(row: Api.Performance.Sheet.Sheet, mode: 'view' | 'edit') {
|
||||
currentRow.value = row;
|
||||
excelMode.value = mode;
|
||||
excelVisible.value = true;
|
||||
}
|
||||
|
||||
function openCreateExcel() {
|
||||
currentRow.value = null;
|
||||
excelMode.value = 'create';
|
||||
excelVisible.value = true;
|
||||
}
|
||||
|
||||
function openAction(row: Api.Performance.Sheet.Sheet, type: 'confirm' | 'reject') {
|
||||
currentRow.value = row;
|
||||
actionType.value = type;
|
||||
actionVisible.value = true;
|
||||
}
|
||||
|
||||
function openRecord(row: Api.Performance.Sheet.Sheet) {
|
||||
currentRow.value = row;
|
||||
recordVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleDownload(row: Api.Performance.Sheet.Sheet) {
|
||||
rowActionLoadingKey.value = `export:${row.id}`;
|
||||
const { error, data: blob } = await downloadPerformanceSheet(row.id);
|
||||
rowActionLoadingKey.value = '';
|
||||
|
||||
if (error || !blob) return;
|
||||
|
||||
downloadBlob(blob, getSheetExportName(row));
|
||||
}
|
||||
|
||||
async function handleExportSelected() {
|
||||
if (!selectedRows.value.length) {
|
||||
window.$message?.warning('请选择要导出的绩效表');
|
||||
return;
|
||||
}
|
||||
|
||||
exporting.value = true;
|
||||
const { error, data: blob } = await batchDownloadPerformanceSheets({
|
||||
ids: selectedRows.value.map(item => item.id)
|
||||
});
|
||||
exporting.value = false;
|
||||
|
||||
if (error || !blob) return;
|
||||
|
||||
downloadBlob(blob, `绩效表_导出选中_${dayjs().format('YYYY-MM-DD')}.zip`);
|
||||
}
|
||||
|
||||
async function handleExportAll() {
|
||||
exporting.value = true;
|
||||
const { error, data: blob } = await exportPerformanceSheets(createExportParams());
|
||||
exporting.value = false;
|
||||
|
||||
if (error || !blob) return;
|
||||
|
||||
downloadBlob(blob, `绩效表_导出全部_${dayjs().format('YYYY-MM-DD')}.zip`);
|
||||
}
|
||||
|
||||
async function handleExportCommand(command: 'selected' | 'all') {
|
||||
if (command === 'selected') {
|
||||
await handleExportSelected();
|
||||
return;
|
||||
}
|
||||
|
||||
await handleExportAll();
|
||||
}
|
||||
|
||||
async function handleSend(row: Api.Performance.Sheet.Sheet) {
|
||||
if (!row.fileId) {
|
||||
window.$message?.warning('请先保存绩效 Excel 后再发送');
|
||||
return;
|
||||
}
|
||||
|
||||
const actionText = row.statusCode === 'rejected' ? '重新发送' : '发送';
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认${actionText}${row.employeeName}的绩效表吗?`, `${actionText}确认`, {
|
||||
type: 'warning',
|
||||
confirmButtonText: `确认${actionText}`,
|
||||
cancelButtonText: '取消'
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
rowActionLoadingKey.value = `send:${row.id}`;
|
||||
const result =
|
||||
row.statusCode === 'rejected' ? await resendPerformanceSheet(row.id) : await sendPerformanceSheet(row.id);
|
||||
rowActionLoadingKey.value = '';
|
||||
|
||||
if (result.error) return;
|
||||
|
||||
window.$message?.success(`绩效表已${actionText}`);
|
||||
await reloadAfterMutation();
|
||||
}
|
||||
|
||||
async function handleDelete(row: Api.Performance.Sheet.Sheet) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除${row.employeeName}的 ${row.periodMonth} 绩效表吗?`, '删除确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确认删除',
|
||||
cancelButtonText: '取消'
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
rowActionLoadingKey.value = `delete:${row.id}`;
|
||||
const { error } = await deletePerformanceSheet(row.id);
|
||||
rowActionLoadingKey.value = '';
|
||||
|
||||
if (error) return;
|
||||
|
||||
window.$message?.success('绩效表已删除');
|
||||
await reloadAfterMutation();
|
||||
}
|
||||
|
||||
async function reloadAfterMutation() {
|
||||
await reloadTable(searchParams.pageNo ?? 1);
|
||||
await loadTeamSummary();
|
||||
}
|
||||
|
||||
async function refreshPageData(page = 1) {
|
||||
await reloadTable(page);
|
||||
await loadTeamSummary();
|
||||
}
|
||||
|
||||
async function loadSubordinateTree() {
|
||||
if (!canUseTeamDashboard.value) return;
|
||||
|
||||
subordinateTreeLoading.value = true;
|
||||
const { error, data: treeData } = await fetchGetMySubordinateTree();
|
||||
subordinateTreeLoading.value = false;
|
||||
|
||||
subordinateTree.value = error || !treeData ? null : treeData;
|
||||
selectedSubordinateUserId.value = treeData?.userId || null;
|
||||
}
|
||||
|
||||
async function loadTeamSummary() {
|
||||
if (!isRootSelected.value) {
|
||||
teamSummary.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
teamSummaryLoading.value = true;
|
||||
const { error, data: summaryData } = await fetchTeamPerformanceSummary({
|
||||
periodMonthStart: formattedPeriodMonthStart.value,
|
||||
periodMonthEnd: formattedPeriodMonthEnd.value
|
||||
});
|
||||
teamSummaryLoading.value = false;
|
||||
|
||||
teamSummary.value = error || !summaryData ? null : summaryData;
|
||||
}
|
||||
|
||||
async function loadDeptOptions() {
|
||||
if (!currentDeptId.value) {
|
||||
currentDeptId.value = authStore.userInfo.deptId || '';
|
||||
}
|
||||
|
||||
if (!currentDeptId.value) {
|
||||
const { error, data: profileDetail } = await fetchGetMyProfileDetail({ userId: authStore.userInfo.userId });
|
||||
if (!error && profileDetail?.deptId) {
|
||||
currentDeptId.value = profileDetail.deptId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentDeptId.value) {
|
||||
deptOptions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const { error, data: deptList } = await fetchGetDeptSelfAndChildren(currentDeptId.value);
|
||||
|
||||
if (error || !deptList) {
|
||||
deptOptions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
deptOptions.value = deptList.map(node => ({ label: node.name, value: String(node.id) }));
|
||||
}
|
||||
|
||||
async function loadDirectSubordinateOptions() {
|
||||
const loginUserId = authStore.userInfo.userId;
|
||||
if (!loginUserId) {
|
||||
directSubordinateOptions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const { error, data: userList } = await fetchGetDirectSubordinates(loginUserId);
|
||||
|
||||
if (error || !userList) {
|
||||
directSubordinateOptions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
directSubordinateOptions.value = userList.map(item => ({
|
||||
label: item.deptName ? `${item.nickname}(${item.deptName})` : item.nickname,
|
||||
value: item.id
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadStatusOptions() {
|
||||
const { error, data: statusDict } = await fetchPerformanceSheetStatusDict();
|
||||
|
||||
if (error || !statusDict) {
|
||||
statusOptions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
statusOptions.value = getPerformanceStatusOptions(statusDict);
|
||||
}
|
||||
|
||||
async function handleTeamViewModeChange(mode: TeamViewMode) {
|
||||
teamViewMode.value = mode;
|
||||
|
||||
if (mode === 'team') {
|
||||
if (!subordinateTree.value) {
|
||||
await loadSubordinateTree();
|
||||
}
|
||||
if (!selectedSubordinateUserId.value) {
|
||||
selectedSubordinateUserId.value = subordinateTree.value?.userId || null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [teamViewMode.value, selectedSubordinateUserId.value] as const,
|
||||
async ([mode], [previousMode]) => {
|
||||
if (mode === 'self' && previousMode === 'self') {
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshPageData(1);
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => isTeamMode.value,
|
||||
() => {
|
||||
reloadColumns();
|
||||
}
|
||||
);
|
||||
|
||||
watch(excelVisible, isVisible => {
|
||||
if (isVisible) return;
|
||||
|
||||
currentRow.value = null;
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadDeptOptions(), loadDirectSubordinateOptions(), loadStatusOptions()]);
|
||||
|
||||
if (canUseTeamDashboard.value) {
|
||||
await loadSubordinateTree();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LookForward title="我的绩效" subtitle="功能建设中,敬请期待" />
|
||||
<div class="my-performance-page">
|
||||
<TeamContextPanel
|
||||
v-if="canUseTeamDashboard"
|
||||
v-model:mode="teamViewMode"
|
||||
:loading="subordinateTreeLoading"
|
||||
:selected-label="selectedTeamLabel"
|
||||
:subordinate-count="subordinateTree?.subordinateCount || 0"
|
||||
@update:mode="handleTeamViewModeChange"
|
||||
>
|
||||
<PerformanceSummary
|
||||
v-if="isRootSelected"
|
||||
:period-month-start="formattedPeriodMonthStart"
|
||||
:period-month-end="formattedPeriodMonthEnd"
|
||||
:loading="teamSummaryLoading"
|
||||
:summary="teamSummary"
|
||||
@reminded="loadTeamSummary"
|
||||
/>
|
||||
</TeamContextPanel>
|
||||
|
||||
<div class="my-performance-page__content" :class="{ 'my-performance-page__content--team': isTeamMode }">
|
||||
<div v-if="canUseTeamDashboard && isTeamMode" class="my-performance-page__sidebar">
|
||||
<SubordinateSelector
|
||||
v-model:selected-user-id="selectedSubordinateUserId"
|
||||
:loading="subordinateTreeLoading"
|
||||
:data="subordinateTree"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="my-performance-page__main">
|
||||
<PerformanceSearch
|
||||
v-model:model="searchParams"
|
||||
:team-mode="isTeamMode"
|
||||
:subordinate-options="subordinateOptions"
|
||||
:dept-options="deptOptions"
|
||||
:status-options="statusOptions"
|
||||
@reset="resetSearchParams"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
|
||||
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">
|
||||
<template #header>
|
||||
<div class="flex flex-wrap items-center justify-between gap-12px">
|
||||
<div class="flex items-center gap-10px">
|
||||
<p class="text-16px font-600">{{ isTeamMode ? '团队绩效' : '我的绩效' }}</p>
|
||||
<ElTag effect="plain">{{ totalCount }}</ElTag>
|
||||
</div>
|
||||
<TableHeaderOperation v-model:columns="columnChecks" :loading="loading" @refresh="reloadTable">
|
||||
<template #default>
|
||||
<ElDropdown v-if="canExport" trigger="click" @command="handleExportCommand">
|
||||
<ElButton plain :loading="exporting">
|
||||
<template #icon>
|
||||
<icon-mdi-download class="text-icon" />
|
||||
</template>
|
||||
导出
|
||||
</ElButton>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem command="selected" :disabled="exporting || !selectedRows.length">
|
||||
导出选中
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem command="all" :disabled="exporting">导出全部</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
<ElButton v-if="isTeamMode && canManageTemplate" plain @click="templateVisible = true">
|
||||
<template #icon>
|
||||
<icon-mdi-file-cog-outline class="text-icon" />
|
||||
</template>
|
||||
模板
|
||||
</ElButton>
|
||||
<ElButton v-if="isTeamMode && canCreate" plain type="primary" @click="openCreateExcel">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus 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"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<template v-for="col in columns" :key="String(col.prop)">
|
||||
<ElTableColumn v-bind="col" />
|
||||
</template>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<PerformanceTemplateDialog
|
||||
v-if="templateVisible"
|
||||
v-model:visible="templateVisible"
|
||||
@updated="reloadAfterMutation"
|
||||
/>
|
||||
|
||||
<PerformanceExcelEditorDrawer
|
||||
v-model:visible="excelVisible"
|
||||
:row-data="currentRow"
|
||||
:mode="excelMode"
|
||||
:subordinate-options="directSubordinateOptions"
|
||||
@saved="reloadAfterMutation"
|
||||
@saved-and-sent="reloadAfterMutation"
|
||||
/>
|
||||
|
||||
<PerformanceActionDialog
|
||||
v-model:visible="actionVisible"
|
||||
:row-data="currentRow"
|
||||
:action-type="actionType"
|
||||
@submitted="reloadAfterMutation"
|
||||
/>
|
||||
|
||||
<PerformanceRecordDialog v-model:visible="recordVisible" :row-data="currentRow" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.my-performance-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.my-performance-page__content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.my-performance-page__main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.my-performance-page__content--team {
|
||||
display: grid;
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.my-performance-page__sidebar {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import type { FormRules } from 'element-plus';
|
||||
import { confirmPerformanceSheet, rejectPerformanceSheet } from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
|
||||
defineOptions({ name: 'PerformanceActionDialog' });
|
||||
|
||||
type ActionType = 'confirm' | 'reject';
|
||||
|
||||
interface Props {
|
||||
rowData?: Api.Performance.Sheet.Sheet | null;
|
||||
actionType?: ActionType;
|
||||
selectableActionType?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
rowData: null,
|
||||
actionType: 'confirm',
|
||||
selectableActionType: false
|
||||
});
|
||||
|
||||
const visible = defineModel<boolean>('visible', { default: false });
|
||||
|
||||
const emit = defineEmits<{
|
||||
submitted: [];
|
||||
}>();
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const submitting = ref(false);
|
||||
const form = reactive<{
|
||||
actionType: ActionType;
|
||||
reason: string;
|
||||
}>({
|
||||
actionType: 'confirm',
|
||||
reason: ''
|
||||
});
|
||||
|
||||
const isReject = computed(() => form.actionType === 'reject');
|
||||
const title = computed(() => {
|
||||
if (props.selectableActionType) return '绩效审批';
|
||||
return isReject.value ? '退回绩效表' : '确认绩效表';
|
||||
});
|
||||
const confirmText = computed(() => {
|
||||
if (props.selectableActionType) return '确认提交';
|
||||
return isReject.value ? '确认退回' : '确认';
|
||||
});
|
||||
const opinionLabel = computed(() => (isReject.value ? '退回原因' : '审批意见'));
|
||||
const opinionPlaceholder = computed(() => (isReject.value ? `请输入${opinionLabel.value}` : '可填写审批意见'));
|
||||
const rules = computed<FormRules>(() => ({
|
||||
reason: isReject.value ? [createRequiredRule('请输入退回原因')] : []
|
||||
}));
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!props.rowData?.id) return;
|
||||
|
||||
if (isReject.value) {
|
||||
try {
|
||||
await validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
const result = isReject.value
|
||||
? await rejectPerformanceSheet(props.rowData.id, { reason: form.reason.trim() })
|
||||
: await confirmPerformanceSheet(props.rowData.id, { reason: form.reason.trim() || undefined });
|
||||
submitting.value = false;
|
||||
|
||||
if (result.error) return;
|
||||
|
||||
window.$message?.success(isReject.value ? '绩效表已退回' : '绩效表已确认');
|
||||
visible.value = false;
|
||||
emit('submitted');
|
||||
}
|
||||
|
||||
watch(visible, isVisible => {
|
||||
if (!isVisible) return;
|
||||
form.actionType = props.actionType;
|
||||
form.reason = '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
preset="sm"
|
||||
append-to-body
|
||||
:confirm-text="confirmText"
|
||||
:confirm-loading="submitting"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||
<ElDescriptions :column="1" border>
|
||||
<ElDescriptionsItem label="绩效月份">{{ props.rowData?.periodMonth || '--' }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="被考核人">{{ props.rowData?.employeeName || '--' }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="实际得分">{{ props.rowData?.actualScoreTotal ?? '--' }}</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
|
||||
<div v-if="props.selectableActionType" class="performance-action-dialog__approval-form">
|
||||
<div class="audit-field">
|
||||
<label>审批结论</label>
|
||||
<div class="audit-conclusion">
|
||||
<button
|
||||
type="button"
|
||||
class="conclusion-btn"
|
||||
:class="{
|
||||
active: form.actionType === 'confirm',
|
||||
pass: form.actionType === 'confirm'
|
||||
}"
|
||||
@click="form.actionType = 'confirm'"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5" />
|
||||
<path
|
||||
d="M5 8.5L7 10.5L11 6"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
确认
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="conclusion-btn"
|
||||
:class="{
|
||||
active: form.actionType === 'reject',
|
||||
reject: form.actionType === 'reject'
|
||||
}"
|
||||
@click="form.actionType = 'reject'"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M6 6L10 10M10 6L6 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
退回
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElFormItem class="mt-16px" :label="opinionLabel" prop="reason">
|
||||
<ElInput
|
||||
v-model="form.reason"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="1000"
|
||||
show-word-limit
|
||||
:placeholder="opinionPlaceholder"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.performance-action-dialog__approval-form {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.audit-field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.audit-field label {
|
||||
color: #475467;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.audit-conclusion {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.conclusion-btn {
|
||||
height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: 1px solid #d8e0e8;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #475467;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
}
|
||||
|
||||
.conclusion-btn:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.conclusion-btn.active.pass {
|
||||
border-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.conclusion-btn.active.reject {
|
||||
border-color: #dc2626;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,765 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
import type { FormRules } from 'element-plus';
|
||||
import JSZip from 'jszip';
|
||||
import '@univerjs/preset-sheets-core/lib/index.css';
|
||||
import {
|
||||
createPerformanceSheet,
|
||||
downloadFile,
|
||||
fetchPerformanceSheet,
|
||||
fetchPerformanceTemplateCurrent,
|
||||
sendPerformanceSheet,
|
||||
updatePerformanceSheetExcel,
|
||||
uploadFile
|
||||
} from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import { createDefaultPeriodMonth, normalizeScoreText } from './performance-shared';
|
||||
|
||||
defineOptions({ name: 'PerformanceExcelEditorDrawer' });
|
||||
|
||||
interface SubordinateOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
rowData?: Api.Performance.Sheet.Sheet | null;
|
||||
mode: 'view' | 'edit' | 'create';
|
||||
subordinateOptions?: SubordinateOption[];
|
||||
showApprovalFooter?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
rowData: null,
|
||||
subordinateOptions: () => [],
|
||||
showApprovalFooter: false
|
||||
});
|
||||
|
||||
const visible = defineModel<boolean>('visible', { default: false });
|
||||
|
||||
const emit = defineEmits<{
|
||||
saved: [];
|
||||
savedAndSent: [];
|
||||
startApproval: [];
|
||||
}>();
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const containerRef = ref<HTMLDivElement>();
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const sending = ref(false);
|
||||
const currentSheet = ref<Api.Performance.Sheet.Sheet | null>(null);
|
||||
const currentTemplate = ref<Api.Performance.Template.Template | null>(null);
|
||||
const errorMessage = ref('');
|
||||
const viewportWidth = ref(typeof window === 'undefined' ? 1920 : window.innerWidth);
|
||||
|
||||
const createForm = reactive({
|
||||
periodMonth: createDefaultPeriodMonth(),
|
||||
employeeId: ''
|
||||
});
|
||||
|
||||
const createFormRules = computed<FormRules>(() => ({
|
||||
periodMonth: [createRequiredRule('请选择绩效月份')],
|
||||
employeeId: [createRequiredRule('请选择下属')]
|
||||
}));
|
||||
const ASSESSED_EMPLOYEE_TEXT_PATTERN = /(被考核人\s*[::]\s*)(.+)/u;
|
||||
|
||||
let univerInstance: any = null;
|
||||
let univerAPI: any = null;
|
||||
let LuckyExcelModule: any = null;
|
||||
let createUniverFn: any = null;
|
||||
let UniverSheetsCorePresetFn: any = null;
|
||||
let univerLocales: Record<string, unknown> | null = null;
|
||||
let excelRuntimeLoading: Promise<void> | null = null;
|
||||
const DEFAULT_SHEET_ZOOM_RATIO = 0.4;
|
||||
|
||||
const isCreateMode = computed(() => props.mode === 'create');
|
||||
const drawerTitle = computed(() => {
|
||||
let action = '查看';
|
||||
if (isCreateMode.value) action = '新增';
|
||||
else if (props.mode === 'edit') action = '编辑';
|
||||
const selectedEmployee = props.subordinateOptions.find(opt => opt.value === createForm.employeeId);
|
||||
const name = currentSheet.value?.employeeName || props.rowData?.employeeName || selectedEmployee?.label || '';
|
||||
|
||||
return `${action}绩效 Excel${name ? ` - ${name}` : ''}`;
|
||||
});
|
||||
const canSave = computed(() => props.mode !== 'view');
|
||||
const showApprovalFooter = computed(() => props.mode === 'view' && props.showApprovalFooter);
|
||||
const drawerSize = computed(() => (viewportWidth.value >= 2560 ? '60%' : '88%'));
|
||||
|
||||
function syncViewportWidth() {
|
||||
viewportWidth.value = window.innerWidth;
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
function handleStartApproval() {
|
||||
emit('startApproval');
|
||||
}
|
||||
|
||||
function disposeUniver() {
|
||||
try {
|
||||
univerInstance?.dispose?.();
|
||||
} catch {
|
||||
// ignore dispose errors so closing the drawer still works
|
||||
}
|
||||
|
||||
univerInstance = null;
|
||||
univerAPI = null;
|
||||
|
||||
if (containerRef.value) {
|
||||
containerRef.value.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureExcelRuntime() {
|
||||
if (LuckyExcelModule && createUniverFn && UniverSheetsCorePresetFn && univerLocales) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!excelRuntimeLoading) {
|
||||
excelRuntimeLoading = Promise.all([
|
||||
import('@zwight/luckyexcel'),
|
||||
import('@univerjs/presets'),
|
||||
import('@univerjs/preset-sheets-core'),
|
||||
import('@univerjs/preset-sheets-core/locales/zh-CN'),
|
||||
import('@univerjs/preset-sheets-core/locales/en-US')
|
||||
]).then(([luckyexcelModule, presetsModule, sheetsCoreModule, zhCNLocaleModule, enUSLocaleModule]) => {
|
||||
LuckyExcelModule = luckyexcelModule.default || luckyexcelModule;
|
||||
createUniverFn = presetsModule.createUniver;
|
||||
UniverSheetsCorePresetFn = sheetsCoreModule.UniverSheetsCorePreset;
|
||||
univerLocales = {
|
||||
'zh-CN': zhCNLocaleModule.default || zhCNLocaleModule,
|
||||
'en-US': enUSLocaleModule.default || enUSLocaleModule
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
await excelRuntimeLoading;
|
||||
}
|
||||
|
||||
function transformExcelToUniver(file: File) {
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
LuckyExcelModule.transformExcelToUniver(
|
||||
file,
|
||||
(snapshot: any) => resolve(snapshot || {}),
|
||||
(error: unknown) => reject(error)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function transformUniverToExcel(snapshot: any, fileName: string) {
|
||||
return new Promise<BlobPart>((resolve, reject) => {
|
||||
LuckyExcelModule.transformUniverToExcel({
|
||||
snapshot,
|
||||
fileName,
|
||||
getBuffer: true,
|
||||
success: (buffer?: unknown) => {
|
||||
if (!buffer) {
|
||||
reject(new Error('Excel 导出结果为空'));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(buffer as BlobPart);
|
||||
},
|
||||
error: (error: Error) => reject(error)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function normalizeExcelBuffer(buffer: BlobPart): Promise<ArrayBuffer> {
|
||||
if (buffer instanceof ArrayBuffer) {
|
||||
return buffer;
|
||||
}
|
||||
|
||||
if (ArrayBuffer.isView(buffer)) {
|
||||
return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength).slice().buffer;
|
||||
}
|
||||
|
||||
if (buffer instanceof Blob) {
|
||||
return buffer.arrayBuffer();
|
||||
}
|
||||
|
||||
throw new Error('Excel 导出结果格式不受支持');
|
||||
}
|
||||
|
||||
function applySheetZoomRatio(snapshot: any, zoomRatio = DEFAULT_SHEET_ZOOM_RATIO) {
|
||||
const data = snapshot || {};
|
||||
|
||||
if (data.sheets && typeof data.sheets === 'object') {
|
||||
Object.values(data.sheets).forEach((sheet: any) => {
|
||||
if (sheet && typeof sheet === 'object') {
|
||||
sheet.zoomRatio = zoomRatio;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function replaceAssessedEmployeeText(value: string, employeeName: string) {
|
||||
if (!ASSESSED_EMPLOYEE_TEXT_PATTERN.test(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return value.replace(ASSESSED_EMPLOYEE_TEXT_PATTERN, `$1${employeeName}`);
|
||||
}
|
||||
|
||||
function injectAssessedEmployeeName(snapshot: any, employeeName: string) {
|
||||
if (!snapshot || !employeeName) return snapshot;
|
||||
|
||||
const visited = new WeakSet<object>();
|
||||
|
||||
const walk = (target: unknown) => {
|
||||
if (typeof target === 'string') {
|
||||
return replaceAssessedEmployeeText(target, employeeName);
|
||||
}
|
||||
|
||||
if (!target || typeof target !== 'object') {
|
||||
return target;
|
||||
}
|
||||
|
||||
if (visited.has(target as object)) {
|
||||
return target;
|
||||
}
|
||||
|
||||
visited.add(target as object);
|
||||
|
||||
if (Array.isArray(target)) {
|
||||
target.forEach((item, index) => {
|
||||
target[index] = walk(item);
|
||||
});
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
Object.entries(target).forEach(([key, value]) => {
|
||||
(target as Record<string, unknown>)[key] = walk(value);
|
||||
});
|
||||
|
||||
return target;
|
||||
};
|
||||
|
||||
return walk(snapshot);
|
||||
}
|
||||
|
||||
function findFirstElementByLocalName(parent: Element | Document, localName: string) {
|
||||
return Array.from(parent.childNodes).find(
|
||||
node => node.nodeType === Node.ELEMENT_NODE && (node as Element).localName === localName
|
||||
) as Element | undefined;
|
||||
}
|
||||
|
||||
function ensureChildElement(document: XMLDocument, parent: Element, localName: string) {
|
||||
const existing = Array.from(parent.childNodes).find(
|
||||
node => node.nodeType === Node.ELEMENT_NODE && (node as Element).localName === localName
|
||||
) as Element | undefined;
|
||||
|
||||
if (existing) return existing;
|
||||
|
||||
const namespace = parent.namespaceURI || document.documentElement?.namespaceURI || null;
|
||||
const element = namespace ? document.createElementNS(namespace, localName) : document.createElement(localName);
|
||||
parent.appendChild(element);
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
function createXmlRoot(document: XMLDocument, localName: string, namespace?: string | null) {
|
||||
const root = namespace ? document.createElementNS(namespace, localName) : document.createElement(localName);
|
||||
document.appendChild(root);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
function applyWorksheetZoomXml(xmlText: string, zoomScale: number) {
|
||||
const document = new DOMParser().parseFromString(xmlText, 'application/xml');
|
||||
const root =
|
||||
document.documentElement && document.documentElement.localName !== 'parsererror'
|
||||
? document.documentElement
|
||||
: createXmlRoot(document, 'worksheet', 'http://schemas.openxmlformats.org/spreadsheetml/2006/main');
|
||||
const sheetViews = ensureChildElement(document, root, 'sheetViews');
|
||||
const sheetView =
|
||||
findFirstElementByLocalName(sheetViews, 'sheetView') || ensureChildElement(document, sheetViews, 'sheetView');
|
||||
|
||||
sheetView.setAttribute('workbookViewId', sheetView.getAttribute('workbookViewId') || '0');
|
||||
sheetView.setAttribute('zoomScale', String(zoomScale));
|
||||
sheetView.setAttribute('zoomScaleNormal', String(zoomScale));
|
||||
sheetView.setAttribute('zoomScalePageLayoutView', String(zoomScale));
|
||||
|
||||
return new XMLSerializer().serializeToString(document);
|
||||
}
|
||||
|
||||
function applyWorkbookZoomXml(xmlText: string, zoomScale: number) {
|
||||
const document = new DOMParser().parseFromString(xmlText, 'application/xml');
|
||||
const root =
|
||||
document.documentElement && document.documentElement.localName !== 'parsererror'
|
||||
? document.documentElement
|
||||
: createXmlRoot(document, 'workbook', 'http://schemas.openxmlformats.org/spreadsheetml/2006/main');
|
||||
const bookViews = ensureChildElement(document, root, 'bookViews');
|
||||
const workbookView =
|
||||
findFirstElementByLocalName(bookViews, 'workbookView') || ensureChildElement(document, bookViews, 'workbookView');
|
||||
|
||||
workbookView.setAttribute('zoomScale', String(zoomScale));
|
||||
workbookView.setAttribute('zoomScaleNormal', String(zoomScale));
|
||||
|
||||
return new XMLSerializer().serializeToString(document);
|
||||
}
|
||||
|
||||
async function applyExcelZoomMetadata(buffer: BlobPart, zoomRatio = DEFAULT_SHEET_ZOOM_RATIO) {
|
||||
const zoomScale = Math.max(10, Math.min(400, Math.round(zoomRatio * 100)));
|
||||
const zip = await JSZip.loadAsync(await normalizeExcelBuffer(buffer));
|
||||
const worksheetPaths = Object.keys(zip.files).filter(path => /^xl\/worksheets\/sheet\d+\.xml$/u.test(path));
|
||||
|
||||
await Promise.all(
|
||||
worksheetPaths.map(async path => {
|
||||
const entry = zip.file(path);
|
||||
if (!entry) return;
|
||||
|
||||
const xmlText = await entry.async('string');
|
||||
zip.file(path, applyWorksheetZoomXml(xmlText, zoomScale));
|
||||
})
|
||||
);
|
||||
|
||||
const workbookEntry = zip.file('xl/workbook.xml');
|
||||
if (workbookEntry) {
|
||||
const workbookXml = await workbookEntry.async('string');
|
||||
zip.file('xl/workbook.xml', applyWorkbookZoomXml(workbookXml, zoomScale));
|
||||
}
|
||||
|
||||
return zip.generateAsync({ type: 'arraybuffer' });
|
||||
}
|
||||
|
||||
function createWorkbook(snapshot: any) {
|
||||
if (!containerRef.value) return;
|
||||
|
||||
disposeUniver();
|
||||
const { univer, univerAPI: api } = createUniverFn({
|
||||
locale: 'zh-CN',
|
||||
locales: univerLocales || undefined,
|
||||
presets: [
|
||||
UniverSheetsCorePresetFn({
|
||||
container: containerRef.value,
|
||||
header: true,
|
||||
toolbar: props.mode !== 'view',
|
||||
formulaBar: props.mode !== 'view'
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
univerInstance = univer;
|
||||
univerAPI = api;
|
||||
|
||||
const unitType = api?.Enum?.UniverInstanceType?.UNIVER_SHEET;
|
||||
if (!unitType) {
|
||||
throw new Error('Univer 工作簿初始化失败');
|
||||
}
|
||||
|
||||
// 在 snapshot 数据中预设缩放比例,保证在线查看和导出文件使用同一套缩放值
|
||||
const data = applySheetZoomRatio(snapshot);
|
||||
|
||||
univer.createUnit(unitType, data);
|
||||
}
|
||||
|
||||
function getActiveWorkbook() {
|
||||
return univerAPI?.getActiveWorkbook?.();
|
||||
}
|
||||
|
||||
function parseCellAddress(address?: string | null) {
|
||||
const text = String(address || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
const matched = text.match(/^([A-Z]+)(\d+)$/u);
|
||||
if (!matched) return null;
|
||||
|
||||
const [, letters, rowText] = matched;
|
||||
let column = 0;
|
||||
for (const letter of letters) {
|
||||
column = column * 26 + letter.charCodeAt(0) - 64;
|
||||
}
|
||||
|
||||
return {
|
||||
row: Number(rowText) - 1,
|
||||
column: column - 1
|
||||
};
|
||||
}
|
||||
|
||||
function readCell(address?: string | null) {
|
||||
const position = parseCellAddress(address);
|
||||
const workbook = getActiveWorkbook();
|
||||
const sheet = workbook?.getActiveSheet?.();
|
||||
if (!position || !sheet) return '';
|
||||
|
||||
try {
|
||||
const range = sheet.getRange(position.row, position.column);
|
||||
const value = range?.getValue?.();
|
||||
|
||||
return normalizeScoreText(value);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function readScores() {
|
||||
const mapping = currentTemplate.value?.scoreCellMapping;
|
||||
|
||||
return {
|
||||
actualScoreTotal: readCell(mapping?.actualScoreTotalCell),
|
||||
baseScoreTotal: readCell(mapping?.baseScoreTotalCell),
|
||||
extraScoreTotal: readCell(mapping?.extraScoreTotalCell)
|
||||
};
|
||||
}
|
||||
|
||||
function getCreateEmployeeName() {
|
||||
return props.subordinateOptions.find(opt => opt.value === createForm.employeeId)?.label || '';
|
||||
}
|
||||
|
||||
function applyCreateEmployeeName(snapshot: any) {
|
||||
if (!isCreateMode.value) return snapshot;
|
||||
|
||||
const employeeName = getCreateEmployeeName();
|
||||
if (!employeeName) return snapshot;
|
||||
|
||||
return injectAssessedEmployeeName(snapshot, employeeName);
|
||||
}
|
||||
|
||||
function createInitialFileName() {
|
||||
const sheet = currentSheet.value;
|
||||
if (sheet) {
|
||||
return sheet.fileName || `${sheet.periodMonth}-绩效表_${sheet.employeeName}.xlsx`;
|
||||
}
|
||||
|
||||
if (isCreateMode.value && createForm.periodMonth && createForm.employeeId) {
|
||||
return `${createForm.periodMonth}-绩效表_${getCreateEmployeeName()}.xlsx`;
|
||||
}
|
||||
|
||||
return '绩效表.xlsx';
|
||||
}
|
||||
|
||||
async function loadWorkbook() {
|
||||
loading.value = true;
|
||||
errorMessage.value = '';
|
||||
currentSheet.value = null;
|
||||
currentTemplate.value = null;
|
||||
|
||||
try {
|
||||
let sourceFileId = '';
|
||||
|
||||
if (isCreateMode.value) {
|
||||
const templateResult = await fetchPerformanceTemplateCurrent();
|
||||
if (templateResult.error || !templateResult.data) {
|
||||
errorMessage.value = '当前没有可用的绩效模板';
|
||||
return;
|
||||
}
|
||||
|
||||
currentTemplate.value = templateResult.data;
|
||||
sourceFileId = templateResult.data.fileId;
|
||||
} else {
|
||||
if (!props.rowData?.id) return;
|
||||
|
||||
const [sheetResult, templateResult] = await Promise.all([
|
||||
fetchPerformanceSheet(props.rowData.id),
|
||||
fetchPerformanceTemplateCurrent()
|
||||
]);
|
||||
|
||||
if (sheetResult.error || !sheetResult.data) {
|
||||
errorMessage.value = '绩效表详情加载失败';
|
||||
return;
|
||||
}
|
||||
|
||||
currentSheet.value = sheetResult.data;
|
||||
currentTemplate.value = templateResult.error ? null : templateResult.data;
|
||||
sourceFileId = currentSheet.value.fileId || currentTemplate.value?.fileId || '';
|
||||
}
|
||||
|
||||
if (!sourceFileId) {
|
||||
errorMessage.value = '当前绩效表没有可用的 Excel 文件';
|
||||
return;
|
||||
}
|
||||
|
||||
const fileResult = await downloadFile(sourceFileId);
|
||||
if (fileResult.error || !fileResult.data) {
|
||||
errorMessage.value = 'Excel 文件下载失败';
|
||||
return;
|
||||
}
|
||||
|
||||
await ensureExcelRuntime();
|
||||
const file = new File([fileResult.data], createInitialFileName(), {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
});
|
||||
const snapshot = await transformExcelToUniver(file);
|
||||
await nextTick();
|
||||
createWorkbook(applyCreateEmployeeName(snapshot));
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : 'Excel 解析失败';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCreatedSheet() {
|
||||
if (currentSheet.value) {
|
||||
return currentSheet.value;
|
||||
}
|
||||
|
||||
if (!createForm.periodMonth || !createForm.employeeId) {
|
||||
throw new Error('请先填写绩效月份和下属');
|
||||
}
|
||||
|
||||
const createResult = await createPerformanceSheet({
|
||||
periodMonth: createForm.periodMonth,
|
||||
employeeId: createForm.employeeId
|
||||
});
|
||||
|
||||
if (createResult.error || !createResult.data) {
|
||||
throw new Error('创建绩效记录失败');
|
||||
}
|
||||
|
||||
const sheetResult = await fetchPerformanceSheet(createResult.data);
|
||||
if (sheetResult.error || !sheetResult.data) {
|
||||
throw new Error('绩效记录已创建,但加载详情失败');
|
||||
}
|
||||
|
||||
currentSheet.value = sheetResult.data;
|
||||
return sheetResult.data;
|
||||
}
|
||||
|
||||
async function executeSave(): Promise<Api.Performance.Sheet.Sheet | null> {
|
||||
const workbook = getActiveWorkbook();
|
||||
if (!workbook) return null;
|
||||
|
||||
const scores = readScores();
|
||||
if (!scores.actualScoreTotal || !scores.baseScoreTotal || !scores.extraScoreTotal) {
|
||||
window.$message?.warning('未能读取完整的三种得分总计,请检查模板单元格映射配置');
|
||||
return null;
|
||||
}
|
||||
|
||||
// create 模式先校验表单
|
||||
if (isCreateMode.value) {
|
||||
try {
|
||||
await validate();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
await ensureExcelRuntime();
|
||||
const sheet = await ensureCreatedSheet();
|
||||
const snapshot = applySheetZoomRatio(workbook.save());
|
||||
const fileName = createInitialFileName();
|
||||
const buffer = await transformUniverToExcel(snapshot, fileName);
|
||||
const excelBuffer = await applyExcelZoomMetadata(buffer);
|
||||
const file = new File([excelBuffer], fileName, {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
});
|
||||
const uploadResult = await uploadFile(file, `performance/sheets/${sheet.periodMonth}`);
|
||||
|
||||
if (uploadResult.error || !uploadResult.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updateResult = await updatePerformanceSheetExcel(sheet.id, {
|
||||
fileId: uploadResult.data.id,
|
||||
fileName,
|
||||
fileVersion: sheet.fileVersion,
|
||||
actualScoreTotal: scores.actualScoreTotal,
|
||||
baseScoreTotal: scores.baseScoreTotal,
|
||||
extraScoreTotal: scores.extraScoreTotal
|
||||
});
|
||||
|
||||
if (updateResult.error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sheet;
|
||||
}
|
||||
|
||||
async function handleSaveDraft() {
|
||||
saving.value = true;
|
||||
try {
|
||||
const sheet = await executeSave();
|
||||
if (!sheet) return;
|
||||
|
||||
window.$message?.success(isCreateMode.value ? '绩效表已保存为草稿' : '绩效 Excel 已保存');
|
||||
visible.value = false;
|
||||
emit('saved');
|
||||
} catch (error) {
|
||||
window.$message?.error(error instanceof Error ? error.message : '绩效 Excel 保存失败');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveAndSend() {
|
||||
sending.value = true;
|
||||
try {
|
||||
const sheet = await executeSave();
|
||||
if (!sheet) return;
|
||||
|
||||
const sendResult = await sendPerformanceSheet(sheet.id);
|
||||
if (sendResult.error) return;
|
||||
|
||||
window.$message?.success('绩效表已保存并发送');
|
||||
visible.value = false;
|
||||
emit('savedAndSent');
|
||||
} catch (error) {
|
||||
window.$message?.error(error instanceof Error ? error.message : '绩效表发送失败');
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(visible, async isVisible => {
|
||||
if (!isVisible) {
|
||||
disposeUniver();
|
||||
currentSheet.value = null;
|
||||
currentTemplate.value = null;
|
||||
// 重置创建表单
|
||||
createForm.periodMonth = createDefaultPeriodMonth();
|
||||
createForm.employeeId = '';
|
||||
return;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
await loadWorkbook();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => createForm.employeeId,
|
||||
async (employeeId, previousEmployeeId) => {
|
||||
if (!visible.value || !isCreateMode.value || !employeeId || employeeId === previousEmployeeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workbook = getActiveWorkbook();
|
||||
if (!workbook) return;
|
||||
|
||||
const snapshot = workbook.save();
|
||||
await nextTick();
|
||||
createWorkbook(applyCreateEmployeeName(snapshot));
|
||||
}
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', syncViewportWidth);
|
||||
disposeUniver();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
syncViewportWidth();
|
||||
window.addEventListener('resize', syncViewportWidth);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDrawer
|
||||
v-model="visible"
|
||||
class="performance-excel-editor-drawer"
|
||||
:title="drawerTitle"
|
||||
body-class="performance-excel-editor-drawer__body"
|
||||
:size="drawerSize"
|
||||
:close-on-click-modal="false"
|
||||
append-to-body
|
||||
>
|
||||
<!-- 创建模式下的表单区域 -->
|
||||
<div v-if="isCreateMode" class="performance-excel-editor__create-form">
|
||||
<ElForm ref="formRef" :model="createForm" :rules="createFormRules" label-position="top" inline>
|
||||
<ElFormItem label="绩效月份" prop="periodMonth" class="performance-excel-editor__form-item">
|
||||
<ElDatePicker
|
||||
v-model="createForm.periodMonth"
|
||||
type="month"
|
||||
value-format="YYYY-MM"
|
||||
placeholder="选择绩效月份"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="下属" prop="employeeId" class="performance-excel-editor__form-item">
|
||||
<ElSelect v-model="createForm.employeeId" filterable placeholder="选择下属" style="width: 200px">
|
||||
<ElOption v-for="opt in props.subordinateOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="performance-excel-editor">
|
||||
<ElAlert
|
||||
v-if="errorMessage"
|
||||
class="performance-excel-editor__alert"
|
||||
type="error"
|
||||
:title="errorMessage"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<div ref="containerRef" class="performance-excel-editor__container" />
|
||||
</div>
|
||||
|
||||
<template v-if="canSave || showApprovalFooter" #footer>
|
||||
<div class="performance-excel-editor__footer">
|
||||
<template v-if="canSave">
|
||||
<ElButton :loading="saving" :disabled="sending" @click="handleSaveDraft">保存草稿</ElButton>
|
||||
<ElButton type="primary" :loading="sending" :disabled="saving" @click="handleSaveAndSend">发送绩效</ElButton>
|
||||
</template>
|
||||
<template v-else-if="showApprovalFooter">
|
||||
<ElButton @click="handleClose">退出审批</ElButton>
|
||||
<ElButton type="primary" @click="handleStartApproval">开始审批</ElButton>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</ElDrawer>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:global(.performance-excel-editor-drawer__body) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 20px 24px 0;
|
||||
}
|
||||
|
||||
.performance-excel-editor__create-form {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 16px;
|
||||
padding: 16px 20px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-blank);
|
||||
}
|
||||
|
||||
.performance-excel-editor__form-item {
|
||||
margin-bottom: 0;
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
.performance-excel-editor__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 12px 24px 20px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.performance-excel-editor {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.performance-excel-editor__alert {
|
||||
margin-bottom: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.performance-excel-editor__container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { fetchPerformanceSheetResponseRecords } from '@/service/api';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import { formatDateTime, getPerformanceActionLabel } from './performance-shared';
|
||||
|
||||
defineOptions({ name: 'PerformanceRecordDialog' });
|
||||
|
||||
interface Props {
|
||||
rowData?: Api.Performance.Sheet.Sheet | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
rowData: null
|
||||
});
|
||||
|
||||
const visible = defineModel<boolean>('visible', { default: false });
|
||||
|
||||
const loading = ref(false);
|
||||
const responseRecords = ref<Api.Performance.Sheet.ResponseRecord[]>([]);
|
||||
|
||||
async function loadData() {
|
||||
if (!props.rowData?.id) return;
|
||||
|
||||
loading.value = true;
|
||||
const { error, data } = await fetchPerformanceSheetResponseRecords(props.rowData.id);
|
||||
responseRecords.value = error || !data ? [] : data;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
watch(visible, isVisible => {
|
||||
if (isVisible) {
|
||||
loadData();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
title="下属反馈历史"
|
||||
preset="lg"
|
||||
append-to-body
|
||||
:show-footer="false"
|
||||
:loading="loading"
|
||||
>
|
||||
<ElTable border :data="responseRecords">
|
||||
<ElTableColumn prop="roundNo" label="轮次" width="80" />
|
||||
<ElTableColumn label="动作" width="110">
|
||||
<template #default="{ row }">{{ getPerformanceActionLabel(row.actionType) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="responderName" label="反馈人" width="120" />
|
||||
<ElTableColumn prop="opinion" label="反馈意见" min-width="240" show-overflow-tooltip />
|
||||
<ElTableColumn label="时间" width="160">
|
||||
<template #default="{ row }">{{ formatDateTime(row.createTime) }}</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { SearchField } from '@/components/custom/table-search-fields.vue';
|
||||
import TableSearchFields from '@/components/custom/table-search-fields.vue';
|
||||
|
||||
defineOptions({ name: 'PerformanceSearch' });
|
||||
|
||||
interface Option {
|
||||
label: string;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
teamMode?: boolean;
|
||||
subordinateOptions?: Option[];
|
||||
deptOptions?: Option[];
|
||||
statusOptions?: Option[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
teamMode: false,
|
||||
subordinateOptions: () => [],
|
||||
deptOptions: () => [],
|
||||
statusOptions: () => []
|
||||
});
|
||||
|
||||
const model = defineModel<Api.Performance.Sheet.SearchParams>('model', { required: true });
|
||||
|
||||
const emit = defineEmits<{
|
||||
reset: [];
|
||||
search: [];
|
||||
}>();
|
||||
|
||||
const baseFields = computed<SearchField[]>(() => [
|
||||
{
|
||||
key: 'periodMonthRange',
|
||||
label: '绩效月份',
|
||||
type: 'dateRange',
|
||||
dateRangeType: 'monthrange',
|
||||
valueFormat: 'YYYY-MM-DD',
|
||||
placeholder: '选择月份区间'
|
||||
},
|
||||
{ key: 'statusCode', label: '状态', type: 'select', placeholder: '请选择状态', options: props.statusOptions }
|
||||
]);
|
||||
|
||||
const teamFields = computed<SearchField[]>(() => [
|
||||
baseFields.value[0],
|
||||
{
|
||||
key: 'employeeId',
|
||||
label: '下属',
|
||||
type: 'select',
|
||||
placeholder: '请选择下属',
|
||||
options: props.subordinateOptions,
|
||||
filterable: true
|
||||
},
|
||||
{ key: 'employeeDeptId', label: '部门', type: 'select', placeholder: '请选择部门', options: props.deptOptions },
|
||||
{ key: 'managerName', label: '直属上级', type: 'input', placeholder: '请输入直属上级' },
|
||||
baseFields.value[1]
|
||||
]);
|
||||
|
||||
const fields = computed<SearchField[]>(() => (props.teamMode ? teamFields.value : baseFields.value));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableSearchFields v-model="model" :fields="fields" :columns="4" @reset="emit('reset')" @search="emit('search')" />
|
||||
</template>
|
||||
@@ -0,0 +1,131 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { getStatusTagType } from '@/constants/status-tag';
|
||||
|
||||
export interface PerformanceCreateDraft {
|
||||
periodMonth: string;
|
||||
employeeId: string;
|
||||
employeeName: string;
|
||||
}
|
||||
|
||||
export const PerformancePermission = {
|
||||
TemplateQuery: 'project:performance-template:query',
|
||||
TemplateUpdate: 'project:performance-template:update',
|
||||
SheetQuery: 'project:performance-sheet:query',
|
||||
SheetCreate: 'project:performance-sheet:create',
|
||||
SheetUpdate: 'project:performance-sheet:update',
|
||||
SheetDelete: 'project:performance-sheet:delete',
|
||||
SheetConfirm: 'project:performance-sheet:confirm',
|
||||
SheetReject: 'project:performance-sheet:reject',
|
||||
SheetExport: 'project:performance-sheet:export',
|
||||
TeamDashboard: 'project:performance-sheet:team-dashboard'
|
||||
} as const;
|
||||
|
||||
export const performanceStatusOptions: Array<{
|
||||
label: string;
|
||||
value: Api.Performance.Common.SheetStatusCode;
|
||||
}> = [
|
||||
{ label: '待发送', value: 'draft' },
|
||||
{ label: '待确认', value: 'sent' },
|
||||
{ label: '已确认', value: 'confirmed' },
|
||||
{ label: '已退回', value: 'rejected' }
|
||||
];
|
||||
|
||||
export function getPerformanceStatusOptions(
|
||||
statusList: Api.Performance.Sheet.StatusDict[] = []
|
||||
): Array<{ label: string; value: string }> {
|
||||
if (!statusList.length) {
|
||||
return performanceStatusOptions.map(item => ({ ...item, value: String(item.value) }));
|
||||
}
|
||||
|
||||
return statusList.map(item => ({
|
||||
label: item.statusName,
|
||||
value: String(item.statusCode)
|
||||
}));
|
||||
}
|
||||
|
||||
export const performanceActionNameMap: Record<string, string> = {
|
||||
send: '发送',
|
||||
resend: '重新发送',
|
||||
confirm: '确认',
|
||||
reject: '退回',
|
||||
delete: '删除'
|
||||
};
|
||||
|
||||
export function getPerformanceStatusLabel(statusCode?: string | null, statusName?: string | null) {
|
||||
return statusName || performanceStatusOptions.find(item => item.value === statusCode)?.label || statusCode || '--';
|
||||
}
|
||||
|
||||
export function resolvePerformanceStatusTagType(statusCode?: string | null) {
|
||||
return getStatusTagType('performanceSheet', statusCode);
|
||||
}
|
||||
|
||||
export function getPerformanceActionLabel(actionCode?: string | null) {
|
||||
if (!actionCode) return '--';
|
||||
|
||||
return performanceActionNameMap[actionCode] || actionCode;
|
||||
}
|
||||
|
||||
export function formatDateTime(value?: string | null) {
|
||||
if (!value) return '--';
|
||||
|
||||
const target = dayjs(value);
|
||||
|
||||
return target.isValid() ? target.format('YYYY-MM-DD HH:mm') : value;
|
||||
}
|
||||
|
||||
export function formatDate(value?: string | null) {
|
||||
if (!value) return '--';
|
||||
|
||||
const target = dayjs(value);
|
||||
|
||||
return target.isValid() ? target.format('YYYY-MM-DD') : value;
|
||||
}
|
||||
|
||||
export function formatScore(value?: string | number | null) {
|
||||
if (value === null || value === undefined || value === '') return '--';
|
||||
|
||||
const numberValue = Number(value);
|
||||
|
||||
return Number.isFinite(numberValue) ? numberValue.toFixed(2) : String(value);
|
||||
}
|
||||
|
||||
export function normalizeScoreText(value: unknown) {
|
||||
const text = String(value ?? '').trim();
|
||||
if (!text) return '';
|
||||
|
||||
const normalized = text.replace(/,/g, '');
|
||||
const numberValue = Number(normalized);
|
||||
|
||||
return Number.isFinite(numberValue) ? numberValue.toFixed(2) : text;
|
||||
}
|
||||
|
||||
export function downloadBlob(blob: Blob, fileName: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
link.click();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function getSheetExportName(row: Api.Performance.Sheet.Sheet) {
|
||||
return row.fileName || `${row.periodMonth}月-绩效表_${row.employeeName}.xlsx`;
|
||||
}
|
||||
|
||||
export function createDefaultPeriodMonth() {
|
||||
return dayjs().format('YYYY-MM');
|
||||
}
|
||||
|
||||
export function getDeptOrgTypeLabel(value?: string | null) {
|
||||
const map: Record<string, string> = {
|
||||
direction: '方向',
|
||||
function: '职能',
|
||||
dept: '部门',
|
||||
team: '团队',
|
||||
company: '公司'
|
||||
};
|
||||
|
||||
return map[value || ''] || value || '--';
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { remindTeamPerformance } from '@/service/api';
|
||||
import { formatDateTime, formatScore, getDeptOrgTypeLabel, getPerformanceStatusLabel } from './performance-shared';
|
||||
|
||||
defineOptions({ name: 'PerformanceSummary' });
|
||||
|
||||
interface Props {
|
||||
periodMonthStart: string;
|
||||
periodMonthEnd: string;
|
||||
loading?: boolean;
|
||||
summary?: Api.Performance.Team.Summary | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
summary: null
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
reminded: [];
|
||||
}>();
|
||||
|
||||
const remindingKey = ref('');
|
||||
|
||||
const deptOrgAverageCount = computed(() => props.summary?.deptOrgAverages?.length ?? 0);
|
||||
|
||||
const periodLabel = computed(() => {
|
||||
const start = props.periodMonthStart;
|
||||
const end = props.periodMonthEnd;
|
||||
if (!start) return '';
|
||||
if (!end || start === end) return start;
|
||||
return `${start} 至 ${end}`;
|
||||
});
|
||||
|
||||
const cards = computed(() => [
|
||||
{ label: '应有总数', value: props.summary?.expectedSheetCount ?? 0 },
|
||||
{ label: '待发送数', value: props.summary?.pendingSendCount ?? 0, key: 'pending_send' as const },
|
||||
{ label: '待确认数', value: props.summary?.pendingConfirmCount ?? 0, key: 'pending_confirm' as const },
|
||||
{ label: '已确认率', value: `${props.summary?.confirmedRate ?? '0.00'}%` },
|
||||
{ label: '各方向绩效平均分', value: deptOrgAverageCount.value, key: 'dept_org_average' as const }
|
||||
]);
|
||||
|
||||
function getPendingSendStatusText(user: Api.Performance.Team.PendingSendUser) {
|
||||
if (!user.sheetId || !user.statusCode) {
|
||||
return '未创建绩效表';
|
||||
}
|
||||
|
||||
return getPerformanceStatusLabel(user.statusCode);
|
||||
}
|
||||
|
||||
async function handleRemind(type: Api.Performance.Common.RemindType, userIds?: string[]) {
|
||||
const key = userIds?.length === 1 ? `${type}:${userIds[0]}` : `${type}:all`;
|
||||
remindingKey.value = key;
|
||||
|
||||
const { error, data } = await remindTeamPerformance({
|
||||
periodMonthStart: props.periodMonthStart,
|
||||
periodMonthEnd: props.periodMonthEnd,
|
||||
remindType: type,
|
||||
userIds
|
||||
});
|
||||
|
||||
remindingKey.value = '';
|
||||
|
||||
if (error) return;
|
||||
|
||||
window.$message?.success(`已催办 ${data?.remindedCount ?? 0} 人`);
|
||||
emit('reminded');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="props.loading" class="performance-summary">
|
||||
<div v-if="periodLabel" class="performance-summary__period">{{ periodLabel }}</div>
|
||||
<div class="performance-summary__grid">
|
||||
<div v-for="card in cards" :key="card.label" class="performance-summary__item">
|
||||
<div class="performance-summary__label">{{ card.label }}</div>
|
||||
<div class="performance-summary__value">
|
||||
<template v-if="card.key === 'pending_send'">
|
||||
<ElPopover placement="bottom" :width="360" trigger="hover">
|
||||
<template #reference>
|
||||
<button type="button" class="performance-summary__link-button">{{ card.value }}</button>
|
||||
</template>
|
||||
|
||||
<div class="performance-summary__popover">
|
||||
<div class="performance-summary__popover-title">待发送人员</div>
|
||||
<div v-if="props.summary?.pendingSendUsers?.length" class="performance-summary__user-list">
|
||||
<div
|
||||
v-for="user in props.summary.pendingSendUsers"
|
||||
:key="`${user.userId}-${user.periodMonth}`"
|
||||
class="performance-summary__user-item"
|
||||
>
|
||||
<div class="performance-summary__user-main">
|
||||
<span>{{ user.userNickname }}</span>
|
||||
<small>
|
||||
{{ user.periodMonth }} · {{ getPendingSendStatusText(user) }},提醒
|
||||
{{ user.managerName || '直属上级' }}
|
||||
</small>
|
||||
</div>
|
||||
<ElButton
|
||||
link
|
||||
type="primary"
|
||||
:loading="remindingKey === `pending_send:${user.userId}`"
|
||||
@click="handleRemind('pending_send', [user.userId])"
|
||||
>
|
||||
催办
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
<ElEmpty v-else :image-size="60" description="暂无待发送人员" />
|
||||
|
||||
<div class="performance-summary__popover-footer">
|
||||
<ElButton
|
||||
size="small"
|
||||
type="primary"
|
||||
plain
|
||||
:loading="remindingKey === 'pending_send:all'"
|
||||
:disabled="!props.summary?.pendingSendUsers?.length"
|
||||
@click="handleRemind('pending_send')"
|
||||
>
|
||||
一键催办全部
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</ElPopover>
|
||||
</template>
|
||||
|
||||
<template v-else-if="card.key === 'pending_confirm'">
|
||||
<ElPopover placement="bottom" :width="340" trigger="hover">
|
||||
<template #reference>
|
||||
<button type="button" class="performance-summary__link-button">{{ card.value }}</button>
|
||||
</template>
|
||||
|
||||
<div class="performance-summary__popover">
|
||||
<div class="performance-summary__popover-title">待确认人员</div>
|
||||
<div v-if="props.summary?.pendingConfirmUsers?.length" class="performance-summary__user-list">
|
||||
<div
|
||||
v-for="user in props.summary.pendingConfirmUsers"
|
||||
:key="`${user.userId}-${user.periodMonth}`"
|
||||
class="performance-summary__user-item"
|
||||
>
|
||||
<div class="performance-summary__user-main">
|
||||
<span>{{ user.userNickname }}</span>
|
||||
<small>{{ user.periodMonth }} · 发送时间:{{ formatDateTime(user.sentTime) }}</small>
|
||||
</div>
|
||||
<ElButton
|
||||
link
|
||||
type="primary"
|
||||
:loading="remindingKey === `pending_confirm:${user.userId}`"
|
||||
@click="handleRemind('pending_confirm', [user.userId])"
|
||||
>
|
||||
催办
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
<ElEmpty v-else :image-size="60" description="暂无待确认人员" />
|
||||
|
||||
<div class="performance-summary__popover-footer">
|
||||
<ElButton
|
||||
size="small"
|
||||
type="primary"
|
||||
plain
|
||||
:loading="remindingKey === 'pending_confirm:all'"
|
||||
:disabled="!props.summary?.pendingConfirmUsers?.length"
|
||||
@click="handleRemind('pending_confirm')"
|
||||
>
|
||||
一键催办全部
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</ElPopover>
|
||||
</template>
|
||||
|
||||
<template v-else-if="card.key === 'dept_org_average'">
|
||||
<ElPopover placement="bottom" :width="360" trigger="hover">
|
||||
<template #reference>
|
||||
<button type="button" class="performance-summary__link-button">{{ card.value }}</button>
|
||||
</template>
|
||||
|
||||
<div class="performance-summary__popover">
|
||||
<div class="performance-summary__popover-title">各方向绩效平均分</div>
|
||||
<div v-if="props.summary?.deptOrgAverages?.length" class="performance-summary__user-list">
|
||||
<div
|
||||
v-for="item in props.summary.deptOrgAverages"
|
||||
:key="item.deptId"
|
||||
class="performance-summary__user-item performance-summary__user-item--score"
|
||||
>
|
||||
<div class="performance-summary__user-main">
|
||||
<span>{{ item.deptName }}</span>
|
||||
<small>{{ getDeptOrgTypeLabel(item.deptOrgType) }} / {{ item.confirmedCount }} 人</small>
|
||||
</div>
|
||||
<strong class="performance-summary__score">{{ formatScore(item.averageScore) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<ElEmpty v-else :image-size="60" description="暂无方向平均分" />
|
||||
</div>
|
||||
</ElPopover>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
{{ card.value }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.performance-summary {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.performance-summary__period {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.performance-summary__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.performance-summary__item {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-blank);
|
||||
}
|
||||
|
||||
.performance-summary__label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.performance-summary__value {
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.performance-summary__link-button {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--el-color-primary);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.performance-summary__popover {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.performance-summary__popover-title {
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.performance-summary__user-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.performance-summary__user-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.performance-summary__user-item--score {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.performance-summary__user-main {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.performance-summary__user-main span {
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.performance-summary__user-main small {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.performance-summary__popover-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.performance-summary__score {
|
||||
color: var(--el-color-primary);
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (width <= 1400px) {
|
||||
.performance-summary__grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 900px) {
|
||||
.performance-summary__grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
.performance-summary__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,513 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import type { UploadFile, UploadFiles } from 'element-plus';
|
||||
import { ElButton, ElTag } from 'element-plus';
|
||||
import {
|
||||
activatePerformanceTemplate,
|
||||
fetchPerformanceTemplatePage,
|
||||
uploadFile,
|
||||
uploadPerformanceTemplate
|
||||
} from '@/service/api';
|
||||
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||
import { useAuth } from '@/hooks/business/auth';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
|
||||
import { PerformancePermission, formatDateTime } from './performance-shared';
|
||||
|
||||
defineOptions({ name: 'PerformanceTemplateDialog' });
|
||||
|
||||
const visible = defineModel<boolean>('visible', { default: false });
|
||||
|
||||
const emit = defineEmits<{
|
||||
updated: [];
|
||||
}>();
|
||||
|
||||
type TemplatePageResponse = Awaited<ReturnType<typeof fetchPerformanceTemplatePage>>;
|
||||
const { hasAuth } = useAuth();
|
||||
const canQueryTemplate = computed(() => hasAuth(PerformancePermission.TemplateQuery));
|
||||
const canUpdateTemplate = computed(() => hasAuth(PerformancePermission.TemplateUpdate));
|
||||
|
||||
const searchParams = reactive<Api.Performance.Template.SearchParams>({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
templateName: undefined,
|
||||
activeFlag: undefined
|
||||
});
|
||||
|
||||
const uploadForm = reactive({
|
||||
templateName: '',
|
||||
remark: '',
|
||||
activeFlag: true,
|
||||
file: null as File | null
|
||||
});
|
||||
|
||||
const uploading = ref(false);
|
||||
const activatingId = ref('');
|
||||
|
||||
const { columns, columnChecks, data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
|
||||
TemplatePageResponse,
|
||||
Api.Performance.Template.Template
|
||||
>({
|
||||
paginationProps: {
|
||||
currentPage: searchParams.pageNo,
|
||||
pageSize: searchParams.pageSize
|
||||
},
|
||||
api: () => fetchPerformanceTemplatePage(searchParams),
|
||||
transform: response => {
|
||||
if (!response.error && response.data) {
|
||||
return {
|
||||
data: response.data.list,
|
||||
pageNum: searchParams.pageNo ?? 1,
|
||||
pageSize: searchParams.pageSize ?? 10,
|
||||
total: response.data.total
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: [],
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
};
|
||||
},
|
||||
onPaginationParamsChange: params => {
|
||||
searchParams.pageNo = params.currentPage ?? 1;
|
||||
searchParams.pageSize = params.pageSize ?? 10;
|
||||
},
|
||||
columns: () => [
|
||||
{ prop: 'index', type: 'index', label: '序号', width: 64 },
|
||||
{ prop: 'templateName', label: '模板名称', minWidth: 170, showOverflowTooltip: true },
|
||||
// { prop: 'fileName', label: '文件名', minWidth: 200, showOverflowTooltip: true },
|
||||
// { prop: 'versionNo', label: '版本', width: 80 },
|
||||
{
|
||||
prop: 'activeFlag',
|
||||
label: '状态',
|
||||
width: 100,
|
||||
formatter: row => <ElTag type={row.activeFlag ? 'success' : 'info'}>{row.activeFlag ? '已启用' : '已禁用'}</ElTag>
|
||||
},
|
||||
{ prop: 'uploadUserName', label: '上传人', width: 110 },
|
||||
{
|
||||
prop: 'uploadTime',
|
||||
label: '上传时间',
|
||||
width: 180,
|
||||
formatter: row => formatDateTime(row.uploadTime)
|
||||
},
|
||||
{
|
||||
prop: 'operate',
|
||||
label: '操作',
|
||||
width: 110,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
formatter: row => <BusinessTableActionCell actions={getTemplateActions(row)} />
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const selectedFileName = computed(() => uploadForm.file?.name || '');
|
||||
|
||||
async function loadTemplatePage(page = 1) {
|
||||
if (!canQueryTemplate.value) return;
|
||||
await getDataByPage(page);
|
||||
}
|
||||
|
||||
function getTemplateActions(row: Api.Performance.Template.Template): BusinessTableAction[] {
|
||||
if (!canUpdateTemplate.value) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'activate',
|
||||
label: row.activeFlag ? '已启用' : '启用',
|
||||
buttonType: 'primary',
|
||||
disabled: row.activeFlag || Boolean(activatingId.value),
|
||||
onClick: () => handleActivate(row)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function handleFileChange(file: UploadFile, _files: UploadFiles) {
|
||||
const rawFile = file.raw;
|
||||
|
||||
if (!rawFile) return;
|
||||
|
||||
uploadForm.file = rawFile;
|
||||
if (!uploadForm.templateName) {
|
||||
uploadForm.templateName = rawFile.name.replace(/\.[^.]+$/u, '');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUploadTemplate() {
|
||||
if (!canUpdateTemplate.value) return;
|
||||
|
||||
if (!uploadForm.file) {
|
||||
window.$message?.warning('请选择 Excel 模板文件');
|
||||
return;
|
||||
}
|
||||
if (!uploadForm.templateName.trim()) {
|
||||
window.$message?.warning('请输入模板名称');
|
||||
return;
|
||||
}
|
||||
|
||||
uploading.value = true;
|
||||
const fileResult = await uploadFile(uploadForm.file, 'performance/templates');
|
||||
if (fileResult.error || !fileResult.data) {
|
||||
uploading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await uploadPerformanceTemplate({
|
||||
templateName: uploadForm.templateName.trim(),
|
||||
fileId: fileResult.data.id,
|
||||
fileName: uploadForm.file.name,
|
||||
activeFlag: uploadForm.activeFlag,
|
||||
remark: uploadForm.remark.trim() || undefined
|
||||
});
|
||||
uploading.value = false;
|
||||
|
||||
if (result.error) return;
|
||||
|
||||
window.$message?.success('绩效模板已上传');
|
||||
Object.assign(uploadForm, {
|
||||
templateName: '',
|
||||
remark: '',
|
||||
activeFlag: true,
|
||||
file: null
|
||||
});
|
||||
await loadTemplatePage(1);
|
||||
emit('updated');
|
||||
}
|
||||
|
||||
async function handleActivate(row: Api.Performance.Template.Template) {
|
||||
if (!canUpdateTemplate.value) return;
|
||||
|
||||
activatingId.value = row.id;
|
||||
const { error } = await activatePerformanceTemplate(row.id);
|
||||
activatingId.value = '';
|
||||
|
||||
if (error) return;
|
||||
|
||||
window.$message?.success('绩效模板已启用');
|
||||
await loadTemplatePage(searchParams.pageNo ?? 1);
|
||||
emit('updated');
|
||||
}
|
||||
|
||||
watch(visible, isVisible => {
|
||||
if (isVisible && canQueryTemplate.value) {
|
||||
loadTemplatePage(1);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
title="绩效模板"
|
||||
preset="lg"
|
||||
append-to-body
|
||||
:show-footer="false"
|
||||
max-body-height="76vh"
|
||||
>
|
||||
<div class="performance-template-dialog">
|
||||
<ElCard v-if="canUpdateTemplate" shadow="never">
|
||||
<ElForm :model="uploadForm" label-position="top" class="performance-template-dialog__upload-form">
|
||||
<div class="performance-template-dialog__upload-grid">
|
||||
<ElFormItem label="模板名称" class="performance-template-dialog__field">
|
||||
<ElInput v-model="uploadForm.templateName" placeholder="请输入模板名称" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem class="performance-template-dialog__field">
|
||||
<template #label>
|
||||
<div class="performance-template-dialog__label">
|
||||
<span>Excel 文件</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="performance-template-dialog__file-row">
|
||||
<ElUpload
|
||||
class="performance-template-dialog__upload-trigger"
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
accept=".xlsx,.xls"
|
||||
:limit="1"
|
||||
:on-change="handleFileChange"
|
||||
>
|
||||
<ElButton plain class="performance-template-dialog__upload-button">
|
||||
<template #icon>
|
||||
<icon-mdi-upload class="text-icon" />
|
||||
</template>
|
||||
选择文件
|
||||
</ElButton>
|
||||
</ElUpload>
|
||||
<div class="performance-template-dialog__file-name-wrapper">
|
||||
<ElTooltip
|
||||
:disabled="!selectedFileName"
|
||||
:content="selectedFileName"
|
||||
placement="top"
|
||||
effect="light"
|
||||
popper-class="performance-template-dialog__file-tooltip"
|
||||
>
|
||||
<div
|
||||
class="performance-template-dialog__file-name"
|
||||
:class="{ 'performance-template-dialog__file-name--placeholder': !selectedFileName }"
|
||||
>
|
||||
<span class="performance-template-dialog__file-name-text">
|
||||
{{ selectedFileName || '未选择文件' }}
|
||||
</span>
|
||||
</div>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
<ElTooltip placement="top" effect="light">
|
||||
<template #content>仅支持 .xlsx格式,选择后会在这里显示文件名</template>
|
||||
<button type="button" class="performance-template-dialog__hint-button" aria-label="Excel 文件说明">
|
||||
<icon-mdi-information-outline />
|
||||
</button>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem
|
||||
label="上传后启用"
|
||||
class="performance-template-dialog__field performance-template-dialog__switch-field"
|
||||
>
|
||||
<div class="performance-template-dialog__switch-box">
|
||||
<span>上传后立即切换为当前模板</span>
|
||||
<ElSwitch v-model="uploadForm.activeFlag" />
|
||||
</div>
|
||||
</ElFormItem>
|
||||
|
||||
<!-- <ElFormItem-->
|
||||
<!-- label="备注"-->
|
||||
<!-- class="performance-template-dialog__field performance-template-dialog__field--full"-->
|
||||
<!-- >-->
|
||||
<!-- <ElInput v-model="uploadForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit />-->
|
||||
<!-- </ElFormItem>-->
|
||||
</div>
|
||||
|
||||
<div class="performance-template-dialog__actions">
|
||||
<ElButton type="primary" :loading="uploading" @click="handleUploadTemplate">上传模板</ElButton>
|
||||
</div>
|
||||
</ElForm>
|
||||
</ElCard>
|
||||
|
||||
<ElCard v-if="canQueryTemplate" shadow="never" body-class="business-table-card-body">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-12px">
|
||||
<p class="text-16px font-600">模板列表</p>
|
||||
<ElSpace wrap alignment="center">
|
||||
<ElButton @click="loadTemplatePage(searchParams.pageNo ?? 1)">
|
||||
<template #icon>
|
||||
<icon-mdi-refresh class="text-icon" :class="{ 'animate-spin': loading }" />
|
||||
</template>
|
||||
刷新
|
||||
</ElButton>
|
||||
<TableColumnSetting v-model:columns="columnChecks" />
|
||||
</ElSpace>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="performance-template-dialog__table">
|
||||
<ElTable v-loading="loading" height="100%" border :data="data">
|
||||
<template v-for="col in columns" :key="String(col.prop)">
|
||||
<ElTableColumn v-bind="col" />
|
||||
</template>
|
||||
</ElTable>
|
||||
</div>
|
||||
|
||||
<div class="mt-16px 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>
|
||||
|
||||
<ElEmpty v-if="!canQueryTemplate && !canUpdateTemplate" :image-size="80" description="当前账号没有绩效模板权限" />
|
||||
</div>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.performance-template-dialog {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.performance-template-dialog__upload-form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.performance-template-dialog__upload-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.performance-template-dialog__field {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.performance-template-dialog__field :deep(.el-form-item__label) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 22px;
|
||||
padding-bottom: 6px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.performance-template-dialog__field :deep(.el-form-item__content) {
|
||||
min-height: 36px;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.performance-template-dialog__field--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.performance-template-dialog__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.performance-template-dialog__file-row {
|
||||
display: grid;
|
||||
grid-template-columns: 112px minmax(0, 1fr) 24px;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
min-height: 36px;
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
.performance-template-dialog__upload-trigger {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.performance-template-dialog__upload-trigger :deep(.el-upload) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.performance-template-dialog__upload-button {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.performance-template-dialog__file-name-wrapper {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.performance-template-dialog__file-name-wrapper :deep(.el-tooltip__trigger) {
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
.performance-template-dialog__file-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
width: 100% !important;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-blank);
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.performance-template-dialog__file-name-text {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.performance-template-dialog__file-name--placeholder {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.performance-template-dialog__hint-button {
|
||||
width: 24px;
|
||||
height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--el-text-color-secondary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
color 0.2s ease;
|
||||
}
|
||||
|
||||
.performance-template-dialog__hint-button:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.performance-template-dialog__switch-field {
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.performance-template-dialog__switch-box {
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-blank);
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.performance-template-dialog__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.performance-template-dialog__table {
|
||||
height: 360px;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.performance-template-dialog__upload-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.performance-template-dialog__switch-field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.performance-template-dialog__upload-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.performance-template-dialog__field--full,
|
||||
.performance-template-dialog__switch-field {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.performance-template-dialog__switch-box {
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,24 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, markRaw, reactive, ref } from 'vue';
|
||||
import { computed, markRaw, reactive, ref, watch } from 'vue';
|
||||
import { ElButton, ElTag } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import { fetchExportOvertimeApplications, fetchGetOvertimeApplicationPage } from '@/service/api';
|
||||
import {
|
||||
fetchExportOvertimeApplications,
|
||||
fetchGetMySubordinateTree,
|
||||
fetchGetOvertimeApplicationPage,
|
||||
fetchGetTeamOvertimeSummary
|
||||
} from '@/service/api';
|
||||
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||
import { useAuth } from '@/hooks/business/auth';
|
||||
import SubordinateSelector from '@/components/custom/subordinate-selector.vue';
|
||||
import TeamContextPanel from '@/components/custom/team-context-panel.vue';
|
||||
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
|
||||
import {
|
||||
type TeamViewContext,
|
||||
type TeamViewMode,
|
||||
collectSubordinateUserIds,
|
||||
findSubordinateNode
|
||||
} from '../shared/team-dashboard';
|
||||
import OvertimeApplicationApprovalRecordDialog from './modules/overtime-application-approval-record-dialog.vue';
|
||||
import OvertimeApplicationDetailDialog from './modules/overtime-application-detail-dialog.vue';
|
||||
import OvertimeApplicationOperateDialog from './modules/overtime-application-operate-dialog.vue';
|
||||
@@ -20,6 +34,7 @@ import {
|
||||
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
|
||||
import IconMdiFileDocumentCheckOutline from '~icons/mdi/file-document-check-outline';
|
||||
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
|
||||
import IconMdiDownloadOutline from '~icons/mdi/download-outline';
|
||||
|
||||
defineOptions({ name: 'OvertimeApplication' });
|
||||
|
||||
@@ -30,6 +45,7 @@ function getInitSearchParams(): Api.OvertimeApplication.OvertimeApplicationSearc
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
keyword: undefined,
|
||||
applicantIds: undefined,
|
||||
applicantName: undefined,
|
||||
approverId: undefined,
|
||||
approverName: undefined,
|
||||
@@ -58,6 +74,13 @@ function transformPageResult(response: OvertimeApplicationPageResponse, pageNo:
|
||||
}
|
||||
|
||||
const searchParams = reactive(getInitSearchParams());
|
||||
const { hasAuth } = useAuth();
|
||||
const teamViewMode = ref<TeamViewMode>('self');
|
||||
const subordinateTreeLoading = ref(false);
|
||||
const subordinateTree = ref<Api.SystemManage.MySubordinateTreeNode | null>(null);
|
||||
const selectedSubordinateUserId = ref<string | null>(null);
|
||||
const teamSummaryLoading = ref(false);
|
||||
const teamSummary = ref<Api.OvertimeApplication.TeamOvertimeSummary | null>(null);
|
||||
const operateVisible = ref(false);
|
||||
const detailVisible = ref(false);
|
||||
const approvalRecordVisible = ref(false);
|
||||
@@ -71,7 +94,70 @@ const ACTION_ICON_MAP = {
|
||||
edit: markRaw(IconMdiPencilOutline)
|
||||
};
|
||||
|
||||
const { columns, columnChecks, data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
|
||||
const canUseTeamDashboard = computed(() => hasAuth('project:overtime-application:team-dashboard'));
|
||||
const allSubordinateUserIds = computed(() => collectSubordinateUserIds(subordinateTree.value));
|
||||
const subordinateOptions = computed(() => {
|
||||
const options: Array<{ label: string; value: string }> = [];
|
||||
|
||||
const walk = (nodes?: Api.SystemManage.MySubordinateTreeNode[] | null) => {
|
||||
nodes?.forEach(node => {
|
||||
options.push({ label: node.userNickname, value: node.userId });
|
||||
walk(node.children ?? null);
|
||||
});
|
||||
};
|
||||
|
||||
walk(subordinateTree.value?.children ?? null);
|
||||
return options;
|
||||
});
|
||||
const selectedSubordinateNode = computed(() =>
|
||||
findSubordinateNode(subordinateTree.value, selectedSubordinateUserId.value)
|
||||
);
|
||||
const isTeamMode = computed(() => teamViewMode.value === 'team');
|
||||
const isRootSelected = computed(() => Boolean(isTeamMode.value && selectedSubordinateNode.value?.isRoot));
|
||||
const summaryPeriodLabel = computed(() => {
|
||||
if (teamSummary.value?.overtimeDateStart && teamSummary.value?.overtimeDateEnd) {
|
||||
return `${teamSummary.value.overtimeDateStart} 至 ${teamSummary.value.overtimeDateEnd}`;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
const selectedTeamLabel = computed(() => {
|
||||
if (!isTeamMode.value) return '我自己';
|
||||
if (!selectedSubordinateNode.value) return '--';
|
||||
return selectedSubordinateNode.value.isRoot ? '全部下属' : selectedSubordinateNode.value.userNickname;
|
||||
});
|
||||
const teamContext = computed<TeamViewContext | null>(() => {
|
||||
if (!canUseTeamDashboard.value) return null;
|
||||
|
||||
return {
|
||||
mode: teamViewMode.value,
|
||||
selectedUserId: selectedSubordinateUserId.value,
|
||||
selectedUserIds:
|
||||
isTeamMode.value && selectedSubordinateUserId.value && !isRootSelected.value
|
||||
? [selectedSubordinateUserId.value]
|
||||
: [],
|
||||
isRootSelected: isRootSelected.value,
|
||||
allSubordinateUserIds: allSubordinateUserIds.value,
|
||||
selectedLabel: selectedTeamLabel.value
|
||||
};
|
||||
});
|
||||
const currentApplicantIds = computed(() => {
|
||||
if (!isTeamMode.value) return null;
|
||||
if (isRootSelected.value) return [];
|
||||
return teamContext.value?.selectedUserIds ?? [];
|
||||
});
|
||||
const resolvedApplicantIds = computed(() => {
|
||||
if (!isTeamMode.value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (searchParams.applicantIds?.length) {
|
||||
return searchParams.applicantIds;
|
||||
}
|
||||
|
||||
return currentApplicantIds.value;
|
||||
});
|
||||
|
||||
const { columns, columnChecks, data, loading, getDataByPage, mobilePagination, reloadColumns } = useUIPaginatedTable<
|
||||
OvertimeApplicationPageResponse,
|
||||
Api.OvertimeApplication.OvertimeApplication
|
||||
>({
|
||||
@@ -79,69 +165,83 @@ const { columns, columnChecks, data, loading, getDataByPage, mobilePagination }
|
||||
currentPage: searchParams.pageNo,
|
||||
pageSize: searchParams.pageSize
|
||||
},
|
||||
api: () => fetchGetOvertimeApplicationPage(searchParams),
|
||||
api: () =>
|
||||
fetchGetOvertimeApplicationPage({
|
||||
...searchParams,
|
||||
applicantIds: resolvedApplicantIds.value
|
||||
}),
|
||||
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: 'applicantName', label: '申请人', minWidth: 120, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'overtimeDate',
|
||||
label: '加班日期',
|
||||
width: 120,
|
||||
formatter: row => formatOvertimeDate(row.overtimeDate)
|
||||
},
|
||||
{ prop: 'overtimeDuration', label: '加班时长', width: 110, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'overtimeReason',
|
||||
label: '加班原因',
|
||||
minWidth: 180,
|
||||
className: 'overtime-application__cell-ellipsis',
|
||||
formatter: row => formatEmptyText(row.overtimeReason)
|
||||
},
|
||||
{
|
||||
prop: 'overtimeContent',
|
||||
label: '加班内容',
|
||||
minWidth: 200,
|
||||
className: 'overtime-application__cell-ellipsis',
|
||||
formatter: row => formatEmptyText(row.overtimeContent)
|
||||
},
|
||||
{
|
||||
prop: 'statusCode',
|
||||
label: '状态',
|
||||
width: 110,
|
||||
align: 'center',
|
||||
formatter: row => (
|
||||
<ElTag type={resolveOvertimeApplicationStatusTagType(row.statusCode)}>
|
||||
{getOvertimeApplicationStatusLabel(row.statusCode, row.statusName)}
|
||||
</ElTag>
|
||||
)
|
||||
},
|
||||
{ prop: 'approverName', label: '审核人', minWidth: 80, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'submitTime',
|
||||
label: '提交时间',
|
||||
minWidth: 150,
|
||||
formatter: row => formatOvertimeDateTime(row.submitTime)
|
||||
},
|
||||
{
|
||||
prop: 'approvalTime',
|
||||
label: '审核时间',
|
||||
minWidth: 150,
|
||||
formatter: row => formatOvertimeDateTime(row.approvalTime)
|
||||
},
|
||||
{
|
||||
prop: 'operate',
|
||||
label: '操作',
|
||||
width: 170,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
|
||||
columns: () => {
|
||||
const cols: UI.TableColumn<Api.OvertimeApplication.OvertimeApplication>[] = [
|
||||
{ prop: 'index', type: 'index', label: '序号', width: 64 },
|
||||
{
|
||||
prop: 'overtimeDate',
|
||||
label: '加班日期',
|
||||
width: 120,
|
||||
formatter: row => formatOvertimeDate(row.overtimeDate)
|
||||
}
|
||||
];
|
||||
|
||||
if (isTeamMode.value) {
|
||||
cols.push({ prop: 'applicantName', label: '申请人', minWidth: 120, showOverflowTooltip: true });
|
||||
}
|
||||
]
|
||||
|
||||
cols.push(
|
||||
{ prop: 'overtimeDuration', label: '加班时长', width: 110, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'overtimeReason',
|
||||
label: '加班原因',
|
||||
minWidth: 180,
|
||||
className: 'overtime-application__cell-ellipsis',
|
||||
formatter: row => formatEmptyText(row.overtimeReason)
|
||||
},
|
||||
{
|
||||
prop: 'overtimeContent',
|
||||
label: '加班内容',
|
||||
minWidth: 200,
|
||||
className: 'overtime-application__cell-ellipsis',
|
||||
formatter: row => formatEmptyText(row.overtimeContent)
|
||||
},
|
||||
{
|
||||
prop: 'statusCode',
|
||||
label: '状态',
|
||||
width: 110,
|
||||
align: 'center',
|
||||
formatter: row => (
|
||||
<ElTag type={resolveOvertimeApplicationStatusTagType(row.statusCode)}>
|
||||
{getOvertimeApplicationStatusLabel(row.statusCode, row.statusName)}
|
||||
</ElTag>
|
||||
)
|
||||
},
|
||||
{ prop: 'approverName', label: '审批人', minWidth: 80, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'submitTime',
|
||||
label: '提交时间',
|
||||
minWidth: 150,
|
||||
formatter: row => formatOvertimeDateTime(row.submitTime)
|
||||
},
|
||||
{
|
||||
prop: 'approvalTime',
|
||||
label: '审批时间',
|
||||
minWidth: 150,
|
||||
formatter: row => formatOvertimeDateTime(row.approvalTime)
|
||||
},
|
||||
{
|
||||
prop: 'operate',
|
||||
label: '操作',
|
||||
width: isTeamMode.value ? 140 : 170,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
|
||||
}
|
||||
);
|
||||
|
||||
return cols;
|
||||
}
|
||||
});
|
||||
|
||||
const totalCount = computed(() => mobilePagination.value.total || data.value.length);
|
||||
@@ -150,13 +250,27 @@ function getRowActions(row: Api.OvertimeApplication.OvertimeApplication): Busine
|
||||
const actions: BusinessTableAction[] = [
|
||||
{
|
||||
key: 'detail',
|
||||
label: '详情',
|
||||
label: '查看',
|
||||
buttonType: 'primary',
|
||||
icon: ACTION_ICON_MAP.detail,
|
||||
onClick: () => openDetail(row)
|
||||
}
|
||||
];
|
||||
|
||||
if (isTeamMode.value) {
|
||||
if (['approved', 'rejected'].includes(row.statusCode)) {
|
||||
actions.push({
|
||||
key: 'approval-record',
|
||||
label: '审批记录',
|
||||
buttonType: 'info',
|
||||
icon: ACTION_ICON_MAP.approvalRecord,
|
||||
onClick: () => openApprovalRecord(row)
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
if (row.statusCode === 'rejected' && row.allowEdit) {
|
||||
actions.push({
|
||||
key: 'edit',
|
||||
@@ -210,10 +324,12 @@ function resetSearchParams() {
|
||||
const pageSize = searchParams.pageSize ?? 10;
|
||||
Object.assign(searchParams, getInitSearchParams(), { pageSize });
|
||||
reloadTable(1);
|
||||
loadTeamSummary();
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
reloadTable(1);
|
||||
loadTeamSummary();
|
||||
}
|
||||
|
||||
function handleSubmitted() {
|
||||
@@ -221,9 +337,20 @@ function handleSubmitted() {
|
||||
reloadTable(searchParams.pageNo ?? 1);
|
||||
}
|
||||
|
||||
function createExportParams() {
|
||||
const { pageNo: _pageNo, pageSize: _pageSize, ...params } = searchParams;
|
||||
return {
|
||||
...params,
|
||||
applicantIds: resolvedApplicantIds.value
|
||||
};
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
exporting.value = true;
|
||||
const { error, data: blob } = await fetchExportOvertimeApplications(searchParams);
|
||||
const { error, data: blob } = await fetchExportOvertimeApplications({
|
||||
...createExportParams(),
|
||||
applicantIds: resolvedApplicantIds.value
|
||||
});
|
||||
exporting.value = false;
|
||||
|
||||
if (error || !blob) {
|
||||
@@ -232,56 +359,174 @@ async function handleExport() {
|
||||
|
||||
downloadBlob(blob, `加班申请_${dayjs().format('YYYY-MM-DD')}.xls`);
|
||||
}
|
||||
|
||||
async function loadSubordinateTree() {
|
||||
if (!canUseTeamDashboard.value) return;
|
||||
|
||||
subordinateTreeLoading.value = true;
|
||||
const { error, data: treeData } = await fetchGetMySubordinateTree();
|
||||
subordinateTreeLoading.value = false;
|
||||
|
||||
subordinateTree.value = error || !treeData ? null : treeData;
|
||||
selectedSubordinateUserId.value = treeData?.userId || null;
|
||||
}
|
||||
|
||||
async function loadTeamSummary() {
|
||||
if (!isRootSelected.value) {
|
||||
teamSummary.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const summaryParams: Api.OvertimeApplication.TeamOvertimeSummaryParams = {};
|
||||
const dateRange = searchParams.overtimeDate;
|
||||
|
||||
if (dateRange?.length === 2) {
|
||||
summaryParams.overtimeDateStart = dateRange[0];
|
||||
summaryParams.overtimeDateEnd = dateRange[1];
|
||||
}
|
||||
|
||||
teamSummaryLoading.value = true;
|
||||
const { error, data: summaryData } = await fetchGetTeamOvertimeSummary(summaryParams);
|
||||
teamSummaryLoading.value = false;
|
||||
|
||||
teamSummary.value = error || !summaryData ? null : summaryData;
|
||||
}
|
||||
|
||||
async function handleTeamViewModeChange(mode: TeamViewMode) {
|
||||
teamViewMode.value = mode;
|
||||
|
||||
if (mode === 'team') {
|
||||
if (!subordinateTree.value) {
|
||||
await loadSubordinateTree();
|
||||
}
|
||||
if (!selectedSubordinateUserId.value) {
|
||||
selectedSubordinateUserId.value = subordinateTree.value?.userId || null;
|
||||
}
|
||||
}
|
||||
|
||||
await reloadTable(1);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [teamViewMode.value, selectedSubordinateUserId.value],
|
||||
async () => {
|
||||
if (!isTeamMode.value) return;
|
||||
await reloadTable(1);
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => isTeamMode.value,
|
||||
() => {
|
||||
reloadColumns();
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => isRootSelected.value,
|
||||
() => {
|
||||
loadTeamSummary();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-col-stretch gap-16px overflow-hidden">
|
||||
<OvertimeApplicationSearch 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 flex-wrap items-center justify-between gap-12px">
|
||||
<div class="flex items-center gap-10px">
|
||||
<p class="text-16px font-600">加班申请</p>
|
||||
<ElTag effect="plain">{{ totalCount }}</ElTag>
|
||||
<div class="overtime-application-page">
|
||||
<TeamContextPanel
|
||||
v-if="canUseTeamDashboard"
|
||||
v-model:mode="teamViewMode"
|
||||
:loading="subordinateTreeLoading"
|
||||
:selected-label="selectedTeamLabel"
|
||||
:subordinate-count="subordinateTree?.subordinateCount || 0"
|
||||
@update:mode="handleTeamViewModeChange"
|
||||
>
|
||||
<div v-if="isRootSelected" v-loading="teamSummaryLoading" class="team-overtime-summary">
|
||||
<div v-if="summaryPeriodLabel" class="team-overtime-summary__period">{{ summaryPeriodLabel }}</div>
|
||||
<div class="team-overtime-summary__grid">
|
||||
<div class="team-overtime-summary__item">
|
||||
<span class="team-overtime-summary__label">申请单数</span>
|
||||
<strong class="team-overtime-summary__value">{{ teamSummary?.totalApplicationCount ?? 0 }}</strong>
|
||||
</div>
|
||||
<div class="team-overtime-summary__item">
|
||||
<span class="team-overtime-summary__label">待审批</span>
|
||||
<strong class="team-overtime-summary__value">{{ teamSummary?.pendingCount ?? 0 }}</strong>
|
||||
</div>
|
||||
<div class="team-overtime-summary__item">
|
||||
<span class="team-overtime-summary__label">已通过</span>
|
||||
<strong class="team-overtime-summary__value">{{ teamSummary?.approvedCount ?? 0 }}</strong>
|
||||
</div>
|
||||
<div class="team-overtime-summary__item">
|
||||
<span class="team-overtime-summary__label">已退回</span>
|
||||
<strong class="team-overtime-summary__value">{{ teamSummary?.rejectedCount ?? 0 }}</strong>
|
||||
</div>
|
||||
<TableHeaderOperation v-model:columns="columnChecks" :loading="loading" @refresh="reloadTable">
|
||||
<template #default>
|
||||
<ElButton plain :loading="exporting" @click="handleExport">
|
||||
<template #icon>
|
||||
<icon-mdi-download class="text-icon" />
|
||||
</template>
|
||||
导出
|
||||
</ElButton>
|
||||
<ElButton plain type="primary" @click="openAdd">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus 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">
|
||||
<template v-for="col in columns" :key="String(col.prop)">
|
||||
<ElTableColumn v-bind="col" />
|
||||
</template>
|
||||
</ElTable>
|
||||
</div>
|
||||
</TeamContextPanel>
|
||||
|
||||
<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 class="overtime-application-page__content" :class="{ 'overtime-application-page__content--team': isTeamMode }">
|
||||
<div v-if="canUseTeamDashboard && isTeamMode" class="overtime-application-page__sidebar">
|
||||
<SubordinateSelector
|
||||
v-model:selected-user-id="selectedSubordinateUserId"
|
||||
:loading="subordinateTreeLoading"
|
||||
:data="subordinateTree"
|
||||
/>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<div class="overtime-application-page__main">
|
||||
<OvertimeApplicationSearch
|
||||
v-model:model="searchParams"
|
||||
:team-mode="isTeamMode"
|
||||
:subordinate-options="subordinateOptions"
|
||||
@reset="resetSearchParams"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
|
||||
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">
|
||||
<template #header>
|
||||
<div class="flex flex-wrap items-center justify-between gap-12px">
|
||||
<div class="flex items-center gap-10px">
|
||||
<p class="text-16px font-600">加班申请</p>
|
||||
<ElTag effect="plain">{{ totalCount }}</ElTag>
|
||||
</div>
|
||||
<TableHeaderOperation v-model:columns="columnChecks" :loading="loading" @refresh="reloadTable">
|
||||
<template #default>
|
||||
<ElButton plain :loading="exporting" @click="handleExport">
|
||||
<template #icon>
|
||||
<icon-mdi-download class="text-icon" />
|
||||
</template>
|
||||
导出
|
||||
</ElButton>
|
||||
<ElButton v-if="!isTeamMode" plain type="primary" @click="openAdd">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus 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">
|
||||
<template v-for="col in columns" :key="String(col.prop)">
|
||||
<ElTableColumn v-bind="col" />
|
||||
</template>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<OvertimeApplicationOperateDialog
|
||||
v-model:visible="operateVisible"
|
||||
@@ -297,6 +542,42 @@ async function handleExport() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.overtime-application-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.overtime-application-page__content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.overtime-application-page__main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.overtime-application-page__content--team {
|
||||
display: grid;
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.overtime-application-page__sidebar {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.overtime-application__reason-link) {
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
@@ -318,4 +599,41 @@ async function handleExport() {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.team-overtime-summary {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.team-overtime-summary__period {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.team-overtime-summary__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.team-overtime-summary__item {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-blank);
|
||||
}
|
||||
|
||||
.team-overtime-summary__label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.team-overtime-summary__value {
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, watch } from 'vue';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
|
||||
defineOptions({ name: 'OvertimeApplicationActionDialog' });
|
||||
|
||||
type ActionType = 'approve' | 'reject';
|
||||
|
||||
interface Props {
|
||||
actionType: ActionType;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [reason: string | null];
|
||||
}>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const model = reactive({
|
||||
reason: ''
|
||||
});
|
||||
|
||||
const title = computed(() => {
|
||||
const map: Record<ActionType, string> = {
|
||||
approve: '通过加班申请',
|
||||
reject: '退回加班申请'
|
||||
};
|
||||
|
||||
return map[props.actionType];
|
||||
});
|
||||
|
||||
const reasonLabel = computed(() => {
|
||||
const map: Record<ActionType, string> = {
|
||||
approve: '审核意见',
|
||||
reject: '退回原因'
|
||||
};
|
||||
|
||||
return map[props.actionType];
|
||||
});
|
||||
|
||||
const reasonRequired = computed(() => props.actionType === 'reject');
|
||||
|
||||
const reasonPlaceholder = computed(() => {
|
||||
if (reasonRequired.value) {
|
||||
return `请输入${reasonLabel.value}`;
|
||||
}
|
||||
|
||||
return '可填写审核意见';
|
||||
});
|
||||
|
||||
const rules = computed(() => ({
|
||||
reason: reasonRequired.value
|
||||
? [
|
||||
createRequiredRule(`请输入${reasonLabel.value}`),
|
||||
{
|
||||
validator: (_rule, value: string, callback) => {
|
||||
if (!value?.trim()) {
|
||||
callback(new Error(`请输入${reasonLabel.value}`));
|
||||
return;
|
||||
}
|
||||
|
||||
callback();
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
: []
|
||||
}));
|
||||
|
||||
async function handleSubmit() {
|
||||
await validate();
|
||||
emit('submit', model.reason.trim() || null);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
async value => {
|
||||
if (value) {
|
||||
model.reason = '';
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
preset="sm"
|
||||
:confirm-loading="props.loading"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
|
||||
<ElFormItem :label="reasonLabel" prop="reason">
|
||||
<ElInput
|
||||
v-model="model.reason"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
maxlength="1000"
|
||||
show-word-limit
|
||||
:placeholder="reasonPlaceholder"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
@@ -1,15 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||
import { fetchGetOvertimeApplicationDetail } from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import { formatOvertimeDate, formatOvertimeDateTime } from './overtime-application-shared';
|
||||
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
|
||||
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
|
||||
import IconMdiChevronLeft from '~icons/mdi/chevron-left';
|
||||
import IconMdiChevronRight from '~icons/mdi/chevron-right';
|
||||
|
||||
defineOptions({ name: 'OvertimeApplicationBatchDetailDialog' });
|
||||
|
||||
type ActionType = 'approve' | 'reject';
|
||||
|
||||
interface Props {
|
||||
/** 选中的加班申请 id 列表(原始 id) */
|
||||
selectedIds: string[];
|
||||
@@ -23,8 +24,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
approve: [];
|
||||
reject: [];
|
||||
submit: [payload: { actionType: ActionType; reason: string | null }];
|
||||
}>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
@@ -34,6 +34,13 @@ const visible = defineModel<boolean>('visible', {
|
||||
const currentIndex = ref(0);
|
||||
const detailData = ref<Api.OvertimeApplication.OvertimeApplication | null>(null);
|
||||
const detailLoading = ref(false);
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const approvalModel = reactive({
|
||||
conclusion: 'approve' as ActionType,
|
||||
opinion: ''
|
||||
});
|
||||
|
||||
const currentId = computed(() => props.selectedIds[currentIndex.value] ?? null);
|
||||
|
||||
@@ -74,12 +81,64 @@ function goNext() {
|
||||
loadDetail();
|
||||
}
|
||||
|
||||
const opinionLabel = computed(() => (approvalModel.conclusion === 'reject' ? '退回原因' : '审批意见'));
|
||||
const opinionRequired = computed(() => approvalModel.conclusion === 'reject');
|
||||
const opinionPlaceholder = computed(() => (opinionRequired.value ? `请输入${opinionLabel.value}` : '可填写审批意见'));
|
||||
|
||||
const rules = computed(() => ({
|
||||
opinion: opinionRequired.value
|
||||
? [
|
||||
createRequiredRule(`请输入${opinionLabel.value}`),
|
||||
{
|
||||
validator: (_rule, value: string, callback) => {
|
||||
if (!value?.trim()) {
|
||||
callback(new Error(`请输入${opinionLabel.value}`));
|
||||
return;
|
||||
}
|
||||
|
||||
callback();
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
: []
|
||||
}));
|
||||
|
||||
function resetApprovalForm() {
|
||||
approvalModel.conclusion = 'approve';
|
||||
approvalModel.opinion = '';
|
||||
nextTick(() => {
|
||||
formRef.value?.clearValidate();
|
||||
});
|
||||
}
|
||||
|
||||
watch(opinionRequired, async () => {
|
||||
if (!visible.value) return;
|
||||
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate('opinion');
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
await validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
emit('submit', {
|
||||
actionType: approvalModel.conclusion,
|
||||
reason: approvalModel.opinion.trim() || null
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
value => {
|
||||
if (value) {
|
||||
currentIndex.value = 0;
|
||||
loadDetail();
|
||||
resetApprovalForm();
|
||||
} else {
|
||||
detailData.value = null;
|
||||
}
|
||||
@@ -122,27 +181,75 @@ watch(
|
||||
</ElDescriptions>
|
||||
<ElEmpty v-else description="未获取到加班申请详情" />
|
||||
|
||||
<div class="batch-detail__approval-form">
|
||||
<div class="audit-field">
|
||||
<label>审批结论</label>
|
||||
<div class="audit-conclusion">
|
||||
<button
|
||||
type="button"
|
||||
class="conclusion-btn"
|
||||
:class="{
|
||||
active: approvalModel.conclusion === 'approve',
|
||||
pass: approvalModel.conclusion === 'approve'
|
||||
}"
|
||||
@click="approvalModel.conclusion = 'approve'"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5" />
|
||||
<path
|
||||
d="M5 8.5L7 10.5L11 6"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
通过
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="conclusion-btn"
|
||||
:class="{
|
||||
active: approvalModel.conclusion === 'reject',
|
||||
reject: approvalModel.conclusion === 'reject'
|
||||
}"
|
||||
@click="approvalModel.conclusion = 'reject'"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M6 6L10 10M10 6L6 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
退回
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElForm ref="formRef" :model="approvalModel" :rules="rules" label-position="top" :validate-on-rule-change="false">
|
||||
<ElFormItem :label="opinionLabel" prop="opinion">
|
||||
<ElInput
|
||||
v-model="approvalModel.opinion"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
maxlength="1000"
|
||||
show-word-limit
|
||||
:placeholder="opinionPlaceholder"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="batch-detail__footer">
|
||||
<span class="batch-detail__footer-hint">将对全部 {{ total }} 项统一执行操作</span>
|
||||
<div class="batch-detail__footer-actions">
|
||||
<ElButton @click="visible = false">取消</ElButton>
|
||||
<ElButton
|
||||
class="batch-detail__approve-btn"
|
||||
type="success"
|
||||
type="primary"
|
||||
:loading="props.actionLoading"
|
||||
:disabled="props.actionLoading || !detailData"
|
||||
@click="emit('approve')"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<template #icon>
|
||||
<IconMdiCheckCircleOutline />
|
||||
</template>
|
||||
通过
|
||||
</ElButton>
|
||||
<ElButton type="danger" plain :disabled="props.actionLoading || !detailData" @click="emit('reject')">
|
||||
<template #icon>
|
||||
<IconMdiCloseCircleOutline />
|
||||
</template>
|
||||
退回
|
||||
确认提交
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -208,13 +315,61 @@ watch(
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.batch-detail__approve-btn {
|
||||
--el-button-bg-color: #0f766e;
|
||||
--el-button-border-color: #0f766e;
|
||||
--el-button-hover-bg-color: #115e59;
|
||||
--el-button-hover-border-color: #115e59;
|
||||
--el-button-active-bg-color: #134e4a;
|
||||
--el-button-active-border-color: #134e4a;
|
||||
.batch-detail__approval-form {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.audit-field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.audit-field label {
|
||||
color: #475467;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.audit-conclusion {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.conclusion-btn {
|
||||
height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: 1px solid #d8e0e8;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #475467;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
}
|
||||
|
||||
.conclusion-btn:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.conclusion-btn.active.pass {
|
||||
border-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.conclusion-btn.active.reject {
|
||||
border-color: #dc2626;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
:deep(.overtime-application-detail-dialog__descriptions .el-descriptions__cell) {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||
import { fetchGetOvertimeApplicationDetail } from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import { formatOvertimeDate, formatOvertimeDateTime } from './overtime-application-shared';
|
||||
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
|
||||
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
|
||||
|
||||
defineOptions({ name: 'OvertimeApplicationDetailDialog' });
|
||||
|
||||
type ActionType = 'approve' | 'reject';
|
||||
|
||||
interface Props {
|
||||
rowData?: Api.OvertimeApplication.OvertimeApplication | null;
|
||||
showApprovalActions?: boolean;
|
||||
@@ -20,8 +21,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
approve: [];
|
||||
reject: [];
|
||||
submit: [payload: { actionType: ActionType; reason: string | null }];
|
||||
}>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
@@ -30,6 +30,36 @@ const visible = defineModel<boolean>('visible', {
|
||||
|
||||
const loading = ref(false);
|
||||
const detailData = ref<Api.OvertimeApplication.OvertimeApplication | null>(null);
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const approvalModel = reactive({
|
||||
conclusion: 'approve' as ActionType,
|
||||
opinion: ''
|
||||
});
|
||||
|
||||
const opinionLabel = computed(() => (approvalModel.conclusion === 'reject' ? '退回原因' : '审批意见'));
|
||||
const opinionRequired = computed(() => approvalModel.conclusion === 'reject');
|
||||
const opinionPlaceholder = computed(() => (opinionRequired.value ? `请输入${opinionLabel.value}` : '可填写审批意见'));
|
||||
|
||||
const rules = computed(() => ({
|
||||
opinion: opinionRequired.value
|
||||
? [
|
||||
createRequiredRule(`请输入${opinionLabel.value}`),
|
||||
{
|
||||
validator: (_rule, value: string, callback) => {
|
||||
if (!value?.trim()) {
|
||||
callback(new Error(`请输入${opinionLabel.value}`));
|
||||
return;
|
||||
}
|
||||
|
||||
callback();
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
: []
|
||||
}));
|
||||
|
||||
async function loadDetail() {
|
||||
if (!props.rowData?.id) {
|
||||
@@ -44,24 +74,47 @@ async function loadDetail() {
|
||||
detailData.value = error || !data ? props.rowData : data;
|
||||
}
|
||||
|
||||
function resetApprovalForm() {
|
||||
approvalModel.conclusion = 'approve';
|
||||
approvalModel.opinion = '';
|
||||
nextTick(() => {
|
||||
formRef.value?.clearValidate();
|
||||
});
|
||||
}
|
||||
|
||||
watch(opinionRequired, async () => {
|
||||
if (!visible.value || !props.showApprovalActions) return;
|
||||
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate('opinion');
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
await validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
emit('submit', {
|
||||
actionType: approvalModel.conclusion,
|
||||
reason: approvalModel.opinion.trim() || null
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
value => {
|
||||
if (value) {
|
||||
loadDetail();
|
||||
resetApprovalForm();
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
title="加班申请详情"
|
||||
preset="md"
|
||||
:loading="loading"
|
||||
:show-footer="props.showApprovalActions"
|
||||
>
|
||||
<BusinessFormDialog v-model="visible" title="加班申请详情" preset="md" :loading="loading">
|
||||
<ElDescriptions v-if="detailData" class="overtime-application-detail-dialog__descriptions" :column="2" border>
|
||||
<ElDescriptionsItem label="申请人" label-class-name="overtime-application-detail-dialog__label">
|
||||
{{ detailData.applicantName }}
|
||||
@@ -84,25 +137,74 @@ watch(
|
||||
</ElDescriptions>
|
||||
<ElEmpty v-else description="未获取到加班申请详情" />
|
||||
|
||||
<div v-if="props.showApprovalActions" class="overtime-application-detail-dialog__approval-form">
|
||||
<div class="audit-field">
|
||||
<label>审批结论</label>
|
||||
<div class="audit-conclusion">
|
||||
<button
|
||||
type="button"
|
||||
class="conclusion-btn"
|
||||
:class="{
|
||||
active: approvalModel.conclusion === 'approve',
|
||||
pass: approvalModel.conclusion === 'approve'
|
||||
}"
|
||||
@click="approvalModel.conclusion = 'approve'"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5" />
|
||||
<path
|
||||
d="M5 8.5L7 10.5L11 6"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
通过
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="conclusion-btn"
|
||||
:class="{
|
||||
active: approvalModel.conclusion === 'reject',
|
||||
reject: approvalModel.conclusion === 'reject'
|
||||
}"
|
||||
@click="approvalModel.conclusion = 'reject'"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M6 6L10 10M10 6L6 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
退回
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElForm ref="formRef" :model="approvalModel" :rules="rules" label-position="top" :validate-on-rule-change="false">
|
||||
<ElFormItem :label="opinionLabel" prop="opinion">
|
||||
<ElInput
|
||||
v-model="approvalModel.opinion"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
maxlength="1000"
|
||||
show-word-limit
|
||||
:placeholder="opinionPlaceholder"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="overtime-application-detail-dialog__footer">
|
||||
<ElButton @click="visible = false">取消</ElButton>
|
||||
<ElButton
|
||||
class="overtime-application-detail-dialog__approve-btn"
|
||||
type="success"
|
||||
v-if="props.showApprovalActions"
|
||||
type="primary"
|
||||
:loading="props.actionLoading"
|
||||
:disabled="props.actionLoading || !detailData"
|
||||
@click="emit('approve')"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<template #icon>
|
||||
<IconMdiCheckCircleOutline />
|
||||
</template>
|
||||
通过
|
||||
</ElButton>
|
||||
<ElButton type="danger" plain :disabled="props.actionLoading || !detailData" @click="emit('reject')">
|
||||
<template #icon>
|
||||
<IconMdiCloseCircleOutline />
|
||||
</template>
|
||||
退回
|
||||
确认提交
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
@@ -116,13 +218,61 @@ watch(
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.overtime-application-detail-dialog__approve-btn {
|
||||
--el-button-bg-color: #0f766e;
|
||||
--el-button-border-color: #0f766e;
|
||||
--el-button-hover-bg-color: #115e59;
|
||||
--el-button-hover-border-color: #115e59;
|
||||
--el-button-active-bg-color: #134e4a;
|
||||
--el-button-active-border-color: #134e4a;
|
||||
.overtime-application-detail-dialog__approval-form {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.audit-field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.audit-field label {
|
||||
color: #475467;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.audit-conclusion {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.conclusion-btn {
|
||||
height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: 1px solid #d8e0e8;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #475467;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
}
|
||||
|
||||
.conclusion-btn:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.conclusion-btn.active.pass {
|
||||
border-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.conclusion-btn.active.reject {
|
||||
border-color: #dc2626;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
:deep(.overtime-application-detail-dialog__descriptions .el-descriptions__cell) {
|
||||
|
||||
@@ -80,7 +80,7 @@ const rules = computed(
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
approverId: [createRequiredRule('请选择审核人')]
|
||||
approverId: [createRequiredRule('请选择审批人')]
|
||||
}) satisfies Record<keyof Api.OvertimeApplication.SaveOvertimeApplicationParams, App.Global.FormRule[]>
|
||||
);
|
||||
|
||||
@@ -189,7 +189,7 @@ watch(
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="审核人" prop="approverId">
|
||||
<ElFormItem label="审批人" prop="approverId">
|
||||
<ElInput
|
||||
class="overtime-application-operate-dialog__readonly-input"
|
||||
:model-value="approverName"
|
||||
|
||||
@@ -5,6 +5,21 @@ import TableSearchFields, { type SearchField } from '@/components/custom/table-s
|
||||
|
||||
defineOptions({ name: 'OvertimeApplicationSearch' });
|
||||
|
||||
interface Option {
|
||||
label: string;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
teamMode?: boolean;
|
||||
subordinateOptions?: Option[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
teamMode: false,
|
||||
subordinateOptions: () => []
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
reset: [];
|
||||
search: [];
|
||||
@@ -15,7 +30,7 @@ const model = defineModel<Api.OvertimeApplication.OvertimeApplicationSearchParam
|
||||
});
|
||||
|
||||
const searchModel = reactive<Record<string, any>>({
|
||||
applicantName: '',
|
||||
applicantIds: undefined,
|
||||
overtimeDate: undefined,
|
||||
statusCode: undefined,
|
||||
approverName: ''
|
||||
@@ -26,11 +41,10 @@ const statusOptions = ref<Array<{ label: string; value: string }>>([]);
|
||||
let syncingFromSource = false;
|
||||
|
||||
watch(
|
||||
() =>
|
||||
[model.value.applicantName, model.value.overtimeDate, model.value.statusCode, model.value.approverName] as const,
|
||||
([applicantName, overtimeDate, statusCode, approverName]) => {
|
||||
() => [model.value.applicantIds, model.value.overtimeDate, model.value.statusCode, model.value.approverName] as const,
|
||||
([applicantIds, overtimeDate, statusCode, approverName]) => {
|
||||
syncingFromSource = true;
|
||||
searchModel.applicantName = applicantName ?? '';
|
||||
searchModel.applicantIds = applicantIds;
|
||||
searchModel.overtimeDate = overtimeDate;
|
||||
searchModel.statusCode = statusCode;
|
||||
searchModel.approverName = approverName ?? '';
|
||||
@@ -40,14 +54,14 @@ watch(
|
||||
);
|
||||
|
||||
watch(
|
||||
() =>
|
||||
[searchModel.applicantName, searchModel.overtimeDate, searchModel.statusCode, searchModel.approverName] as const,
|
||||
([applicantName, overtimeDate, statusCode, approverName]) => {
|
||||
() => [searchModel.applicantIds, searchModel.overtimeDate, searchModel.statusCode, searchModel.approverName] as const,
|
||||
([applicantIds, overtimeDate, statusCode, approverName]) => {
|
||||
if (syncingFromSource) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.value.applicantName = applicantName?.trim() || undefined;
|
||||
model.value.applicantIds = applicantIds;
|
||||
model.value.applicantName = undefined;
|
||||
model.value.overtimeDate = overtimeDate;
|
||||
model.value.statusCode = statusCode;
|
||||
model.value.approverName = approverName?.trim() || undefined;
|
||||
@@ -73,33 +87,48 @@ onMounted(async () => {
|
||||
await loadStatusOptions();
|
||||
});
|
||||
|
||||
const fields = computed<SearchField[]>(() => [
|
||||
{
|
||||
key: 'applicantName',
|
||||
label: '申请人',
|
||||
type: 'input',
|
||||
placeholder: '请输入申请人'
|
||||
},
|
||||
{
|
||||
key: 'overtimeDate',
|
||||
label: '加班日期',
|
||||
type: 'dateRange',
|
||||
placeholder: '请选择加班日期'
|
||||
},
|
||||
{
|
||||
key: 'statusCode',
|
||||
label: '状态',
|
||||
type: 'select',
|
||||
options: statusOptions.value,
|
||||
placeholder: '请选择状态'
|
||||
},
|
||||
{
|
||||
key: 'approverName',
|
||||
label: '审核人',
|
||||
type: 'input',
|
||||
placeholder: '请输入审核人'
|
||||
const fields = computed<SearchField[]>(() => {
|
||||
const baseFields: SearchField[] = [
|
||||
...(props.teamMode
|
||||
? [
|
||||
{
|
||||
key: 'applicantIds',
|
||||
label: '申请人',
|
||||
type: 'select' as const,
|
||||
options: props.subordinateOptions,
|
||||
placeholder: '请选择申请人',
|
||||
filterable: true,
|
||||
transformValue: (value: string | number | null | undefined) => (value ? [value] : undefined),
|
||||
resolveValue: (value: unknown) => (Array.isArray(value) ? value[0] : value)
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'overtimeDate',
|
||||
label: '加班日期',
|
||||
type: 'dateRange',
|
||||
placeholder: '请选择加班日期'
|
||||
},
|
||||
{
|
||||
key: 'statusCode',
|
||||
label: '状态',
|
||||
type: 'select',
|
||||
options: statusOptions.value,
|
||||
placeholder: '请选择状态'
|
||||
}
|
||||
];
|
||||
|
||||
if (props.teamMode) {
|
||||
baseFields.push({
|
||||
key: 'approverName',
|
||||
label: '审批人',
|
||||
type: 'input',
|
||||
placeholder: '请输入审批人'
|
||||
});
|
||||
}
|
||||
]);
|
||||
|
||||
return baseFields;
|
||||
});
|
||||
|
||||
function handleReset() {
|
||||
emit('reset');
|
||||
|
||||
70
src/views/personal-center/shared/team-dashboard.ts
Normal file
70
src/views/personal-center/shared/team-dashboard.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
export type TeamViewMode = 'self' | 'team';
|
||||
|
||||
export interface TeamSelectableUser {
|
||||
userId: string;
|
||||
userNickname: string;
|
||||
}
|
||||
|
||||
export interface TeamSelectionState {
|
||||
mode: TeamViewMode;
|
||||
selectedUserId: string | null;
|
||||
selectedUserIds: string[] | null;
|
||||
isRootSelected: boolean;
|
||||
}
|
||||
|
||||
export interface TeamViewContext extends TeamSelectionState {
|
||||
allSubordinateUserIds: string[];
|
||||
selectedLabel: string;
|
||||
}
|
||||
|
||||
export function resolveTeamQueryUserIds(context: TeamViewContext | null | undefined): string[] | null {
|
||||
if (!context || context.mode !== 'team') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (context.isRootSelected) {
|
||||
return [...context.allSubordinateUserIds];
|
||||
}
|
||||
|
||||
return context.selectedUserIds ? [...context.selectedUserIds] : [];
|
||||
}
|
||||
|
||||
export function collectSubordinateUserIds(root: Api.SystemManage.MySubordinateTreeNode | null | undefined): string[] {
|
||||
if (!root) return [];
|
||||
|
||||
const ids: string[] = [];
|
||||
|
||||
const walk = (nodes?: Api.SystemManage.MySubordinateTreeNode[] | null) => {
|
||||
nodes?.forEach(node => {
|
||||
ids.push(node.userId);
|
||||
walk(node.children ?? null);
|
||||
});
|
||||
};
|
||||
|
||||
walk(root.children ?? null);
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function findSubordinateNode(
|
||||
root: Api.SystemManage.MySubordinateTreeNode | null | undefined,
|
||||
userId: string | null
|
||||
): Api.SystemManage.MySubordinateTreeNode | null {
|
||||
if (!root || !userId) return null;
|
||||
|
||||
if (root.userId === userId) {
|
||||
return root;
|
||||
}
|
||||
|
||||
const stack = [...(root.children ?? [])];
|
||||
while (stack.length) {
|
||||
const current = stack.shift()!;
|
||||
if (current.userId === userId) {
|
||||
return current;
|
||||
}
|
||||
if (current.children?.length) {
|
||||
stack.push(...current.children);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,17 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { type ComputedRef, type Ref, computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import { onBeforeRouteLeave } from 'vue-router';
|
||||
import { fetchGetProjectReportOwnerProjectOptions } from '@/service/api';
|
||||
import { fetchGetMySubordinateTree, fetchGetProjectReportOwnerProjectOptions } from '@/service/api';
|
||||
import { useAuth } from '@/hooks/business/auth';
|
||||
import SubordinateSelector from '@/components/custom/subordinate-selector.vue';
|
||||
import TeamContextPanel from '@/components/custom/team-context-panel.vue';
|
||||
import {
|
||||
type TeamViewContext,
|
||||
type TeamViewMode,
|
||||
collectSubordinateUserIds,
|
||||
findSubordinateNode
|
||||
} from '../shared/team-dashboard';
|
||||
import TeamReportSummary from './shared/components/team-report-summary.vue';
|
||||
import WorkReportCreateDialog from './shared/components/create-dialog.vue';
|
||||
import WorkReportPrototypePageDialog from './shared/components/prototype-page-dialog.vue';
|
||||
import WorkReportTabs from './shared/components/tabs.vue';
|
||||
import {
|
||||
WORK_REPORT_PROJECT_OWNER_PERMISSION,
|
||||
WORK_REPORT_TYPE_LABEL,
|
||||
type WorkReportRow,
|
||||
type WorkReportType
|
||||
type WorkReportType,
|
||||
getWorkReportTypeDisplayLabel
|
||||
} from './shared/types';
|
||||
import { formatIsoWeekRangeLabel } from './shared/utils';
|
||||
import type { resolveWorkReportSummaryPeriod } from './shared/utils';
|
||||
import WeeklyReportIndex from './weekly/index.vue';
|
||||
import WeeklyReportApprovalRecordDialog from './weekly/modules/approval-record-dialog.vue';
|
||||
import MonthlyReportIndex from './monthly/index.vue';
|
||||
@@ -24,11 +35,21 @@ defineOptions({ name: 'PersonalCenterWorkReport' });
|
||||
type PageDialogMode = 'add' | 'edit' | 'detail';
|
||||
type ReportListExpose = {
|
||||
reload: (page?: number) => Promise<void>;
|
||||
teamSummary: Ref<Api.WorkReport.Common.TeamReportSummary | null>;
|
||||
teamSummaryLoading: Ref<boolean>;
|
||||
summaryPeriod: Ref<ReturnType<typeof resolveWorkReportSummaryPeriod>>;
|
||||
summaryPeriodKeys: ComputedRef<string[]>;
|
||||
hasSearchedDateRange: ComputedRef<boolean>;
|
||||
loadTeamSummary: () => Promise<void>;
|
||||
};
|
||||
|
||||
const { hasAuth } = useAuth();
|
||||
|
||||
const activeTab = ref<WorkReportType>('weekly');
|
||||
const teamViewMode = ref<TeamViewMode>('self');
|
||||
const subordinateTreeLoading = ref(false);
|
||||
const subordinateTree = ref<Api.SystemManage.MySubordinateTreeNode | null>(null);
|
||||
const selectedSubordinateUserId = ref<string | null>(null);
|
||||
const createVisible = ref(false);
|
||||
const pageDialogVisible = ref(false);
|
||||
const pageDialogMode = ref<PageDialogMode>('detail');
|
||||
@@ -50,18 +71,58 @@ const monthlyRef = ref<ReportListExpose | null>(null);
|
||||
const projectRef = ref<ReportListExpose | null>(null);
|
||||
|
||||
const canShowProjectTab = computed(() => hasAuth(WORK_REPORT_PROJECT_OWNER_PERMISSION));
|
||||
const canUseTeamDashboard = computed(() => hasAuth('project:work-report:team-dashboard'));
|
||||
const allSubordinateUserIds = computed(() => collectSubordinateUserIds(subordinateTree.value));
|
||||
const selectedSubordinateNode = computed(() =>
|
||||
findSubordinateNode(subordinateTree.value, selectedSubordinateUserId.value)
|
||||
);
|
||||
const subordinateOptions = computed(() => {
|
||||
const options: Array<{ label: string; value: string }> = [];
|
||||
|
||||
const walk = (nodes?: Api.SystemManage.MySubordinateTreeNode[] | null) => {
|
||||
nodes?.forEach(node => {
|
||||
options.push({ label: node.userNickname, value: node.userId });
|
||||
walk(node.children ?? null);
|
||||
});
|
||||
};
|
||||
|
||||
walk(subordinateTree.value?.children ?? null);
|
||||
return options;
|
||||
});
|
||||
const isRootSelected = computed(() => Boolean(selectedSubordinateNode.value?.isRoot));
|
||||
const selectedTeamLabel = computed(() => {
|
||||
if (teamViewMode.value === 'self') return '我自己';
|
||||
if (!selectedSubordinateNode.value) return '--';
|
||||
return selectedSubordinateNode.value.isRoot ? '全部下属' : selectedSubordinateNode.value.userNickname;
|
||||
});
|
||||
const teamContext = computed<TeamViewContext | null>(() => {
|
||||
if (!canUseTeamDashboard.value) return null;
|
||||
|
||||
return {
|
||||
mode: teamViewMode.value,
|
||||
selectedUserId: selectedSubordinateUserId.value,
|
||||
selectedUserIds:
|
||||
teamViewMode.value === 'team' && selectedSubordinateUserId.value && !isRootSelected.value
|
||||
? [selectedSubordinateUserId.value]
|
||||
: [],
|
||||
isRootSelected: teamViewMode.value === 'team' && isRootSelected.value,
|
||||
allSubordinateUserIds: allSubordinateUserIds.value,
|
||||
selectedLabel: selectedTeamLabel.value
|
||||
};
|
||||
});
|
||||
|
||||
/** 项目选项是否加载成功(用于项目半月报列表内部判断) */
|
||||
const projectOptionsLoaded = ref(false);
|
||||
|
||||
const visibleTabs = computed<Array<{ label: string; name: WorkReportType }>>(() => {
|
||||
const isTeamReportMode = teamViewMode.value === 'team';
|
||||
const tabs: Array<{ label: string; name: WorkReportType }> = [
|
||||
{ label: WORK_REPORT_TYPE_LABEL.weekly, name: 'weekly' },
|
||||
{ label: WORK_REPORT_TYPE_LABEL.monthly, name: 'monthly' }
|
||||
{ label: getWorkReportTypeDisplayLabel('weekly', isTeamReportMode), name: 'weekly' },
|
||||
{ label: getWorkReportTypeDisplayLabel('monthly', isTeamReportMode), name: 'monthly' }
|
||||
];
|
||||
|
||||
if (canShowProjectTab.value) {
|
||||
tabs.push({ label: WORK_REPORT_TYPE_LABEL.project, name: 'project' });
|
||||
tabs.push({ label: getWorkReportTypeDisplayLabel('project', isTeamReportMode), name: 'project' });
|
||||
}
|
||||
|
||||
return tabs;
|
||||
@@ -79,6 +140,30 @@ function getListRef(reportType: WorkReportType) {
|
||||
return weeklyRef.value;
|
||||
}
|
||||
|
||||
const activeReportRef = computed(() => getListRef(activeTab.value));
|
||||
const activeTeamSummary = computed(() => activeReportRef.value?.teamSummary ?? null);
|
||||
const activeTeamSummaryLoading = computed(() => activeReportRef.value?.teamSummaryLoading ?? false);
|
||||
const activeSummaryPeriodKeys = computed(() => {
|
||||
const activeRef = activeReportRef.value;
|
||||
if (!activeRef) return [];
|
||||
return activeRef.summaryPeriodKeys ?? [];
|
||||
});
|
||||
const activeSummaryReportType = computed<Api.WorkReport.Common.ReportType>(() => {
|
||||
if (activeTab.value === 'monthly') return 'monthly';
|
||||
if (activeTab.value === 'project') return 'project';
|
||||
return 'weekly';
|
||||
});
|
||||
const activeSummaryPeriodLabel = computed(() => {
|
||||
const summaryPeriod = activeReportRef.value?.summaryPeriod;
|
||||
if (!summaryPeriod) return '';
|
||||
|
||||
if (activeTab.value === 'weekly') {
|
||||
return formatIsoWeekRangeLabel(summaryPeriod.periodStartDate, summaryPeriod.periodEndDate);
|
||||
}
|
||||
|
||||
return summaryPeriod.periodLabel ?? '';
|
||||
});
|
||||
|
||||
async function loadProjectOptions() {
|
||||
if (!canShowProjectTab.value) return;
|
||||
|
||||
@@ -87,6 +172,17 @@ async function loadProjectOptions() {
|
||||
projectOptionsLoaded.value = !error;
|
||||
}
|
||||
|
||||
async function loadSubordinateTree() {
|
||||
if (!canUseTeamDashboard.value) return;
|
||||
|
||||
subordinateTreeLoading.value = true;
|
||||
const { error, data } = await fetchGetMySubordinateTree();
|
||||
subordinateTreeLoading.value = false;
|
||||
|
||||
subordinateTree.value = error || !data ? null : data;
|
||||
selectedSubordinateUserId.value = data?.userId || null;
|
||||
}
|
||||
|
||||
function openCreate(reportType: WorkReportType) {
|
||||
currentReportType.value = reportType;
|
||||
createVisible.value = true;
|
||||
@@ -133,9 +229,10 @@ function openApprovalRecord(reportType: WorkReportType, row: WorkReportRow) {
|
||||
approvalRecordVisible.value = true;
|
||||
}
|
||||
|
||||
function handleTabChange(tab: WorkReportType) {
|
||||
async function handleTabChange(tab: WorkReportType) {
|
||||
activeTab.value = tab;
|
||||
getListRef(tab)?.reload(1);
|
||||
await nextTick();
|
||||
await getListRef(tab)?.reload(1);
|
||||
}
|
||||
|
||||
async function reloadReport(reportType = currentReportType.value) {
|
||||
@@ -147,14 +244,45 @@ function handleSubmitted() {
|
||||
reloadReport(currentReportType.value);
|
||||
}
|
||||
|
||||
async function handleSummaryRemind() {
|
||||
await activeReportRef.value?.loadTeamSummary();
|
||||
}
|
||||
|
||||
function closeFloatingPanels() {
|
||||
createVisible.value = false;
|
||||
pageDialogVisible.value = false;
|
||||
approvalRecordVisible.value = false;
|
||||
}
|
||||
|
||||
async function handleTeamViewModeChange(mode: TeamViewMode) {
|
||||
teamViewMode.value = mode;
|
||||
|
||||
if (mode === 'team') {
|
||||
if (!subordinateTree.value) {
|
||||
await loadSubordinateTree();
|
||||
}
|
||||
if (!selectedSubordinateUserId.value) {
|
||||
selectedSubordinateUserId.value = subordinateTree.value?.userId || null;
|
||||
}
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
await getListRef(activeTab.value)?.reload(1);
|
||||
}
|
||||
|
||||
watch(selectedSubordinateUserId, async (currentUserId, previousUserId) => {
|
||||
if (!canUseTeamDashboard.value || teamViewMode.value !== 'team') return;
|
||||
if (!currentUserId || currentUserId === previousUserId) return;
|
||||
|
||||
await nextTick();
|
||||
await getListRef(activeTab.value)?.reload(1);
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await loadProjectOptions();
|
||||
if (canUseTeamDashboard.value) {
|
||||
await loadSubordinateTree();
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeRouteLeave(() => {
|
||||
@@ -167,16 +295,52 @@ onBeforeRouteLeave(() => {
|
||||
class="work-report-page-shell min-h-560px gap-16px overflow-hidden xl:grid xl:grid-cols-[240px_minmax(0,1fr)] lt-xl:flex lt-xl:flex-col lt-xl:overflow-auto"
|
||||
>
|
||||
<!-- 左侧:报告类型导航 -->
|
||||
<div class="flex-col-stretch gap-16px xl:min-h-0">
|
||||
<WorkReportTabs :active-tab="activeTab" :tabs="visibleTabs" @update:active-tab="handleTabChange" />
|
||||
<div
|
||||
class="work-report-page-shell__sidebar"
|
||||
:class="{ 'work-report-page-shell__sidebar--team': canUseTeamDashboard && teamViewMode === 'team' }"
|
||||
>
|
||||
<WorkReportTabs
|
||||
class="work-report-page-shell__sidebar-card"
|
||||
:active-tab="activeTab"
|
||||
:tabs="visibleTabs"
|
||||
@update:active-tab="handleTabChange"
|
||||
/>
|
||||
<SubordinateSelector
|
||||
v-if="canUseTeamDashboard && teamViewMode === 'team'"
|
||||
v-model:selected-user-id="selectedSubordinateUserId"
|
||||
class="work-report-page-shell__sidebar-card"
|
||||
:loading="subordinateTreeLoading"
|
||||
:data="subordinateTree"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:搜索区 + 列表区 -->
|
||||
<div class="flex-col-stretch gap-16px xl:min-h-0">
|
||||
<TeamContextPanel
|
||||
v-if="canUseTeamDashboard"
|
||||
v-model:mode="teamViewMode"
|
||||
:loading="subordinateTreeLoading"
|
||||
:selected-label="selectedTeamLabel"
|
||||
:subordinate-count="subordinateTree?.subordinateCount || 0"
|
||||
@update:mode="handleTeamViewModeChange"
|
||||
>
|
||||
<TeamReportSummary
|
||||
v-if="isRootSelected && teamViewMode === 'team'"
|
||||
:report-type="activeSummaryReportType"
|
||||
:period-keys="activeSummaryPeriodKeys"
|
||||
:period-label="activeSummaryPeriodLabel"
|
||||
:loading="activeTeamSummaryLoading"
|
||||
:summary="activeTeamSummary"
|
||||
@reminded="handleSummaryRemind"
|
||||
/>
|
||||
</TeamContextPanel>
|
||||
|
||||
<WeeklyReportIndex
|
||||
v-show="activeTab === 'weekly'"
|
||||
ref="weeklyRef"
|
||||
class="flex-1-hidden"
|
||||
:team-context="teamContext"
|
||||
:subordinate-options="subordinateOptions"
|
||||
@create="openCreate('weekly')"
|
||||
@edit="openEdit('weekly', $event)"
|
||||
@detail="openDetail('weekly', $event)"
|
||||
@@ -187,6 +351,8 @@ onBeforeRouteLeave(() => {
|
||||
v-show="activeTab === 'monthly'"
|
||||
ref="monthlyRef"
|
||||
class="flex-1-hidden"
|
||||
:team-context="teamContext"
|
||||
:subordinate-options="subordinateOptions"
|
||||
@create="openCreate('monthly')"
|
||||
@edit="openEdit('monthly', $event)"
|
||||
@detail="openDetail('monthly', $event)"
|
||||
@@ -198,6 +364,7 @@ onBeforeRouteLeave(() => {
|
||||
v-show="activeTab === 'project'"
|
||||
ref="projectRef"
|
||||
class="flex-1-hidden"
|
||||
:team-context="teamContext"
|
||||
:project-options="projectOptions"
|
||||
:project-options-loaded="projectOptionsLoaded"
|
||||
@create="openCreate('project')"
|
||||
@@ -238,4 +405,25 @@ onBeforeRouteLeave(() => {
|
||||
.work-report-page-shell {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.work-report-page-shell__sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.work-report-page-shell__sidebar {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.work-report-page-shell__sidebar--team {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.work-report-page-shell__sidebar-card {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
<script setup lang="tsx">
|
||||
/* eslint-disable no-void */
|
||||
import { markRaw, reactive, ref } from 'vue';
|
||||
import { computed, markRaw, reactive, ref, watch } from 'vue';
|
||||
import { ElMessageBox, ElTag } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
fetchDeleteMonthlyReport,
|
||||
fetchExportMonthlyReportContent,
|
||||
fetchGetMonthlyReportPage,
|
||||
fetchGetTeamReportSummary,
|
||||
fetchSubmitMonthlyReport
|
||||
} 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 { type TeamViewContext } from '@/views/personal-center/shared/team-dashboard';
|
||||
import {
|
||||
WORK_REPORT_TYPE_LABEL,
|
||||
type WorkReportRow,
|
||||
createMonthlySearchParams,
|
||||
createWorkReportContentExportFallbackName,
|
||||
@@ -21,19 +23,27 @@ import {
|
||||
formatEmptyText,
|
||||
formatPeriod,
|
||||
getWorkReportStatusLabel,
|
||||
getWorkReportTypeDisplayLabel,
|
||||
resolveExportFilename,
|
||||
resolveWorkReportStatusTagType,
|
||||
transformWorkReportPage
|
||||
} from '../shared/types';
|
||||
import { buildMonthlyPeriodFromMonth, resolveWorkReportSummaryPeriod } from '../shared/utils';
|
||||
import MonthlyReportSearch from './modules/search-panel.vue';
|
||||
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
|
||||
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
|
||||
import IconMdiFileDocumentCheckOutline from '~icons/mdi/file-document-check-outline';
|
||||
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
|
||||
import IconMdiSendOutline from '~icons/mdi/send-outline';
|
||||
import IconMdiDownloadOutline from '~icons/mdi/download-outline';
|
||||
|
||||
defineOptions({ name: 'MonthlyWorkReportIndex' });
|
||||
|
||||
const props = defineProps<{
|
||||
teamContext?: TeamViewContext | null;
|
||||
subordinateOptions?: Array<{ label: string; value: string }>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'create'): void;
|
||||
(e: 'edit', row: WorkReportRow): void;
|
||||
@@ -45,73 +55,207 @@ const { hasAuth } = useAuth();
|
||||
const exporting = ref(false);
|
||||
const selectedRows = ref<Api.WorkReport.Monthly.MonthlyReport[]>([]);
|
||||
const searchParams = reactive(createMonthlySearchParams());
|
||||
const teamSummaryLoading = ref(false);
|
||||
const teamSummary = ref<Api.WorkReport.Common.TeamReportSummary | null>(null);
|
||||
|
||||
const ACTION_ICON_MAP = {
|
||||
detail: markRaw(IconMdiEyeOutline),
|
||||
edit: markRaw(IconMdiPencilOutline),
|
||||
submit: markRaw(IconMdiSendOutline),
|
||||
delete: markRaw(IconMdiDeleteOutline),
|
||||
approvalRecord: markRaw(IconMdiFileDocumentCheckOutline)
|
||||
approvalRecord: markRaw(IconMdiFileDocumentCheckOutline),
|
||||
export: markRaw(IconMdiDownloadOutline)
|
||||
};
|
||||
|
||||
const isTeamMode = computed(() => props.teamContext?.mode === 'team');
|
||||
const isTeamRootSelected = computed(() => Boolean(isTeamMode.value && props.teamContext?.isRootSelected));
|
||||
const currentTeamReporterIds = computed(() => {
|
||||
if (!isTeamMode.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isTeamRootSelected.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return props.teamContext?.selectedUserIds ?? [];
|
||||
});
|
||||
const resolvedTeamReporterIds = computed(() => {
|
||||
if (!isTeamMode.value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (searchParams.reporterIds?.length) {
|
||||
return searchParams.reporterIds;
|
||||
}
|
||||
|
||||
return currentTeamReporterIds.value;
|
||||
});
|
||||
const reportTitle = computed(() => getWorkReportTypeDisplayLabel('monthly', isTeamMode.value));
|
||||
const normalizedPeriodRange = computed(() => {
|
||||
const periodRange = searchParams.periodStartDate;
|
||||
if (!periodRange?.length) {
|
||||
return periodRange;
|
||||
}
|
||||
|
||||
const [startDate, endDate] = periodRange;
|
||||
const start = dayjs(startDate);
|
||||
const end = dayjs(endDate || startDate);
|
||||
|
||||
if (!start.isValid() || !end.isValid()) {
|
||||
return periodRange;
|
||||
}
|
||||
|
||||
return [start.startOf('month').format('YYYY-MM-DD'), end.endOf('month').format('YYYY-MM-DD')];
|
||||
});
|
||||
|
||||
const table = useUIPaginatedTable<
|
||||
Awaited<ReturnType<typeof fetchGetMonthlyReportPage>>,
|
||||
Api.WorkReport.Monthly.MonthlyReport
|
||||
>({
|
||||
paginationProps: { currentPage: searchParams.pageNo, pageSize: searchParams.pageSize },
|
||||
api: () => fetchGetMonthlyReportPage(searchParams),
|
||||
api: () =>
|
||||
fetchGetMonthlyReportPage({
|
||||
...searchParams,
|
||||
periodStartDate: normalizedPeriodRange.value,
|
||||
reporterIds: resolvedTeamReporterIds.value
|
||||
}),
|
||||
transform: response => transformWorkReportPage(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: 'periodLabel', label: '月份', minWidth: 80, formatter: row => formatPeriod(row) },
|
||||
{
|
||||
prop: 'reporterDeptName',
|
||||
label: '部门',
|
||||
minWidth: 80,
|
||||
showOverflowTooltip: true,
|
||||
formatter: row => row.reporterDeptName || '--'
|
||||
},
|
||||
{ prop: 'supervisorName', label: '直属上级', minWidth: 80 },
|
||||
{ prop: 'totalWorkHours', label: '总工时', minWidth: 80, formatter: row => formatEmptyText(row.totalWorkHours) },
|
||||
{
|
||||
prop: 'statusCode',
|
||||
label: '状态',
|
||||
minWidth: 80,
|
||||
align: 'center',
|
||||
formatter: row => (
|
||||
<ElTag type={resolveWorkReportStatusTagType(row.statusCode)}>
|
||||
{getWorkReportStatusLabel(row.statusCode, row.statusName)}
|
||||
</ElTag>
|
||||
)
|
||||
},
|
||||
{ prop: 'submitTime', label: '提交时间', minWidth: 100, formatter: row => formatDateTime(row.submitTime) },
|
||||
{ prop: 'approvalTime', label: '审批时间', minWidth: 100, formatter: row => formatDateTime(row.approvalTime) },
|
||||
{
|
||||
prop: 'operate',
|
||||
label: '操作',
|
||||
width: 180,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
|
||||
columns: () => {
|
||||
const cols: UI.TableColumn<Api.WorkReport.Monthly.MonthlyReport>[] = [
|
||||
{ prop: 'index', type: 'index', label: '序号', width: 64 },
|
||||
{ prop: 'periodLabel', label: '月份', minWidth: 80, formatter: row => formatPeriod(row) }
|
||||
];
|
||||
|
||||
if (isTeamMode.value) {
|
||||
cols.push({ prop: 'reporterName', label: '提交人', minWidth: 100, showOverflowTooltip: true });
|
||||
}
|
||||
]
|
||||
|
||||
cols.push(
|
||||
{
|
||||
prop: 'reporterDeptName',
|
||||
label: '部门',
|
||||
minWidth: 80,
|
||||
showOverflowTooltip: true,
|
||||
formatter: row => row.reporterDeptName || '--'
|
||||
},
|
||||
{ prop: 'supervisorName', label: '直属上级', minWidth: 80 },
|
||||
{ prop: 'totalWorkHours', label: '总工时', minWidth: 80, formatter: row => formatEmptyText(row.totalWorkHours) },
|
||||
{
|
||||
prop: 'statusCode',
|
||||
label: '状态',
|
||||
minWidth: 80,
|
||||
align: 'center',
|
||||
formatter: row => (
|
||||
<ElTag type={resolveWorkReportStatusTagType(row.statusCode)}>
|
||||
{getWorkReportStatusLabel(row.statusCode, row.statusName)}
|
||||
</ElTag>
|
||||
)
|
||||
},
|
||||
{ prop: 'submitTime', label: '提交时间', minWidth: 100, formatter: row => formatDateTime(row.submitTime) },
|
||||
{ prop: 'approvalTime', label: '审批时间', minWidth: 100, formatter: row => formatDateTime(row.approvalTime) },
|
||||
{
|
||||
prop: 'operate',
|
||||
label: '操作',
|
||||
width: isTeamMode.value ? 140 : 180,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
|
||||
}
|
||||
);
|
||||
|
||||
return cols;
|
||||
}
|
||||
});
|
||||
|
||||
// 团队统计始终使用当前周期(本月),不跟随列表第一条数据的周期
|
||||
const summaryPeriod = computed(() =>
|
||||
resolveWorkReportSummaryPeriod('monthly', {
|
||||
periodRange: normalizedPeriodRange.value
|
||||
})
|
||||
);
|
||||
const summaryPeriodKeys = computed(() => {
|
||||
const dateRange = normalizedPeriodRange.value;
|
||||
const fallbackKey = summaryPeriod.value.periodKey;
|
||||
|
||||
if (!dateRange?.length) {
|
||||
return fallbackKey ? [fallbackKey] : [];
|
||||
}
|
||||
|
||||
const [startDate, endDate] = dateRange;
|
||||
const start = dayjs(startDate);
|
||||
const end = dayjs(endDate || startDate);
|
||||
|
||||
if (!start.isValid() || !end.isValid()) {
|
||||
return fallbackKey ? [fallbackKey] : [];
|
||||
}
|
||||
|
||||
const keys: string[] = [];
|
||||
const endBoundary = end.endOf('month');
|
||||
|
||||
for (
|
||||
let cursor = start.startOf('month');
|
||||
cursor.isBefore(endBoundary, 'month') || cursor.isSame(endBoundary, 'month');
|
||||
cursor = cursor.add(1, 'month')
|
||||
) {
|
||||
keys.push(buildMonthlyPeriodFromMonth(cursor).periodKey);
|
||||
}
|
||||
|
||||
return keys;
|
||||
});
|
||||
const hasSearchedDateRange = computed(() => searchParams.periodStartDate?.length === 2);
|
||||
|
||||
watch(
|
||||
() => isTeamMode.value,
|
||||
() => {
|
||||
table.reloadColumns();
|
||||
}
|
||||
);
|
||||
|
||||
function getRowActions(row: Api.WorkReport.Monthly.MonthlyReport): BusinessTableAction[] {
|
||||
const actions: BusinessTableAction[] = [
|
||||
{
|
||||
key: 'detail',
|
||||
label: '详情',
|
||||
label: '查看',
|
||||
buttonType: 'primary',
|
||||
icon: ACTION_ICON_MAP.detail,
|
||||
onClick: () => emit('detail', row)
|
||||
}
|
||||
];
|
||||
|
||||
if (isTeamMode.value) {
|
||||
actions.push({
|
||||
key: 'export',
|
||||
label: '导出',
|
||||
buttonType: 'success',
|
||||
icon: ACTION_ICON_MAP.export,
|
||||
onClick: () =>
|
||||
exportReportContent(
|
||||
{
|
||||
exportAll: false,
|
||||
ids: [row.id]
|
||||
},
|
||||
1
|
||||
)
|
||||
});
|
||||
|
||||
if (['approved', 'rejected'].includes(row.statusCode)) {
|
||||
actions.push({
|
||||
key: 'approval-record',
|
||||
label: '审批记录',
|
||||
buttonType: 'info',
|
||||
icon: ACTION_ICON_MAP.approvalRecord,
|
||||
onClick: () => emit('approvalRecord', row)
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
if (['draft', 'rejected'].includes(row.statusCode) && row.allowEdit && hasAuth('project:work-report:update')) {
|
||||
actions.push({
|
||||
key: 'edit',
|
||||
@@ -154,6 +298,7 @@ function getRowActions(row: Api.WorkReport.Monthly.MonthlyReport): BusinessTable
|
||||
|
||||
async function reload(page?: number) {
|
||||
await table.getDataByPage(page ?? searchParams.pageNo ?? 1);
|
||||
await loadTeamSummary();
|
||||
}
|
||||
|
||||
function resetSearchParams() {
|
||||
@@ -186,7 +331,7 @@ async function handleSubmitReport(row: Api.WorkReport.Monthly.MonthlyReport) {
|
||||
|
||||
async function handleDelete(row: Api.WorkReport.Monthly.MonthlyReport) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除 ${formatPeriod(row)} 吗?`, '删除确认', {
|
||||
await ElMessageBox.confirm(`确认删除该报告吗?`, '删除确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确认',
|
||||
cancelButtonText: '取消'
|
||||
@@ -209,7 +354,11 @@ function handleSelectionChange(rows: Api.WorkReport.Monthly.MonthlyReport[]) {
|
||||
|
||||
function createExportSearchParams() {
|
||||
const { pageNo: _pageNo, pageSize: _pageSize, ...params } = searchParams;
|
||||
return params;
|
||||
return {
|
||||
...params,
|
||||
periodStartDate: normalizedPeriodRange.value,
|
||||
reporterIds: resolvedTeamReporterIds.value
|
||||
};
|
||||
}
|
||||
|
||||
async function exportReportContent(
|
||||
@@ -267,18 +416,56 @@ async function handleExportCommand(command: 'selected' | 'all') {
|
||||
await handleExportAll();
|
||||
}
|
||||
|
||||
defineExpose({ reload });
|
||||
async function loadTeamSummary() {
|
||||
if (!isTeamRootSelected.value) {
|
||||
teamSummaryLoading.value = false;
|
||||
teamSummary.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const dateRange = normalizedPeriodRange.value;
|
||||
const summaryParams: Api.WorkReport.Common.TeamReportSummaryParams = { reportType: 'monthly' };
|
||||
|
||||
if (dateRange?.length === 2) {
|
||||
summaryParams.periodStartDate = dateRange[0];
|
||||
summaryParams.periodEndDate = dateRange[1];
|
||||
} else {
|
||||
summaryParams.periodKey = summaryPeriod.value.periodKey;
|
||||
}
|
||||
|
||||
teamSummaryLoading.value = true;
|
||||
const { error, data } = await fetchGetTeamReportSummary(summaryParams);
|
||||
teamSummaryLoading.value = false;
|
||||
|
||||
teamSummary.value = error || !data ? null : data;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
reload,
|
||||
teamSummary,
|
||||
teamSummaryLoading,
|
||||
summaryPeriod,
|
||||
summaryPeriodKeys,
|
||||
hasSearchedDateRange,
|
||||
loadTeamSummary
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-col-stretch gap-16px overflow-hidden">
|
||||
<MonthlyReportSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
|
||||
<MonthlyReportSearch
|
||||
v-model:model="searchParams"
|
||||
:team-mode="isTeamMode"
|
||||
:subordinate-options="props.subordinateOptions"
|
||||
@reset="resetSearchParams"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
|
||||
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">
|
||||
<template #header>
|
||||
<div class="flex flex-wrap items-center justify-between gap-12px">
|
||||
<div class="flex items-center gap-10px">
|
||||
<p class="text-16px font-600">{{ WORK_REPORT_TYPE_LABEL.monthly }}</p>
|
||||
<p class="text-16px font-600">{{ reportTitle }}</p>
|
||||
<ElTag effect="plain">{{ table.mobilePagination.value.total || 0 }}</ElTag>
|
||||
</div>
|
||||
|
||||
@@ -304,7 +491,13 @@ defineExpose({ reload });
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
<ElButton v-auth="'project:work-report:create'" plain type="primary" @click="emit('create')">
|
||||
<ElButton
|
||||
v-if="!isTeamMode"
|
||||
v-auth="'project:work-report:create'"
|
||||
plain
|
||||
type="primary"
|
||||
@click="emit('create')"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
|
||||
@@ -801,7 +801,7 @@ watch(
|
||||
|
||||
<div class="form-actions approval-form-actions">
|
||||
<ElButton @click="emit('back')">退出审批</ElButton>
|
||||
<ElButton type="primary" class="btn-submit" @click="openAuditDialog">开始审批</ElButton>
|
||||
<ElButton type="primary" @click="openAuditDialog">开始审批</ElButton>
|
||||
</div>
|
||||
|
||||
<BusinessFormDialog
|
||||
@@ -915,8 +915,8 @@ watch(
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
border-radius: 16px;
|
||||
background: #ccfbf1;
|
||||
color: #0f766e;
|
||||
background: var(--el-color-primary-light-8);
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
@@ -1009,12 +1009,12 @@ watch(
|
||||
}
|
||||
|
||||
.radio-group-full :deep(.el-radio.is-checked) {
|
||||
border-color: #0f766e;
|
||||
background: #f0fdfa;
|
||||
border-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.radio-group-full :deep(.el-radio__input.is-checked + .el-radio__label) {
|
||||
color: #0f766e;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.review-grid,
|
||||
@@ -1123,7 +1123,7 @@ watch(
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: #0f766e;
|
||||
background: var(--el-color-primary);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
@@ -1292,7 +1292,7 @@ watch(
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
padding-left: 14px;
|
||||
color: #0f766e;
|
||||
color: var(--el-color-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
white-space: normal;
|
||||
@@ -1308,10 +1308,10 @@ watch(
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
background: #0f766e;
|
||||
background: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.rich-editor :deep(.rich-section-tasks) {
|
||||
.rich-editor :deep(.rich-section-task) {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding-left: 14px;
|
||||
@@ -1324,7 +1324,7 @@ watch(
|
||||
}
|
||||
|
||||
.rich-editor :deep(.rich-category-line) {
|
||||
color: #0f766e;
|
||||
color: var(--el-color-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.6;
|
||||
@@ -1454,7 +1454,7 @@ watch(
|
||||
.structured-section-title {
|
||||
position: relative;
|
||||
padding-left: 14px;
|
||||
color: #0f766e;
|
||||
color: var(--el-color-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
@@ -1467,7 +1467,7 @@ watch(
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
background: #0f766e;
|
||||
background: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.structured-section-tasks {
|
||||
@@ -1551,14 +1551,14 @@ watch(
|
||||
}
|
||||
|
||||
.conclusion-btn:hover {
|
||||
border-color: #0f766e;
|
||||
color: #0f766e;
|
||||
border-color: var(--el-color-primary);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.conclusion-btn.active.pass {
|
||||
border-color: #0f766e;
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
border-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.conclusion-btn.active.reject {
|
||||
@@ -1689,9 +1689,9 @@ watch(
|
||||
}
|
||||
|
||||
.feedback-tag.success {
|
||||
background: #ecfdf5;
|
||||
color: #059669;
|
||||
border: 1px solid #d1fae5;
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
border: 1px solid var(--el-color-primary-light-7);
|
||||
}
|
||||
|
||||
.feedback-tag.warning {
|
||||
@@ -1838,16 +1838,6 @@ watch(
|
||||
box-shadow: 0 -8px 18px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
background: #0f766e !important;
|
||||
border-color: #0f766e !important;
|
||||
}
|
||||
|
||||
.btn-submit:hover {
|
||||
background: #0d9488 !important;
|
||||
border-color: #0d9488 !important;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.form-head,
|
||||
.compose-grid,
|
||||
|
||||
@@ -342,6 +342,52 @@ function createStructuredSectionsFromTextV2(text: string, defaultCategory: strin
|
||||
return sections.filter(section => section.tasks.length);
|
||||
}
|
||||
|
||||
function isStructuredMetricsText(text: string) {
|
||||
return /(?:\bP\d+\b|进度\s*\d+(?:\.\d+)?%|\d+(?:\.\d+)?%|\d+(?:\.\d+)?h)/iu.test(text.trim());
|
||||
}
|
||||
|
||||
function extractStructuredTaskParts(normalizedText: string) {
|
||||
const colonIndex = normalizedText.search(/[::]/u);
|
||||
const mainText = colonIndex >= 0 ? normalizedText.slice(0, colonIndex).trim() : normalizedText.trim();
|
||||
const detailText = colonIndex >= 0 ? normalizedText.slice(colonIndex + 1).trim() : '';
|
||||
|
||||
const bracketMatches = Array.from(mainText.matchAll(/[((]([^()()]*)[))]/gu));
|
||||
const completeMetricsMatch = [...bracketMatches].reverse().find(match => isStructuredMetricsText(match[1] || ''));
|
||||
|
||||
if (completeMetricsMatch && completeMetricsMatch.index !== undefined) {
|
||||
const fullMatch = completeMetricsMatch[0] || '';
|
||||
const metricsText = completeMetricsMatch[1] || '';
|
||||
const startIndex = completeMetricsMatch.index;
|
||||
const endIndex = startIndex + fullMatch.length;
|
||||
|
||||
return {
|
||||
rawTitle: `${mainText.slice(0, startIndex)}${mainText.slice(endIndex)}`.trim(),
|
||||
metricsText,
|
||||
detail: detailText
|
||||
};
|
||||
}
|
||||
|
||||
const lastOpenIndex = Math.max(mainText.lastIndexOf('('), mainText.lastIndexOf('('));
|
||||
const lastCloseIndex = Math.max(mainText.lastIndexOf(')'), mainText.lastIndexOf(')'));
|
||||
|
||||
if (lastOpenIndex > lastCloseIndex) {
|
||||
const metricsText = mainText.slice(lastOpenIndex + 1).trim();
|
||||
if (isStructuredMetricsText(metricsText)) {
|
||||
return {
|
||||
rawTitle: mainText.slice(0, lastOpenIndex).trim(),
|
||||
metricsText,
|
||||
detail: detailText
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
rawTitle: mainText,
|
||||
metricsText: '',
|
||||
detail: detailText
|
||||
};
|
||||
}
|
||||
|
||||
function parseStructuredSectionTaskText(
|
||||
text: string,
|
||||
fallback?: Partial<StructuredTask>,
|
||||
@@ -350,13 +396,7 @@ function parseStructuredSectionTaskText(
|
||||
const normalizedText = stripStructuredTaskPrefixV2(text);
|
||||
if (!normalizedText) return null;
|
||||
|
||||
const structuredMatch =
|
||||
normalizedText.match(/^(.+?)(?:[((]([^()()]*)[))])?(?:\s*[::]\s*(.*))?$/u) ||
|
||||
normalizedText.match(/^(.+?)(?:\(([^()]*)\))?(?::\s*(.*))?$/u);
|
||||
|
||||
if (!structuredMatch) return null;
|
||||
|
||||
const [, rawTitle, metricsText = '', detail = ''] = structuredMatch;
|
||||
const { rawTitle, metricsText, detail } = extractStructuredTaskParts(normalizedText);
|
||||
const title = stripStructuredTaskSuffixV2(rawTitle);
|
||||
if (!title) return null;
|
||||
|
||||
@@ -412,7 +452,7 @@ function parseStructuredSectionsFromEditorV2(
|
||||
return {
|
||||
...task,
|
||||
detail: fallbackTask?.detail || task.detail || '',
|
||||
hours: task.hours ?? fallbackTask?.hours
|
||||
hours: task.hours
|
||||
};
|
||||
})
|
||||
};
|
||||
@@ -558,7 +598,9 @@ function resolveTaskMetrics(metricsText: string, fallback?: Partial<StructuredTa
|
||||
.filter(Boolean);
|
||||
const priorityText = metricsText.match(/\bP\d+\b/iu)?.[0] || metricParts.find(item => /^P?\d+$/iu.test(item));
|
||||
const priority = normalizePriorityCode(priorityText || (useFallback ? fallback?.priority : undefined));
|
||||
const progressText = metricsText.match(/进度\s*(\d+(?:\.\d+)?)%/u)?.[1];
|
||||
const progressText =
|
||||
metricsText.match(/进度\s*(\d+(?:\.\d+)?)%/u)?.[1] ||
|
||||
metricParts.find(item => /^\d+(?:\.\d+)?%$/u.test(item))?.replace(/%$/u, '');
|
||||
const hoursText = metricsText.match(/(\d+(?:\.\d+)?)h/u)?.[1];
|
||||
|
||||
return {
|
||||
@@ -591,7 +633,7 @@ function toReviewItem(item: Api.WorkReport.Common.PersonalReportReviewItem): Rev
|
||||
: escapeHtml(item.contentText || '').replace(/\n/g, '<br>') || EMPTY_TEXT,
|
||||
contentSections,
|
||||
reflection: item.reflectionText || '',
|
||||
removable: false,
|
||||
removable: true,
|
||||
source: item
|
||||
};
|
||||
}
|
||||
@@ -728,6 +770,13 @@ function removePlanItem(index: number) {
|
||||
if (item?.sourceIndex !== undefined) props.model.planItems.splice(item.sourceIndex, 1);
|
||||
}
|
||||
|
||||
function removeReviewItem(index: number) {
|
||||
const item = reviewItems.value[index];
|
||||
if (!item?.source) return;
|
||||
const sourceIndex = props.model.reviewItems.indexOf(item.source);
|
||||
if (sourceIndex >= 0) props.model.reviewItems.splice(sourceIndex, 1);
|
||||
}
|
||||
|
||||
function focusEditField(key: string) {
|
||||
activeEditField.value = key;
|
||||
}
|
||||
@@ -736,9 +785,19 @@ function blurEditField(key: string) {
|
||||
if (activeEditField.value === key) activeEditField.value = '';
|
||||
}
|
||||
|
||||
function isStructuredEditorUnchanged(text: string, sections: StructuredSection[] | undefined, showHours = false) {
|
||||
if (!sections?.length) return false;
|
||||
return normalizeEditorText(text) === createStructuredTextV2(sections, showHours);
|
||||
}
|
||||
|
||||
function syncRichContent(item: ReviewItem, event: Event) {
|
||||
const target = event.currentTarget as HTMLElement;
|
||||
if (!item.source) return;
|
||||
if (isStructuredEditorUnchanged(target.innerText, item.contentSections, true)) {
|
||||
item.source.contentJson = createSectionsJson(item.contentSections || []);
|
||||
item.source.contentText = createStructuredTextV2(item.contentSections || [], true);
|
||||
return;
|
||||
}
|
||||
const sections = parseStructuredSectionsFromEditorV2(
|
||||
target,
|
||||
item.contentSections || [],
|
||||
@@ -751,6 +810,11 @@ function syncRichContent(item: ReviewItem, event: Event) {
|
||||
function syncRichTarget(item: PlanItem, event: Event) {
|
||||
const target = event.currentTarget as HTMLElement;
|
||||
if (!item.source) return;
|
||||
if (isStructuredEditorUnchanged(target.innerText, item.targetSections)) {
|
||||
item.source.targetJson = createSectionsJson(item.targetSections || []);
|
||||
item.source.targetText = createStructuredTextV2(item.targetSections || []);
|
||||
return;
|
||||
}
|
||||
const sections = parseStructuredSectionsFromEditorV2(
|
||||
target,
|
||||
item.targetSections || [],
|
||||
@@ -836,7 +900,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
confirm-button-text="删除"
|
||||
cancel-button-text="取消"
|
||||
width="220"
|
||||
@confirm="reviewItems.splice(index, 1)"
|
||||
@confirm="removeReviewItem(index)"
|
||||
>
|
||||
<template #reference>
|
||||
<button class="item-remove-btn" aria-label="删除回顾">×</button>
|
||||
@@ -1077,7 +1141,6 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
<ElButton
|
||||
size="small"
|
||||
type="primary"
|
||||
class="btn-submit"
|
||||
:disabled="
|
||||
!planForm.workItem.trim() ||
|
||||
!planForm.sections.some(section => section.category.trim() && section.tasks.length)
|
||||
@@ -1094,7 +1157,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
<div v-if="!isReadonly" class="form-actions">
|
||||
<!-- <ElButton>重置表单</ElButton>-->
|
||||
<ElButton @click="emit('save')">保存草稿</ElButton>
|
||||
<ElButton type="primary" class="btn-submit" @click="emit('submit')">提交审批</ElButton>
|
||||
<ElButton type="primary" @click="emit('submit')">提交审批</ElButton>
|
||||
</div>
|
||||
|
||||
<BusinessFormDialog
|
||||
@@ -1327,8 +1390,8 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
border-radius: 16px;
|
||||
background: #ccfbf1;
|
||||
color: #0f766e;
|
||||
background: var(--el-color-primary-light-8);
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
@@ -1421,12 +1484,12 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
}
|
||||
|
||||
.radio-group-full :deep(.el-radio.is-checked) {
|
||||
border-color: #0f766e;
|
||||
background: #f0fdfa;
|
||||
border-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.radio-group-full :deep(.el-radio__input.is-checked + .el-radio__label) {
|
||||
color: #0f766e;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.review-grid,
|
||||
@@ -1546,7 +1609,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: #0f766e;
|
||||
background: var(--el-color-primary);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
@@ -1744,8 +1807,8 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
}
|
||||
|
||||
.rich-editor:focus {
|
||||
border-color: #0f766e;
|
||||
box-shadow: 0 0 0 2px rgba(15, 118, 110, 0.1);
|
||||
border-color: var(--el-color-primary);
|
||||
box-shadow: 0 0 0 2px var(--el-color-primary-light-8);
|
||||
}
|
||||
|
||||
.rich-editor:empty::before {
|
||||
@@ -1769,7 +1832,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
padding-left: 14px;
|
||||
color: #0f766e;
|
||||
color: var(--el-color-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
white-space: normal;
|
||||
@@ -1785,10 +1848,10 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
background: #0f766e;
|
||||
background: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.rich-editor :deep(.rich-section-tasks) {
|
||||
.rich-editor :deep(.rich-section-task) {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding-left: 14px;
|
||||
@@ -1828,7 +1891,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
}
|
||||
|
||||
.rich-editor :deep(.rich-category-line) {
|
||||
color: #0f766e;
|
||||
color: var(--el-color-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.6;
|
||||
@@ -1899,7 +1962,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
.structured-section-title {
|
||||
position: relative;
|
||||
padding-left: 14px;
|
||||
color: #0f766e;
|
||||
color: var(--el-color-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
@@ -1912,7 +1975,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
background: #0f766e;
|
||||
background: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.structured-task {
|
||||
@@ -1928,7 +1991,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
.structured-task-category {
|
||||
position: relative;
|
||||
padding-left: 14px;
|
||||
color: #0f766e;
|
||||
color: var(--el-color-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
@@ -1941,7 +2004,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
background: #0f766e;
|
||||
background: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.structured-task-title {
|
||||
@@ -1988,9 +2051,9 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
}
|
||||
|
||||
.plan-section.active {
|
||||
border-color: #cfe3e0;
|
||||
background: #f7fbfa;
|
||||
box-shadow: inset 0 0 0 1px rgba(15, 118, 110, 0.06);
|
||||
border-color: var(--el-color-primary-light-7);
|
||||
background: var(--el-color-primary-light-9);
|
||||
box-shadow: inset 0 0 0 1px var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.plan-section-head {
|
||||
@@ -2124,7 +2187,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
}
|
||||
|
||||
.inline-plan-card {
|
||||
border-color: rgba(15, 118, 110, 0.42);
|
||||
border-color: var(--el-color-primary-light-5);
|
||||
background: #f8fbfc;
|
||||
}
|
||||
|
||||
@@ -2171,14 +2234,14 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
.inline-plan-card :deep(.el-input__wrapper.is-focus),
|
||||
.inline-plan-card :deep(.el-select__wrapper.is-focused) {
|
||||
box-shadow:
|
||||
0 0 0 1px #0f766e inset,
|
||||
0 0 0 2px rgba(15, 118, 110, 0.1);
|
||||
0 0 0 1px var(--el-color-primary) inset,
|
||||
0 0 0 2px var(--el-color-primary-light-8);
|
||||
}
|
||||
|
||||
.inline-plan-card :deep(.el-textarea__inner:focus) {
|
||||
box-shadow:
|
||||
0 0 0 1px #0f766e inset,
|
||||
0 0 0 2px rgba(15, 118, 110, 0.1);
|
||||
0 0 0 1px var(--el-color-primary) inset,
|
||||
0 0 0 2px var(--el-color-primary-light-8);
|
||||
}
|
||||
|
||||
.inline-plan-card .inline-task-row :deep(.el-select__selected-item),
|
||||
@@ -2216,8 +2279,8 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
.inline-plan-card .inline-task-row :deep(.el-input-number:focus-within .el-input__wrapper) {
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 0 0 1px #0f766e inset,
|
||||
0 0 0 2px rgba(15, 118, 110, 0.1) !important;
|
||||
0 0 0 1px var(--el-color-primary) inset,
|
||||
0 0 0 2px var(--el-color-primary-light-8) !important;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
@@ -2237,16 +2300,6 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
box-shadow: 0 -8px 18px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
background: #0f766e !important;
|
||||
border-color: #0f766e !important;
|
||||
}
|
||||
|
||||
.btn-submit:hover {
|
||||
background: #0d9488 !important;
|
||||
border-color: #0d9488 !important;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.form-head,
|
||||
.compose-grid,
|
||||
|
||||
@@ -4,6 +4,8 @@ import SharedWorkReportSearch from '../../shared/components/search-panel.vue';
|
||||
defineOptions({ name: 'MonthlyReportSearch' });
|
||||
|
||||
defineProps<{
|
||||
teamMode?: boolean;
|
||||
subordinateOptions?: Array<{ label: string; value: string }>;
|
||||
projectOptions?: Api.WorkReport.Project.ProjectReportOwnerProjectOption[];
|
||||
}>();
|
||||
|
||||
@@ -19,6 +21,8 @@ const emit = defineEmits<{
|
||||
<SharedWorkReportSearch
|
||||
v-model:model="model"
|
||||
report-type="monthly"
|
||||
:team-mode="teamMode"
|
||||
:subordinate-options="subordinateOptions"
|
||||
:project-options="projectOptions"
|
||||
@reset="emit('reset')"
|
||||
@search="emit('search')"
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
<script setup lang="tsx">
|
||||
/* eslint-disable no-void */
|
||||
import { markRaw, reactive, ref } from 'vue';
|
||||
import { computed, markRaw, reactive, ref } from 'vue';
|
||||
import { ElMessageBox, ElTag } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
fetchDeleteProjectReport,
|
||||
fetchExportProjectReportContent,
|
||||
fetchGetProjectReportPage,
|
||||
fetchGetTeamReportSummary,
|
||||
fetchSubmitProjectReport
|
||||
} 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 { type TeamViewContext, resolveTeamQueryUserIds } from '@/views/personal-center/shared/team-dashboard';
|
||||
import {
|
||||
WORK_REPORT_TYPE_LABEL,
|
||||
type WorkReportRow,
|
||||
createProjectSearchParams,
|
||||
createWorkReportContentExportFallbackName,
|
||||
@@ -20,22 +22,25 @@ import {
|
||||
formatDateTime,
|
||||
formatEmptyText,
|
||||
formatPeriod,
|
||||
getProjectReportFlagLabel,
|
||||
getWorkReportStatusLabel,
|
||||
getWorkReportTypeDisplayLabel,
|
||||
resolveExportFilename,
|
||||
resolveWorkReportStatusTagType,
|
||||
transformWorkReportPage
|
||||
} from '../shared/types';
|
||||
import { buildProjectPeriodFromMonth, resolveWorkReportSummaryPeriod } from '../shared/utils';
|
||||
import ProjectReportSearch from './modules/search-panel.vue';
|
||||
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
|
||||
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
|
||||
import IconMdiFileDocumentCheckOutline from '~icons/mdi/file-document-check-outline';
|
||||
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
|
||||
import IconMdiSendOutline from '~icons/mdi/send-outline';
|
||||
import IconMdiDownloadOutline from '~icons/mdi/download-outline';
|
||||
|
||||
defineOptions({ name: 'ProjectWorkReportIndex' });
|
||||
|
||||
const props = defineProps<{
|
||||
teamContext?: TeamViewContext | null;
|
||||
projectOptions: Api.WorkReport.Project.ProjectReportOwnerProjectOption[];
|
||||
projectOptionsLoaded: boolean;
|
||||
}>();
|
||||
@@ -51,21 +56,50 @@ const { hasAuth } = useAuth();
|
||||
const exporting = ref(false);
|
||||
const selectedRows = ref<Api.WorkReport.Project.ProjectReport[]>([]);
|
||||
const searchParams = reactive(createProjectSearchParams());
|
||||
const teamSummaryLoading = ref(false);
|
||||
const teamSummary = ref<Api.WorkReport.Common.TeamReportSummary | null>(null);
|
||||
|
||||
const ACTION_ICON_MAP = {
|
||||
detail: markRaw(IconMdiEyeOutline),
|
||||
edit: markRaw(IconMdiPencilOutline),
|
||||
submit: markRaw(IconMdiSendOutline),
|
||||
delete: markRaw(IconMdiDeleteOutline),
|
||||
approvalRecord: markRaw(IconMdiFileDocumentCheckOutline)
|
||||
approvalRecord: markRaw(IconMdiFileDocumentCheckOutline),
|
||||
export: markRaw(IconMdiDownloadOutline)
|
||||
};
|
||||
|
||||
const isTeamMode = computed(() => props.teamContext?.mode === 'team');
|
||||
const isTeamRootSelected = computed(() => Boolean(isTeamMode.value && props.teamContext?.isRootSelected));
|
||||
const currentProjectOwnerIds = computed(() => resolveTeamQueryUserIds(props.teamContext));
|
||||
const reportTitle = computed(() => getWorkReportTypeDisplayLabel('project', isTeamMode.value));
|
||||
const normalizedPeriodRange = computed(() => {
|
||||
const periodRange = searchParams.periodStartDate;
|
||||
if (!periodRange?.length) {
|
||||
return periodRange;
|
||||
}
|
||||
|
||||
const [startDate, endDate] = periodRange;
|
||||
const start = dayjs(startDate);
|
||||
const end = dayjs(endDate || startDate);
|
||||
|
||||
if (!start.isValid() || !end.isValid()) {
|
||||
return periodRange;
|
||||
}
|
||||
|
||||
return [start.startOf('month').format('YYYY-MM-DD'), end.endOf('month').format('YYYY-MM-DD')];
|
||||
});
|
||||
|
||||
const table = useUIPaginatedTable<
|
||||
Awaited<ReturnType<typeof fetchGetProjectReportPage>>,
|
||||
Api.WorkReport.Project.ProjectReport
|
||||
>({
|
||||
paginationProps: { currentPage: searchParams.pageNo, pageSize: searchParams.pageSize },
|
||||
api: () => fetchGetProjectReportPage(searchParams),
|
||||
api: () =>
|
||||
fetchGetProjectReportPage({
|
||||
...searchParams,
|
||||
periodStartDate: normalizedPeriodRange.value,
|
||||
projectOwnerIds: currentProjectOwnerIds.value
|
||||
}),
|
||||
transform: response => transformWorkReportPage(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
|
||||
onPaginationParamsChange: params => {
|
||||
searchParams.pageNo = params.currentPage ?? 1;
|
||||
@@ -73,9 +107,11 @@ const table = useUIPaginatedTable<
|
||||
},
|
||||
columns: () => [
|
||||
{ prop: 'index', type: 'index', label: '序号', width: 64 },
|
||||
...(isTeamMode.value
|
||||
? [{ prop: 'projectOwnerName', label: '提交人', minWidth: 100, showOverflowTooltip: true }]
|
||||
: []),
|
||||
{ prop: 'projectName', label: '项目名称', minWidth: 200, showOverflowTooltip: true },
|
||||
{ prop: 'periodLabel', label: '半月周期', minWidth: 120, formatter: row => formatPeriod(row) },
|
||||
// { prop: 'flag', label: '半月', width: 90, formatter: row => getProjectReportFlagLabel(row.flag) },
|
||||
{ prop: 'projectOwnerName', label: '项目负责人', minWidth: 80 },
|
||||
{
|
||||
prop: 'technicalOwnerName',
|
||||
@@ -101,7 +137,7 @@ const table = useUIPaginatedTable<
|
||||
{
|
||||
prop: 'operate',
|
||||
label: '操作',
|
||||
width: 180,
|
||||
width: isTeamMode.value ? 140 : 180,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
|
||||
@@ -109,17 +145,84 @@ const table = useUIPaginatedTable<
|
||||
]
|
||||
});
|
||||
|
||||
// 团队统计始终使用当前周期(当前半月),不跟随列表第一条数据的周期
|
||||
const summaryPeriod = computed(() =>
|
||||
resolveWorkReportSummaryPeriod('project', {
|
||||
periodRange: normalizedPeriodRange.value
|
||||
})
|
||||
);
|
||||
const summaryPeriodKeys = computed(() => {
|
||||
const dateRange = normalizedPeriodRange.value;
|
||||
const fallbackKey = summaryPeriod.value.periodKey;
|
||||
|
||||
if (!dateRange?.length) {
|
||||
return fallbackKey ? [fallbackKey] : [];
|
||||
}
|
||||
|
||||
const [startDate, endDate] = dateRange;
|
||||
const start = dayjs(startDate);
|
||||
const end = dayjs(endDate || startDate);
|
||||
|
||||
if (!start.isValid() || !end.isValid()) {
|
||||
return fallbackKey ? [fallbackKey] : [];
|
||||
}
|
||||
|
||||
const keys: string[] = [];
|
||||
const endBoundary = end.endOf('month');
|
||||
|
||||
for (
|
||||
let cursor = start.startOf('month');
|
||||
cursor.isBefore(endBoundary, 'month') || cursor.isSame(endBoundary, 'month');
|
||||
cursor = cursor.add(1, 'month')
|
||||
) {
|
||||
keys.push(buildProjectPeriodFromMonth(cursor, 1).periodKey);
|
||||
keys.push(buildProjectPeriodFromMonth(cursor, 2).periodKey);
|
||||
}
|
||||
|
||||
return keys;
|
||||
});
|
||||
const hasSearchedDateRange = computed(() => searchParams.periodStartDate?.length === 2);
|
||||
|
||||
function getRowActions(row: Api.WorkReport.Project.ProjectReport): BusinessTableAction[] {
|
||||
const actions: BusinessTableAction[] = [
|
||||
{
|
||||
key: 'detail',
|
||||
label: '详情',
|
||||
label: '查看',
|
||||
buttonType: 'primary',
|
||||
icon: ACTION_ICON_MAP.detail,
|
||||
onClick: () => emit('detail', row)
|
||||
}
|
||||
];
|
||||
|
||||
if (isTeamMode.value) {
|
||||
actions.push({
|
||||
key: 'export',
|
||||
label: '导出',
|
||||
buttonType: 'success',
|
||||
icon: ACTION_ICON_MAP.export,
|
||||
onClick: () =>
|
||||
exportReportContent(
|
||||
{
|
||||
exportAll: false,
|
||||
ids: [row.id]
|
||||
},
|
||||
1
|
||||
)
|
||||
});
|
||||
|
||||
if (['approved', 'rejected'].includes(row.statusCode)) {
|
||||
actions.push({
|
||||
key: 'approval-record',
|
||||
label: '审批记录',
|
||||
buttonType: 'info',
|
||||
icon: ACTION_ICON_MAP.approvalRecord,
|
||||
onClick: () => emit('approvalRecord', row)
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
if (['draft', 'rejected'].includes(row.statusCode) && row.allowEdit && hasAuth('project:work-report:update')) {
|
||||
actions.push({
|
||||
key: 'edit',
|
||||
@@ -162,6 +265,7 @@ function getRowActions(row: Api.WorkReport.Project.ProjectReport): BusinessTable
|
||||
|
||||
async function reload(page?: number) {
|
||||
await table.getDataByPage(page ?? searchParams.pageNo ?? 1);
|
||||
await loadTeamSummary();
|
||||
}
|
||||
|
||||
function resetSearchParams() {
|
||||
@@ -194,7 +298,7 @@ async function handleSubmitReport(row: Api.WorkReport.Project.ProjectReport) {
|
||||
|
||||
async function handleDelete(row: Api.WorkReport.Project.ProjectReport) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除 ${formatPeriod(row)} 吗?`, '删除确认', {
|
||||
await ElMessageBox.confirm(`确认删除该报告吗?`, '删除确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确认',
|
||||
cancelButtonText: '取消'
|
||||
@@ -217,7 +321,11 @@ function handleSelectionChange(rows: Api.WorkReport.Project.ProjectReport[]) {
|
||||
|
||||
function createExportSearchParams() {
|
||||
const { pageNo: _pageNo, pageSize: _pageSize, ...params } = searchParams;
|
||||
return params;
|
||||
return {
|
||||
...params,
|
||||
periodStartDate: normalizedPeriodRange.value,
|
||||
projectOwnerIds: currentProjectOwnerIds.value
|
||||
};
|
||||
}
|
||||
|
||||
async function exportReportContent(
|
||||
@@ -275,7 +383,39 @@ async function handleExportCommand(command: 'selected' | 'all') {
|
||||
await handleExportAll();
|
||||
}
|
||||
|
||||
defineExpose({ reload });
|
||||
async function loadTeamSummary() {
|
||||
if (!isTeamRootSelected.value) {
|
||||
teamSummaryLoading.value = false;
|
||||
teamSummary.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const dateRange = normalizedPeriodRange.value;
|
||||
const summaryParams: Api.WorkReport.Common.TeamReportSummaryParams = { reportType: 'project' };
|
||||
|
||||
if (dateRange?.length === 2) {
|
||||
summaryParams.periodStartDate = dateRange[0];
|
||||
summaryParams.periodEndDate = dateRange[1];
|
||||
} else {
|
||||
summaryParams.periodKey = summaryPeriod.value.periodKey;
|
||||
}
|
||||
|
||||
teamSummaryLoading.value = true;
|
||||
const { error, data } = await fetchGetTeamReportSummary(summaryParams);
|
||||
teamSummaryLoading.value = false;
|
||||
|
||||
teamSummary.value = error || !data ? null : data;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
reload,
|
||||
teamSummary,
|
||||
teamSummaryLoading,
|
||||
summaryPeriod,
|
||||
summaryPeriodKeys,
|
||||
hasSearchedDateRange,
|
||||
loadTeamSummary
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -296,7 +436,7 @@ defineExpose({ reload });
|
||||
<template #header>
|
||||
<div class="flex flex-wrap items-center justify-between gap-12px">
|
||||
<div class="flex items-center gap-10px">
|
||||
<p class="text-16px font-600">{{ WORK_REPORT_TYPE_LABEL.project }}</p>
|
||||
<p class="text-16px font-600">{{ reportTitle }}</p>
|
||||
<ElTag effect="plain">{{ table.mobilePagination.value.total || 0 }}</ElTag>
|
||||
</div>
|
||||
|
||||
@@ -322,7 +462,13 @@ defineExpose({ reload });
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
<ElButton v-auth="'project:work-report:create'" plain type="primary" @click="emit('create')">
|
||||
<ElButton
|
||||
v-if="!isTeamMode"
|
||||
v-auth="'project:work-report:create'"
|
||||
plain
|
||||
type="primary"
|
||||
@click="emit('create')"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
|
||||
@@ -127,7 +127,7 @@ function toWorkItem(item: Api.WorkReport.Project.ProjectReportItem, index: numbe
|
||||
}
|
||||
|
||||
const currentWorks = computed<WorkItem[]>(() => {
|
||||
return (props.model.currentItems || []).map((item, index) => toWorkItem(item, index, false));
|
||||
return (props.model.currentItems || []).map((item, index) => toWorkItem(item, index, true));
|
||||
});
|
||||
|
||||
const nextPlans = computed<WorkItem[]>(() => {
|
||||
@@ -273,6 +273,11 @@ function removePlanItem(index: number) {
|
||||
if (item?.sourceIndex !== undefined) props.model.nextItems.splice(item.sourceIndex, 1);
|
||||
}
|
||||
|
||||
function removeCurrentWorkItem(index: number) {
|
||||
const item = currentWorks.value[index];
|
||||
if (item?.sourceIndex !== undefined) props.model.currentItems.splice(item.sourceIndex, 1);
|
||||
}
|
||||
|
||||
function startTitleEdit(item: WorkItem) {
|
||||
titleEditSnapshot.value[item.id] = item.title;
|
||||
}
|
||||
@@ -288,9 +293,9 @@ function notifyTitleSaved(item: WorkItem) {
|
||||
item.title = nextTitle;
|
||||
if (item.source) item.source.itemTitle = nextTitle;
|
||||
delete titleEditSnapshot.value[item.id];
|
||||
if (nextTitle !== previousTitle) {
|
||||
ElMessage.success('名称已成功修改');
|
||||
}
|
||||
// if (nextTitle !== previousTitle) {
|
||||
// ElMessage.success('名称已成功修改');
|
||||
// }
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -373,7 +378,7 @@ function notifyTitleSaved(item: WorkItem) {
|
||||
<div class="review-grid layout-row">
|
||||
<div v-if="!currentWorks.length">{{ EMPTY_TEXT }}</div>
|
||||
<div v-for="(item, index) in currentWorks" :key="item.id" class="review-card compact-work-card">
|
||||
<div class="review-card-head">
|
||||
<div class="review-card-head" :class="{ 'no-remove': item.removable === false }">
|
||||
<span class="row-index">{{ index + 1 }}</span>
|
||||
<div class="review-title-readonly">
|
||||
<div class="work-title-line">
|
||||
@@ -390,6 +395,18 @@ function notifyTitleSaved(item: WorkItem) {
|
||||
<span class="meta-chip">共 {{ item.hours }}h</span>
|
||||
</div>
|
||||
</div>
|
||||
<ElPopconfirm
|
||||
v-if="item.removable !== false && !isReadonly"
|
||||
title="确认删除这条工作内容吗?"
|
||||
confirm-button-text="删除"
|
||||
cancel-button-text="取消"
|
||||
width="220"
|
||||
@confirm="removeCurrentWorkItem(index)"
|
||||
>
|
||||
<template #reference>
|
||||
<button class="item-remove-btn" aria-label="删除工作内容">×</button>
|
||||
</template>
|
||||
</ElPopconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -474,7 +491,6 @@ function notifyTitleSaved(item: WorkItem) {
|
||||
<ElButton
|
||||
size="small"
|
||||
type="primary"
|
||||
class="btn-submit"
|
||||
:disabled="!planForm.title.trim() || isDuplicatePlanTitle"
|
||||
@click="submitInlinePlan"
|
||||
>
|
||||
@@ -520,11 +536,11 @@ function notifyTitleSaved(item: WorkItem) {
|
||||
<div v-if="!isReadonly" class="form-actions">
|
||||
<!-- <ElButton>重置表单</ElButton>-->
|
||||
<ElButton @click="emit('save')">保存草稿</ElButton>
|
||||
<ElButton type="primary" class="btn-submit" @click="emit('submit')">提交审批</ElButton>
|
||||
<ElButton type="primary" @click="emit('submit')">提交审批</ElButton>
|
||||
</div>
|
||||
<div v-else-if="scene === 'approval'" class="form-actions approval-form-actions">
|
||||
<ElButton @click="emit('back')">退出审批</ElButton>
|
||||
<ElButton type="primary" class="btn-submit" @click="emit('requestApprove')">开始审批</ElButton>
|
||||
<ElButton type="primary" @click="emit('requestApprove')">开始审批</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -567,8 +583,8 @@ function notifyTitleSaved(item: WorkItem) {
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
border-radius: 16px;
|
||||
background: #ccfbf1;
|
||||
color: #0f766e;
|
||||
background: var(--el-color-primary-light-8);
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
@@ -671,8 +687,8 @@ function notifyTitleSaved(item: WorkItem) {
|
||||
}
|
||||
|
||||
.member-chip.more {
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@@ -715,7 +731,7 @@ function notifyTitleSaved(item: WorkItem) {
|
||||
}
|
||||
|
||||
.compact-work-card:hover {
|
||||
border-color: rgba(15, 118, 110, 0.45);
|
||||
border-color: var(--el-color-primary-light-5);
|
||||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
@@ -728,10 +744,7 @@ function notifyTitleSaved(item: WorkItem) {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.review-card-head {
|
||||
grid-template-columns: 28px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.review-card-head.no-remove,
|
||||
.plan-card-head.no-remove {
|
||||
grid-template-columns: 28px minmax(0, 1fr);
|
||||
}
|
||||
@@ -742,7 +755,7 @@ function notifyTitleSaved(item: WorkItem) {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: #0f766e;
|
||||
background: var(--el-color-primary);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
@@ -806,7 +819,7 @@ function notifyTitleSaved(item: WorkItem) {
|
||||
}
|
||||
|
||||
.work-title-input:focus {
|
||||
border-bottom-color: #0f766e;
|
||||
border-bottom-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.work-title-line span {
|
||||
@@ -908,8 +921,8 @@ function notifyTitleSaved(item: WorkItem) {
|
||||
|
||||
.rich-editor:focus {
|
||||
box-shadow:
|
||||
0 0 0 1px #0f766e inset,
|
||||
0 0 0 2px rgba(15, 118, 110, 0.1);
|
||||
0 0 0 1px var(--el-color-primary) inset,
|
||||
0 0 0 2px var(--el-color-primary-light-8);
|
||||
}
|
||||
|
||||
.rich-editor:empty::before {
|
||||
@@ -938,7 +951,7 @@ function notifyTitleSaved(item: WorkItem) {
|
||||
}
|
||||
|
||||
.inline-plan-card {
|
||||
border-color: rgba(15, 118, 110, 0.42);
|
||||
border-color: var(--el-color-primary-light-5);
|
||||
background: #f8fbfc;
|
||||
}
|
||||
|
||||
@@ -1001,8 +1014,8 @@ function notifyTitleSaved(item: WorkItem) {
|
||||
.inline-plan-card :deep(.el-input__wrapper.is-focus),
|
||||
.inline-plan-card :deep(.el-select__wrapper.is-focused) {
|
||||
box-shadow:
|
||||
0 0 0 1px #0f766e inset,
|
||||
0 0 0 2px rgba(15, 118, 110, 0.1);
|
||||
0 0 0 1px var(--el-color-primary) inset,
|
||||
0 0 0 2px var(--el-color-primary-light-8);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
@@ -1034,16 +1047,6 @@ function notifyTitleSaved(item: WorkItem) {
|
||||
box-shadow: 0 -8px 18px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
background: #0f766e !important;
|
||||
border-color: #0f766e !important;
|
||||
}
|
||||
|
||||
.btn-submit:hover {
|
||||
background: #0d9488 !important;
|
||||
border-color: #0d9488 !important;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.compose-grid,
|
||||
.review-grid,
|
||||
|
||||
@@ -385,14 +385,14 @@ async function handleSubmit() {
|
||||
}
|
||||
|
||||
.conclusion-btn:hover {
|
||||
border-color: #0f766e;
|
||||
color: #0f766e;
|
||||
border-color: var(--el-color-primary);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.conclusion-btn.active.pass {
|
||||
border-color: #0f766e;
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
border-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.conclusion-btn.active.reject {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { Calendar } from '@element-plus/icons-vue';
|
||||
import isoWeek from 'dayjs/plugin/isoWeek';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import {
|
||||
type WorkReportPeriodOption,
|
||||
@@ -12,6 +14,8 @@ import {
|
||||
} from '../utils';
|
||||
import { WORK_REPORT_TYPE_LABEL, type WorkReportType } from '../types';
|
||||
|
||||
dayjs.extend(isoWeek);
|
||||
|
||||
defineOptions({ name: 'WorkReportCreateDialog' });
|
||||
|
||||
interface Props {
|
||||
@@ -44,11 +48,24 @@ const emit = defineEmits<{
|
||||
|
||||
const selectedPeriodKey = ref('');
|
||||
const selectedProjectId = ref('');
|
||||
const customWeekDate = ref('');
|
||||
const rawCustomWeekDate = ref('');
|
||||
const customMonth = ref('');
|
||||
const customProjectMonth = ref('');
|
||||
const customProjectFlag = ref(1);
|
||||
|
||||
// 自定义周报周期:无论用户点哪一天,都归一到该 ISO 周的周一,
|
||||
// 这样 ElDatePicker 使用内置 dayjs 的 ww(locale week)也能正确显示 ISO 周数。
|
||||
const customWeekDate = computed<string>({
|
||||
get: () => rawCustomWeekDate.value,
|
||||
set: val => {
|
||||
if (!val) {
|
||||
rawCustomWeekDate.value = val;
|
||||
return;
|
||||
}
|
||||
rawCustomWeekDate.value = dayjs(val).startOf('isoWeek').format('YYYY-MM-DD');
|
||||
}
|
||||
});
|
||||
|
||||
const selectedReportType = computed<WorkReportType>(() => {
|
||||
if (props.defaultReportType === 'project' && !props.projectVisible) return 'weekly';
|
||||
return props.defaultReportType;
|
||||
@@ -157,110 +174,118 @@ function handleConfirm() {
|
||||
:close-on-click-modal="false"
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<div v-if="selectedReportType === 'project'" class="work-report-create-dialog__project-select">
|
||||
<label class="work-report-create-dialog__label">项目</label>
|
||||
<ElSelect v-model="selectedProjectId" class="w-full" placeholder="请选择项目" filterable>
|
||||
<ElOption
|
||||
v-for="item in props.projectOptions"
|
||||
:key="item.id"
|
||||
:label="item.projectCode ? `${item.projectName}(${item.projectCode})` : item.projectName"
|
||||
:value="item.id"
|
||||
/>
|
||||
</ElSelect>
|
||||
</div>
|
||||
|
||||
<div class="work-report-create-dialog__section">
|
||||
<div class="work-report-create-dialog__grid is-period">
|
||||
<button
|
||||
v-for="item in activePeriodOptions"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="work-report-create-dialog__choice"
|
||||
:class="{ 'is-active': selectedPeriodKey === item.key }"
|
||||
@click="selectedPeriodKey = item.key"
|
||||
<!-- 必须用单根节点包裹:ElScrollbar 默认 slot 接收多根 Fragment 时,destroy-on-close 后 patch 会触发 null __vnode 错误 -->
|
||||
<div class="work-report-create-dialog__body">
|
||||
<div v-if="selectedReportType === 'project'" class="work-report-create-dialog__project-select">
|
||||
<label class="work-report-create-dialog__label">项目</label>
|
||||
<ElSelect
|
||||
v-model="selectedProjectId"
|
||||
class="work-report-create-dialog__project-select-control w-full"
|
||||
placeholder="请选择项目"
|
||||
filterable
|
||||
>
|
||||
<div class="work-report-create-dialog__choice-title">{{ item.label }}</div>
|
||||
<div class="work-report-create-dialog__choice-desc">{{ item.description }}</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="work-report-create-dialog__choice"
|
||||
:class="{ 'is-active': selectedPeriodKey === 'custom' }"
|
||||
@click="selectedPeriodKey = 'custom'"
|
||||
>
|
||||
<div class="work-report-create-dialog__choice-title">自定义周期</div>
|
||||
<div class="work-report-create-dialog__choice-desc">
|
||||
{{
|
||||
selectedReportType === 'weekly'
|
||||
? '选择某一周作为周报周期。'
|
||||
: selectedReportType === 'monthly'
|
||||
? '选择某一月作为月报周期。'
|
||||
: '选择某个月的上半月或下半月。'
|
||||
}}
|
||||
</div>
|
||||
</button>
|
||||
<ElOption
|
||||
v-for="item in props.projectOptions"
|
||||
:key="item.id"
|
||||
:label="item.projectCode ? `${item.projectName}(${item.projectCode})` : item.projectName"
|
||||
:value="item.id"
|
||||
/>
|
||||
</ElSelect>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedPeriodKey === 'custom'" class="work-report-create-dialog__custom-period">
|
||||
<div v-if="selectedReportType === 'weekly'" class="work-report-create-dialog__custom-row">
|
||||
<div class="work-report-create-dialog__field work-report-create-dialog__field--inline">
|
||||
<label class="work-report-create-dialog__label">周报周期</label>
|
||||
<ElDatePicker
|
||||
v-model="customWeekDate"
|
||||
type="date"
|
||||
format="YYYY[年第]ww[周]"
|
||||
value-format="YYYY-MM-DD"
|
||||
popper-class="work-report-create-date-popper"
|
||||
placeholder="请选择周报周期"
|
||||
/>
|
||||
<div v-if="customPeriodPreviewLabel" class="work-report-create-dialog__period-preview">
|
||||
{{ customPeriodPreviewLabel }}
|
||||
<div class="work-report-create-dialog__section">
|
||||
<div class="work-report-create-dialog__grid is-period">
|
||||
<button
|
||||
v-for="item in activePeriodOptions"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="work-report-create-dialog__choice"
|
||||
:class="{ 'is-active': selectedPeriodKey === item.key }"
|
||||
@click="selectedPeriodKey = item.key"
|
||||
>
|
||||
<div class="work-report-create-dialog__choice-title">{{ item.label }}</div>
|
||||
<div class="work-report-create-dialog__choice-desc">{{ item.description }}</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="work-report-create-dialog__choice"
|
||||
:class="{ 'is-active': selectedPeriodKey === 'custom' }"
|
||||
@click="selectedPeriodKey = 'custom'"
|
||||
>
|
||||
<div class="work-report-create-dialog__choice-title">自定义周期</div>
|
||||
<div class="work-report-create-dialog__choice-desc">
|
||||
{{
|
||||
selectedReportType === 'weekly'
|
||||
? '选择某一周作为周报周期。'
|
||||
: selectedReportType === 'monthly'
|
||||
? '选择某一月作为月报周期。'
|
||||
: '选择某个月的上半月或下半月。'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="selectedReportType === 'monthly'" class="work-report-create-dialog__custom-row">
|
||||
<div class="work-report-create-dialog__field work-report-create-dialog__field--inline">
|
||||
<label class="work-report-create-dialog__label">月报周期</label>
|
||||
<ElDatePicker
|
||||
v-model="customMonth"
|
||||
type="month"
|
||||
value-format="YYYY-MM"
|
||||
popper-class="work-report-create-date-popper"
|
||||
placeholder="请选择月份"
|
||||
/>
|
||||
<div v-if="customPeriodPreviewLabel" class="work-report-create-dialog__period-preview">
|
||||
{{ customPeriodPreviewLabel }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="work-report-create-dialog__custom-project">
|
||||
<div class="work-report-create-dialog__custom-project-grid">
|
||||
<div class="work-report-create-dialog__custom-project-item">
|
||||
<div class="work-report-create-dialog__custom-project-item-label">选择月份</div>
|
||||
<div v-if="selectedPeriodKey === 'custom'" class="work-report-create-dialog__custom-period">
|
||||
<div v-if="selectedReportType === 'weekly'" class="work-report-create-dialog__custom-row">
|
||||
<div class="work-report-create-dialog__field work-report-create-dialog__field--inline">
|
||||
<label class="work-report-create-dialog__label">周报周期</label>
|
||||
<ElDatePicker
|
||||
v-model="customProjectMonth"
|
||||
class="w-full"
|
||||
v-model="customWeekDate"
|
||||
type="date"
|
||||
format="YYYY[年第]ww[周]"
|
||||
value-format="YYYY-MM-DD"
|
||||
popper-class="work-report-create-date-popper"
|
||||
placeholder="请选择周报周期"
|
||||
/>
|
||||
<div v-if="customPeriodPreviewLabel" class="work-report-create-dialog__period-preview">
|
||||
{{ customPeriodPreviewLabel }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="selectedReportType === 'monthly'" class="work-report-create-dialog__custom-row">
|
||||
<div class="work-report-create-dialog__field work-report-create-dialog__field--inline">
|
||||
<label class="work-report-create-dialog__label">月报周期</label>
|
||||
<ElDatePicker
|
||||
v-model="customMonth"
|
||||
type="month"
|
||||
value-format="YYYY-MM"
|
||||
popper-class="work-report-create-date-popper"
|
||||
placeholder="请选择月份"
|
||||
/>
|
||||
</div>
|
||||
<div class="work-report-create-dialog__custom-project-item">
|
||||
<div class="work-report-create-dialog__custom-project-item-label">选择半月</div>
|
||||
<ElSegmented
|
||||
v-model="customProjectFlag"
|
||||
:options="projectHalfOptions"
|
||||
class="work-report-create-dialog__half-segmented"
|
||||
/>
|
||||
<div v-if="customPeriodPreviewLabel" class="work-report-create-dialog__period-preview">
|
||||
{{ customPeriodPreviewLabel }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="customPeriodPreviewLabel" class="work-report-create-dialog__period-preview">
|
||||
<ElIcon class="work-report-create-dialog__period-preview-icon"><Calendar /></ElIcon>
|
||||
<span class="work-report-create-dialog__period-preview-text">已选周期:</span>
|
||||
<span class="work-report-create-dialog__period-preview-value">{{ customPeriodPreviewLabel }}</span>
|
||||
|
||||
<div v-else class="work-report-create-dialog__custom-project">
|
||||
<div class="work-report-create-dialog__custom-project-grid">
|
||||
<div class="work-report-create-dialog__custom-project-item">
|
||||
<div class="work-report-create-dialog__custom-project-item-label">选择月份</div>
|
||||
<ElDatePicker
|
||||
v-model="customProjectMonth"
|
||||
class="w-full"
|
||||
type="month"
|
||||
value-format="YYYY-MM"
|
||||
popper-class="work-report-create-date-popper"
|
||||
placeholder="请选择月份"
|
||||
/>
|
||||
</div>
|
||||
<div class="work-report-create-dialog__custom-project-item">
|
||||
<div class="work-report-create-dialog__custom-project-item-label">选择半月</div>
|
||||
<ElSegmented
|
||||
v-model="customProjectFlag"
|
||||
:options="projectHalfOptions"
|
||||
class="work-report-create-dialog__half-segmented"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="customPeriodPreviewLabel" class="work-report-create-dialog__period-preview">
|
||||
<ElIcon class="work-report-create-dialog__period-preview-icon"><Calendar /></ElIcon>
|
||||
<span class="work-report-create-dialog__period-preview-text">已选周期:</span>
|
||||
<span class="work-report-create-dialog__period-preview-value">{{ customPeriodPreviewLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -320,13 +345,13 @@ function handleConfirm() {
|
||||
}
|
||||
|
||||
.work-report-create-dialog__choice:hover {
|
||||
border-color: rgba(15, 118, 110, 0.28);
|
||||
border-color: var(--el-color-primary-light-3);
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.work-report-create-dialog__choice.is-active {
|
||||
border-color: #0f766e;
|
||||
background: #ecfdf5;
|
||||
border-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.work-report-create-dialog__choice-title {
|
||||
@@ -347,6 +372,16 @@ function handleConfirm() {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.work-report-create-dialog__project-select-control :deep(.el-select__wrapper) {
|
||||
min-height: 46px;
|
||||
border-radius: 12px;
|
||||
padding-inline: 14px;
|
||||
}
|
||||
|
||||
.work-report-create-dialog__project-select-control :deep(.el-select__selected-item) {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.work-report-create-dialog__field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
@@ -381,9 +416,9 @@ function handleConfirm() {
|
||||
.work-report-create-dialog__custom-period {
|
||||
margin-top: 14px;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(15, 118, 110, 0.18);
|
||||
border: 1px solid var(--el-color-primary-light-7);
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, #f8fffd 0%, #ffffff 100%);
|
||||
background: linear-gradient(180deg, var(--el-color-primary-light-9) 0%, #ffffff 100%);
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
@@ -422,7 +457,7 @@ function handleConfirm() {
|
||||
}
|
||||
|
||||
.work-report-create-dialog__custom-project-item:hover {
|
||||
border-color: rgba(15, 118, 110, 0.4);
|
||||
border-color: var(--el-color-primary-light-5);
|
||||
}
|
||||
|
||||
.work-report-create-dialog__custom-project-item-label {
|
||||
@@ -460,10 +495,10 @@ function handleConfirm() {
|
||||
gap: 6px;
|
||||
min-height: 32px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid rgba(15, 118, 110, 0.18);
|
||||
border: 1px solid var(--el-color-primary-light-7);
|
||||
border-radius: 999px;
|
||||
background: #ecfdf5;
|
||||
color: #0f766e;
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
@@ -472,7 +507,7 @@ function handleConfirm() {
|
||||
|
||||
.work-report-create-dialog__period-preview-icon {
|
||||
font-size: 14px;
|
||||
color: #0f766e;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.work-report-create-dialog__period-preview-text {
|
||||
@@ -481,7 +516,7 @@ function handleConfirm() {
|
||||
}
|
||||
|
||||
.work-report-create-dialog__period-preview-value {
|
||||
color: #0f766e;
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@@ -529,6 +564,6 @@ function handleConfirm() {
|
||||
|
||||
:global(.work-report-create-date-popper .el-date-table td.current:not(.disabled) .el-date-table-cell__text),
|
||||
:global(.work-report-create-date-popper .el-month-table td.current:not(.disabled) .cell) {
|
||||
background-color: #0f766e;
|
||||
background-color: var(--el-color-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
fetchInitMonthlyReport,
|
||||
fetchInitProjectReport,
|
||||
fetchInitWeeklyReport,
|
||||
fetchPerformanceMonthlyResult,
|
||||
fetchPreviewMonthlyReportDefaultDraft,
|
||||
fetchPreviewProjectReportDefaultDraft,
|
||||
fetchPreviewWeeklyReportDefaultDraft,
|
||||
@@ -111,6 +112,7 @@ const monthlyApprovalDraft = reactive<Api.WorkReport.Monthly.MonthlyReportApprov
|
||||
supervisorSignName: '',
|
||||
supervisorSignedDate: ''
|
||||
});
|
||||
const monthlyPerformanceAutoFilled = ref(false);
|
||||
|
||||
const weeklyModel = reactive<Api.WorkReport.Weekly.WeeklyReportSaveParams>(createWeeklySaveParams());
|
||||
const monthlyModel = reactive<Api.WorkReport.Monthly.MonthlyReportSaveParams>(createMonthlySaveParams());
|
||||
@@ -167,6 +169,7 @@ function resetModels() {
|
||||
supervisorSignName: '',
|
||||
supervisorSignedDate: ''
|
||||
});
|
||||
monthlyPerformanceAutoFilled.value = false;
|
||||
}
|
||||
|
||||
function patchMonthlyApprovalDefaults(report?: Partial<Api.WorkReport.Monthly.MonthlyReport> | null) {
|
||||
@@ -417,6 +420,11 @@ async function refreshDraft() {
|
||||
|
||||
if (result.error || !result.data) return;
|
||||
|
||||
baseInfo.value = {
|
||||
...(baseInfo.value || {}),
|
||||
...result.data
|
||||
} as WorkReportRow;
|
||||
|
||||
if (props.reportType === 'weekly') {
|
||||
applyWeeklyEditableFields(result.data as Api.WorkReport.Weekly.WeeklyReport);
|
||||
} else if (props.reportType === 'monthly') {
|
||||
@@ -471,9 +479,19 @@ watch(visible, async isVisible => {
|
||||
|
||||
if (props.rowData?.id) {
|
||||
await loadDetail(props.rowData.id);
|
||||
await fillMonthlyPerformanceResult();
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => currentStage.value,
|
||||
stage => {
|
||||
if (stage === 'approval') {
|
||||
fillMonthlyPerformanceResult();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function hasTextValue(value: unknown) {
|
||||
const text = String(value ?? '')
|
||||
.replace(/<[^>]*>/g, '')
|
||||
@@ -706,6 +724,45 @@ function handleBack() {
|
||||
|
||||
function handleViewApproval() {
|
||||
currentStage.value = 'approval';
|
||||
fillMonthlyPerformanceResult();
|
||||
}
|
||||
|
||||
function resolveMonthlyPeriodMonth() {
|
||||
const startDate = monthlyModel.periodStartDate || baseInfo.value?.periodStartDate;
|
||||
const fromStartDate = dayjs(startDate);
|
||||
if (fromStartDate.isValid()) {
|
||||
return fromStartDate.format('YYYY-MM');
|
||||
}
|
||||
|
||||
const periodKey = monthlyModel.periodKey || baseInfo.value?.periodKey;
|
||||
const periodKeyMatch = String(periodKey || '').match(/(\d{4})[-/年]?(\d{1,2})/u);
|
||||
if (periodKeyMatch) {
|
||||
return `${periodKeyMatch[1]}-${periodKeyMatch[2].padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const periodLabel = monthlyModel.periodLabel || baseInfo.value?.periodLabel;
|
||||
const periodLabelMatch = String(periodLabel || '').match(/(\d{4})[-/年]?(\d{1,2})/u);
|
||||
if (periodLabelMatch) {
|
||||
return `${periodLabelMatch[1]}-${periodLabelMatch[2].padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
async function fillMonthlyPerformanceResult() {
|
||||
if (props.reportType !== 'monthly' || monthlyPerformanceAutoFilled.value) return;
|
||||
if (monthlyApprovalDraft.performanceResult?.trim()) return;
|
||||
|
||||
const employeeId = (baseInfo.value as Api.WorkReport.Monthly.MonthlyReport | null)?.reporterId;
|
||||
const periodMonth = resolveMonthlyPeriodMonth();
|
||||
if (!employeeId || !periodMonth) return;
|
||||
|
||||
monthlyPerformanceAutoFilled.value = true;
|
||||
const { error, data } = await fetchPerformanceMonthlyResult(employeeId, periodMonth);
|
||||
if (error || !data?.actualScoreTotal) return;
|
||||
if (monthlyApprovalDraft.performanceResult?.trim()) return;
|
||||
|
||||
monthlyApprovalDraft.performanceResult = String(data.actualScoreTotal);
|
||||
}
|
||||
|
||||
function handleRequestApprove() {
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
/* eslint-disable no-void */
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import isoWeek from 'dayjs/plugin/isoWeek';
|
||||
import { fetchGetWorkReportStatusDict } from '@/service/api';
|
||||
import type { SearchField } from '@/components/custom/table-search-fields.vue';
|
||||
import TableSearchFields from '@/components/custom/table-search-fields.vue';
|
||||
import { BOOLEAN_TRUE_FALSE_OPTIONS, type WorkReportSearchParams, type WorkReportType } from '../types';
|
||||
import { formatIsoWeekRangeLabel, normalizeWeeklySearchRange } from '../utils';
|
||||
|
||||
dayjs.extend(isoWeek);
|
||||
|
||||
defineOptions({ name: 'WorkReportSearch' });
|
||||
|
||||
interface Props {
|
||||
reportType: WorkReportType;
|
||||
teamMode?: boolean;
|
||||
subordinateOptions?: Array<{ label: string; value: string }>;
|
||||
projectOptions?: Api.WorkReport.Project.ProjectReportOwnerProjectOption[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
teamMode: false,
|
||||
subordinateOptions: () => [],
|
||||
projectOptions: () => []
|
||||
});
|
||||
|
||||
@@ -35,6 +44,12 @@ const statusOptions = computed(() =>
|
||||
}))
|
||||
);
|
||||
|
||||
const weeklyPeriodPlaceholder = computed(() => {
|
||||
const range = normalizeWeeklySearchRange(model.value.periodStartDate as string[] | undefined);
|
||||
if (!range?.length) return '请选择周报周期';
|
||||
return formatIsoWeekRangeLabel(range[0], range[1]) || '请选择周报周期';
|
||||
});
|
||||
|
||||
const fields = computed<SearchField[]>(() => {
|
||||
const baseFields: SearchField[] = [
|
||||
{ key: 'statusCode', label: '状态', type: 'select', options: statusOptions.value, placeholder: '请选择状态' },
|
||||
@@ -51,8 +66,34 @@ const fields = computed<SearchField[]>(() => {
|
||||
};
|
||||
|
||||
if (props.reportType === 'weekly') {
|
||||
const weeklyPeriodField: SearchField = {
|
||||
key: 'periodStartDate',
|
||||
label: '周期',
|
||||
type: 'dateRange',
|
||||
placeholder: weeklyPeriodPlaceholder.value,
|
||||
format: 'YYYY[年第]ww[周]',
|
||||
rangeSeparator: '至'
|
||||
};
|
||||
|
||||
const teamReporterField: SearchField[] = props.teamMode
|
||||
? [
|
||||
{
|
||||
key: 'reporterIds',
|
||||
label: '提交人',
|
||||
type: 'select',
|
||||
options: props.subordinateOptions,
|
||||
placeholder: '请选择提交人',
|
||||
filterable: true,
|
||||
transformValue: value => (value ? [value] : undefined),
|
||||
resolveValue: value => (Array.isArray(value) ? value[0] : value)
|
||||
}
|
||||
]
|
||||
: [];
|
||||
|
||||
return [
|
||||
...baseFields,
|
||||
baseFields[0],
|
||||
weeklyPeriodField,
|
||||
...teamReporterField,
|
||||
{
|
||||
key: 'isBusinessTrip',
|
||||
label: '是否出差',
|
||||
@@ -75,13 +116,29 @@ const fields = computed<SearchField[]>(() => {
|
||||
label: item.projectCode ? `${item.projectName}(${item.projectCode})` : item.projectName,
|
||||
value: item.id
|
||||
})),
|
||||
placeholder: '请选择项目'
|
||||
placeholder: '请选择项目',
|
||||
filterable: true
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
if (props.reportType === 'monthly') {
|
||||
return [baseFields[0], monthPeriodField];
|
||||
const teamReporterField: SearchField[] = props.teamMode
|
||||
? [
|
||||
{
|
||||
key: 'reporterIds',
|
||||
label: '提交人',
|
||||
type: 'select',
|
||||
options: props.subordinateOptions,
|
||||
placeholder: '请选择提交人',
|
||||
filterable: true,
|
||||
transformValue: value => (value ? [value] : undefined),
|
||||
resolveValue: value => (Array.isArray(value) ? value[0] : value)
|
||||
}
|
||||
]
|
||||
: [];
|
||||
|
||||
return [baseFields[0], monthPeriodField, ...teamReporterField];
|
||||
}
|
||||
|
||||
return baseFields;
|
||||
@@ -95,6 +152,34 @@ async function loadStatusDict() {
|
||||
onMounted(() => {
|
||||
loadStatusDict();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.reportType,
|
||||
type => {
|
||||
if (type !== 'weekly') return;
|
||||
model.value.periodStartDate = normalizeWeeklySearchRange(model.value.periodStartDate as string[] | undefined);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => model.value.periodStartDate,
|
||||
value => {
|
||||
if (props.reportType !== 'weekly') return;
|
||||
|
||||
const normalizedValue = normalizeWeeklySearchRange(value as string[] | undefined);
|
||||
const currentValue = Array.isArray(value) ? value : [];
|
||||
|
||||
if (
|
||||
normalizedValue?.length === currentValue.length &&
|
||||
normalizedValue?.every((item, index) => item === currentValue[index])
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.value.periodStartDate = normalizedValue;
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { fetchRemindTeamReport } from '@/service/api';
|
||||
import { formatIsoWeekRangeLabel } from '../utils';
|
||||
import { formatWeeklyPeriodLabel } from '../types';
|
||||
|
||||
defineOptions({ name: 'TeamReportSummary' });
|
||||
|
||||
interface Props {
|
||||
reportType: Api.WorkReport.Common.ReportType;
|
||||
periodKeys?: string[];
|
||||
periodLabel?: string;
|
||||
loading?: boolean;
|
||||
summary?: Api.WorkReport.Common.TeamReportSummary | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
periodKeys: () => [],
|
||||
periodLabel: '',
|
||||
loading: false,
|
||||
summary: null
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
reminded: [];
|
||||
}>();
|
||||
|
||||
const remindingAll = ref(false);
|
||||
const remindingItemKey = ref('');
|
||||
const canRemind = computed(() => props.periodKeys.length > 0);
|
||||
|
||||
function formatSummaryPeriodLabel() {
|
||||
if (!props.summary?.periodStartDate || !props.summary?.periodEndDate) {
|
||||
return props.periodLabel;
|
||||
}
|
||||
|
||||
const start = dayjs(props.summary.periodStartDate);
|
||||
const end = dayjs(props.summary.periodEndDate);
|
||||
|
||||
if (!start.isValid() || !end.isValid()) {
|
||||
return `${props.summary.periodStartDate} 至 ${props.summary.periodEndDate}`;
|
||||
}
|
||||
|
||||
if (props.reportType === 'monthly' || props.reportType === 'project') {
|
||||
const startMonth = start.format('YYYY-MM');
|
||||
const endMonth = end.format('YYYY-MM');
|
||||
return startMonth === endMonth ? startMonth : `${startMonth} 至 ${endMonth}`;
|
||||
}
|
||||
|
||||
if (props.reportType === 'weekly') {
|
||||
return formatIsoWeekRangeLabel(props.summary.periodStartDate, props.summary.periodEndDate);
|
||||
}
|
||||
|
||||
return `${start.format('YYYY-MM-DD')} 至 ${end.format('YYYY-MM-DD')}`;
|
||||
}
|
||||
|
||||
const displayPeriodLabel = computed(() => formatSummaryPeriodLabel());
|
||||
|
||||
const cards = computed(() => [
|
||||
{ label: '应填报告数', value: props.summary?.expectedReportCount ?? 0 },
|
||||
{ label: '已提交报告数', value: props.summary?.submittedReportCount ?? 0 },
|
||||
{ label: '未提交报告数', value: props.summary?.unsubmittedReportCount ?? 0, key: 'unsubmitted' as const },
|
||||
{ label: '待审批报告数', value: props.summary?.pendingApprovalReportCount ?? 0 }
|
||||
]);
|
||||
|
||||
function getUnsubmittedReportSecondaryText(item: Api.WorkReport.Common.TeamReportUnsubmittedReport) {
|
||||
if (props.reportType === 'project') {
|
||||
return item.projectName ? `${item.projectName} · ${item.periodLabel}` : item.periodLabel;
|
||||
}
|
||||
|
||||
if (props.reportType === 'weekly') {
|
||||
const weeklyPeriodMatch = item.periodKey.match(/^weekly-(\d{4}-\d{2}-\d{2})-(\d{4}-\d{2}-\d{2})$/u);
|
||||
|
||||
if (weeklyPeriodMatch) {
|
||||
return formatWeeklyPeriodLabel(weeklyPeriodMatch[1], weeklyPeriodMatch[2], item.periodLabel);
|
||||
}
|
||||
|
||||
const weeklyRangeMatch = item.periodLabel.match(/^(\d{4}-\d{2}-\d{2})\s*至\s*(\d{4}-\d{2}-\d{2})$/u);
|
||||
|
||||
if (weeklyRangeMatch) {
|
||||
return formatWeeklyPeriodLabel(weeklyRangeMatch[1], weeklyRangeMatch[2], item.periodLabel);
|
||||
}
|
||||
|
||||
return formatWeeklyPeriodLabel(undefined, undefined, item.periodLabel);
|
||||
}
|
||||
|
||||
return item.periodLabel;
|
||||
}
|
||||
|
||||
function getUnsubmittedReportKey(item: Api.WorkReport.Common.TeamReportUnsubmittedReport) {
|
||||
return `${item.userId}-${item.projectId || 'none'}-${item.periodKey}`;
|
||||
}
|
||||
|
||||
function getSingleRemindSuccessText(remindedCount: number, preciseProjectReminder: boolean) {
|
||||
if (preciseProjectReminder) {
|
||||
return `已发送 ${remindedCount} 条催办提醒`;
|
||||
}
|
||||
|
||||
return `已催办 ${remindedCount} 人`;
|
||||
}
|
||||
|
||||
async function handleRemind(userIds?: string[]) {
|
||||
if (!props.periodKeys.length) return;
|
||||
|
||||
const isSingleUserReminder = userIds?.length === 1;
|
||||
|
||||
remindingAll.value = true;
|
||||
|
||||
const results = await Promise.all(
|
||||
props.periodKeys.map(periodKey =>
|
||||
fetchRemindTeamReport({
|
||||
reportType: props.reportType,
|
||||
periodKey,
|
||||
userIds
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
remindingAll.value = false;
|
||||
|
||||
const remindedCount = results.reduce((total, result) => {
|
||||
if (result.error) return total;
|
||||
return total + (result.data?.remindedCount ?? 0);
|
||||
}, 0);
|
||||
|
||||
if (!remindedCount) return;
|
||||
|
||||
window.$message?.success(
|
||||
props.periodKeys.length > 1
|
||||
? '已按所选区间发送催办提醒'
|
||||
: getSingleRemindSuccessText(remindedCount, props.reportType === 'project' && isSingleUserReminder)
|
||||
);
|
||||
emit('reminded');
|
||||
}
|
||||
|
||||
async function handleRemindItem(item: Api.WorkReport.Common.TeamReportUnsubmittedReport) {
|
||||
const itemKey = getUnsubmittedReportKey(item);
|
||||
remindingItemKey.value = itemKey;
|
||||
|
||||
const result = await fetchRemindTeamReport({
|
||||
reportType: props.reportType,
|
||||
periodKey: item.periodKey,
|
||||
userIds: [item.userId],
|
||||
projectId: props.reportType === 'project' ? item.projectId : undefined
|
||||
});
|
||||
|
||||
remindingItemKey.value = '';
|
||||
|
||||
if (result.error) return;
|
||||
|
||||
const remindedCount = result.data?.remindedCount ?? 0;
|
||||
if (!remindedCount) return;
|
||||
|
||||
window.$message?.success(getSingleRemindSuccessText(remindedCount, props.reportType === 'project'));
|
||||
emit('reminded');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="props.loading" class="team-report-summary">
|
||||
<div v-if="displayPeriodLabel" class="team-report-summary__period">{{ displayPeriodLabel }}</div>
|
||||
|
||||
<div class="team-report-summary__grid">
|
||||
<div v-for="card in cards" :key="card.label" class="team-report-summary__item">
|
||||
<div class="team-report-summary__label">{{ card.label }}</div>
|
||||
<div class="team-report-summary__value">
|
||||
<template v-if="card.key === 'unsubmitted'">
|
||||
<ElPopover placement="bottom" :width="300" trigger="hover">
|
||||
<template #reference>
|
||||
<button type="button" class="team-report-summary__link-button">{{ card.value }}</button>
|
||||
</template>
|
||||
|
||||
<div class="team-report-summary__popover">
|
||||
<div class="team-report-summary__popover-title">未提交报告</div>
|
||||
<div v-if="props.summary?.unsubmittedReports?.length" class="team-report-summary__user-list">
|
||||
<div
|
||||
v-for="item in props.summary.unsubmittedReports"
|
||||
:key="getUnsubmittedReportKey(item)"
|
||||
class="team-report-summary__user-item"
|
||||
>
|
||||
<div class="team-report-summary__user-content">
|
||||
<div class="team-report-summary__user-name">{{ item.userNickname }}</div>
|
||||
<div class="team-report-summary__user-meta">
|
||||
{{ getUnsubmittedReportSecondaryText(item) }}
|
||||
</div>
|
||||
</div>
|
||||
<ElButton
|
||||
v-if="canRemind"
|
||||
link
|
||||
type="primary"
|
||||
:loading="remindingItemKey === getUnsubmittedReportKey(item)"
|
||||
@click="handleRemindItem(item)"
|
||||
>
|
||||
催办
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
<ElEmpty v-else :image-size="60" description="暂无未提交报告" />
|
||||
|
||||
<div v-if="canRemind" class="team-report-summary__popover-footer">
|
||||
<ElButton
|
||||
size="small"
|
||||
type="primary"
|
||||
plain
|
||||
:loading="remindingAll"
|
||||
:disabled="!props.summary?.unsubmittedReports?.length"
|
||||
@click="handleRemind()"
|
||||
>
|
||||
一键催办全部
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</ElPopover>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ card.value }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.team-report-summary {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.team-report-summary__period {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.team-report-summary__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.team-report-summary__item {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-blank);
|
||||
}
|
||||
|
||||
.team-report-summary__label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.team-report-summary__value {
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.team-report-summary__link-button {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--el-color-primary);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.team-report-summary__popover {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.team-report-summary__popover-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.team-report-summary__user-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.team-report-summary__user-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.team-report-summary__user-name {
|
||||
min-width: 0;
|
||||
color: var(--el-text-color-regular);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.team-report-summary__user-content {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.team-report-summary__user-meta {
|
||||
min-width: 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.team-report-summary__popover-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (width <= 1200px) {
|
||||
.team-report-summary__grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.team-report-summary__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -41,6 +41,16 @@ export const WORK_REPORT_TYPE_LABEL: Record<WorkReportType, string> = {
|
||||
project: '项目半月报'
|
||||
};
|
||||
|
||||
export const TEAM_WORK_REPORT_TYPE_LABEL: Record<WorkReportType, string> = {
|
||||
weekly: '团队周报',
|
||||
monthly: '团队月报',
|
||||
project: '团队项目半月报'
|
||||
};
|
||||
|
||||
export function getWorkReportTypeDisplayLabel(reportType: WorkReportType, isTeamMode = false) {
|
||||
return isTeamMode ? TEAM_WORK_REPORT_TYPE_LABEL[reportType] : WORK_REPORT_TYPE_LABEL[reportType];
|
||||
}
|
||||
|
||||
export const WORK_REPORT_STATUS_LABEL: Record<string, string> = {
|
||||
draft: '待提交',
|
||||
pending_approval: '待审批',
|
||||
@@ -145,12 +155,16 @@ export function createInitBaseSearchParams() {
|
||||
export function createWeeklySearchParams(): Api.WorkReport.Weekly.WeeklyReportSearchParams {
|
||||
return {
|
||||
...createInitBaseSearchParams(),
|
||||
reporterIds: undefined,
|
||||
isBusinessTrip: undefined
|
||||
};
|
||||
}
|
||||
|
||||
export function createMonthlySearchParams(): Api.WorkReport.Monthly.MonthlyReportSearchParams {
|
||||
return createInitBaseSearchParams();
|
||||
return {
|
||||
...createInitBaseSearchParams(),
|
||||
reporterIds: undefined
|
||||
};
|
||||
}
|
||||
|
||||
export function createProjectSearchParams(): Api.WorkReport.Project.ProjectReportSearchParams {
|
||||
|
||||
@@ -20,6 +20,14 @@ export interface WorkReportPeriodOption {
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkReportResolvedPeriod {
|
||||
periodKey: string;
|
||||
periodLabel: string;
|
||||
periodStartDate: string;
|
||||
periodEndDate: string;
|
||||
flag?: number;
|
||||
}
|
||||
|
||||
function formatRangeLabel(start: dayjs.Dayjs, end: dayjs.Dayjs) {
|
||||
return `${start.format('YYYY-MM-DD')} 至 ${end.format('YYYY-MM-DD')}`;
|
||||
}
|
||||
@@ -30,10 +38,35 @@ export function formatPeriodDisplayLabel(label?: string | null) {
|
||||
.replace(/\s*(周报|月报|项目半月报|半月报)\s*$/u, '');
|
||||
}
|
||||
|
||||
// 使用 ISO 周数,确保与 buildWeeklyPeriodFromDate 的计算一致
|
||||
export function getIsoWeekDisplay(date: string | dayjs.Dayjs) {
|
||||
const selectedDate = dayjs(date);
|
||||
if (!selectedDate.isValid()) return '';
|
||||
return `${selectedDate.format('YYYY')} 第${String(selectedDate.isoWeek()).padStart(2, '0')} 周`;
|
||||
// isoWeek() 返回 ISO 8601 周数(周一为一周起始)
|
||||
return `${selectedDate.format('GGGG')} 第${String(selectedDate.isoWeek()).padStart(2, '0')} 周`;
|
||||
}
|
||||
|
||||
export function formatIsoWeekCompactLabel(date: string | dayjs.Dayjs) {
|
||||
const selectedDate = dayjs(date);
|
||||
if (!selectedDate.isValid()) return '';
|
||||
|
||||
const weekDate = selectedDate.startOf('isoWeek');
|
||||
const weekYear = weekDate.add(3, 'day').format('YYYY');
|
||||
return `${weekYear}-${String(weekDate.isoWeek()).padStart(2, '0')}周`;
|
||||
}
|
||||
|
||||
export function formatIsoWeekRangeLabel(
|
||||
startDate?: string | dayjs.Dayjs | null,
|
||||
endDate?: string | dayjs.Dayjs | null
|
||||
) {
|
||||
const startLabel = startDate ? formatIsoWeekCompactLabel(startDate) : '';
|
||||
const endLabel = endDate ? formatIsoWeekCompactLabel(endDate) : '';
|
||||
|
||||
if (!startLabel && !endLabel) return '';
|
||||
if (!endLabel || startLabel === endLabel) return startLabel;
|
||||
if (!startLabel) return endLabel;
|
||||
|
||||
return `${startLabel} 至 ${endLabel}`;
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line max-params */
|
||||
@@ -57,6 +90,20 @@ export function buildWeeklyPeriodFromDate(date: string | dayjs.Dayjs) {
|
||||
return buildPeriod('weekly', start, end, formatRangeLabel(start, end));
|
||||
}
|
||||
|
||||
export function normalizeWeeklySearchRange(periodRange?: string[] | null): string[] | undefined {
|
||||
if (!periodRange?.length) return undefined;
|
||||
|
||||
const [startDate, endDate] = periodRange;
|
||||
const start = dayjs(startDate);
|
||||
const end = dayjs(endDate || startDate);
|
||||
|
||||
if (!start.isValid() || !end.isValid()) {
|
||||
return periodRange ?? undefined;
|
||||
}
|
||||
|
||||
return [start.startOf('isoWeek').format('YYYY-MM-DD'), end.startOf('isoWeek').format('YYYY-MM-DD')];
|
||||
}
|
||||
|
||||
export function buildMonthlyPeriodFromMonth(month: string | dayjs.Dayjs) {
|
||||
const selectedMonth = dayjs(month);
|
||||
const start = selectedMonth.startOf('month');
|
||||
@@ -192,3 +239,72 @@ export function getReportTypePeriodOptions(now = dayjs()) {
|
||||
project: getProjectPeriodOptions(now)
|
||||
} as const;
|
||||
}
|
||||
|
||||
type PeriodRange = string[] | null | undefined;
|
||||
|
||||
interface ResolveWorkReportSummaryPeriodOptions {
|
||||
currentRow?: Partial<WorkReportResolvedPeriod> | null;
|
||||
periodRange?: PeriodRange;
|
||||
flag?: number | null;
|
||||
}
|
||||
|
||||
function getRangeReferenceDate(periodRange?: PeriodRange) {
|
||||
const [, endDate] = periodRange || [];
|
||||
return endDate || periodRange?.[0] || '';
|
||||
}
|
||||
|
||||
function inferProjectSummaryFlag(referenceDate: string, explicitFlag?: number | null) {
|
||||
if (explicitFlag === 1 || explicitFlag === 2) {
|
||||
return explicitFlag;
|
||||
}
|
||||
|
||||
const selectedDate = dayjs(referenceDate);
|
||||
if (selectedDate.isValid()) {
|
||||
return selectedDate.date() > 15 ? 2 : 1;
|
||||
}
|
||||
|
||||
return getProjectPeriodOptions()[0]?.flag || 1;
|
||||
}
|
||||
|
||||
export function resolveWorkReportSummaryPeriod(
|
||||
reportType: WorkReportType,
|
||||
options: ResolveWorkReportSummaryPeriodOptions = {}
|
||||
): WorkReportResolvedPeriod {
|
||||
const { currentRow, periodRange, flag } = options;
|
||||
|
||||
if (currentRow?.periodKey && currentRow.periodStartDate && currentRow.periodEndDate) {
|
||||
return {
|
||||
periodKey: currentRow.periodKey,
|
||||
periodLabel: currentRow.periodLabel || '',
|
||||
periodStartDate: currentRow.periodStartDate,
|
||||
periodEndDate: currentRow.periodEndDate,
|
||||
flag: currentRow.flag
|
||||
};
|
||||
}
|
||||
|
||||
const referenceDate = getRangeReferenceDate(periodRange);
|
||||
const fallbackNow = dayjs();
|
||||
|
||||
if (reportType === 'weekly') {
|
||||
return referenceDate ? buildWeeklyPeriodFromDate(referenceDate) : buildWeeklyPeriodFromDate(fallbackNow);
|
||||
}
|
||||
|
||||
if (reportType === 'monthly') {
|
||||
return referenceDate ? buildMonthlyPeriodFromMonth(referenceDate) : buildMonthlyPeriodFromMonth(fallbackNow);
|
||||
}
|
||||
|
||||
if (referenceDate) {
|
||||
const resolvedFlag = inferProjectSummaryFlag(referenceDate, flag);
|
||||
return {
|
||||
...buildProjectPeriodFromMonth(referenceDate, resolvedFlag),
|
||||
flag: resolvedFlag
|
||||
};
|
||||
}
|
||||
|
||||
const fallbackOption = getProjectPeriodOptions(fallbackNow)[0];
|
||||
|
||||
return {
|
||||
...fallbackOption.period,
|
||||
flag: fallbackOption.flag
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user