9 Commits

Author SHA1 Message Date
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
32 changed files with 1142 additions and 284 deletions

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

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

@@ -65,6 +65,11 @@ type MonthlyResultResponse = Omit<Api.Performance.Sheet.MonthlyResult, 'sheetId'
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'
@@ -185,6 +190,28 @@ function normalizeMonthlyResult(response: MonthlyResultResponse): Api.Performanc
};
}
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,
@@ -464,6 +491,17 @@ export async function fetchPerformanceMonthlyResult(employeeId: string, periodMo
);
}
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,

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

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

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

@@ -21,6 +21,10 @@ declare namespace Api {
actualScoreTotalCell?: string | null;
baseScoreTotalCell?: string | null;
extraScoreTotalCell?: string | null;
resultDescriptionCells?: string[] | null;
actualScoreCells?: string[] | null;
baseScoreCells?: string[] | null;
extraScoreCells?: string[] | null;
}
interface Template {
@@ -172,6 +176,12 @@ declare namespace Api {
extraScoreTotal?: string | number | null;
statusCode?: Common.SheetStatusCode | null;
}
interface PrefillData {
sourceSheetId?: string | null;
sourcePeriodMonth?: string | null;
cellValues: Record<string, string>;
}
}
namespace Team {

View File

@@ -239,7 +239,7 @@ declare namespace Api {
type: string;
ownerId: string;
ownerNickname?: string | null;
/** 所属执行的负责人 userId按钮可见度公式用跨执行查询永远为 null按钮判定退化为只看权限码 */
/** 所属执行的负责人 userId按钮可见度公式用跨执行查询为空时,前端无法识别执行负责人身份 */
executionOwnerId: string | null;
/** 父任务负责人 userId一级任务为 null */
parentTaskOwnerId: string | null;
@@ -491,7 +491,7 @@ declare namespace Api {
interface MyWorklogWeekResult {
/** 归一后的周一日期 YYYY-MM-DD */
weekStart: string;
/** 周一~周逐日工时(固定 5 元素;均摊推算值,周末份额归周五 */
/** 周一~周逐日工时(固定 7 元素;按周填报时仅均摊到工作日,周末单天工时保留在周末当天 */
dailyHours: number[];
/** 本周工时按归属分布hours 降序 */
distribution: WorklogDistributionItem[];
@@ -502,6 +502,7 @@ declare namespace Api {
userId: string;
userNickname: string;
items: WorklogDistributionItem[];
overtimeHours: number;
}
/** 工作台「团队工时周聚合」响应GET /project/project/me/team-worklog-week周标准工时后端不返回前端落常量 35 */

View File

@@ -45,7 +45,7 @@ function canEditRow(row: Api.Feedback.FeedbackItem) {
}
function getInitSearchParams(): Api.Feedback.FeedbackSearchParams {
return { pageNo: 1, pageSize: 20, type: undefined, status: 1, title: undefined, creator: undefined };
return { pageNo: 1, pageSize: 20, type: undefined, status: '1', title: undefined, creator: undefined };
}
function transformPageResult(

View File

@@ -2,7 +2,6 @@
import { computed, onMounted, ref } from 'vue';
import { FEEDBACK_STATUS_DICT_CODE } from '@/constants/dict';
import { fetchGetUserSimpleList } from '@/service/api';
import { useDict } from '@/hooks/business/dict';
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
defineOptions({ name: 'FeedbackSearch' });
@@ -14,9 +13,8 @@ const emit = defineEmits<{
const model = defineModel<Api.Feedback.FeedbackSearchParams>('model', { required: true });
// 提交人下拉选项(全员简易列value 用户 idID 铁律 string
// 提交人下拉选项全员简表value 使用用户 idID 铁律 string
const userOptions = ref<{ label: string; value: string }[]>([]);
const { dictOptions: statusOptions } = useDict(FEEDBACK_STATUS_DICT_CODE);
// 分类保留在左侧分面;搜索区补充标题关键词、提交人、状态
const fields = computed<SearchField[]>(() => [
@@ -32,9 +30,11 @@ const fields = computed<SearchField[]>(() => [
{
key: 'status',
label: '状态',
type: 'select',
type: 'dict',
dictCode: FEEDBACK_STATUS_DICT_CODE,
placeholder: '请选择状态',
options: statusOptions.value
resolveValue: value => (value === null || value === undefined || value === '' ? undefined : String(value)),
transformValue: value => (value === null || value === undefined || value === '' ? undefined : String(value))
}
]);
@@ -50,7 +50,7 @@ function reset() {
}
function search() {
// 输入期不实时 trim避免受控 input 吃掉词间空格;提交前规整一次,空串归一为 undefined
// 输入期不实时 trim避免受控 input 吃掉词间空格;提交前规整一次,空串归一为 undefined
model.value.title = model.value.title?.trim() || undefined;
emit('search');
}

View File

@@ -1,7 +1,12 @@
<script setup lang="tsx">
import { computed, reactive, ref } from 'vue';
import { INFRA_OPERATE_TYPE_DICT_CODE } from '@/constants/dict';
import { fetchExportApiAccessLog, fetchGetApiAccessLog, fetchGetApiAccessLogPage } from '@/service/api';
import {
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';
@@ -59,6 +64,7 @@ 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[] = [
@@ -176,6 +182,22 @@ 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);
@@ -191,7 +213,12 @@ async function handleExport() {
<template>
<div class="h-full min-h-0 flex-col-stretch gap-16px overflow-hidden">
<ApiAccessLogSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
<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>

View File

@@ -1,6 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue';
import { SYSTEM_USER_TYPE_DICT_CODE } from '@/constants/dict';
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
defineOptions({ name: 'ApiAccessLogSearch' });
@@ -12,7 +11,19 @@ const emit = defineEmits<{
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: '应用名',
@@ -51,20 +62,14 @@ const fields = computed<SearchField[]>(() => [
},
resolveValue: value => (value === null || value === undefined ? '' : String(value))
},
{
key: 'userId',
label: '用户编号',
type: 'input',
placeholder: '请输入用户编号'
},
{
key: 'userType',
label: '用户类型',
type: 'dict',
placeholder: '请选择用户类型',
dictCode: SYSTEM_USER_TYPE_DICT_CODE,
filterable: true
},
// {
// key: 'userType',
// label: '用户类型',
// type: 'dict',
// placeholder: '请选择用户类型',
// dictCode: SYSTEM_USER_TYPE_DICT_CODE,
// filterable: true
// },
{
key: 'beginTime',
label: '请求时间',

View File

@@ -1,7 +1,12 @@
<script setup lang="tsx">
import { computed, reactive, ref } from 'vue';
import { INFRA_API_ERROR_LOG_PROCESS_STATUS_DICT_CODE } from '@/constants/dict';
import { fetchExportApiErrorLog, fetchGetApiErrorLog, fetchGetApiErrorLogPage } from '@/service/api';
import {
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';
@@ -51,6 +56,7 @@ 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[] = [
@@ -168,6 +174,22 @@ 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);
@@ -183,7 +205,12 @@ async function handleExport() {
<template>
<div class="h-full min-h-0 flex-col-stretch gap-16px overflow-hidden">
<ApiErrorLogSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
<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>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import { INFRA_API_ERROR_LOG_PROCESS_STATUS_DICT_CODE, SYSTEM_USER_TYPE_DICT_CODE } from '@/constants/dict';
import { 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' });
@@ -12,7 +12,19 @@ const emit = defineEmits<{
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: '应用名',
@@ -33,20 +45,14 @@ const fields = computed<SearchField[]>(() => [
dictCode: INFRA_API_ERROR_LOG_PROCESS_STATUS_DICT_CODE,
filterable: true
},
{
key: 'userId',
label: '用户编号',
type: 'input',
placeholder: '请输入用户编号'
},
{
key: 'userType',
label: '用户类型',
type: 'dict',
placeholder: '请选择用户类型',
dictCode: SYSTEM_USER_TYPE_DICT_CODE,
filterable: true
},
// {
// key: 'userType',
// label: '用户类型',
// type: 'dict',
// placeholder: '请选择用户类型',
// dictCode: SYSTEM_USER_TYPE_DICT_CODE,
// filterable: true
// },
{
key: 'exceptionTime',
label: '异常时间',

View File

@@ -466,7 +466,7 @@ async function handleExportSelected() {
if (error || !blob) return;
downloadBlob(blob, `绩效表_导出选中_${dayjs().format('YYYY-MM-DD')}.zip`);
downloadBlob(blob, `绩效表_导出选中_${dayjs().format('YYYY-MM-DD')}.xlsx`);
}
async function handleExportAll() {
@@ -476,7 +476,7 @@ async function handleExportAll() {
if (error || !blob) return;
downloadBlob(blob, `绩效表_导出全部_${dayjs().format('YYYY-MM-DD')}.zip`);
downloadBlob(blob, `绩效表_导出全部_${dayjs().format('YYYY-MM-DD')}.xlsx`);
}
async function handleExportCommand(command: 'selected' | 'all') {

View File

@@ -7,6 +7,7 @@ import {
createPerformanceSheet,
downloadFile,
fetchPerformanceSheet,
fetchPerformanceSheetPrefill,
fetchPerformanceTemplateCurrent,
sendPerformanceSheet,
updatePerformanceSheetExcel,
@@ -27,19 +28,25 @@ interface Props {
mode: 'view' | 'edit' | 'create';
subordinateOptions?: SubordinateOption[];
showApprovalFooter?: boolean;
initialEmployeeId?: string;
initialPeriodMonth?: string;
hideDraftAction?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
rowData: null,
subordinateOptions: () => [],
showApprovalFooter: false
showApprovalFooter: false,
initialEmployeeId: '',
initialPeriodMonth: '',
hideDraftAction: false
});
const visible = defineModel<boolean>('visible', { default: false });
const emit = defineEmits<{
saved: [];
savedAndSent: [];
saved: [payload: PerformanceSheetSavePayload];
savedAndSent: [payload: PerformanceSheetSavePayload];
startApproval: [];
}>();
@@ -48,18 +55,29 @@ 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('请选择下属')]
@@ -367,6 +385,10 @@ function getActiveWorkbook() {
return univerAPI?.getActiveWorkbook?.();
}
function getCreateContextKey() {
return `${createForm.periodMonth || ''}::${createForm.employeeId || ''}`;
}
function parseCellAddress(address?: string | null) {
const text = String(address || '')
.trim()
@@ -402,6 +424,29 @@ function readCell(address?: string | null) {
}
}
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;
@@ -438,9 +483,36 @@ function createInitialFileName() {
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;
@@ -492,6 +564,14 @@ async function loadWorkbook() {
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 {
@@ -526,7 +606,17 @@ async function ensureCreatedSheet() {
return sheetResult.data;
}
async function executeSave(): Promise<Api.Performance.Sheet.Sheet | null> {
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;
@@ -573,18 +663,23 @@ async function executeSave(): Promise<Api.Performance.Sheet.Sheet | null> {
return null;
}
return sheet;
return {
sheet,
actualScoreTotal: scores.actualScoreTotal,
baseScoreTotal: scores.baseScoreTotal,
extraScoreTotal: scores.extraScoreTotal
};
}
async function handleSaveDraft() {
saving.value = true;
try {
const sheet = await executeSave();
if (!sheet) return;
const saveResult = await executeSave();
if (!saveResult) return;
window.$message?.success(isCreateMode.value ? '绩效表已保存为草稿' : '绩效 Excel 已保存');
visible.value = false;
emit('saved');
emit('saved', saveResult);
} catch (error) {
window.$message?.error(error instanceof Error ? error.message : '绩效 Excel 保存失败');
} finally {
@@ -595,15 +690,15 @@ async function handleSaveDraft() {
async function handleSaveAndSend() {
sending.value = true;
try {
const sheet = await executeSave();
if (!sheet) return;
const saveResult = await executeSave();
if (!saveResult) return;
const sendResult = await sendPerformanceSheet(sheet.id);
const sendResult = await sendPerformanceSheet(saveResult.sheet.id);
if (sendResult.error) return;
window.$message?.success('绩效表已保存并发送');
visible.value = false;
emit('savedAndSent');
emit('savedAndSent', saveResult);
} catch (error) {
window.$message?.error(error instanceof Error ? error.message : '绩效表发送失败');
} finally {
@@ -616,29 +711,48 @@ watch(visible, async isVisible => {
disposeUniver();
currentSheet.value = null;
currentTemplate.value = null;
// 重置创建表单
createForm.periodMonth = createDefaultPeriodMonth();
createForm.employeeId = '';
loadedCreateContextKey.value = '';
createContextInitializing.value = false;
prefillLoading.value = false;
prefillSourcePeriodMonth.value = '';
initializeCreateForm();
return;
}
await nextTick();
await loadWorkbook();
createContextInitializing.value = isCreateMode.value;
try {
if (isCreateMode.value) {
initializeCreateForm();
}
await nextTick();
await loadWorkbook();
} finally {
createContextInitializing.value = false;
}
});
watch(
() => createForm.employeeId,
async (employeeId, previousEmployeeId) => {
if (!visible.value || !isCreateMode.value || !employeeId || employeeId === previousEmployeeId) {
() => [createForm.employeeId, createForm.periodMonth] as const,
async ([employeeId, periodMonth], [previousEmployeeId, previousPeriodMonth]) => {
if (!visible.value || !isCreateMode.value || createContextInitializing.value) {
return;
}
const workbook = getActiveWorkbook();
if (!workbook) return;
const currentKey = `${periodMonth || ''}::${employeeId || ''}`;
const previousKey = `${previousPeriodMonth || ''}::${previousEmployeeId || ''}`;
if (
currentKey === previousKey ||
currentKey === loadedCreateContextKey.value ||
loading.value ||
prefillLoading.value
) {
return;
}
const snapshot = workbook.save();
await nextTick();
createWorkbook(applyCreateEmployeeName(snapshot));
await loadWorkbook();
}
);
@@ -672,14 +786,24 @@ onMounted(() => {
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">
<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">
@@ -698,12 +822,26 @@ onMounted(() => {
<template v-if="canSave || showApprovalFooter" #footer>
<div class="performance-excel-editor__footer">
<template v-if="canSave">
<ElButton :loading="saving" :disabled="sending" @click="handleSaveDraft">保存草稿</ElButton>
<ElButton type="primary" :loading="sending" :disabled="saving" @click="handleSaveAndSend">发送绩效</ElButton>
<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>
<ElButton @click="handleClose">退出</ElButton>
<ElButton type="primary" @click="handleStartApproval">审批</ElButton>
</template>
</div>
</template>
@@ -732,6 +870,12 @@ onMounted(() => {
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;

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,
@@ -73,6 +75,10 @@ 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 auditDialogVisible = ref(false);
const auditForm = reactive({
@@ -90,7 +96,6 @@ const pageValidationModel = reactive({
}
});
const pageRules: FormRules = {
meetingDate: [createRequiredRule('请选择面谈时间')],
performanceResult: [
createRequiredRule('请输入绩效考核结果'),
{
@@ -177,6 +182,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) {
// 只允许数字和一个小数点
@@ -563,6 +586,37 @@ 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);
}
watch(rejectOpinionRequired, async () => {
if (!auditDialogVisible.value) return;
@@ -614,10 +668,7 @@ 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%" />
</ElFormItem>
@@ -730,12 +781,25 @@ watch(
<span class="field-required-mark">*</span>
</div>
<ElFormItem class="performance-inline-form-item" prop="performanceResult">
<ElInput
v-model="performanceForm.score"
class="performance-input"
placeholder="请输入考核分数"
@input="handlePerformanceScoreInput"
/>
<div class="performance-input-group">
<ElInput
v-model="performanceForm.score"
class="performance-input"
placeholder="请输入考核分数"
readonly
@input="handlePerformanceScoreInput"
/>
<ElButton
plain
size="small"
type="primary"
:loading="performanceDrawerLoading"
:disabled="!performanceTargetOption.length"
@click="openPerformanceDrawer"
>
填写绩效
</ElButton>
</div>
</ElFormItem>
</div>
</div>
@@ -800,8 +864,8 @@ watch(
</ElForm>
<div class="form-actions approval-form-actions">
<ElButton @click="emit('back')">退出审批</ElButton>
<ElButton type="primary" @click="openAuditDialog">开始审批</ElButton>
<ElButton @click="emit('back')">退出</ElButton>
<ElButton type="primary" @click="openAuditDialog">审批</ElButton>
</div>
<BusinessFormDialog
@@ -871,6 +935,18 @@ watch(
</ElForm>
</div>
</BusinessFormDialog>
<PerformanceExcelEditorDrawer
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>
@@ -1794,13 +1870,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 +1894,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;

View File

@@ -539,8 +539,8 @@ function notifyTitleSaved(item: WorkItem) {
<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" @click="emit('requestApprove')">开始审批</ElButton>
<ElButton @click="emit('back')">退出</ElButton>
<ElButton type="primary" @click="emit('requestApprove')">审批</ElButton>
</div>
</div>
</template>

View File

@@ -359,6 +359,7 @@ function resolveTaskItemTypeLabel(value?: string | null) {
}
const STRUCTURED_TASK_PREFIX_RE = /^(?:(?:\d+[..、])|(?:\d+\s+)|(?:[一二三四五六七八九十百千万]+[、.]))\s*/u;
const WORKLOG_DAY_SEPARATOR = '[[WR_DAY_SPLIT]]';
function stripStructuredTaskPrefixV2(value: string) {
return value.trim().replace(STRUCTURED_TASK_PREFIX_RE, '');
@@ -385,9 +386,9 @@ function formatStructuredTaskDisplayLine(task: StructuredTask, index: number, sh
function getWorkLogEntries(detail: string): string[] {
if (!detail) return [];
// 仅按中文分号切分,避免误伤文本中的其他标点;每段作为单独一天的工作日志展示
// 仅按显式分隔标记切分,避免正文里的中文分号被误判成跨天分隔
return detail
.split('')
.split(WORKLOG_DAY_SEPARATOR)
.map(item => item.trim())
.filter(Boolean);
}
@@ -1497,8 +1498,8 @@ function syncRichSupport(item: PlanItem, event: Event) {
<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" @click="emit('requestApprove')">开始审批</ElButton>
<ElButton @click="emit('back')">退出</ElButton>
<ElButton type="primary" @click="emit('requestApprove')">审批</ElButton>
</div>
<BusinessFormDialog

View File

@@ -350,12 +350,24 @@ const columns = computed(() => [
prop: 'title',
label: '需求名称',
minWidth: 200,
className: 'requirement-title-column',
formatter: (row: Api.Product.Requirement) => {
return (
<ElTooltip content={row.title} placement="top" show-after={300}>
<ElButton link type="primary" class="requirement-title" onClick={() => openView(row)}>
<span
class="requirement-title"
role="button"
tabindex={0}
onClick={() => openView(row)}
onKeydown={event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openView(row);
}
}}
>
{row.title}
</ElButton>
</span>
</ElTooltip>
);
}
@@ -1035,14 +1047,29 @@ onMounted(async () => {
}
:deep(.requirement-title) {
display: block;
flex: 1 1 auto;
min-width: 0;
padding: 0;
font-weight: 500;
max-width: 180px;
color: var(--el-color-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.5;
height: auto;
vertical-align: middle;
cursor: pointer;
}
:deep(.requirement-title-column .cell) {
display: flex;
align-items: center;
justify-content: flex-start;
min-width: 0;
}
:deep(.requirement-title:hover) {
text-decoration: underline;
}
:deep(.requirement-title--terminal) {

View File

@@ -0,0 +1,67 @@
interface TaskMutationIdentity {
currentUserId: string;
projectManagerUserId?: string | null;
}
/**
* 判定对象是否处于可编辑/可操作状态。
*
* 按按钮可见度矩阵spec §4.2 / §4.3 注释 `allowEdit === true 即 pending / active 状态`
* 可编辑状态严格 = `pending` OR `active`**不含 paused / completed / cancelled**。
*
* 不用 `record.allowEdit === true`:列表 VO 后端不一定下发该字段,
* 经 `normalizeProjectExecution` 的 `Boolean(undefined) === false` 会让列表所有行误判为不可编辑。
* 直接读 `statusCode` 字段(列表 / 详情 VO 都必下发,状态机核心字段)。
*/
export function isMutable(record: { statusCode: string }): boolean {
return record.statusCode === 'pending' || record.statusCode === 'active';
}
function isTaskMutationActor(task: Api.Project.ProjectTask, identity: TaskMutationIdentity): boolean {
const { currentUserId, projectManagerUserId } = identity;
if (!currentUserId) return false;
return (
currentUserId === task.ownerId ||
currentUserId === task.executionOwnerId ||
(Boolean(projectManagerUserId) && currentUserId === projectManagerUserId)
);
}
export function isExecutionMutationActor(
execution: Api.Project.ProjectExecution,
identity: TaskMutationIdentity
): boolean {
const { currentUserId, projectManagerUserId } = identity;
if (!currentUserId) return false;
return (
currentUserId === execution.ownerId || (Boolean(projectManagerUserId) && currentUserId === projectManagerUserId)
);
}
export function canEditExecutionByIdentity(
execution: Api.Project.ProjectExecution,
identity: TaskMutationIdentity
): boolean {
return isMutable(execution) && isExecutionMutationActor(execution, identity);
}
export function canDeleteExecutionByIdentity(
execution: Api.Project.ProjectExecution,
identity: TaskMutationIdentity
): boolean {
if (execution.statusCode === 'completed') return false;
return isExecutionMutationActor(execution, identity);
}
export function canEditTaskByIdentity(task: Api.Project.ProjectTask, identity: TaskMutationIdentity): boolean {
return isMutable(task) && isTaskMutationActor(task, identity);
}
export function canDeleteTaskByIdentity(task: Api.Project.ProjectTask, identity: TaskMutationIdentity): boolean {
if (task.statusCode === 'completed') return false;
return isTaskMutationActor(task, identity);
}

View File

@@ -1,5 +1,5 @@
import { ref, watch } from 'vue';
import { fetchGetProjectRequirementPage } from '@/service/api/project';
import { fetchGetExecutionRequirementOptions } from '@/service/api/project';
export interface ProjectRequirementTreeNode {
id: string;
@@ -32,10 +32,10 @@ export function useProjectRequirementOptions(projectId: () => string | null) {
loading.value = true;
try {
const { data, error } = await fetchGetProjectRequirementPage({
const { data, error } = await fetchGetExecutionRequirementOptions({
projectId: id,
pageNo: 1,
pageSize: 100
pageSize: -1
});
if (error || !data?.list) {
@@ -86,6 +86,7 @@ export function useProjectRequirementOptions(projectId: () => string | null) {
}
});
}
pruneEmptyChildren(roots);
return roots;

View File

@@ -1,5 +1,5 @@
import { computed, markRaw } from 'vue';
import { useAuthStore } from '@/store/modules/auth';
import { markRaw } from 'vue';
import { useTaskPermissions } from './use-task-permissions';
import IconMdiClipboardEditOutline from '~icons/mdi/clipboard-edit-outline';
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
@@ -25,18 +25,14 @@ export interface TaskActionEmits {
/**
* 任务行操作按钮的集中装配。
*
* 只在这里收口任务行按钮的最终显示口径,不改动底层子任务 / 父任务负责人 / 执行负责人那套权限体系:
* - 当前用户是任务负责人task.ownerId: 显示 填报 / 编辑 / 删除
* - 当前用户是协办人task.assignees[].userId: 只显示 填报
* - 都不是: 也显示 填报
* 这里收口任务行按钮的最终显示口径;编辑 / 删除身份规则统一落在 useTaskPermissions。
* 权限码只做后端兜底,不作为前端按钮展示的单独放行条件。
*/
export function useTaskActions(emits: TaskActionEmits) {
const authStore = useAuthStore();
const currentUserId = computed(() => authStore.userInfo.userId || '');
const { canEditTask, canDeleteTask } = useTaskPermissions();
function createActions(row: Api.Project.ProjectTask): TaskAction[] {
const actions: TaskAction[] = [];
const isOwner = Boolean(currentUserId.value) && row.ownerId === currentUserId.value;
actions.push({
key: 'report',
@@ -46,11 +42,7 @@ export function useTaskActions(emits: TaskActionEmits) {
onClick: () => emits.report(row)
});
if (!isOwner) {
return actions;
}
if (row.statusCode !== 'completed') {
if (canEditTask(row)) {
actions.push({
key: 'edit',
tooltip: '编辑',
@@ -60,13 +52,15 @@ export function useTaskActions(emits: TaskActionEmits) {
});
}
actions.push({
key: 'delete',
tooltip: '删除',
icon: markRaw(IconMdiDeleteOutline),
type: 'danger',
onClick: () => emits.remove(row)
});
if (canDeleteTask(row)) {
actions.push({
key: 'delete',
tooltip: '删除',
icon: markRaw(IconMdiDeleteOutline),
type: 'danger',
onClick: () => emits.remove(row)
});
}
return actions;
}

View File

@@ -12,7 +12,6 @@ export interface CascadeTriggerPayload {
export interface UseTaskCompletionCascadeOptions {
projectId: ComputedRef<string>;
executionId: ComputedRef<string>;
/** 由调用方提供:打开 StatusActionDialog 的钩子composable 不持有 dialog 实例 */
openStatusActionDialog: (task: ProjectTask, action: TaskAction, fromCascade: boolean) => void;
/** 从 task 的 availableActions 里找出"完成"动作;找不到返回 null */
@@ -74,7 +73,7 @@ export function useTaskCompletionCascade(options: UseTaskCompletionCascadeOption
return;
}
const siblings = await loadSiblings(completedTask.parentTaskId);
const siblings = await loadSiblings(completedTask.parentTaskId, completedTask.executionId);
if (siblings === null) {
window.$message?.warning('父任务级联检查失败');
return;
@@ -84,7 +83,7 @@ export function useTaskCompletionCascade(options: UseTaskCompletionCascadeOption
return;
}
const parent = await fetchTaskDetail(completedTask.parentTaskId);
const parent = await fetchTaskDetail(completedTask.parentTaskId, completedTask.executionId);
if (!parent) {
window.$message?.warning('父任务级联检查失败');
return;
@@ -122,7 +121,8 @@ export function useTaskCompletionCascade(options: UseTaskCompletionCascadeOption
}
try {
const result = await fetchGetProjectTaskWorklogPage(options.projectId.value, options.executionId.value, task.id, {
// 跨执行视角下 workspace 没有执行上下文,级联链路一律取任务自身的 executionId
const result = await fetchGetProjectTaskWorklogPage(options.projectId.value, task.executionId, task.id, {
pageNo: 1,
pageSize: -1
});
@@ -153,10 +153,10 @@ export function useTaskCompletionCascade(options: UseTaskCompletionCascadeOption
}
}
/** 拉父级下所有子任务;接口失败返回 null与"空列表"区分,由调用方降级处理) */
async function loadSiblings(parentTaskId: string): Promise<ProjectTask[] | null> {
/** 拉父级下所有子任务;接口失败返回 null与"空列表"区分,由调用方降级处理)。父子任务同执行executionId 取被完成任务自身的值 */
async function loadSiblings(parentTaskId: string, executionId: string): Promise<ProjectTask[] | null> {
try {
const result = await fetchGetProjectTaskPage(options.projectId.value, options.executionId.value, {
const result = await fetchGetProjectTaskPage(options.projectId.value, executionId, {
pageNo: 1,
pageSize: -1,
parentTaskId
@@ -171,9 +171,9 @@ export function useTaskCompletionCascade(options: UseTaskCompletionCascadeOption
}
/** 拉父任务详情;失败返回 null */
async function fetchTaskDetail(taskId: string): Promise<ProjectTask | null> {
async function fetchTaskDetail(taskId: string, executionId: string): Promise<ProjectTask | null> {
try {
const result = await fetchGetProjectTask(options.projectId.value, options.executionId.value, taskId);
const result = await fetchGetProjectTask(options.projectId.value, executionId, taskId);
if (result.error || !result.data) {
return null;
}

View File

@@ -1,16 +1,23 @@
import { computed } from 'vue';
import { useAuthStore } from '@/store/modules/auth';
import { useObjectContextStore } from '@/store/modules/object-context';
import {
canDeleteExecutionByIdentity,
canDeleteTaskByIdentity,
canEditExecutionByIdentity,
canEditTaskByIdentity,
isExecutionMutationActor,
isMutable
} from './task-permission-rules';
/**
* 任务 / 执行按钮可见度集中判定
*
* 关键领域规则:
* - 任务负责人本人不能编辑 / 删除自己负责的任务(增删改归上级 / 项目负责人裁决)
* - 任务编辑 / 删除入口先按身份收口:任务负责人、任务所属执行负责人、项目负责人
* - 权限码只作为后端最终校验的兜底,不单独决定前端按钮可见度
* - 本人能做的:状态推进(含 cancel "退出"任务)、加协办人、在自己任务下新增子任务
* - 执行负责人对子任务无编辑 / 删除权(子任务归父任务 owner 管)
* - 执行负责人能维护一级任务协办人(一级任务 `executionOwnerId` 通道;子任务不开此通道)
* - 父任务负责人能改 / 删子任务,但不能给子任务加协办人 / 建孙任务 / 推进状态
*
* 权限码来源:`project:*` / `project:execution:*` / `project:task:*` 是**对象域权限码**
* 挂在项目对象上下文里(项目负责人 / 项目协作者等角色),从 objectContextStore.buttonCodes 取,
@@ -22,46 +29,50 @@ export function useTaskPermissions() {
const currentUserId = computed(() => authStore.userInfo.userId || '');
const buttonCodeSet = computed(() => new Set(objectContextStore.buttonCodes));
const currentProjectManagerUserId = computed(() => {
const summary = objectContextStore.objectSummary as Api.Project.ProjectContext | null;
return summary?.currentProject?.managerUserId || null;
});
function hasPermission(code: string): boolean {
return buttonCodeSet.value.has(code);
}
/**
* 判定对象是否处于可编辑/可操作状态。
*
* 按按钮可见度矩阵spec §4.2 / §4.3 注释 `allowEdit === true 即 pending / active 状态`
* 可编辑状态严格 = `pending` OR `active`**不含 paused / completed / cancelled**。
*
* 不用 `record.allowEdit === true`:列表 VO 后端不一定下发该字段,
* 经 `normalizeProjectExecution` 的 `Boolean(undefined) === false` 会让列表所有行误判为不可编辑。
* 直接读 `statusCode` 字段(列表 / 详情 VO 都必下发,状态机核心字段)。
*/
function isMutable(record: { statusCode: string }): boolean {
return record.statusCode === 'pending' || record.statusCode === 'active';
}
// —— 执行侧 ——
function canEditExecution(execution: Api.Project.ProjectExecution): boolean {
return (
isMutable(execution) && (hasPermission('project:execution:update') || currentUserId.value === execution.ownerId)
);
return canEditExecutionByIdentity(execution, {
currentUserId: currentUserId.value,
projectManagerUserId: currentProjectManagerUserId.value
});
}
function canDeleteExecution(execution: Api.Project.ProjectExecution): boolean {
// completed 终态后端硬卡(不允许主动删,级联删除时由后端兜底),前端按钮也拦掉
if (execution.statusCode === 'completed') return false;
return hasPermission('project:execution:delete');
return canDeleteExecutionByIdentity(execution, {
currentUserId: currentUserId.value,
projectManagerUserId: currentProjectManagerUserId.value
});
}
/** 协办人弹窗内的写操作(设为负责人 / 失效 / 新增协办人)与编辑同一身份口径:执行负责人或项目负责人 */
function canChangeExecutionOwner(execution: Api.Project.ProjectExecution): boolean {
return isMutable(execution) && hasPermission('project:execution:owner');
return (
isMutable(execution) &&
isExecutionMutationActor(execution, {
currentUserId: currentUserId.value,
projectManagerUserId: currentProjectManagerUserId.value
})
);
}
function canManageExecutionAssignee(execution: Api.Project.ProjectExecution): boolean {
return (
isMutable(execution) && (hasPermission('project:execution:assignee') || currentUserId.value === execution.ownerId)
isMutable(execution) &&
isExecutionMutationActor(execution, {
currentUserId: currentUserId.value,
projectManagerUserId: currentProjectManagerUserId.value
})
);
}
@@ -86,20 +97,17 @@ export function useTaskPermissions() {
}
function canEditTask(task: Api.Project.ProjectTask): boolean {
if (!isMutable(task)) return false;
if (hasPermission('project:task:update')) return true;
return isTopLevelTask(task)
? currentUserId.value === task.executionOwnerId
: currentUserId.value === task.parentTaskOwnerId;
return canEditTaskByIdentity(task, {
currentUserId: currentUserId.value,
projectManagerUserId: currentProjectManagerUserId.value
});
}
function canDeleteTask(task: Api.Project.ProjectTask): boolean {
// completed 终态后端硬卡(不允许主动删,级联删除时由后端兜底),前端按钮也拦掉
if (task.statusCode === 'completed') return false;
if (hasPermission('project:task:delete')) return true;
return isTopLevelTask(task)
? currentUserId.value === task.executionOwnerId
: currentUserId.value === task.parentTaskOwnerId;
return canDeleteTaskByIdentity(task, {
currentUserId: currentUserId.value,
projectManagerUserId: currentProjectManagerUserId.value
});
}
function canCreateTopLevelTask(execution: Api.Project.ProjectExecution): boolean {

View File

@@ -51,7 +51,7 @@ watch(visible, value => {
<BusinessFormDialog
v-model="visible"
:title="dialogTitle"
preset="lg"
preset="xl"
:show-footer="false"
:scrollbar="false"
@closed="handleClosed"

View File

@@ -7,6 +7,7 @@ import {
fetchCreateProjectTaskAssignee,
fetchDeleteProjectTask,
fetchGetProjectExecutionAssignees,
fetchGetProjectMembers,
fetchGetProjectTask,
fetchGetProjectTaskAssignees,
fetchGetProjectTaskBoardPageCross,
@@ -292,7 +293,7 @@ const { data, loading, getData, getDataByPage, mobilePagination } = useUIPaginat
} as unknown as TaskPageResponse);
}
// 统一走跨执行接口:身份维度(my/all)与范围维度(全部/状态/具体执行)自由组合
// 单执行场景下 executionOwnerId 等字段会是 null,按钮权限通过全局权限码 RBAC 控制
// 单执行场景下任务详情会带 executionOwnerId;跨执行接口缺失时前端只能识别任务/项目负责人身份
return fetchGetProjectTaskPageCross(props.projectId, buildCrossSearchParams());
},
transform: response => transformTaskPage(response, pageNo.value, pageSize.value),
@@ -538,7 +539,6 @@ async function handleOperateSubmit(payload: Api.Project.SaveProjectTaskParams) {
const cascade = useTaskCompletionCascade({
projectId: computed(() => props.projectId),
executionId,
openStatusActionDialog: async (task, action, fromCascade) => {
currentTask.value = task;
currentStatusAction.value = action;
@@ -667,11 +667,31 @@ async function refreshAssigneesAfterMutation() {
async function loadExecutionAssigneeOptions(specificExecutionId?: string) {
const targetId = specificExecutionId || executionId.value;
if (!props.projectId || !targetId) {
if (!props.projectId) {
executionAssigneeOptions.value = [];
return;
}
if (!targetId) {
const { error, data: members } = await fetchGetProjectMembers(props.projectId);
if (error || !members) {
executionAssigneeOptions.value = [];
return;
}
executionAssigneeOptions.value = members
.filter(item => item.status === 0)
.map(item => ({
id: item.userId,
nickname: item.userNickname || item.userId,
username: null,
deptName: item.roleName || item.roleCode || null
}));
return;
}
const { error, data: assignees } = await fetchGetProjectExecutionAssignees(props.projectId, targetId);
if (error || !assignees) {
@@ -802,13 +822,10 @@ watch(
// execution 锚定变化 → 拉/清"执行协办人选项"(操作弹层用),任务列表的重拉由 scopedExecutionIds watch 统一处理
watch(
() => props.execution?.id,
async value => {
if (!value) {
executionAssigneeOptions.value = [];
return;
}
async () => {
await loadExecutionAssigneeOptions();
}
},
{ immediate: true }
);
// 范围维度变化(scopedExecutionIds / scopedExecutionInvolveUserId / scopedExecutionStatusCodes 引用变:
@@ -832,9 +849,10 @@ watch(
if (!canLoadAnyTasks.value) {
data.value = [];
taskStatusBoard.value = null;
executionAssigneeOptions.value = [];
return;
}
await Promise.all([getDataByPage(1), loadTaskStatusBoard()]);
await Promise.all([getDataByPage(1), loadTaskStatusBoard(), loadExecutionAssigneeOptions()]);
}
);

View File

@@ -352,11 +352,23 @@ const columns = computed(() => [
prop: 'title',
label: '需求名称',
minWidth: 220,
className: 'requirement-title-column',
formatter: (row: Api.Project.ProjectRequirement) => (
<ElTooltip content={row.title} placement="top" show-after={300}>
<ElButton link type="primary" class="requirement-title" onClick={() => openView(row)}>
<span
class="requirement-title"
role="button"
tabindex={0}
onClick={() => openView(row)}
onKeydown={event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openView(row);
}
}}
>
{row.title}
</ElButton>
</span>
</ElTooltip>
)
},
@@ -1020,14 +1032,29 @@ Promise.all([loadStatusOptions()]);
}
:deep(.requirement-title) {
display: block;
flex: 1 1 auto;
min-width: 0;
padding: 0;
font-weight: 500;
max-width: 200px;
color: var(--el-color-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.5;
height: auto;
vertical-align: middle;
cursor: pointer;
}
:deep(.requirement-title-column .cell) {
display: flex;
align-items: center;
justify-content: flex-start;
min-width: 0;
}
:deep(.requirement-title:hover) {
text-decoration: underline;
}
:deep(.requirement-table-card-body) {

View File

@@ -253,12 +253,18 @@ export interface WorkbenchWorklogDistributionItem {
/** 周标准工时:系统无配置项,后端不返回,产品确认前端落常量 35不是 40 */
export const WORKBENCH_WEEK_TARGET_HOURS = 35;
/** 单周工时视图builder 衍生;逐日工时为后端按填报日期段均摊到工作日的推算值 */
/** 单周工时视图builder 衍生;接口返回周一~周日逐日工时,按周填报时仅均摊到工作日) */
export interface WorkbenchWeekWorklogView {
weekStart: string;
weekLabel: string;
/** 周一~周逐日工时(5 长度,均摊推算值,周末份额归周五 */
/** 周一~周逐日工时(7 长度;周末单天工时保留在周末当天 */
dailyHours: number[];
/** 是否展示周末两列;仅当周六或周日任一天 > 0 时展示 */
showWeekend: boolean;
/** 图表/逐日展示实际使用的横轴标签 */
visibleDayLabels: string[];
/** 图表/逐日展示实际使用的逐日工时数据 */
visibleDailyHours: number[];
/** 本周累计 */
totalHours: number;
target: number;
@@ -269,7 +275,10 @@ export interface WorkbenchWeekWorklogView {
distribution: WorkbenchWorklogDistributionItem[];
}
const WEEKDAY_COUNT = 5;
const WORKDAY_COUNT = 5;
const FULL_WEEKDAY_COUNT = 7;
const WORKDAY_LABELS = ['一', '二', '三', '四', '五'] as const;
const FULL_WEEKDAY_LABELS = ['一', '二', '三', '四', '五', '六', '日'] as const;
function roundHours(value: number) {
return Math.round(value * 10) / 10;
@@ -291,7 +300,12 @@ export function buildWorkbenchWeekWorklogView(source: Api.Project.MyWorklogWeekR
const start = dayjs(source.weekStart);
const weekLabel = start.isValid() ? `${start.isoWeekYear()}年第${start.isoWeek()}` : source.weekStart;
const dailyHours = Array.from({ length: WEEKDAY_COUNT }, (_, i) => roundHours(source.dailyHours[i] ?? 0));
const dailyHours = Array.from({ length: FULL_WEEKDAY_COUNT }, (_, i) => roundHours(source.dailyHours[i] ?? 0));
const sat = Number(dailyHours[5] ?? 0);
const sun = Number(dailyHours[6] ?? 0);
const showWeekend = sat > 0 || sun > 0;
const visibleDayLabels = showWeekend ? [...FULL_WEEKDAY_LABELS] : [...WORKDAY_LABELS];
const visibleDailyHours = showWeekend ? [...dailyHours] : dailyHours.slice(0, WORKDAY_COUNT);
const totalHours = roundHours(dailyHours.reduce((s, h) => s + h, 0));
const target = WORKBENCH_WEEK_TARGET_HOURS;
const delta = roundHours(totalHours - target);
@@ -301,6 +315,9 @@ export function buildWorkbenchWeekWorklogView(source: Api.Project.MyWorklogWeekR
weekStart: source.weekStart,
weekLabel,
dailyHours,
showWeekend,
visibleDayLabels,
visibleDailyHours,
totalHours,
target,
delta,
@@ -320,8 +337,6 @@ export function getGreeting(hour: number = dayjs().hour()) {
// === 团队工时分布D16 团队 tab原 C12 teamWorklog ===
export type WorkbenchTeamWorklogItemKind = 'project' | 'personal' | 'other';
/** 团队工时分布视图builder 衍生) */
export interface WorkbenchTeamWorklogView {
weekStart: string;
@@ -330,11 +345,8 @@ export interface WorkbenchTeamWorklogView {
memberId: string;
memberName: string;
totalHours: number;
overtimeHours: number;
}>;
/** 项目/个人/其他 列(保序,去重) */
categories: Array<{ key: string; label: string; kind: WorkbenchTeamWorklogItemKind }>;
/** 每个 category 一个 seriesdata[i] = 第 i 个成员该 category 的工时(缺则 0 */
seriesMatrix: Array<{ key: string; label: string; kind: WorkbenchTeamWorklogItemKind; data: number[] }>;
/** 团队总工时 */
totalHours: number;
/** 团队人均工时 */
@@ -345,10 +357,6 @@ export interface WorkbenchTeamWorklogView {
fillRate: number;
/** 偏低人数:< 团队均值 × 0.8 */
lowCount: number;
/** 加班人数:> 周标准 × 1.125 */
highCount: number;
/** 加班判定阈值小时KPI 文案展示用 */
overtimeThreshold: number;
/** 工时最低成员(用于底部小结) */
lowest: { memberName: string; hours: number } | null;
/** 工时最高成员(用于底部小结) */
@@ -360,68 +368,46 @@ export function buildWorkbenchTeamWorklogView(source: Api.Project.TeamWorklogWee
const weekLabel = start.isValid() ? `${start.isoWeekYear()}年第${start.isoWeek()}` : source.weekStart;
// 契约members[0] 恒为当前用户,展示加「(我)」后缀
const members = source.members.map((member, index) => ({
memberId: member.userId,
memberName: index === 0 ? `${member.userNickname}(我)` : member.userNickname,
items: member.items.map(toWorklogDistributionItem)
}));
const members = source.members.map((member, index) => {
const items = member.items.map(toWorklogDistributionItem);
const totalHours = roundHours(items.reduce((sum, item) => sum + item.hours, 0));
const overtimeHours = roundHours(member.overtimeHours ?? 0);
// 列保序去重:按成员遍历顺序首次出现即入列;项目优先、个人/其他按出现顺序追加
const categoryMap = new Map<string, { key: string; label: string; kind: WorkbenchTeamWorklogItemKind }>();
for (const m of members) {
for (const it of m.items) {
if (!categoryMap.has(it.key)) {
categoryMap.set(it.key, { key: it.key, label: it.label, kind: it.kind });
}
}
}
const categories = Array.from(categoryMap.values());
return {
memberId: member.userId,
memberName: index === 0 ? `${member.userNickname}(我)` : member.userNickname,
totalHours,
overtimeHours
};
});
const memberView = members.map(m => ({
memberId: m.memberId,
memberName: m.memberName,
totalHours: roundHours(m.items.reduce((s, it) => s + it.hours, 0))
}));
const seriesMatrix = categories.map(cat => ({
...cat,
data: members.map(m => {
const hit = m.items.find(it => it.key === cat.key);
return hit ? roundHours(hit.hours) : 0;
})
}));
const totalHours = roundHours(memberView.reduce((s, m) => s + m.totalHours, 0));
const memberCount = memberView.length;
const totalHours = roundHours(members.reduce((sum, member) => sum + member.totalHours, 0));
const memberCount = members.length;
const averageHours = memberCount > 0 ? roundHours(totalHours / memberCount) : 0;
const expectedTotalHours = memberCount * WORKBENCH_WEEK_TARGET_HOURS;
const fillRate = expectedTotalHours > 0 ? clampPercent((totalHours / expectedTotalHours) * 100) : 0;
const lowThreshold = averageHours * 0.8;
const highThreshold = WORKBENCH_WEEK_TARGET_HOURS * 1.125;
const lowCount = memberView.filter(m => m.totalHours < lowThreshold).length;
const highCount = memberView.filter(m => m.totalHours > highThreshold).length;
const lowCount = members.filter(member => member.totalHours < lowThreshold).length;
let lowest: { memberName: string; hours: number } | null = null;
let highest: { memberName: string; hours: number } | null = null;
for (const m of memberView) {
if (!lowest || m.totalHours < lowest.hours) lowest = { memberName: m.memberName, hours: m.totalHours };
if (!highest || m.totalHours > highest.hours) highest = { memberName: m.memberName, hours: m.totalHours };
for (const member of members) {
if (!lowest || member.totalHours < lowest.hours)
lowest = { memberName: member.memberName, hours: member.totalHours };
if (!highest || member.totalHours > highest.hours)
highest = { memberName: member.memberName, hours: member.totalHours };
}
return {
weekStart: source.weekStart,
weekLabel,
members: memberView,
categories,
seriesMatrix,
members,
totalHours,
averageHours,
expectedTotalHours,
fillRate,
lowCount,
highCount,
overtimeThreshold: roundHours(highThreshold),
lowest,
highest
};

View File

@@ -206,7 +206,7 @@ function buildMyBarOption(): ECOption {
grid: { left: 28, right: 8, top: 16, bottom: 24, containLabel: false },
xAxis: {
type: 'category',
data: ['一', '二', '三', '四', '五'],
data: v?.visibleDayLabels ?? ['一', '二', '三', '四', '五'],
axisTick: { show: false },
axisLine: { lineStyle: { color: '#e5e7eb' } },
axisLabel: { color: '#6b7280', fontSize: 11 }
@@ -221,7 +221,7 @@ function buildMyBarOption(): ECOption {
name: '每日工时',
type: 'bar',
barWidth: 18,
data: v?.dailyHours ?? [],
data: v?.visibleDailyHours ?? [],
itemStyle: { color: DAY_BAR_COLOR, borderRadius: [2, 2, 0, 0] }
}
]
@@ -243,14 +243,9 @@ const { domRef: myBarRef, updateOptions: updateMyBar } = useEcharts(buildMyBarOp
const teamView = computed(() => (teamWeekData.value ? buildWorkbenchTeamWorklogView(teamWeekData.value) : null));
const teamSeriesWithColor = computed(() =>
(teamView.value?.seriesMatrix ?? []).map(s => ({ ...s, color: getWorkbenchItemColor(s.key, s.kind) }))
);
function buildTeamBarOption(): ECOption {
const v = teamView.value;
if (!v) return {};
const colored = teamSeriesWithColor.value;
return {
tooltip: {
trigger: 'axis',
@@ -259,15 +254,15 @@ function buildTeamBarOption(): ECOption {
const params = Array.isArray(rawParams) ? rawParams : [rawParams];
if (!params.length) return '';
const name = params[0].axisValue as string;
const total = params.reduce((s: number, p: any) => s + (Number(p.value) || 0), 0);
const lines = params
.filter((p: any) => Number(p.value) > 0)
.map((p: any) => `${p.marker}${p.seriesName} <b style="margin-left:6px">${p.value}h</b>`)
.join('<br/>');
return `<div style="font-weight:600;margin-bottom:4px">${name} · ${total}h</div>${lines}`;
const totalHours = Number(params.find((p: any) => p.seriesName === '总工时')?.value ?? 0);
const overtimeHours = Number(params.find((p: any) => p.seriesName === '加班工时')?.value ?? 0);
return [
`<div style="font-weight:600;margin-bottom:4px">${name}</div>`,
`总工时:${totalHours}h`,
`加班工时:${overtimeHours}h`
].join('<br/>');
}
},
// 不显示项目图例:团队参与项目繁杂,图例会很长很乱;项目明细由 tooltip 悬浮呈现
grid: { left: 32, right: 12, top: 16, bottom: 24, containLabel: false },
xAxis: {
type: 'category',
@@ -281,17 +276,28 @@ function buildTeamBarOption(): ECOption {
splitLine: { lineStyle: { color: '#f3f4f6' } },
axisLabel: { color: '#9ca3af', fontSize: 10, formatter: '{value}h' }
},
series: colored.map((s, i) => ({
name: s.label,
type: 'bar',
stack: 'member',
barWidth: 22,
data: s.data,
itemStyle: {
color: s.color,
borderRadius: i === colored.length - 1 ? [3, 3, 0, 0] : 0
series: [
{
name: '总工时',
type: 'bar',
barWidth: 16,
data: v.members.map(member => member.totalHours),
itemStyle: {
color: '#409EFF',
borderRadius: [3, 3, 0, 0]
}
},
{
name: '加班工时',
type: 'bar',
barWidth: 16,
data: v.members.map(member => member.overtimeHours),
itemStyle: {
color: '#F59E0B',
borderRadius: [3, 3, 0, 0]
}
}
}))
]
};
}
@@ -357,7 +363,10 @@ watch(activeTab, async tab => {
<div class="ww-section-title">
<SvgIcon icon="mdi:calendar-week" class="ww-section-icon" />
<span>每日工时</span>
<ElTooltip content="系统按填报日期段均摊到工作日的推算值(周末份额计入周五),非逐日实填" placement="top">
<ElTooltip
content="接口返回周一~周日逐日工时;按周填报时仅均摊到工作日,周末单天工时保留在周末当天展示"
placement="top"
>
<span class="ww-section-info-wrap">
<SvgIcon icon="mdi:information-outline" class="ww-section-info" />
</span>
@@ -417,14 +426,6 @@ watch(activeTab, async tab => {
</span>
<span class="tw-kpi__sub">低于均值 80%</span>
</div>
<div class="tw-kpi">
<span class="tw-kpi__label">加班</span>
<span class="tw-kpi__value" :class="{ 'is-warn': teamView.highCount > 0 }">
{{ teamView.highCount }}
<span class="tw-kpi__unit"></span>
</span>
<span class="tw-kpi__sub"> {{ teamView.overtimeThreshold }}h</span>
</div>
</div>
<div ref="teamBarRef" class="tw-bar" />
@@ -594,7 +595,7 @@ watch(activeTab, async tab => {
/* ============ 团队工时 ============ */
.tw-kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-bottom: 12px;
flex-shrink: 0;
@@ -621,9 +622,6 @@ watch(activeTab, async tab => {
.tw-kpi__value.is-danger {
color: var(--el-color-danger);
}
.tw-kpi__value.is-warn {
color: var(--el-color-warning);
}
.tw-kpi__unit {
font-size: 12px;
font-weight: 500;

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { type Component, computed, markRaw, onActivated, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import dayjs from 'dayjs';
import type { RouteKey } from '@elegant-router/types';
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict';
@@ -251,6 +252,16 @@ const TASK_ACTION_ICONS: Partial<Record<Api.Project.ProjectTaskActionCode, Compo
cancel: markRaw(IconMdiCloseCircleOutline)
};
function normalizeTodoDeadline(deadline: string | null) {
if (!deadline) return null;
if (/^\d{4}-\d{2}-\d{2}$/.test(deadline)) {
return dayjs(deadline).endOf('day').format('YYYY-MM-DD HH:mm');
}
return deadline;
}
function getTaskActionButtonType(code: Api.Project.ProjectTaskActionCode) {
if (code === 'complete') return 'success' as const;
if (code === 'cancel') return 'danger' as const;
@@ -820,7 +831,7 @@ async function loadMyTaskItems() {
category: 'task' as const,
title: task.taskTitle,
createdTime: task.createTime,
deadline: task.plannedEndDate,
deadline: normalizeTodoDeadline(task.plannedEndDate),
source: task.projectName,
progressRate: task.progressRate,
priority: mapTaskTodoPriority(task.priority),
@@ -850,7 +861,7 @@ async function loadPersonalTodoItems() {
category: 'personal',
title: item.taskTitle,
createdTime: item.createTime,
deadline: item.plannedEndDate,
deadline: normalizeTodoDeadline(item.plannedEndDate),
source: '我的事项',
progressRate: item.progressRate,
routeKey: 'personal-center_my-item'

View File

@@ -0,0 +1,179 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
canDeleteExecutionByIdentity,
canDeleteTaskByIdentity,
canEditExecutionByIdentity,
canEditTaskByIdentity
} from '../src/views/project/project/execution/composables/task-permission-rules';
function createExecution(overrides: Partial<Api.Project.ProjectExecution> = {}): Api.Project.ProjectExecution {
return {
id: 'execution-1',
projectId: 'project-1',
projectRequirementId: null,
projectRequirementName: null,
projectRequirementStatusCode: null,
executionName: 'execution',
executionType: null,
ownerId: 'execution-owner',
ownerNickname: null,
statusCode: 'active',
statusName: null,
terminal: false,
allowEdit: true,
availableActions: [],
plannedStartDate: null,
plannedEndDate: null,
actualStartDate: null,
actualEndDate: null,
progressRate: 0,
priority: '2',
priorityName: null,
executionDesc: null,
lastStatusReason: null,
createTime: '2026-07-07 10:00:00',
updateTime: '2026-07-07 10:00:00',
...overrides
};
}
function createTask(overrides: Partial<Api.Project.ProjectTask> = {}): Api.Project.ProjectTask {
return {
id: 'task-1',
projectId: 'project-1',
executionId: 'execution-1',
executionName: '执行',
executionStatusCode: 'active',
parentTaskId: null,
projectRequirementId: null,
projectRequirementName: null,
projectRequirementStatusCode: null,
taskTitle: '任务',
type: '',
ownerId: 'task-owner',
ownerNickname: null,
executionOwnerId: 'execution-owner',
parentTaskOwnerId: null,
statusCode: 'active',
statusName: null,
terminal: false,
allowEdit: true,
availableActions: [],
progressRate: 0,
plannedStartDate: null,
plannedEndDate: null,
actualStartDate: null,
actualEndDate: null,
priority: '2',
priorityName: null,
taskDesc: null,
lastStatusReason: null,
assignees: null,
attachments: [],
totalSpentHours: null,
createTime: '2026-07-07 10:00:00',
updateTime: '2026-07-07 10:00:00',
...overrides
};
}
describe('task mutation permission rules', () => {
it('allows task owner, execution owner, and project manager to edit mutable tasks', () => {
const task = createTask();
assert.equal(
canEditTaskByIdentity(task, { currentUserId: 'task-owner', projectManagerUserId: 'project-manager' }),
true
);
assert.equal(
canEditTaskByIdentity(task, { currentUserId: 'execution-owner', projectManagerUserId: 'project-manager' }),
true
);
assert.equal(
canEditTaskByIdentity(task, { currentUserId: 'project-manager', projectManagerUserId: 'project-manager' }),
true
);
});
it('does not allow unrelated users to edit just because they have a task permission code', () => {
const task = createTask();
assert.equal(
canEditTaskByIdentity(task, {
currentUserId: 'developer',
projectManagerUserId: 'project-manager'
}),
false
);
});
it('uses the same identity rule for delete and only blocks completed tasks by status', () => {
assert.equal(
canDeleteTaskByIdentity(createTask({ statusCode: 'active' }), {
currentUserId: 'execution-owner',
projectManagerUserId: 'project-manager'
}),
true
);
assert.equal(
canDeleteTaskByIdentity(createTask({ statusCode: 'completed' }), {
currentUserId: 'execution-owner',
projectManagerUserId: 'project-manager'
}),
false
);
});
});
describe('execution mutation permission rules', () => {
it('allows only execution owner and project manager to edit mutable executions', () => {
const execution = createExecution();
assert.equal(
canEditExecutionByIdentity(execution, {
currentUserId: 'execution-owner',
projectManagerUserId: 'project-manager'
}),
true
);
assert.equal(
canEditExecutionByIdentity(execution, {
currentUserId: 'project-manager',
projectManagerUserId: 'project-manager'
}),
true
);
assert.equal(
canEditExecutionByIdentity(execution, {
currentUserId: 'developer',
projectManagerUserId: 'project-manager'
}),
false
);
});
it('uses the same identity rule for delete and only blocks completed executions by status', () => {
assert.equal(
canDeleteExecutionByIdentity(createExecution({ statusCode: 'active' }), {
currentUserId: 'execution-owner',
projectManagerUserId: 'project-manager'
}),
true
);
assert.equal(
canDeleteExecutionByIdentity(createExecution({ statusCode: 'active' }), {
currentUserId: 'developer',
projectManagerUserId: 'project-manager'
}),
false
);
assert.equal(
canDeleteExecutionByIdentity(createExecution({ statusCode: 'completed' }), {
currentUserId: 'execution-owner',
projectManagerUserId: 'project-manager'
}),
false
);
});
});