Compare commits

34 Commits

Author SHA1 Message Date
dk
08fff8bbc5 feat(performance): 新增绩效下签功能支持
- 在月报审批页面增加 sign-off 场景支持
- 添加绩效下签对话框组件实现批量下签功能
- 增加导入绩效压缩包和直接下签两种操作模式
- 实现电子签名预览和日期格式化显示功能
- 更新绩效权限配置增加下签相关权限
- 优化审批对话框标题和按钮文案显示
- 添加导入结果提示和文件验证功能
- 实现团队模式下的下签操作入口
- 完善绩效表格状态和操作权限判断逻辑
2026-07-19 18:21:14 +08:00
dk
c2f9c922cf feat(workbench): 添加待确认tab并调整绩效管理页面标题 2026-07-19 15:02:55 +08:00
dk
7f31bed6f6 feat(personal-center): 将加班申请功能重构为业务学习功能。
fix(week-report): 修复周报中工作日志的显示问题。
feat(work-report): 点击编辑时,自动刷新出最新的内容。
2026-07-19 14:04:51 +08:00
dk
f6cf03a34a refactor(logo): 更换图片logo 2026-07-15 16:07:27 +08:00
dk
54c97c9729 fix(工作台-工时、周报、日志管理): 详细如下。
- 移除历史工作日分隔符兼容逻辑,仅使用显式分隔标记切分
- 扩展工时数据显示范围从5个工作日到7天完整周,并添加周末展示控制
- 在团队工时统计中新增加班工时字段并调整图表展示方式
- 移除团队工时分布中的分类矩阵,简化数据结构
- 为API访问日志和错误日志搜索组件添加用户选择功能
- 调整日志搜索界面布局,将用户编号输入改为用户选择下拉框
- 移除用户类型筛选条件以简化搜索界面
- 更新工时图表配置以支持周末数据显示和新的数据结构
2026-07-14 11:43:47 +08:00
27c136c906 fix(projects): 1、修复管俊提的工时填报完毕后,提示的404报错;2、调整执行协办人可以编辑协办人列表的操作; 2026-07-14 11:24:49 +08:00
503ea1e255 fix(projects): 未读消息数字对不上的问题修复 2026-07-13 14:30:50 +08:00
dk
b6609e10fe fix(performance): 修正绩效表导出文件格式
- 将选中数据导出的文件格式从 .zip 更改为 .xlsx
- 将全部数据导出的文件格式从 .zip 更改为 .xlsx
- 确保导出功能与实际文件类型匹配
2026-07-09 16:22:27 +08:00
dk
e2094831e4 feat(performance、workReport、workbench): 添加绩效表预填功能并优化工作台组件 2026-07-08 20:08:04 +08:00
62b97a2fd7 fix(projects): 执行和任务的按钮控制 2026-07-08 11:12:22 +08:00
dk
a0dcd39c23 feat(performance、notify、weekly): 添加绩效考核功能、通知跳转链接、优化工作日志分割逻辑。 2026-07-07 16:51:34 +08:00
dk
c1f710fbd8 feat(requirement): 优化需求标题列的交互体验
feat(project): 新增执行阶段需求选项获取接口
feat(membership): 优化项目负责人选项加载逻辑
fix(task): 优化任务操作按钮的显示逻辑
feat(todo): 优化待办任务截止时间显示
2026-07-06 16:51:48 +08:00
dk
bc5815416b fix(工作反馈、工作日志): 添加超大尺寸对话框预设并优化反馈搜索功能。
- 在 BusinessFormDialog 组件中添加 xl 尺寸预设,宽度设置为 1200px
- 移除 feedback-search.vue 中废弃的 useDict 钩子导入
- 将反馈搜索的状态选择器从普通下拉改为字典类型,统一值转换逻辑
- 更新反馈初始搜索参数中的状态值类型从数字改为字符串
- 任务工时对话框使用 xl 预设以获得更大显示区域
2026-07-03 17:34:48 +08:00
dk
9d147ee263 feat(feedback): 增加反馈搜索状态筛选功能
- 在反馈搜索组件中引入状态字典选项
- 为反馈搜索区域增加状态选择器字段
- 设置反馈页面初始搜索参数中的状态值为1

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

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

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

View File

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

View File

@@ -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
}
};

View File

@@ -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,11 +338,44 @@
"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",
"component": "view.personal-center_my-performance",
"title": "我的绩效",
"title": "绩效管理",
"routeTitle": "personal-center_my-performance",
"i18nKey": "route.personal-center_my-performance",
"icon": "mdi:trophy-outline",
@@ -356,7 +389,7 @@
"redirect": null,
"props": null,
"meta": {
"title": "我的绩效",
"title": "绩效管理",
"i18nKey": "route.personal-center_my-performance",
"icon": "mdi:trophy-outline",
"localIcon": null,
@@ -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,

View File

@@ -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

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 930 KiB

View File

@@ -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
);
}
});

View File

@@ -7,7 +7,7 @@ defineOptions({
inheritAttrs: false
});
type DialogPreset = 'sm' | 'md' | 'lg';
type DialogPreset = 'sm' | 'md' | 'lg' | 'xl';
interface Props {
title: string;
@@ -50,7 +50,8 @@ const visible = defineModel<boolean>({
const DIALOG_WIDTH_MAP: Record<DialogPreset, string> = {
sm: '520px',
md: '720px',
lg: '960px'
lg: '960px',
xl: '1200px'
};
const dialogWidth = computed(() => props.width ?? DIALOG_WIDTH_MAP[props.preset]);
@@ -82,6 +83,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"

View File

@@ -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>

View File

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

View File

@@ -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>

View File

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

View File

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

View File

@@ -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>

View File

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

View File

@@ -113,9 +113,9 @@ export const RDMS_REQ_CAN_DELETE_STATUS_DICT_CODE = 'rdms_req_can_delete_status'
export const RDMS_WORKLOG_DIFFICULTY_DICT_CODE = 'rdms_task_item_worklog_difficulty';
/**
* 加班时长快捷选项字典编码
* 学习时长快捷选项字典编码
*
* 对应业务字段:加班申请中的 overtimeDuration
* 对应业务字段:业务学习中的 overtimeDuration
* 来源口径:`overtime-application-design.md` 明确时长下拉字典为 rdms_overtime_duration
*/
export const RDMS_OVERTIME_DURATION_DICT_CODE = 'rdms_overtime_duration';
@@ -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';
/**
* 意见反馈分类字典编码
*
* 对应业务字段:意见反馈 type1 缺陷 / 2 体验问题 / 3 功能建议)
* 来源口径:`2026-06-25-意见反馈-前端API.html` 明确分类字典为 feedback_type后端已落库。
*/
export const FEEDBACK_TYPE_DICT_CODE = 'feedback_type';
/**
* 意见反馈处理状态字典编码
*
* 对应业务字段:意见反馈 status1 待处理 / 2 处理中 / 3 已处理 / 4 已忽略)
* 来源口径:同上,后端已落库。提交不传 status后端强制「待处理」。
*/
export const FEEDBACK_STATUS_DICT_CODE = 'feedback_status';
/**
* 系统用户类型字典编码
*
* 对应业务字段:系统日志中的 userType
* 来源口径:后端 DictTypeConstants.USER_TYPE = user_type
*/
export const SYSTEM_USER_TYPE_DICT_CODE = 'user_type';
/**
* 系统登录日志类型字典编码
*
* 对应业务字段:登录日志中的 logType
* 来源口径:后端 DictTypeConstants.LOGIN_TYPE = system_login_type
*/
export const SYSTEM_LOGIN_TYPE_DICT_CODE = 'system_login_type';
/**
* 系统登录结果字典编码
*
* 对应业务字段:登录日志中的 result
* 来源口径:后端 DictTypeConstants.LOGIN_RESULT = system_login_result
*/
export const SYSTEM_LOGIN_RESULT_DICT_CODE = 'system_login_result';
/**
* 基础设施操作分类字典编码
*
* 对应业务字段API 访问日志中的 operateType
* 来源口径:后端 DictTypeConstants.OPERATE_TYPE = infra_operate_type
*/
export const INFRA_OPERATE_TYPE_DICT_CODE = 'infra_operate_type';
/**
* API 错误日志处理状态字典编码
*
* 对应业务字段API 错误日志中的 processStatus
* 来源口径:后端 DictTypeConstants.API_ERROR_LOG_PROCESS_STATUS = infra_api_error_log_process_status
*/
export const INFRA_API_ERROR_LOG_PROCESS_STATUS_DICT_CODE = 'infra_api_error_log_process_status';
/**
* 系统请求方式字典编码
*
* 对应业务字段:操作日志中的 requestMethod
* 来源口径:用户明确指定请求方式下拉来自运行时字典 system_request_method
*/
export const SYSTEM_REQUEST_METHOD_DICT_CODE = 'system_request_method';

View File

@@ -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',
@@ -95,11 +104,18 @@ const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>>
completed: 'success',
cancelled: 'danger'
},
// 加班申请
// 业务学习
overtimeApplication: {
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)
);
}

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useDebounceFn, useInfiniteScroll } from '@vueuse/core';
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
import { NOTIFY_MESSAGE_LEVEL_DICT_CODE } from '@/constants/dict';
import {
fetchGetMyNotifyMessagePage,
@@ -14,6 +16,7 @@ import { formatDateTime, formatRelativeTime } from '@/utils/datetime';
defineOptions({ name: 'NotificationBell' });
const dictStore = useDictStore();
const router = useRouter();
const PAGE_SIZE = 10;
const UNREAD_COUNT_POLL_INTERVAL = 15 * 1000;
@@ -50,6 +53,12 @@ const searchKeyword = ref('');
const detailVisible = ref(false);
const detailMessage = ref<Api.NotifyMessage.NotifyMessage | null>(null);
interface NotifyRenderParts {
prefix: string;
clickable: string;
suffix: string;
}
function keywordParam() {
return searchKeyword.value.trim() || undefined;
}
@@ -202,14 +211,102 @@ function openDetail(row: Api.NotifyMessage.NotifyMessage) {
}
}
function getClickableText(message: Api.NotifyMessage.NotifyMessage) {
const params = message.templateParams;
switch (message.templateCode) {
case 'execution_assigned':
return params?.executionName?.trim() || '';
case 'task_assigned':
return params?.taskName?.trim() || '';
case 'project_created':
return params?.projectName?.trim() || '';
default:
return '';
}
}
function canJump(message: Api.NotifyMessage.NotifyMessage) {
const params = message.templateParams;
return Boolean(params?.jumpType && params.projectId && getClickableText(message));
}
function renderNotifyParts(message: Api.NotifyMessage.NotifyMessage): NotifyRenderParts {
const content = message.templateContent || '';
const clickable = getClickableText(message);
if (!content || !clickable) {
return { prefix: content, clickable: '', suffix: '' };
}
const startIndex = content.indexOf(clickable);
if (startIndex < 0) {
return { prefix: content, clickable: '', suffix: '' };
}
return {
prefix: content.slice(0, startIndex),
clickable,
suffix: content.slice(startIndex + clickable.length)
};
}
async function handleNotifyJump(message: Api.NotifyMessage.NotifyMessage) {
const params = message.templateParams;
if (!params?.jumpType || !params.projectId) return;
const query: Record<string, string> = {
[OBJECT_CONTEXT_QUERY_KEY]: params.projectId
};
let path = '';
switch (params.jumpType) {
case 'project':
path = '/project/project/overview';
break;
case 'execution':
path = '/project/project/execution';
if (params.executionName) {
query.executionName = params.executionName;
}
break;
case 'task':
path = '/project/project/execution';
if (params.taskName) {
query.taskName = params.taskName;
}
break;
default:
return;
}
detailVisible.value = false;
drawerOpen.value = false;
await router.push({ path, query });
}
async function onNotifyLinkClick(row: Api.NotifyMessage.NotifyMessage) {
if (!canJump(row)) return;
if (!row.readStatus) {
await markRead(row);
}
await handleNotifyJump(row);
}
async function markAllRead() {
const { error } = await fetchUpdateAllNotifyMessageRead();
if (error) return;
if (error) {
window.$message?.error('全部标记已读失败,请重试');
return;
}
unreadCount.value = 0;
resetList('unread');
resetList('read');
loadPage(activeTab.value);
// 以后端真实未读数兜底校准,避免乐观置 0 与后端状态偏差(不必等 15s 轮询)
refreshUnreadCount();
}
let pollTimer: ReturnType<typeof setInterval> | null = null;
@@ -268,7 +365,7 @@ onBeforeUnmount(() => {
<template #label>
<span class="notification-bell__tab-label">
未读
<span class="notification-bell__tab-count">{{ listStates.unread.total }}</span>
<span class="notification-bell__tab-count">{{ badgeLabel }}</span>
</span>
</template>
<ElScrollbar ref="unreadScrollbar" class="notification-bell__scroll">
@@ -281,7 +378,25 @@ onBeforeUnmount(() => {
>
<span class="notification-bell__row-dot" :style="{ backgroundColor: levelDotColor(row.level) }" />
<div class="notification-bell__row-body">
<div class="notification-bell__row-title">{{ row.templateContent }}</div>
<div class="notification-bell__row-title">
<template v-if="canJump(row)">
<template v-for="(part, index) in [renderNotifyParts(row)]" :key="`${row.id}-${index}`">
<span>{{ part.prefix }}</span>
<button
v-if="part.clickable"
type="button"
class="notification-bell__inline-link"
@click.stop="onNotifyLinkClick(row)"
>
{{ part.clickable }}
</button>
<span>{{ part.suffix }}</span>
</template>
</template>
<template v-else>
{{ row.templateContent }}
</template>
</div>
<div class="notification-bell__row-meta">
<DictTag :dict-code="NOTIFY_MESSAGE_LEVEL_DICT_CODE" :value="row.level" size="small" round />
<span class="notification-bell__row-time">{{ formatRelativeTime(row.createTime) }}</span>
@@ -312,7 +427,25 @@ onBeforeUnmount(() => {
>
<span class="notification-bell__row-dot" :style="{ backgroundColor: levelDotColor(row.level) }" />
<div class="notification-bell__row-body">
<div class="notification-bell__row-title">{{ row.templateContent }}</div>
<div class="notification-bell__row-title">
<template v-if="canJump(row)">
<template v-for="(part, index) in [renderNotifyParts(row)]" :key="`${row.id}-${index}`">
<span>{{ part.prefix }}</span>
<button
v-if="part.clickable"
type="button"
class="notification-bell__inline-link"
@click.stop="onNotifyLinkClick(row)"
>
{{ part.clickable }}
</button>
<span>{{ part.suffix }}</span>
</template>
</template>
<template v-else>
{{ row.templateContent }}
</template>
</div>
<div class="notification-bell__row-meta">
<DictTag :dict-code="NOTIFY_MESSAGE_LEVEL_DICT_CODE" :value="row.level" size="small" round />
<span class="notification-bell__row-time">{{ formatRelativeTime(row.createTime) }}</span>
@@ -350,7 +483,25 @@ onBeforeUnmount(() => {
</div>
</template>
<div v-if="detailMessage" class="notification-bell__detail-body">
<div class="notification-bell__detail-content">{{ detailMessage.templateContent }}</div>
<div class="notification-bell__detail-content">
<template v-if="canJump(detailMessage)">
<template v-for="(part, index) in [renderNotifyParts(detailMessage)]" :key="`detail-${index}`">
<span>{{ part.prefix }}</span>
<button
v-if="part.clickable"
type="button"
class="notification-bell__inline-link"
@click.stop="onNotifyLinkClick(detailMessage)"
>
{{ part.clickable }}
</button>
<span>{{ part.suffix }}</span>
</template>
</template>
<template v-else>
{{ detailMessage.templateContent }}
</template>
</div>
<div class="notification-bell__detail-time">收到于 {{ formatDateTime(detailMessage.createTime) }}</div>
</div>
<template #footer>
@@ -578,6 +729,19 @@ onBeforeUnmount(() => {
font-weight: 500;
}
.notification-bell__inline-link {
padding: 0;
border: 0;
background: transparent;
color: var(--el-color-primary);
cursor: pointer;
font: inherit;
}
.notification-bell__inline-link:hover {
text-decoration: underline;
}
.notification-bell__row-meta {
display: flex;
align-items: center;

View File

@@ -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',

View File

@@ -173,12 +173,18 @@ const local: App.I18n.Schema = {
'personal-center_work-report_weekly': '个人周报',
'personal-center_work-report_monthly': '个人月报',
'personal-center_work-report_project': '项目半月报',
'personal-center_my-performance': '我的绩效',
'personal-center_my-performance': '绩效管理',
'personal-center_my-application': '我的申请',
'personal-center_overtime-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: '请输入确认密码',

View File

@@ -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"),

View File

@@ -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
}
},

View File

@@ -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)?",

View File

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

View File

@@ -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 ?? []
};

View File

@@ -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)
}));
}

View File

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

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

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

View File

@@ -1,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';

View File

@@ -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);

View File

@@ -0,0 +1,591 @@
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 ImportZipResultResponse = Omit<Api.Performance.Sheet.ImportZipResult, 'messages'> & {
workbookCount?: number | string | null;
sheetCount?: number | string | null;
successCount?: number | string | null;
skippedCount?: number | string | null;
failedCount?: number | string | null;
messages?: string[] | null;
};
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 PrefillResponse = Omit<Api.Performance.Sheet.PrefillData, 'sourceSheetId' | 'cellValues'> & {
sourceSheetId?: StringIdResponse | null;
cellValues?: Record<string, string | number | null | undefined> | null;
};
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,
signOffStatus: response.signOffStatus ?? null,
signOffStatusName: response.signOffStatusName ?? null,
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 normalizePrefillData(response: PrefillResponse): Api.Performance.Sheet.PrefillData {
const cellValues = Object.fromEntries(
Object.entries(response.cellValues || {}).flatMap(([address, value]) => {
const normalizedAddress = String(address || '')
.trim()
.toUpperCase();
if (!normalizedAddress || value === null || value === undefined) {
return [];
}
return [[normalizedAddress, String(value)]];
})
);
return {
sourceSheetId: normalizeNullableStringId(response.sourceSheetId),
sourcePeriodMonth: response.sourcePeriodMonth ?? null,
cellValues
};
}
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);
appendValue(query, 'signOffStatus', params.signOffStatus);
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 async function importPerformanceSheetZip(data: Api.Performance.Sheet.ImportZipParams) {
const formData = new FormData();
formData.append('file', data.file);
formData.append('periodMonth', data.periodMonth);
data.employeeIds?.forEach(id => {
formData.append('employeeIds', id);
});
const result = await request<ImportZipResultResponse>({
...safeJsonRequestConfig,
url: `${SHEET_PREFIX}/import-zip`,
method: 'post',
data: formData
});
return mapServiceResult(result as ServiceRequestResult<ImportZipResultResponse>, payload => ({
workbookCount: normalizeTotal(payload.workbookCount),
sheetCount: normalizeTotal(payload.sheetCount),
successCount: normalizeTotal(payload.successCount),
skippedCount: normalizeTotal(payload.skippedCount),
failedCount: normalizeTotal(payload.failedCount),
messages: payload.messages ?? []
}));
}
export async function signOffPerformanceSheets(data: Api.Performance.Sheet.SignOffParams) {
const result = await request<StringIdResponse>({
...safeJsonRequestConfig,
url: `${SHEET_PREFIX}/sign-off`,
method: 'post',
data: {
...data,
employeeIds: data.employeeIds && data.employeeIds.length ? data.employeeIds : undefined,
remark: data.remark?.trim() || undefined
}
});
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 async function fetchPerformanceSheetPrefill(employeeId: string, periodMonth: string) {
const result = await request<PrefillResponse>({
...safeJsonRequestConfig,
url: `${SHEET_PREFIX}/prefill`,
method: 'get',
params: { employeeId, periodMonth }
});
return mapServiceResult(result as ServiceRequestResult<PrefillResponse>, normalizePrefillData);
}
export function fetchPerformanceSheetStatusDict() {
return request<Api.Performance.Sheet.StatusDict[]>({
...safeJsonRequestConfig,
url: `${SHEET_PREFIX}/status-dict`,
method: 'get'
});
}
export function fetchPerformanceSheetStatusTransitions() {
return request<Api.Performance.Sheet.StatusTransition[]>({
...safeJsonRequestConfig,
url: `${SHEET_PREFIX}/status-transitions`,
method: 'get'
});
}
export async function fetchTeamPerformanceSummary(params: Api.Performance.Team.SummaryParams = {}) {
const result = await request<TeamSummaryResponse>({
...safeJsonRequestConfig,
url: `${TEAM_PREFIX}/summary`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<TeamSummaryResponse>, normalizeTeamSummary);
}
export function remindTeamPerformance(data: Api.Performance.Team.RemindParams) {
return request<Api.Performance.Team.RemindResult>({
...safeJsonRequestConfig,
url: `${TEAM_PREFIX}/remind`,
method: 'post',
data
});
}

View File

@@ -11,9 +11,15 @@ import { normalizeProductMember, normalizeProductSettings } from './product-shar
const PRODUCT_PREFIX = `${WEB_SERVICE_PREFIX}/project/product`;
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)
};

View File

@@ -499,7 +499,7 @@ export function normalizeTeamLoad(response: TeamLoadResponse): Api.Project.TeamL
export function normalizeMyWorklogWeek(response: MyWorklogWeekResponse): Api.Project.MyWorklogWeekResult {
return {
weekStart: response.weekStart ?? '',
dailyHours: response.dailyHours ?? [0, 0, 0, 0, 0],
dailyHours: response.dailyHours ?? [0, 0, 0, 0, 0, 0, 0],
distribution: (response.distribution ?? []).map(item => ({
...normalizeWorklogDistributionItem(item),
hours: typeof item.hours === 'number' ? item.hours : 0
@@ -513,6 +513,7 @@ export function normalizeTeamWorklogWeek(response: TeamWorklogWeekResponse): Api
members: (response.members ?? []).map(member => ({
userId: normalizeStringId(member.userId),
userNickname: member.userNickname ?? '',
overtimeHours: typeof member.overtimeHours === 'number' ? member.overtimeHours : 0,
items: (member.items ?? []).map(item => ({
...normalizeWorklogDistributionItem(item),
hours: typeof item.hours === 'number' ? item.hours : 0

View File

@@ -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)
@@ -1140,6 +1150,21 @@ export async function fetchGetProjectRequirementPage(params?: Api.Project.Projec
}));
}
/** 获取项目需求分页列表(排除了终止态的需求) */
export async function fetchGetExecutionRequirementOptions(params?: Api.Project.ProjectRequirementSearchParams) {
const result = await request<ProjectRequirementPageResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_REQUIREMENT_PREFIX}/execution-options`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<ProjectRequirementPageResponse>, data => ({
...data,
list: data.list.map(normalizeProjectRequirement)
}));
}
/** 获取项目需求树形列表 */
export async function fetchGetProjectRequirementTree(params?: Api.Project.ProjectRequirementSearchParams) {
const result = await request<ProjectRequirementPageResponse>({

View File

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

View File

@@ -17,6 +17,30 @@ const DEPT_PREFIX = `${SYSTEM_SERVICE_PREFIX}/dept`;
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`;
const USER_SIGNATURE_PREFIX = `${SYSTEM_SERVICE_PREFIX}/user-signatures`;
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 +142,17 @@ type UserManagementRelationTreeResponse = Omit<
children?: UserManagementRelationTreeResponse[] | null;
};
type MySubordinateTreeNodeResponse = Omit<Api.SystemManage.MySubordinateTreeNode, 'userId' | 'children'> & {
userId: string | number;
children?: MySubordinateTreeNodeResponse[] | null;
};
type UserSignatureResponse = Omit<Api.SystemManage.UserSignature, 'id' | 'userId' | 'fileId'> & {
id: string | number;
userId: string | number;
fileId: string | number;
};
function normalizeUserSimple(user: UserSimpleResponse): Api.SystemManage.UserSimple {
return {
...user,
@@ -181,6 +216,28 @@ function normalizeUserManagementRelationTree(
};
}
function normalizeMySubordinateTreeNode(node: MySubordinateTreeNodeResponse): Api.SystemManage.MySubordinateTreeNode {
return {
...node,
userId: normalizeStringId(node.userId),
children: node.children?.map(normalizeMySubordinateTreeNode) ?? null
};
}
function normalizeUserSignature(signature: UserSignatureResponse): Api.SystemManage.UserSignature {
return {
...signature,
id: normalizeStringId(signature.id),
userId: normalizeStringId(signature.userId),
fileId: normalizeStringId(signature.fileId),
fileName: signature.fileName ?? null,
status: signature.status ?? null,
remark: signature.remark ?? null,
createTime: signature.createTime ?? null,
updateTime: signature.updateTime ?? null
};
}
/** 获取角色分页 */
export async function fetchGetRolePage(params?: Api.SystemManage.RoleSearchParams) {
const query = createRolePageQuery(params);
@@ -311,6 +368,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 +513,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 +634,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[]>({
@@ -672,6 +763,49 @@ export function fetchAssignUserRoles(data: Api.SystemManage.AssignUserRoleParams
});
}
export async function fetchGetUserSignature(userId: string) {
const result = await request<UserSignatureResponse | null>({
...safeJsonRequestConfig,
url: `${USER_SIGNATURE_PREFIX}/get`,
method: 'get',
params: { userId }
});
return mapServiceResult(result as ServiceRequestResult<UserSignatureResponse | null>, data =>
data ? normalizeUserSignature(data) : null
);
}
export function fetchSaveUserSignature(data: Api.SystemManage.SaveUserSignatureParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${USER_SIGNATURE_PREFIX}/save`,
method: 'post',
data
});
}
export function fetchDeleteUserSignature(userId: string) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${USER_SIGNATURE_PREFIX}/delete`,
method: 'delete',
params: { userId }
});
}
export async function fetchGetMyUserSignature() {
const result = await request<UserSignatureResponse | null>({
...safeJsonRequestConfig,
url: `${USER_SIGNATURE_PREFIX}/my`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<UserSignatureResponse | null>, data =>
data ? normalizeUserSignature(data) : null
);
}
// ==================== 用户管理链路相关 API ====================
/**
* 获取用户管理链路树形结构
@@ -712,6 +846,29 @@ export async function fetchGetUserManagementRelationQuery(query: UserManagementR
);
}
/** 获取当前登录用户下属树 */
export async function fetchGetMySubordinateTree() {
return request<MySubordinateTreeNodeResponse>({
...safeJsonRequestConfig,
url: `${USER_MANAGEMENT_RELATION_PREFIX}/my-subordinate-tree`,
method: 'get'
}).then(result =>
mapServiceResult(result as ServiceRequestResult<MySubordinateTreeNodeResponse>, normalizeMySubordinateTreeNode)
);
}
/** 获取某用户当前生效的直属下级列表 */
export async function fetchGetDirectSubordinates(userId: string) {
const result = await request<UserSimpleResponse[]>({
...safeJsonRequestConfig,
url: `${USER_MANAGEMENT_RELATION_PREFIX}/direct-subordinates`,
method: 'get',
params: { userId }
});
return mapServiceResult(result as ServiceRequestResult<UserSimpleResponse[]>, data => data.map(normalizeUserSimple));
}
/**
* 获取用户管理链路详情
*

View File

@@ -88,17 +88,41 @@ type ApprovalRecordResponse = Omit<
type MonthlyApprovalRecordResponse = Omit<
Api.WorkReport.Monthly.MonthlyReportApprovalRecord,
'id' | 'statusLogId' | 'auditorUserId'
'id' | 'statusLogId' | 'auditorUserId' | 'employeeSignatureFileId' | 'supervisorSignatureFileId'
> & {
id: StringIdResponse;
statusLogId: StringIdResponse;
auditorUserId: StringIdResponse;
employeeSignatureFileId?: MaybeStringIdResponse;
supervisorSignatureFileId?: MaybeStringIdResponse;
};
type MonthlySignOffDetailResponse = {
report: MonthlyReportResponse;
latestApprovalRecord?: MonthlyApprovalRecordResponse | null;
};
type ProjectOptionResponse = Omit<Api.WorkReport.Project.ProjectReportOwnerProjectOption, 'id'> & {
id: StringIdResponse;
};
type TeamReportUnsubmittedReportResponse = Omit<
Api.WorkReport.Common.TeamReportUnsubmittedReport,
'userId' | 'projectId'
> & {
userId: StringIdResponse;
projectId?: StringIdResponse | null;
};
type TeamReportSummaryResponse = Omit<
Api.WorkReport.Common.TeamReportSummary,
'unsubmittedReports' | 'periodStartDate' | 'periodEndDate'
> & {
unsubmittedReports?: TeamReportUnsubmittedReportResponse[] | null;
periodStartDate?: unknown;
periodEndDate?: unknown;
};
function normalizeBooleanFlag(value: boolean | number | string | null | undefined) {
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value === 1;
@@ -173,6 +197,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 +228,21 @@ 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);
appendValue(query, 'signOffStatus', params.signOffStatus);
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();
@@ -262,6 +306,8 @@ function normalizeMonthlyReport(response: MonthlyReportResponse): Api.WorkReport
reporterDeptName: response.reporterDeptName ?? null,
reporterPostName: response.reporterPostName ?? null,
statusName: response.statusName || response.statusCode,
signOffStatus: response.signOffStatus ?? null,
signOffStatusName: response.signOffStatusName ?? null,
allowEdit: normalizeBooleanFlag(response.allowEdit),
terminal: normalizeBooleanFlag(response.terminal),
totalWorkHours: normalizeReportTotalWorkHours(response.totalWorkHours, fallbackTotalWorkHours),
@@ -325,7 +371,12 @@ function normalizeMonthlyApprovalRecord(
statusLogId: normalizeStringId(response.statusLogId),
auditorUserId: normalizeStringId(response.auditorUserId),
conclusion: normalizeApprovalConclusion(response.conclusion),
opinion: response.opinion ?? null
opinion: response.opinion ?? null,
meetingDate: normalizeDateText(response.meetingDate) ?? null,
employeeSignatureFileId: normalizeNullableStringId(response.employeeSignatureFileId),
employeeSignedDate: normalizeDateText(response.employeeSignedDate) ?? null,
supervisorSignatureFileId: normalizeNullableStringId(response.supervisorSignatureFileId),
supervisorSignedDate: normalizeDateText(response.supervisorSignedDate) ?? null
};
}
@@ -338,6 +389,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 +510,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>>({
@@ -614,6 +712,19 @@ export async function fetchGetMonthlyReportApprovalPage(params: Api.WorkReport.M
);
}
export async function fetchGetMonthlyReportSignOffPage(params: Api.WorkReport.Monthly.MonthlyReportSearchParams = {}) {
const query = createMonthlyPageQuery(params);
const result = await request<PageResponse<MonthlyReportResponse>>({
...safeJsonRequestConfig,
url: query ? `${MONTHLY_PREFIX}/sign-off-page?${query}` : `${MONTHLY_PREFIX}/sign-off-page`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<PageResponse<MonthlyReportResponse>>, data =>
mapPage(data, normalizeMonthlyReport)
);
}
export async function fetchGetMonthlyReportDetail(id: string) {
const result = await request<MonthlyReportResponse>({
...safeJsonRequestConfig,
@@ -716,6 +827,34 @@ export async function fetchGetMonthlyReportApprovalRecords(id: string) {
);
}
export async function fetchGetMonthlyReportSignOffDetail(id: string) {
const result = await request<MonthlySignOffDetailResponse>({
...safeJsonRequestConfig,
url: `${MONTHLY_PREFIX}/${id}/sign-off-detail`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<MonthlySignOffDetailResponse>, data => ({
report: normalizeMonthlyReport(data.report),
latestApprovalRecord: data.latestApprovalRecord ? normalizeMonthlyApprovalRecord(data.latestApprovalRecord) : null
}));
}
export function fetchConfirmMonthlyReportSignOff(
id: string,
data: Api.WorkReport.Monthly.MonthlyReportSignOffConfirmParams
) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${MONTHLY_PREFIX}/${id}/confirm-sign-off`,
method: 'post',
data: {
meetingDate: data.meetingDate,
employeeSignedDate: data.employeeSignedDate || undefined
}
});
}
export function fetchExportMonthlyReports(params: Api.WorkReport.Monthly.MonthlyReportSearchParams = {}) {
const query = createMonthlyPageQuery(params);
return request<Blob, 'blob'>({

View File

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

View File

@@ -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
});
}

View File

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

View File

@@ -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
View File

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

View File

@@ -5,6 +5,18 @@ declare namespace Api {
* backend api module: "notify-message"(站内信 · 我的收件箱)
*/
namespace NotifyMessage {
type NotifyJumpType = 'project' | 'execution' | 'task';
interface NotifyMessageTemplateParams {
jumpType?: NotifyJumpType;
projectId?: string;
projectName?: string;
executionName?: string;
taskName?: string;
roleName?: string;
[key: string]: unknown;
}
interface PageParams {
pageNo: number;
pageSize: number;
@@ -19,6 +31,8 @@ declare namespace Api {
interface NotifyMessage {
/** 站内信编号(雪花 Long按 string 接收) */
id: string;
/** 模板编码 */
templateCode: string;
/** 发送人名称(模板配置的发件人显示名) */
templateNickname: string;
/** 最终消息正文(占位符已渲染,直接展示) */
@@ -33,6 +47,8 @@ declare namespace Api {
readTime: string | number | null;
/** 收到时间 */
createTime: string | number;
/** 模板参数(部分模板支持跳转) */
templateParams?: NotifyMessageTemplateParams;
}
/** 我的站内信分页查询参数 */

View File

@@ -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;
}
}
}

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

@@ -0,0 +1,267 @@
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;
resultDescriptionCells?: string[] | null;
actualScoreCells?: string[] | null;
baseScoreCells?: string[] | null;
extraScoreCells?: 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;
signOffStatus?: string | null;
signOffStatusName?: string | null;
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;
signOffStatus: string;
}
>;
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 ImportZipParams {
file: File;
periodMonth: string;
employeeIds?: string[] | null;
}
interface ImportZipResult {
workbookCount: number;
sheetCount: number;
successCount: number;
skippedCount: number;
failedCount: number;
messages: string[];
}
interface SignOffParams {
periodMonth: string;
employeeIds?: string[] | null;
remark?: string | null;
}
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;
}
interface PrefillData {
sourceSheetId?: string | null;
sourcePeriodMonth?: string | null;
cellValues: Record<string, string>;
}
}
namespace Team {
interface SummaryParams {
periodMonthStart?: string | null;
periodMonthEnd?: string | null;
}
interface PendingSendUser {
periodMonth: string;
userId: string;
userNickname: string;
managerUserId?: string | null;
managerName: string;
sheetId?: string | null;
statusCode?: Common.SheetStatusCode | null;
}
interface PendingConfirmUser {
periodMonth: string;
userId: string;
userNickname: string;
sheetId: string;
sentTime?: string | null;
}
interface DeptOrgAverage {
deptId: string;
deptName: string;
deptOrgType: string;
averageScore?: string | number | null;
confirmedCount: number;
}
interface Summary {
periodMonthStart: string;
periodMonthEnd: string;
expectedSheetCount: number;
pendingSendCount: number;
pendingConfirmCount: number;
confirmedRate: string | number;
pendingSendUsers: PendingSendUser[];
pendingConfirmUsers: PendingConfirmUser[];
deptOrgAverages: DeptOrgAverage[];
}
interface RemindParams {
periodMonthStart?: string | null;
periodMonthEnd?: string | null;
remindType: Common.RemindType;
userIds?: string[] | null;
}
interface RemindResult {
remindedCount: number;
}
}
}
}

View File

@@ -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'

View File

@@ -170,6 +170,10 @@ declare namespace Api {
name: string;
size?: number;
contentType?: string;
/** 对象存储配置编号(上传返回),与 path 一起拼永久代理 URL */
configId?: string;
/** 文件相对路径(上传返回),与 configId 一起拼永久代理 URL */
path?: string;
}
/** 任务详情 / 分页响应里嵌入的活跃协办人引用(按加入时间正序) */
@@ -235,7 +239,7 @@ declare namespace Api {
type: string;
ownerId: string;
ownerNickname?: string | null;
/** 所属执行的负责人 userId按钮可见度公式用跨执行查询永远为 null按钮判定退化为只看权限码 */
/** 所属执行的负责人 userId按钮可见度公式用跨执行查询为空时,前端无法识别执行负责人身份 */
executionOwnerId: string | null;
/** 父任务负责人 userId一级任务为 null */
parentTaskOwnerId: string | null;
@@ -487,7 +491,7 @@ declare namespace Api {
interface MyWorklogWeekResult {
/** 归一后的周一日期 YYYY-MM-DD */
weekStart: string;
/** 周一~周逐日工时(固定 5 元素;均摊推算值,周末份额归周五 */
/** 周一~周逐日工时(固定 7 元素;按周填报时仅均摊到工作日,周末单天工时保留在周末当天 */
dailyHours: number[];
/** 本周工时按归属分布hours 降序 */
distribution: WorklogDistributionItem[];
@@ -498,6 +502,7 @@ declare namespace Api {
userId: string;
userNickname: string;
items: WorklogDistributionItem[];
overtimeHours: number;
}
/** 工作台「团队工时周聚合」响应GET /project/project/me/team-worklog-week周标准工时后端不返回前端落常量 35 */
@@ -851,6 +856,8 @@ declare namespace Api {
createTime: string;
/** 更新时间 */
updateTime: string;
/** 当前登录用户在该项目的角色(后端只读计算字段,随登录身份变化;无角色为 [] */
currentUserRoles: Api.Common.CurrentUserRole[];
}
interface ProjectContext {
@@ -1160,8 +1167,8 @@ declare namespace Api {
categoryName?: string | null;
/** 需求来源类型 */
sourceType: ProjectRequirementSourceType;
/** 来源业务 ID */
sourceBizId?: string | null;
/** 来源业务编号 */
sourceBizCode?: string | null;
/** 优先级 */
priority: ProjectRequirementPriority;
/** 优先级名称 */
@@ -1285,7 +1292,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 +1310,7 @@ declare namespace Api {
| 'attachments'
| 'category'
| 'priority'
| 'sourceBizCode'
| 'proposerId'
| 'proposerNickname'
| 'currentHandlerUserId'

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

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

View File

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

View File

@@ -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;
};
@@ -151,6 +189,8 @@ declare namespace Api {
periodEndDate: string;
statusCode: Common.WorkReportStatusCode | string;
statusName: string;
signOffStatus?: string | null;
signOffStatusName?: string | null;
allowEdit: boolean;
terminal: boolean;
totalWorkHours?: number | string | null;
@@ -164,7 +204,10 @@ declare namespace Api {
planItems: Common.PersonalReportPlanItem[];
}
type MonthlyReportSearchParams = Common.WorkReportBaseSearchParams;
type MonthlyReportSearchParams = Common.WorkReportBaseSearchParams & {
reporterIds?: string[] | null;
signOffStatus?: string | null;
};
interface MonthlyReportSaveParams {
periodKey: string;
@@ -205,10 +248,22 @@ declare namespace Api {
improvementSuggestion?: string | null;
performanceResult?: string | null;
employeeSignName?: string | null;
employeeSignatureFileId?: string | null;
employeeSignedDate?: string | null;
supervisorSignName?: string | null;
supervisorSignatureFileId?: string | null;
supervisorSignedDate?: string | null;
}
interface MonthlyReportSignOffConfirmParams {
meetingDate: string;
employeeSignedDate?: string | null;
}
interface MonthlyReportSignOffDetail {
report: MonthlyReport;
latestApprovalRecord: MonthlyReportApprovalRecord | null;
}
}
namespace Project {
@@ -266,6 +321,7 @@ declare namespace Api {
}
type ProjectReportSearchParams = Common.WorkReportBaseSearchParams & {
projectOwnerIds?: string[] | null;
projectId?: string | null;
flag?: number | null;
};

View File

@@ -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,63 @@ 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']
IconMdiFolderZipOutline: typeof import('~icons/mdi/folder-zip-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']
IconMdiPenCheckOutline: typeof import('~icons/mdi/pen-check-outline')['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']

View File

@@ -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"

View File

@@ -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>

View File

@@ -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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,273 @@
<script setup lang="tsx">
import { computed, reactive, ref } from 'vue';
import { INFRA_OPERATE_TYPE_DICT_CODE } from '@/constants/dict';
import {
fetchExportApiAccessLog,
fetchGetApiAccessLog,
fetchGetApiAccessLogPage,
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 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 userOptions = ref<Array<{ label: string; value: string }>>([]);
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 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 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"
: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>API访问日志列表</p>
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag>
</div>
<TableHeaderOperation
v-model:columns="columnChecks"
:disabled-delete="true"
:loading="loading"
@refresh="getData"
>
<template #default>
<ElButton v-if="canExport" plain type="primary" :loading="exporting" @click="handleExport">
<template #icon>
<icon-mdi-download class="text-icon" />
</template>
导出
</ElButton>
</template>
</TableHeaderOperation>
</div>
</template>
<div class="flex-1">
<ElTable v-loading="loading" height="100%" border row-key="id" :data="data">
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
</ElTable>
</div>
<div class="mt-20px flex justify-end">
<ElPagination
v-if="mobilePagination.total"
layout="total,prev,pager,next,sizes"
v-bind="mobilePagination"
@current-change="mobilePagination['current-change']"
@size-change="mobilePagination['size-change']"
/>
</div>
</ElCard>
<LogDetailDialog
v-model:visible="detailVisible"
title="API访问日志详情"
:row-data="currentRow"
:sections="detailSections"
:fetch-detail="fetchGetApiAccessLog"
/>
</div>
</template>

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
import { computed } from 'vue';
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 props = defineProps<{
userOptions: Array<{ label: string; value: string }>;
}>();
const fields = computed<SearchField[]>(() => [
{
key: 'userId',
label: '用户名',
type: 'select',
placeholder: '请选择用户名',
options: props.userOptions,
filterable: true
},
{
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: 'userType',
// label: '用户类型',
// type: 'dict',
// placeholder: '请选择用户类型',
// dictCode: SYSTEM_USER_TYPE_DICT_CODE,
// filterable: true
// },
{
key: 'beginTime',
label: '请求时间',
type: 'dateRange',
placeholder: '请选择请求时间',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
format: 'YYYY-MM-DD HH:mm:ss'
}
]);
</script>
<template>
<TableSearchFields v-model="model" :fields="fields" :columns="4" @reset="emit('reset')" @search="emit('search')" />
</template>

View File

@@ -0,0 +1,265 @@
<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,
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 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 userOptions = ref<Array<{ label: string; value: string }>>([]);
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 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 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"
: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>API错误日志列表</p>
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag>
</div>
<TableHeaderOperation
v-model:columns="columnChecks"
:disabled-delete="true"
:loading="loading"
@refresh="getData"
>
<template #default>
<ElButton v-if="canExport" plain type="primary" :loading="exporting" @click="handleExport">
<template #icon>
<icon-mdi-download class="text-icon" />
</template>
导出
</ElButton>
</template>
</TableHeaderOperation>
</div>
</template>
<div class="flex-1">
<ElTable v-loading="loading" height="100%" border row-key="id" :data="data">
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
</ElTable>
</div>
<div class="mt-20px flex justify-end">
<ElPagination
v-if="mobilePagination.total"
layout="total,prev,pager,next,sizes"
v-bind="mobilePagination"
@current-change="mobilePagination['current-change']"
@size-change="mobilePagination['size-change']"
/>
</div>
</ElCard>
<LogDetailDialog
v-model:visible="detailVisible"
title="API错误日志详情"
:row-data="currentRow"
:sections="detailSections"
:fetch-detail="fetchGetApiErrorLog"
/>
</div>
</template>

View File

@@ -0,0 +1,69 @@
<script setup lang="ts">
import { computed } from 'vue';
import { INFRA_API_ERROR_LOG_PROCESS_STATUS_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 props = defineProps<{
userOptions: Array<{ label: string; value: string }>;
}>();
const fields = computed<SearchField[]>(() => [
{
key: 'userId',
label: '用户名',
type: 'select',
placeholder: '请选择用户名',
options: props.userOptions,
filterable: true
},
{
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: 'userType',
// label: '用户类型',
// type: 'dict',
// placeholder: '请选择用户类型',
// dictCode: SYSTEM_USER_TYPE_DICT_CODE,
// filterable: true
// },
{
key: 'exceptionTime',
label: '异常时间',
type: 'dateRange',
placeholder: '请选择异常时间',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
format: 'YYYY-MM-DD HH:mm:ss'
}
]);
</script>
<template>
<TableSearchFields v-model="model" :fields="fields" :columns="4" @reset="emit('reset')" @search="emit('search')" />
</template>

View File

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

View 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>

View 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>

View 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>

View File

@@ -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>

View 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`;
}

View 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>

View File

@@ -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,6 +281,8 @@ function buildRowActions(row: Api.PersonalItem.PersonalItem): PersonalItemRowAct
type: 'primary',
onClick: async () => openDetail(row, 'worklog')
},
...(!isCompleted
? ([
{
key: 'edit',
tooltip: '编辑',
@@ -291,7 +293,11 @@ function buildRowActions(row: Api.PersonalItem.PersonalItem): PersonalItemRowAct
editingData.value = row;
openOperateDialog();
}
},
}
] satisfies PersonalItemRowAction[])
: []),
...(!isCompleted
? ([
{
key: 'delete',
tooltip: '删除',
@@ -311,7 +317,9 @@ function buildRowActions(row: Api.PersonalItem.PersonalItem): PersonalItemRowAct
actionName: pauseAction?.actionName ?? '暂停',
needReason: pauseAction?.needReason ?? false
})
},
}
] satisfies PersonalItemRowAction[])
: []),
// {
// key: 'status-cancel',
// tooltip: cancelAction?.actionName ?? '取消',
@@ -326,6 +334,8 @@ function buildRowActions(row: Api.PersonalItem.PersonalItem): PersonalItemRowAct
// })
// },
...lifecycleActions,
...(!isCompleted
? ([
{
key: 'status-complete',
tooltip: completeAction?.actionName ?? '完成',
@@ -339,6 +349,8 @@ function buildRowActions(row: Api.PersonalItem.PersonalItem): PersonalItemRowAct
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">

View File

@@ -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,25 +158,64 @@ 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">
<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"
@@ -111,16 +223,19 @@ watch(
filterable
placeholder="请选择执行"
class="w-full"
:loading="loading"
:loading="executionLoading"
:disabled="!model.projectId"
>
<ElOption
v-for="option in executionOptions"
:key="option.executionId"
:label="getExecutionOptionLabel(option)"
:value="option.executionId"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
</BusinessFormDialog>
</template>

View File

@@ -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();
}
}

View File

@@ -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');
}

View File

@@ -1,3 +1,892 @@
<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 PerformanceSignOffDialog from './modules/performance-sign-off-dialog.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 signOffVisible = 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 canSignOff = computed(
() => hasAuth(PerformancePermission.SheetImport) && hasAuth(PerformancePermission.SheetSignOff)
);
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);
const currentActionEmployeeIds = computed(() => {
if (!isTeamMode.value) {
return undefined;
}
return currentEmployeeIds.value ?? undefined;
});
const signOffInitialPeriodMonth = computed(() => formattedPeriodMonthEnd.value || createDefaultPeriodMonth());
function canHandlePersonalSheet(row: Api.Performance.Sheet.Sheet) {
return (
row.statusCode === 'sent' &&
['pending_employee_confirm', 'employee_rejected'].includes(String(row.signOffStatus || ''))
);
}
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 (canHandlePersonalSheet(row)) {
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 openSignOffDialog() {
signOffVisible.value = true;
}
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')}.xlsx`);
}
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')}.xlsx`);
}
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 && canSignOff" plain @click="openSignOffDialog">
<template #icon>
<icon-mdi-check-circle-outline class="text-icon" />
</template>
下签
</ElButton>
<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" />
<PerformanceSignOffDialog
v-model:visible="signOffVisible"
open-mode="sign-off"
:employee-ids="currentActionEmployeeIds"
:initial-period-month="signOffInitialPeriodMonth"
:team-label="selectedTeamLabel"
@submitted="reloadAfterMutation"
/>
</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>

View File

@@ -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>

View File

@@ -0,0 +1,909 @@
<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,
fetchPerformanceSheetPrefill,
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;
initialEmployeeId?: string;
initialPeriodMonth?: string;
hideDraftAction?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
rowData: null,
subordinateOptions: () => [],
showApprovalFooter: false,
initialEmployeeId: '',
initialPeriodMonth: '',
hideDraftAction: false
});
const visible = defineModel<boolean>('visible', { default: false });
const emit = defineEmits<{
saved: [payload: PerformanceSheetSavePayload];
savedAndSent: [payload: PerformanceSheetSavePayload];
startApproval: [];
}>();
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const containerRef = ref<HTMLDivElement>();
const loading = ref(false);
const prefillLoading = 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 prefillSourcePeriodMonth = ref('');
const viewportWidth = ref(typeof window === 'undefined' ? 1920 : window.innerWidth);
const loadedCreateContextKey = ref('');
const createContextInitializing = ref(false);
const createForm = reactive({
periodMonth: createDefaultPeriodMonth(),
employeeId: ''
});
interface PerformanceSheetSavePayload {
sheet: Api.Performance.Sheet.Sheet;
actualScoreTotal: string;
baseScoreTotal: string;
extraScoreTotal: string;
}
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 getCreateContextKey() {
return `${createForm.periodMonth || ''}::${createForm.employeeId || ''}`;
}
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 writeCell(address: string, value: string) {
const position = parseCellAddress(address);
const workbook = getActiveWorkbook();
const sheet = workbook?.getActiveSheet?.();
if (!position || !sheet) return false;
try {
const range = sheet.getRange(position.row, position.column);
range?.setValue?.(value);
return true;
} catch {
return false;
}
}
function applyPrefillValues(cellValues: Record<string, string>) {
Object.entries(cellValues).forEach(([address, value]) => {
if (!address) return;
writeCell(address, value);
});
}
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 applyCreatePrefill() {
prefillSourcePeriodMonth.value = '';
if (!isCreateMode.value || !createForm.employeeId || !createForm.periodMonth) {
return;
}
prefillLoading.value = true;
try {
const { error, data } = await fetchPerformanceSheetPrefill(createForm.employeeId, createForm.periodMonth);
if (error || !data) {
return;
}
prefillSourcePeriodMonth.value = data.sourcePeriodMonth ?? '';
if (Object.keys(data.cellValues).length) {
applyPrefillValues(data.cellValues);
}
} finally {
prefillLoading.value = false;
}
}
async function loadWorkbook() {
loading.value = true;
prefillLoading.value = false;
errorMessage.value = '';
prefillSourcePeriodMonth.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));
if (isCreateMode.value) {
await nextTick();
await applyCreatePrefill();
loadedCreateContextKey.value = getCreateContextKey();
} else {
loadedCreateContextKey.value = '';
}
} 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;
}
function createInitialPeriodMonth() {
return props.initialPeriodMonth || createDefaultPeriodMonth();
}
function initializeCreateForm() {
createForm.periodMonth = createInitialPeriodMonth();
createForm.employeeId = props.initialEmployeeId || '';
prefillSourcePeriodMonth.value = '';
}
async function executeSave(): Promise<PerformanceSheetSavePayload | 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,
actualScoreTotal: scores.actualScoreTotal,
baseScoreTotal: scores.baseScoreTotal,
extraScoreTotal: scores.extraScoreTotal
};
}
async function handleSaveDraft() {
saving.value = true;
try {
const saveResult = await executeSave();
if (!saveResult) return;
window.$message?.success(isCreateMode.value ? '绩效表已保存为草稿' : '绩效 Excel 已保存');
visible.value = false;
emit('saved', saveResult);
} catch (error) {
window.$message?.error(error instanceof Error ? error.message : '绩效 Excel 保存失败');
} finally {
saving.value = false;
}
}
async function handleSaveAndSend() {
sending.value = true;
try {
const saveResult = await executeSave();
if (!saveResult) return;
const sendResult = await sendPerformanceSheet(saveResult.sheet.id);
if (sendResult.error) return;
window.$message?.success('绩效表已保存并发送');
visible.value = false;
emit('savedAndSent', saveResult);
} 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;
loadedCreateContextKey.value = '';
createContextInitializing.value = false;
prefillLoading.value = false;
prefillSourcePeriodMonth.value = '';
initializeCreateForm();
return;
}
createContextInitializing.value = isCreateMode.value;
try {
if (isCreateMode.value) {
initializeCreateForm();
}
await nextTick();
await loadWorkbook();
} finally {
createContextInitializing.value = false;
}
});
watch(
() => [createForm.employeeId, createForm.periodMonth] as const,
async ([employeeId, periodMonth], [previousEmployeeId, previousPeriodMonth]) => {
if (!visible.value || !isCreateMode.value || createContextInitializing.value) {
return;
}
const currentKey = `${periodMonth || ''}::${employeeId || ''}`;
const previousKey = `${previousPeriodMonth || ''}::${previousEmployeeId || ''}`;
if (
currentKey === previousKey ||
currentKey === loadedCreateContextKey.value ||
loading.value ||
prefillLoading.value
) {
return;
}
await nextTick();
await loadWorkbook();
}
);
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="选择绩效月份"
:disabled="loading || prefillLoading || saving || sending"
/>
</ElFormItem>
<ElFormItem label="下属" prop="employeeId" class="performance-excel-editor__form-item">
<ElSelect
v-model="createForm.employeeId"
filterable
placeholder="选择下属"
style="width: 200px"
:disabled="loading || prefillLoading || saving || sending"
>
<ElOption v-for="opt in props.subordinateOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</ElSelect>
</ElFormItem>
</ElForm>
<div v-if="prefillLoading || prefillSourcePeriodMonth" class="performance-excel-editor__prefill-tip">
{{ prefillLoading ? '正在自动带出上一份绩效数据...' : `已自动带出 ${prefillSourcePeriodMonth} 的绩效数据` }}
</div>
</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
v-if="!props.hideDraftAction"
:loading="saving"
:disabled="sending || loading || prefillLoading"
@click="handleSaveDraft"
>
保存草稿
</ElButton>
<ElButton
type="primary"
:loading="sending"
:disabled="saving || loading || prefillLoading"
@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__prefill-tip {
margin-top: 12px;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,133 @@
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',
SheetImport: 'project:performance-sheet:import',
SheetSignOff: 'project:performance-sheet:sign-off',
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 || '--';
}

View File

@@ -0,0 +1,300 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import type { UploadFile, UploadInstance } from 'element-plus';
import { importPerformanceSheetZip, signOffPerformanceSheets } from '@/service/api';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
defineOptions({ name: 'PerformanceSignOffDialog' });
type OpenMode = 'import' | 'sign-off';
type ActionMode = 'direct' | 'import';
const props = withDefaults(
defineProps<{
openMode?: OpenMode;
employeeIds?: string[] | null;
initialPeriodMonth?: string;
teamLabel?: string;
}>(),
{
openMode: 'sign-off',
employeeIds: undefined,
initialPeriodMonth: '',
teamLabel: ''
}
);
const visible = defineModel<boolean>('visible', { default: false });
const emit = defineEmits<{
submitted: [];
}>();
const uploadRef = ref<UploadInstance>();
const submitting = ref(false);
const importResult = ref<Api.Performance.Sheet.ImportZipResult | null>(null);
const form = reactive<{
actionMode: ActionMode;
periodMonth: string;
remark: string;
file: File | null;
fileName: string;
}>({
actionMode: 'direct',
periodMonth: '',
remark: '',
file: null,
fileName: ''
});
const isImportOnly = computed(() => props.openMode === 'import');
const needsZipFile = computed(() => isImportOnly.value || form.actionMode === 'import');
const title = computed(() => (isImportOnly.value ? '导入绩效压缩包' : '一键下签'));
const confirmText = computed(() => (isImportOnly.value ? '开始导入' : '确认执行'));
const teamLabelText = computed(() => {
if (needsZipFile.value) {
return '压缩包中含有的下属';
}
return props.teamLabel || '当前范围';
});
function resetForm() {
form.actionMode = 'direct';
form.periodMonth = props.initialPeriodMonth || '';
form.remark = '';
form.file = null;
form.fileName = '';
importResult.value = null;
uploadRef.value?.clearFiles();
}
function handleFileChange(file: UploadFile) {
const rawFile = file.raw;
uploadRef.value?.clearFiles();
if (!rawFile) {
return;
}
if (!rawFile.name.toLowerCase().endsWith('.zip')) {
window.$message?.warning('请选择 zip 压缩包');
return;
}
form.file = rawFile;
form.fileName = rawFile.name;
}
async function handleSubmit() {
if (!form.periodMonth) {
window.$message?.warning('请选择绩效月份');
return;
}
if (needsZipFile.value && !form.file) {
window.$message?.warning('请选择绩效压缩包');
return;
}
submitting.value = true;
if (needsZipFile.value && form.file) {
const importResponse = await importPerformanceSheetZip({
file: form.file,
periodMonth: form.periodMonth,
employeeIds: props.employeeIds
});
if (importResponse.error || !importResponse.data) {
submitting.value = false;
return;
}
importResult.value = importResponse.data;
if (isImportOnly.value) {
submitting.value = false;
if (importResponse.data.failedCount > 0) {
window.$message?.warning('导入存在失败项,请处理后重试');
return;
}
window.$message?.success('绩效压缩包导入成功');
visible.value = false;
emit('submitted');
return;
}
if (importResponse.data.failedCount > 0) {
submitting.value = false;
window.$message?.warning('导入存在失败项,已停止后续下签');
return;
}
}
if (!isImportOnly.value) {
const signOffResponse = await signOffPerformanceSheets({
periodMonth: form.periodMonth,
employeeIds: props.employeeIds,
remark: form.remark
});
submitting.value = false;
if (signOffResponse.error) {
return;
}
window.$message?.success('绩效表下签成功');
visible.value = false;
emit('submitted');
return;
}
submitting.value = false;
}
watch(visible, isVisible => {
if (isVisible) {
resetForm();
}
});
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="title"
preset="md"
append-to-body
:confirm-text="confirmText"
:confirm-loading="submitting"
@confirm="handleSubmit"
>
<ElForm label-position="top">
<ElRow :gutter="16">
<ElCol v-if="!isImportOnly" :span="24">
<ElFormItem label="执行方式">
<ElRadioGroup v-model="form.actionMode">
<ElRadio value="import">导入绩效压缩包后下签</ElRadio>
<ElRadio value="direct">直接下签</ElRadio>
</ElRadioGroup>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="绩效月份" required style="width: 100%">
<ElDatePicker
v-model="form.periodMonth"
class="w-full"
type="month"
value-format="YYYY-MM"
placeholder="请选择绩效月份"
style="width: 100%"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="适用范围">
<ElInput :model-value="teamLabelText" readonly />
</ElFormItem>
</ElCol>
<ElCol v-if="needsZipFile" :span="24">
<ElFormItem label="绩效压缩包" required>
<div class="performance-sign-off-dialog__upload-row">
<ElUpload
ref="uploadRef"
:auto-upload="false"
:show-file-list="false"
accept=".zip"
:limit="1"
:on-change="handleFileChange"
>
<ElButton plain>选择 zip 文件</ElButton>
</ElUpload>
<div
class="performance-sign-off-dialog__file-name"
:class="{ 'performance-sign-off-dialog__file-name--placeholder': !form.fileName }"
>
{{ form.fileName || '未选择文件' }}
</div>
</div>
</ElFormItem>
</ElCol>
<ElCol v-if="!isImportOnly" :span="24">
<ElFormItem label="备注">
<ElInput
v-model="form.remark"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="可填写本次下签说明"
/>
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
<ElAlert
v-if="importResult"
:type="importResult.failedCount > 0 ? 'warning' : 'success'"
show-icon
:closable="false"
class="mt-12px"
>
<template #title>
导入结果工作簿 {{ importResult.workbookCount }} 工作表 {{ importResult.sheetCount }} 成功
{{ importResult.successCount }} 跳过 {{ importResult.skippedCount }} 失败
{{ importResult.failedCount }}
</template>
<div v-if="importResult.messages.length" class="performance-sign-off-dialog__messages">
<div v-for="(message, index) in importResult.messages" :key="`${index}-${message}`">
{{ message }}
</div>
</div>
</ElAlert>
</BusinessFormDialog>
</template>
<style scoped>
.performance-sign-off-dialog__upload-row {
display: grid;
grid-template-columns: 120px minmax(0, 1fr);
gap: 10px;
align-items: center;
}
.performance-sign-off-dialog__file-name {
min-width: 0;
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;
line-height: 34px;
text-overflow: ellipsis;
white-space: nowrap;
}
.performance-sign-off-dialog__file-name--placeholder {
color: var(--el-text-color-placeholder);
}
.performance-sign-off-dialog__messages {
display: grid;
gap: 4px;
margin-top: 8px;
font-size: 13px;
line-height: 1.5;
}
</style>

View File

@@ -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>

View File

@@ -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&#45;&#45;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>

View File

@@ -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,32 +165,43 @@ 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: () => [
columns: () => {
const cols: UI.TableColumn<Api.OvertimeApplication.OvertimeApplication>[] = [
{ prop: 'index', type: 'index', label: '序号', width: 64 },
{ prop: 'applicantName', label: '申请人', minWidth: 120, showOverflowTooltip: true },
{
prop: 'overtimeDate',
label: '加班日期',
label: '学习日期',
width: 120,
formatter: row => formatOvertimeDate(row.overtimeDate)
},
{ prop: 'overtimeDuration', label: '加班时长', width: 110, showOverflowTooltip: true },
}
];
if (isTeamMode.value) {
cols.push({ prop: 'applicantName', label: '申请人', minWidth: 120, showOverflowTooltip: true });
}
cols.push(
{ prop: 'overtimeDuration', label: '学习时长', width: 110, showOverflowTooltip: true },
{
prop: 'overtimeReason',
label: '加班原因',
label: '学习原因',
minWidth: 180,
className: 'overtime-application__cell-ellipsis',
formatter: row => formatEmptyText(row.overtimeReason)
},
{
prop: 'overtimeContent',
label: '加班内容',
label: '学习内容',
minWidth: 200,
className: 'overtime-application__cell-ellipsis',
formatter: row => formatEmptyText(row.overtimeContent)
@@ -120,7 +217,7 @@ const { columns, columnChecks, data, loading, getDataByPage, mobilePagination }
</ElTag>
)
},
{ prop: 'approverName', label: '审人', minWidth: 80, showOverflowTooltip: true },
{ prop: 'approverName', label: '审人', minWidth: 80, showOverflowTooltip: true },
{
prop: 'submitTime',
label: '提交时间',
@@ -129,19 +226,22 @@ const { columns, columnChecks, data, loading, getDataByPage, mobilePagination }
},
{
prop: 'approvalTime',
label: '审时间',
label: '审时间',
minWidth: 150,
formatter: row => formatOvertimeDateTime(row.approvalTime)
},
{
prop: 'operate',
label: '操作',
width: 170,
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,28 +337,155 @@ 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) {
return;
}
downloadBlob(blob, `加班申请_${dayjs().format('YYYY-MM-DD')}.xls`);
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" />
<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>
</div>
</div>
</TeamContextPanel>
<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>
<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>
<p class="text-16px font-600">业务学习</p>
<ElTag effect="plain">{{ totalCount }}</ElTag>
</div>
<TableHeaderOperation v-model:columns="columnChecks" :loading="loading" @refresh="reloadTable">
@@ -253,7 +496,7 @@ async function handleExport() {
</template>
导出
</ElButton>
<ElButton plain type="primary" @click="openAdd">
<ElButton v-if="!isTeamMode" plain type="primary" @click="openAdd">
<template #icon>
<icon-ic-round-plus class="text-icon" />
</template>
@@ -282,6 +525,8 @@ async function handleExport() {
/>
</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;
@@ -311,11 +592,48 @@ async function handleExport() {
white-space: nowrap;
}
/* 加班原因/加班内容:单元格内容溢出时仅显示省略号,不弹出 tooltip */
/* 学习原因/学习内容:单元格内容溢出时仅显示省略号,不弹出 tooltip */
:deep(.overtime-application__cell-ellipsis .cell) {
display: block;
overflow: hidden;
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>

View File

@@ -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>

View File

@@ -49,7 +49,7 @@ watch(
<template>
<BusinessFormDialog
v-model="visible"
title="加班申请审批记录"
title="业务学习审批记录"
width="820px"
:loading="loading"
:show-footer="false"

View File

@@ -1,19 +1,20 @@
<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 */
/** 选中的业务学习 id 列表(原始 id */
selectedIds: string[];
/** 全部加班申请行数据,用于通过 id 查找 */
/** 全部业务学习行数据,用于通过 id 查找 */
rows: Api.OvertimeApplication.OvertimeApplication[];
actionLoading?: boolean;
}
@@ -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;
}
@@ -104,45 +163,93 @@ watch(
<ElDescriptionsItem label="申请人" label-class-name="overtime-application-detail-dialog__label">
{{ detailData.applicantName }}
</ElDescriptionsItem>
<ElDescriptionsItem label="加班日期" label-class-name="overtime-application-detail-dialog__label--compact">
<ElDescriptionsItem label="学习日期" label-class-name="overtime-application-detail-dialog__label--compact">
{{ formatOvertimeDate(detailData.overtimeDate) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="加班时长" label-class-name="overtime-application-detail-dialog__label">
<ElDescriptionsItem label="学习时长" label-class-name="overtime-application-detail-dialog__label">
{{ detailData.overtimeDuration }}
</ElDescriptionsItem>
<ElDescriptionsItem label="提交时间" label-class-name="overtime-application-detail-dialog__label--compact">
{{ formatOvertimeDateTime(detailData.submitTime) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="加班原因" :span="2" label-class-name="overtime-application-detail-dialog__label">
<ElDescriptionsItem label="学习原因" :span="2" label-class-name="overtime-application-detail-dialog__label">
{{ detailData.overtimeReason }}
</ElDescriptionsItem>
<ElDescriptionsItem label="加班内容" :span="2" label-class-name="overtime-application-detail-dialog__label">
<ElDescriptionsItem label="学习内容" :span="2" label-class-name="overtime-application-detail-dialog__label">
{{ detailData.overtimeContent }}
</ElDescriptionsItem>
</ElDescriptions>
<ElEmpty v-else description="未获取到加班申请详情" />
<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) {

View File

@@ -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,65 +74,137 @@ 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 }}
</ElDescriptionsItem>
<ElDescriptionsItem label="加班日期" label-class-name="overtime-application-detail-dialog__label--compact">
<ElDescriptionsItem label="学习日期" label-class-name="overtime-application-detail-dialog__label--compact">
{{ formatOvertimeDate(detailData.overtimeDate) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="加班时长" label-class-name="overtime-application-detail-dialog__label">
<ElDescriptionsItem label="学习时长" label-class-name="overtime-application-detail-dialog__label">
{{ detailData.overtimeDuration }}
</ElDescriptionsItem>
<ElDescriptionsItem label="提交时间" label-class-name="overtime-application-detail-dialog__label--compact">
{{ formatOvertimeDateTime(detailData.submitTime) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="加班原因" :span="2" label-class-name="overtime-application-detail-dialog__label">
<ElDescriptionsItem label="学习原因" :span="2" label-class-name="overtime-application-detail-dialog__label">
{{ detailData.overtimeReason }}
</ElDescriptionsItem>
<ElDescriptionsItem label="加班内容" :span="2" label-class-name="overtime-application-detail-dialog__label">
<ElDescriptionsItem label="学习内容" :span="2" label-class-name="overtime-application-detail-dialog__label">
{{ detailData.overtimeContent }}
</ElDescriptionsItem>
</ElDescriptions>
<ElEmpty v-else description="未获取到加班申请详情" />
<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) {

View File

@@ -43,21 +43,21 @@ const approverName = ref('');
const currentUserName = computed(() => authStore.userInfo.nickname || authStore.userInfo.userName || '--');
const isEdit = computed(() => props.operateType === 'edit');
const title = computed(() => (isEdit.value ? '修改并重新提交' : '新增加班申请'));
const title = computed(() => (isEdit.value ? '修改并重新提交' : '新增业务学习'));
const model = reactive<Api.OvertimeApplication.SaveOvertimeApplicationParams>(createDefaultModel());
const rules = computed(
() =>
({
overtimeDate: [createRequiredRule('请选择加班日期')],
overtimeDuration: [createRequiredRule('请选择加班时长')],
overtimeDate: [createRequiredRule('请选择学习日期')],
overtimeDuration: [createRequiredRule('请选择学习时长')],
overtimeReason: [
createRequiredRule('请输入加班原因'),
createRequiredRule('请输入学习原因'),
{
validator: (_rule, value: string, callback) => {
if (!value?.trim()) {
callback(new Error('请输入加班原因'));
callback(new Error('请输入学习原因'));
return;
}
@@ -67,11 +67,11 @@ const rules = computed(
}
],
overtimeContent: [
createRequiredRule('请输入加班内容'),
createRequiredRule('请输入学习内容'),
{
validator: (_rule, value: string, callback) => {
if (!value?.trim()) {
callback(new Error('请输入加班内容'));
callback(new Error('请输入学习内容'));
return;
}
@@ -80,7 +80,7 @@ const rules = computed(
trigger: 'blur'
}
],
approverId: [createRequiredRule('请选择审人')]
approverId: [createRequiredRule('请选择审人')]
}) satisfies Record<keyof Api.OvertimeApplication.SaveOvertimeApplicationParams, App.Global.FormRule[]>
);
@@ -152,7 +152,7 @@ async function handleSubmit() {
return;
}
window.$message?.success(isEdit.value ? '加班申请已重新提交' : '加班申请已提交');
window.$message?.success(isEdit.value ? '业务学习已重新提交' : '业务学习已提交');
visible.value = false;
emit('submitted');
}
@@ -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"
@@ -199,52 +199,52 @@ watch(
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="加班日期" prop="overtimeDate" style="width: 100%">
<ElFormItem label="学习日期" prop="overtimeDate" style="width: 100%">
<ElDatePicker
v-model="model.overtimeDate"
class="w-full"
type="date"
value-format="YYYY-MM-DD"
style="width: 100%"
placeholder="请选择加班日期"
placeholder="请选择学习日期"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="加班时长" prop="overtimeDuration">
<ElFormItem label="学习时长" prop="overtimeDuration">
<DictSelect
v-model="model.overtimeDuration"
:dict-code="RDMS_OVERTIME_DURATION_DICT_CODE"
placeholder="请选择加班时长"
placeholder="请选择学习时长"
/>
</ElFormItem>
</ElCol>
</ElRow>
</BusinessFormSection>
<BusinessFormSection title="加班说明">
<BusinessFormSection title="学习说明">
<ElRow :gutter="16">
<ElCol :span="24">
<ElFormItem label="加班原因" prop="overtimeReason">
<ElFormItem label="学习原因" prop="overtimeReason">
<ElInput
v-model="model.overtimeReason"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请输入加班原因"
placeholder="请输入学习原因"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="加班内容" prop="overtimeContent">
<ElFormItem label="学习内容" prop="overtimeContent">
<ElInput
v-model="model.overtimeContent"
type="textarea"
:rows="4"
maxlength="1000"
show-word-limit
placeholder="请输入加班内容"
placeholder="请输入学习内容"
/>
</ElFormItem>
</ElCol>

View File

@@ -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,18 +87,27 @@ onMounted(async () => {
await loadStatusOptions();
});
const fields = computed<SearchField[]>(() => [
const fields = computed<SearchField[]>(() => {
const baseFields: SearchField[] = [
...(props.teamMode
? [
{
key: 'applicantName',
key: 'applicantIds',
label: '申请人',
type: 'input',
placeholder: '请输入申请人'
},
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: '加班日期',
label: '学习日期',
type: 'dateRange',
placeholder: '请选择加班日期'
placeholder: '请选择学习日期'
},
{
key: 'statusCode',
@@ -92,14 +115,20 @@ const fields = computed<SearchField[]>(() => [
type: 'select',
options: statusOptions.value,
placeholder: '请选择状态'
},
{
key: 'approverName',
label: '审核人',
type: 'input',
placeholder: '请输入审核人'
}
]);
];
if (props.teamMode) {
baseFields.push({
key: 'approverName',
label: '审批人',
type: 'input',
placeholder: '请输入审批人'
});
}
return baseFields;
});
function handleReset() {
emit('reset');

View 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;
}

View File

@@ -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>

View File

@@ -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,29 +55,87 @@ 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: () => [
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) },
{ 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: '部门',
@@ -93,25 +161,101 @@ 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" />
}
]
);
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>

View File

@@ -4,9 +4,11 @@ import { computed, nextTick, reactive, ref, watch } from 'vue';
import type { FormRules } from 'element-plus';
import dayjs from 'dayjs';
import { RDMS_REQ_PRIORITY_DICT_CODE, RDMS_TASK_ITEM_TYPE_DICT_CODE } from '@/constants/dict';
import { fetchPerformanceSheetPage } from '@/service/api';
import { useForm, useFormRules } from '@/hooks/common/form';
import { useDict } from '@/hooks/business/dict';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import PerformanceExcelEditorDrawer from '../../../my-performance/modules/performance-excel-editor-drawer.vue';
import {
type WorkReportStructuredSection,
type WorkReportStructuredTask,
@@ -18,16 +20,20 @@ const props = defineProps<{
reportType: string;
period: string;
mode?: 'add' | 'edit' | 'detail';
scene?: 'fill' | 'detail' | 'approval';
scene?: 'fill' | 'detail' | 'approval' | 'sign-off';
baseInfo?: Api.WorkReport.Monthly.MonthlyReport | null;
model: Api.WorkReport.Monthly.MonthlyReportSaveParams;
approvalModel: Api.WorkReport.Monthly.MonthlyReportApproveParams;
supervisorSignaturePreviewUrl?: string;
supervisorSignatureLoading?: boolean;
employeeSignaturePreviewUrl?: string;
}>();
const emit = defineEmits<{
back: [];
requestApprove: [];
requestReject: [];
requestConfirmSignOff: [];
changeApproval: [payload: Api.WorkReport.Monthly.MonthlyReportApproveParams];
viewAudit: [];
}>();
@@ -73,6 +79,13 @@ const EMPTY_TEXT = '';
const { getLabel: getTaskItemTypeLabel } = useDict(RDMS_TASK_ITEM_TYPE_DICT_CODE);
const { dictData: priorityDictData, getLabel: getPriorityLabel } = useDict(RDMS_REQ_PRIORITY_DICT_CODE);
const todayText = computed(() => dayjs().format('YYYY-MM-DD'));
const performanceDrawerVisible = ref(false);
const performanceDrawerLoading = ref(false);
const performanceDrawerMode = ref<'create' | 'edit'>('create');
const performanceDrawerRow = ref<Api.Performance.Sheet.Sheet | null>(null);
const isSignOffScene = computed(() => props.scene === 'sign-off');
const hasSupervisorSignaturePreview = computed(() => Boolean(props.supervisorSignaturePreviewUrl));
const hasEmployeeSignaturePreview = computed(() => Boolean(props.employeeSignaturePreviewUrl));
const auditDialogVisible = ref(false);
const auditForm = reactive({
@@ -89,8 +102,31 @@ const pageValidationModel = reactive({
return props.approvalModel.performanceResult || '';
}
});
const pageRules: FormRules = {
meetingDate: [createRequiredRule('请选择面谈时间')],
function formatDisplayDate(value: unknown) {
const text = String(value ?? '')
.replace(/^\[\s*/u, '')
.replace(/\s*\]$/u, '')
.trim();
if (!text) return '--';
const commaDateMatch = text.match(/^(\d{4})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})$/u);
if (commaDateMatch) {
const [, year, month, day] = commaDateMatch;
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
}
return text;
}
const pageRules = computed<FormRules>(() => {
if (isSignOffScene.value) {
return {
meetingDate: [createRequiredRule('请选择面谈日期')]
};
}
return {
performanceResult: [
createRequiredRule('请输入绩效考核结果'),
{
@@ -105,7 +141,8 @@ const pageRules: FormRules = {
trigger: 'blur'
}
]
};
};
});
const auditRules = computed<FormRules>(() => ({
opinion: rejectOpinionRequired.value
? [
@@ -177,6 +214,24 @@ const performanceForm = reactive({
}
});
const performanceTargetOption = computed(() => {
const reporterId = props.baseInfo?.reporterId || '';
const reporterName = props.baseInfo?.reporterName || '';
if (!reporterId || !reporterName) return [];
return [{ label: reporterName, value: reporterId }];
});
const performanceInitialEmployeeId = computed(() => props.baseInfo?.reporterId || '');
const performanceInitialPeriodMonth = computed(() => {
const periodDate = props.baseInfo?.periodStartDate || props.baseInfo?.periodEndDate || '';
if (!periodDate) return '';
const targetMonth = dayjs(periodDate);
return targetMonth.isValid() ? targetMonth.format('YYYY-MM') : '';
});
/** 绩效分数输入限制0-100最多一位小数 */
function handlePerformanceScoreInput(value: string) {
// 只允许数字和一个小数点
@@ -229,24 +284,12 @@ const mainForm = reactive({
});
const signatureForm = reactive({
get employeeSign() {
return props.approvalModel.employeeSignName || '';
},
set employeeSign(value: string) {
patchApproval('employeeSignName', value);
},
get employeeDate() {
return props.approvalModel.employeeSignedDate || todayText.value;
return props.approvalModel.employeeSignedDate || '';
},
set employeeDate(value: string) {
patchApproval('employeeSignedDate', value);
},
get supervisorSign() {
return props.approvalModel.supervisorSignName || '';
},
set supervisorSign(value: string) {
patchApproval('supervisorSignName', value);
},
get supervisorDate() {
return props.approvalModel.supervisorSignedDate || todayText.value;
},
@@ -537,7 +580,8 @@ async function submitAudit() {
}
}
patchApproval('employeeSignedDate', signatureForm.employeeDate);
patchApproval('employeeSignName', undefined);
patchApproval('employeeSignedDate', undefined);
patchApproval('supervisorSignedDate', signatureForm.supervisorDate);
patchApproval(
'reason',
@@ -563,6 +607,47 @@ function openAuditDialog() {
});
}
async function openPerformanceDrawer() {
const reporterId = props.baseInfo?.reporterId || '';
const periodMonth = performanceInitialPeriodMonth.value;
performanceDrawerMode.value = 'create';
performanceDrawerRow.value = null;
if (reporterId && periodMonth) {
performanceDrawerLoading.value = true;
const { error, data } = await fetchPerformanceSheetPage({
pageNo: 1,
pageSize: 1,
employeeId: reporterId,
periodMonthRange: [periodMonth, periodMonth]
});
performanceDrawerLoading.value = false;
const existingSheet = !error && data?.list?.length ? data.list[0] : null;
if (existingSheet) {
performanceDrawerMode.value = 'edit';
performanceDrawerRow.value = existingSheet;
}
}
performanceDrawerVisible.value = true;
}
function handlePerformanceSaved(payload: { actualScoreTotal: string }) {
patchApproval('performanceResult', payload.actualScoreTotal);
}
async function handleConfirmSignOff() {
try {
await validatePageForm();
} catch {
return;
}
emit('requestConfirmSignOff');
}
watch(rejectOpinionRequired, async () => {
if (!auditDialogVisible.value) return;
@@ -614,12 +699,15 @@ watch(
<ElInput v-model="mainForm.supervisor" disabled />
</div>
<div class="field field-form-item">
<label>
面谈时间
<span class="field-required-mark">*</span>
</label>
<label>面谈时间</label>
<ElFormItem class="field-inline-form-item" prop="meetingDate">
<ElDatePicker v-model="mainForm.meetingDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
<ElDatePicker
v-model="mainForm.meetingDate"
type="date"
value-format="YYYY-MM-DD"
disabled
style="width: 100%"
/>
</ElFormItem>
</div>
</div>
@@ -680,10 +768,23 @@ watch(
<span class="feedback-tag success">优势</span>
</div>
<div class="feedback-cell">
<ElInput v-model="feedbackForm.strengthDesc" type="textarea" :rows="2" placeholder="请输入优势描述" />
<div v-if="isSignOffScene" class="rich-editor readonly feedback-readonly">
{{ feedbackForm.strengthDesc || '' }}
</div>
<ElInput
v-else
v-model="feedbackForm.strengthDesc"
type="textarea"
:rows="2"
placeholder="请输入优势描述"
/>
</div>
<div class="feedback-cell">
<div v-if="isSignOffScene" class="rich-editor readonly feedback-readonly">
{{ feedbackForm.strengthExample || '' }}
</div>
<ElInput
v-else
v-model="feedbackForm.strengthExample"
type="textarea"
:rows="2"
@@ -696,10 +797,23 @@ watch(
<span class="feedback-tag warning">劣势</span>
</div>
<div class="feedback-cell">
<ElInput v-model="feedbackForm.weaknessDesc" type="textarea" :rows="2" placeholder="请输入劣势描述" />
<div v-if="isSignOffScene" class="rich-editor readonly feedback-readonly">
{{ feedbackForm.weaknessDesc || '' }}
</div>
<ElInput
v-else
v-model="feedbackForm.weaknessDesc"
type="textarea"
:rows="2"
placeholder="请输入劣势描述"
/>
</div>
<div class="feedback-cell">
<div v-if="isSignOffScene" class="rich-editor readonly feedback-readonly">
{{ feedbackForm.weaknessExample || '' }}
</div>
<ElInput
v-else
v-model="feedbackForm.weaknessExample"
type="textarea"
:rows="2"
@@ -709,7 +823,11 @@ watch(
</div>
</div>
<div class="feedback-right">
<div v-if="isSignOffScene" class="rich-editor readonly feedback-readonly">
{{ feedbackForm.improvementSuggestion || '' }}
</div>
<ElInput
v-else
v-model="feedbackForm.improvementSuggestion"
type="textarea"
:rows="5"
@@ -727,15 +845,29 @@ watch(
<div class="performance-result">
<div class="performance-label">
绩效考核结果
<span class="field-required-mark">*</span>
<span v-if="!isSignOffScene" class="field-required-mark">*</span>
</div>
<ElFormItem class="performance-inline-form-item" prop="performanceResult">
<div class="performance-input-group">
<ElInput
v-model="performanceForm.score"
class="performance-input"
placeholder="请输入考核分数"
readonly
@input="handlePerformanceScoreInput"
/>
<ElButton
v-if="!isSignOffScene"
plain
size="small"
type="primary"
:loading="performanceDrawerLoading"
:disabled="!performanceTargetOption.length"
@click="openPerformanceDrawer"
>
填写绩效
</ElButton>
</div>
</ElFormItem>
</div>
</div>
@@ -772,21 +904,68 @@ watch(
</div>
<div class="signature-section">
<template v-if="isSignOffScene">
<div class="signature-row">
<span>被考核人签名</span>
<ElInput v-model="signatureForm.employeeSign" class="signature-input" placeholder="请输入签名" />
<span>日期</span>
<ElDatePicker
v-model="signatureForm.employeeDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
class="signature-date"
<div
class="signature-preview-panel"
:class="{ 'signature-preview-panel--empty': !hasEmployeeSignaturePreview }"
>
<img
v-if="hasEmployeeSignaturePreview"
:src="props.employeeSignaturePreviewUrl"
alt="被考核人电子签名"
class="signature-preview-image"
/>
<span v-else>未维护电子签名</span>
</div>
<span>日期</span>
<div class="signature-readonly">{{ formatDisplayDate(signatureForm.employeeDate) }}</div>
</div>
<div class="signature-row">
<span>上级签名</span>
<ElInput v-model="signatureForm.supervisorSign" class="signature-input" placeholder="请输入签名" />
<div
v-loading="props.supervisorSignatureLoading"
class="signature-preview-panel"
:class="{ 'signature-preview-panel--empty': !hasSupervisorSignaturePreview }"
>
<img
v-if="hasSupervisorSignaturePreview"
:src="props.supervisorSignaturePreviewUrl"
alt="上级电子签名"
class="signature-preview-image"
/>
<span v-else>未维护电子签名</span>
</div>
<span>日期</span>
<div class="signature-readonly">{{ formatDisplayDate(props.approvalModel.supervisorSignedDate) }}</div>
</div>
<div v-if="!hasEmployeeSignaturePreview" class="signature-warning">
当前账号未维护电子签名确认后后端将无法回填员工签名图片
</div>
</template>
<template v-else>
<div class="signature-row">
<span>被考核人签名</span>
<div class="signature-readonly">将在下签确认时自动补齐</div>
<span>日期</span>
<div class="signature-readonly">将在下签确认时自动补齐</div>
</div>
<div class="signature-row">
<span>上级签名</span>
<div
v-loading="props.supervisorSignatureLoading"
class="signature-preview-panel"
:class="{ 'signature-preview-panel--empty': !hasSupervisorSignaturePreview }"
>
<img
v-if="hasSupervisorSignaturePreview"
:src="props.supervisorSignaturePreviewUrl"
alt="上级电子签名"
class="signature-preview-image"
/>
<span v-else>未维护电子签名</span>
</div>
<span>日期</span>
<ElDatePicker
v-model="signatureForm.supervisorDate"
@@ -796,15 +975,21 @@ watch(
class="signature-date"
/>
</div>
<div v-if="!hasSupervisorSignaturePreview" class="signature-warning">
当前登录用户未维护电子签名审批提交后将无法回填上级签名图片
</div>
</template>
</div>
</ElForm>
<div class="form-actions approval-form-actions">
<ElButton @click="emit('back')">退出审批</ElButton>
<ElButton type="primary" class="btn-submit" @click="openAuditDialog">开始审批</ElButton>
<ElButton @click="emit('back')">退出</ElButton>
<ElButton v-if="isSignOffScene" type="primary" @click="handleConfirmSignOff">确认</ElButton>
<ElButton v-else type="primary" @click="openAuditDialog">审批</ElButton>
</div>
<BusinessFormDialog
v-if="!isSignOffScene"
v-model="auditDialogVisible"
title="月报审批"
preset="sm"
@@ -871,6 +1056,19 @@ watch(
</ElForm>
</div>
</BusinessFormDialog>
<PerformanceExcelEditorDrawer
v-if="!isSignOffScene"
v-model:visible="performanceDrawerVisible"
:row-data="performanceDrawerRow"
:mode="performanceDrawerMode"
:subordinate-options="performanceTargetOption"
:initial-employee-id="performanceInitialEmployeeId"
:initial-period-month="performanceInitialPeriodMonth"
hide-draft-action
@saved="handlePerformanceSaved"
@saved-and-sent="handlePerformanceSaved"
/>
</div>
</template>
@@ -915,8 +1113,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 +1207,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 +1321,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 +1490,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 +1506,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 +1522,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 +1652,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 +1665,7 @@ watch(
width: 4px;
height: 16px;
border-radius: 999px;
background: #0f766e;
background: var(--el-color-primary);
}
.structured-section-tasks {
@@ -1551,14 +1749,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 {
@@ -1655,6 +1853,11 @@ watch(
height: 100% !important;
}
.feedback-readonly {
width: 100%;
min-height: 60px;
}
.feedback-cell {
padding: 16px;
border-right: 1px solid #f1f5f9;
@@ -1689,9 +1892,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 {
@@ -1724,27 +1927,37 @@ watch(
border-top: 1px solid #f1f5f9;
}
.signature-input {
width: 140px;
}
.signature-input :deep(.el-input__wrapper) {
box-shadow: none !important;
background: transparent;
.signature-readonly {
min-width: 180px;
padding: 0 4px 4px;
border-bottom: 1px solid #e2e8f0;
border-radius: 0;
padding: 0 4px;
}
.signature-input :deep(.el-input__inner) {
color: #64748b;
font-size: 14px;
text-align: center;
color: #334155;
font-weight: 600;
}
.signature-input :deep(.el-input__inner::placeholder) {
color: #cbd5e1;
font-weight: 400;
.signature-preview-panel {
display: flex;
align-items: center;
justify-content: center;
width: 116px;
height: 44px;
padding: 3px;
border: 1px dashed #cbd5e1;
border-radius: 8px;
background: #f8fafc;
box-sizing: border-box;
}
.signature-preview-panel--empty {
color: #94a3b8;
font-size: 13px;
}
.signature-preview-image {
max-width: 98px;
max-height: 28px;
object-fit: contain;
}
.signature-date {
@@ -1770,6 +1983,14 @@ watch(
font-weight: 400;
}
.signature-warning {
margin-top: 8px;
color: #c2410c;
font-size: 13px;
font-weight: 600;
text-align: right;
}
.performance-result {
display: flex;
align-items: center;
@@ -1794,13 +2015,19 @@ watch(
flex: 0 0 auto;
}
.performance-input-group {
display: inline-flex;
align-items: center;
gap: 12px;
}
.performance-input {
width: 200px;
}
.performance-input :deep(.el-input__wrapper) {
box-shadow: none !important;
background: transparent;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
border-radius: 0;
padding: 0 4px;
@@ -1812,6 +2039,10 @@ watch(
text-align: center;
}
.performance-input :deep(.el-input__inner[readonly]) {
cursor: default;
}
.performance-input :deep(.el-input__inner::placeholder) {
color: #cbd5e1;
font-weight: 400;
@@ -1838,16 +2069,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,

View File

@@ -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,

View File

@@ -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')"

View File

@@ -0,0 +1,198 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue';
import dayjs from 'dayjs';
import {
downloadFile,
fetchConfirmMonthlyReportSignOff,
fetchGetMonthlyReportSignOffDetail,
fetchGetMyUserSignature
} from '@/service/api';
import MonthlyReportApprovalPage from './approval-page.vue';
defineOptions({ name: 'MonthlySignOffPage' });
const props = defineProps<{
reportId?: string;
baseInfo?: Api.WorkReport.Monthly.MonthlyReport | null;
}>();
const emit = defineEmits<{
back: [];
submitted: [];
}>();
const loading = ref(false);
const submitting = ref(false);
const detail = ref<Api.WorkReport.Monthly.MonthlyReportSignOffDetail | null>(null);
const supervisorSignaturePreviewUrl = ref('');
const supervisorSignatureObjectUrl = ref<string | null>(null);
const employeeSignaturePreviewUrl = ref('');
const employeeSignatureObjectUrl = ref<string | null>(null);
const report = computed(() => detail.value?.report || props.baseInfo || null);
const monthlyModel = computed<Api.WorkReport.Monthly.MonthlyReportSaveParams>(() => ({
periodKey: report.value?.periodKey || '',
periodLabel: report.value?.periodLabel || '',
periodStartDate: report.value?.periodStartDate || '',
periodEndDate: report.value?.periodEndDate || '',
reviewItems: report.value?.reviewItems || [],
planItems: report.value?.planItems || []
}));
const approvalDraft = reactive<Api.WorkReport.Monthly.MonthlyReportApproveParams>({
reason: '',
meetingDate: dayjs().format('YYYY-MM-DD'),
strengthDesc: '',
strengthExample: '',
weaknessDesc: '',
weaknessExample: '',
improvementSuggestion: '',
performanceResult: '',
employeeSignName: undefined,
employeeSignedDate: dayjs().format('YYYY-MM-DD'),
supervisorSignName: '',
supervisorSignedDate: ''
});
function patchApprovalRecord(record: Api.WorkReport.Monthly.MonthlyReportApprovalRecord | null | undefined) {
Object.assign(approvalDraft, {
reason: '',
meetingDate: dayjs().format('YYYY-MM-DD'),
strengthDesc: record?.strengthDesc || '',
strengthExample: record?.strengthExample || '',
weaknessDesc: record?.weaknessDesc || '',
weaknessExample: record?.weaknessExample || '',
improvementSuggestion: record?.improvementSuggestion || '',
performanceResult: record?.performanceResult || '',
employeeSignName: record?.employeeSignName,
employeeSignedDate: dayjs().format('YYYY-MM-DD'),
supervisorSignName: record?.supervisorSignName || '',
supervisorSignedDate: record?.supervisorSignedDate || ''
});
}
function revokePreviewUrl(target: 'supervisor' | 'employee') {
const refValue = target === 'supervisor' ? supervisorSignatureObjectUrl : employeeSignatureObjectUrl;
const previewRef = target === 'supervisor' ? supervisorSignaturePreviewUrl : employeeSignaturePreviewUrl;
if (refValue.value) {
URL.revokeObjectURL(refValue.value);
refValue.value = null;
}
previewRef.value = '';
}
async function loadSignaturePreview(fileId: string | null | undefined, target: 'supervisor' | 'employee') {
revokePreviewUrl(target);
if (!fileId) {
return;
}
const { error, data } = await downloadFile(fileId);
if (error || !data) {
return;
}
const objectUrl = URL.createObjectURL(data);
if (target === 'supervisor') {
supervisorSignatureObjectUrl.value = objectUrl;
supervisorSignaturePreviewUrl.value = objectUrl;
return;
}
employeeSignatureObjectUrl.value = objectUrl;
employeeSignaturePreviewUrl.value = objectUrl;
}
async function loadDetail() {
if (!props.reportId) {
detail.value = null;
patchApprovalRecord(null);
revokePreviewUrl('supervisor');
revokePreviewUrl('employee');
return;
}
loading.value = true;
const [detailResult, mySignatureResult] = await Promise.all([
fetchGetMonthlyReportSignOffDetail(props.reportId),
fetchGetMyUserSignature()
]);
loading.value = false;
if (detailResult.error || !detailResult.data) {
detail.value = null;
patchApprovalRecord(null);
revokePreviewUrl('supervisor');
revokePreviewUrl('employee');
return;
}
detail.value = detailResult.data;
patchApprovalRecord(detailResult.data.latestApprovalRecord);
await Promise.all([
loadSignaturePreview(detailResult.data.latestApprovalRecord?.supervisorSignatureFileId, 'supervisor'),
loadSignaturePreview(mySignatureResult.error ? null : mySignatureResult.data?.fileId, 'employee')
]);
}
function handleApprovalChange(payload: Api.WorkReport.Monthly.MonthlyReportApproveParams) {
Object.assign(approvalDraft, payload);
}
async function handleConfirm() {
if (!props.reportId) {
return;
}
submitting.value = true;
const result = await fetchConfirmMonthlyReportSignOff(props.reportId, {
meetingDate: approvalDraft.meetingDate || dayjs().format('YYYY-MM-DD'),
employeeSignedDate: approvalDraft.employeeSignedDate || undefined
});
submitting.value = false;
if (result.error) {
return;
}
window.$message?.success('月报下签确认成功');
emit('submitted');
}
watch(
() => props.reportId,
() => {
loadDetail();
},
{ immediate: true }
);
onBeforeUnmount(() => {
revokePreviewUrl('supervisor');
revokePreviewUrl('employee');
});
</script>
<template>
<div v-loading="loading">
<MonthlyReportApprovalPage
report-type="个人月报"
:period="report?.periodLabel || ''"
mode="detail"
scene="sign-off"
:base-info="report"
:model="monthlyModel"
:approval-model="approvalDraft"
:supervisor-signature-preview-url="supervisorSignaturePreviewUrl"
:employee-signature-preview-url="employeeSignaturePreviewUrl"
:supervisor-signature-loading="false"
@back="emit('back')"
@change-approval="handleApprovalChange"
@request-confirm-sign-off="handleConfirm"
/>
</div>
</template>

View File

@@ -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>

View File

@@ -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 @click="emit('back')">退出</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,

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