Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 54c97c9729 | |||
| 27c136c906 | |||
| 503ea1e255 | |||
| b6609e10fe | |||
| e2094831e4 | |||
| 62b97a2fd7 | |||
| a0dcd39c23 | |||
| c1f710fbd8 | |||
| bc5815416b |
@@ -7,7 +7,7 @@ defineOptions({
|
|||||||
inheritAttrs: false
|
inheritAttrs: false
|
||||||
});
|
});
|
||||||
|
|
||||||
type DialogPreset = 'sm' | 'md' | 'lg';
|
type DialogPreset = 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -50,7 +50,8 @@ const visible = defineModel<boolean>({
|
|||||||
const DIALOG_WIDTH_MAP: Record<DialogPreset, string> = {
|
const DIALOG_WIDTH_MAP: Record<DialogPreset, string> = {
|
||||||
sm: '520px',
|
sm: '520px',
|
||||||
md: '720px',
|
md: '720px',
|
||||||
lg: '960px'
|
lg: '960px',
|
||||||
|
xl: '1200px'
|
||||||
};
|
};
|
||||||
|
|
||||||
const dialogWidth = computed(() => props.width ?? DIALOG_WIDTH_MAP[props.preset]);
|
const dialogWidth = computed(() => props.width ?? DIALOG_WIDTH_MAP[props.preset]);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
import { useDebounceFn, useInfiniteScroll } from '@vueuse/core';
|
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 { NOTIFY_MESSAGE_LEVEL_DICT_CODE } from '@/constants/dict';
|
||||||
import {
|
import {
|
||||||
fetchGetMyNotifyMessagePage,
|
fetchGetMyNotifyMessagePage,
|
||||||
@@ -14,6 +16,7 @@ import { formatDateTime, formatRelativeTime } from '@/utils/datetime';
|
|||||||
defineOptions({ name: 'NotificationBell' });
|
defineOptions({ name: 'NotificationBell' });
|
||||||
|
|
||||||
const dictStore = useDictStore();
|
const dictStore = useDictStore();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
const UNREAD_COUNT_POLL_INTERVAL = 15 * 1000;
|
const UNREAD_COUNT_POLL_INTERVAL = 15 * 1000;
|
||||||
@@ -50,6 +53,12 @@ const searchKeyword = ref('');
|
|||||||
const detailVisible = ref(false);
|
const detailVisible = ref(false);
|
||||||
const detailMessage = ref<Api.NotifyMessage.NotifyMessage | null>(null);
|
const detailMessage = ref<Api.NotifyMessage.NotifyMessage | null>(null);
|
||||||
|
|
||||||
|
interface NotifyRenderParts {
|
||||||
|
prefix: string;
|
||||||
|
clickable: string;
|
||||||
|
suffix: string;
|
||||||
|
}
|
||||||
|
|
||||||
function keywordParam() {
|
function keywordParam() {
|
||||||
return searchKeyword.value.trim() || undefined;
|
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() {
|
async function markAllRead() {
|
||||||
const { error } = await fetchUpdateAllNotifyMessageRead();
|
const { error } = await fetchUpdateAllNotifyMessageRead();
|
||||||
if (error) return;
|
if (error) {
|
||||||
|
window.$message?.error('全部标记已读失败,请重试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
unreadCount.value = 0;
|
unreadCount.value = 0;
|
||||||
resetList('unread');
|
resetList('unread');
|
||||||
resetList('read');
|
resetList('read');
|
||||||
loadPage(activeTab.value);
|
loadPage(activeTab.value);
|
||||||
|
// 以后端真实未读数兜底校准,避免乐观置 0 与后端状态偏差(不必等 15s 轮询)
|
||||||
|
refreshUnreadCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
@@ -268,7 +365,7 @@ onBeforeUnmount(() => {
|
|||||||
<template #label>
|
<template #label>
|
||||||
<span class="notification-bell__tab-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>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<ElScrollbar ref="unreadScrollbar" class="notification-bell__scroll">
|
<ElScrollbar ref="unreadScrollbar" class="notification-bell__scroll">
|
||||||
@@ -281,7 +378,25 @@ onBeforeUnmount(() => {
|
|||||||
>
|
>
|
||||||
<span class="notification-bell__row-dot" :style="{ backgroundColor: levelDotColor(row.level) }" />
|
<span class="notification-bell__row-dot" :style="{ backgroundColor: levelDotColor(row.level) }" />
|
||||||
<div class="notification-bell__row-body">
|
<div class="notification-bell__row-body">
|
||||||
<div class="notification-bell__row-title">{{ row.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">
|
<div class="notification-bell__row-meta">
|
||||||
<DictTag :dict-code="NOTIFY_MESSAGE_LEVEL_DICT_CODE" :value="row.level" size="small" round />
|
<DictTag :dict-code="NOTIFY_MESSAGE_LEVEL_DICT_CODE" :value="row.level" size="small" round />
|
||||||
<span class="notification-bell__row-time">{{ formatRelativeTime(row.createTime) }}</span>
|
<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) }" />
|
<span class="notification-bell__row-dot" :style="{ backgroundColor: levelDotColor(row.level) }" />
|
||||||
<div class="notification-bell__row-body">
|
<div class="notification-bell__row-body">
|
||||||
<div class="notification-bell__row-title">{{ row.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">
|
<div class="notification-bell__row-meta">
|
||||||
<DictTag :dict-code="NOTIFY_MESSAGE_LEVEL_DICT_CODE" :value="row.level" size="small" round />
|
<DictTag :dict-code="NOTIFY_MESSAGE_LEVEL_DICT_CODE" :value="row.level" size="small" round />
|
||||||
<span class="notification-bell__row-time">{{ formatRelativeTime(row.createTime) }}</span>
|
<span class="notification-bell__row-time">{{ formatRelativeTime(row.createTime) }}</span>
|
||||||
@@ -350,7 +483,25 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="detailMessage" class="notification-bell__detail-body">
|
<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 class="notification-bell__detail-time">收到于 {{ formatDateTime(detailMessage.createTime) }}</div>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -578,6 +729,19 @@ onBeforeUnmount(() => {
|
|||||||
font-weight: 500;
|
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 {
|
.notification-bell__row-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ type MonthlyResultResponse = Omit<Api.Performance.Sheet.MonthlyResult, 'sheetId'
|
|||||||
employeeId: StringIdResponse;
|
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<
|
type TeamSummaryResponse = Omit<
|
||||||
Api.Performance.Team.Summary,
|
Api.Performance.Team.Summary,
|
||||||
| 'expectedSheetCount'
|
| '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 {
|
function normalizeTeamSummary(response: TeamSummaryResponse): Api.Performance.Team.Summary {
|
||||||
return {
|
return {
|
||||||
...response,
|
...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() {
|
export function fetchPerformanceSheetStatusDict() {
|
||||||
return request<Api.Performance.Sheet.StatusDict[]>({
|
return request<Api.Performance.Sheet.StatusDict[]>({
|
||||||
...safeJsonRequestConfig,
|
...safeJsonRequestConfig,
|
||||||
|
|||||||
@@ -499,7 +499,7 @@ export function normalizeTeamLoad(response: TeamLoadResponse): Api.Project.TeamL
|
|||||||
export function normalizeMyWorklogWeek(response: MyWorklogWeekResponse): Api.Project.MyWorklogWeekResult {
|
export function normalizeMyWorklogWeek(response: MyWorklogWeekResponse): Api.Project.MyWorklogWeekResult {
|
||||||
return {
|
return {
|
||||||
weekStart: response.weekStart ?? '',
|
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 => ({
|
distribution: (response.distribution ?? []).map(item => ({
|
||||||
...normalizeWorklogDistributionItem(item),
|
...normalizeWorklogDistributionItem(item),
|
||||||
hours: typeof item.hours === 'number' ? item.hours : 0
|
hours: typeof item.hours === 'number' ? item.hours : 0
|
||||||
@@ -513,6 +513,7 @@ export function normalizeTeamWorklogWeek(response: TeamWorklogWeekResponse): Api
|
|||||||
members: (response.members ?? []).map(member => ({
|
members: (response.members ?? []).map(member => ({
|
||||||
userId: normalizeStringId(member.userId),
|
userId: normalizeStringId(member.userId),
|
||||||
userNickname: member.userNickname ?? '',
|
userNickname: member.userNickname ?? '',
|
||||||
|
overtimeHours: typeof member.overtimeHours === 'number' ? member.overtimeHours : 0,
|
||||||
items: (member.items ?? []).map(item => ({
|
items: (member.items ?? []).map(item => ({
|
||||||
...normalizeWorklogDistributionItem(item),
|
...normalizeWorklogDistributionItem(item),
|
||||||
hours: typeof item.hours === 'number' ? item.hours : 0
|
hours: typeof item.hours === 'number' ? item.hours : 0
|
||||||
|
|||||||
@@ -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) {
|
export async function fetchGetProjectRequirementTree(params?: Api.Project.ProjectRequirementSearchParams) {
|
||||||
const result = await request<ProjectRequirementPageResponse>({
|
const result = await request<ProjectRequirementPageResponse>({
|
||||||
|
|||||||
16
src/typings/api/notify-message.d.ts
vendored
16
src/typings/api/notify-message.d.ts
vendored
@@ -5,6 +5,18 @@ declare namespace Api {
|
|||||||
* backend api module: "notify-message"(站内信 · 我的收件箱)
|
* backend api module: "notify-message"(站内信 · 我的收件箱)
|
||||||
*/
|
*/
|
||||||
namespace NotifyMessage {
|
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 {
|
interface PageParams {
|
||||||
pageNo: number;
|
pageNo: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
@@ -19,6 +31,8 @@ declare namespace Api {
|
|||||||
interface NotifyMessage {
|
interface NotifyMessage {
|
||||||
/** 站内信编号(雪花 Long,按 string 接收) */
|
/** 站内信编号(雪花 Long,按 string 接收) */
|
||||||
id: string;
|
id: string;
|
||||||
|
/** 模板编码 */
|
||||||
|
templateCode: string;
|
||||||
/** 发送人名称(模板配置的发件人显示名) */
|
/** 发送人名称(模板配置的发件人显示名) */
|
||||||
templateNickname: string;
|
templateNickname: string;
|
||||||
/** 最终消息正文(占位符已渲染,直接展示) */
|
/** 最终消息正文(占位符已渲染,直接展示) */
|
||||||
@@ -33,6 +47,8 @@ declare namespace Api {
|
|||||||
readTime: string | number | null;
|
readTime: string | number | null;
|
||||||
/** 收到时间 */
|
/** 收到时间 */
|
||||||
createTime: string | number;
|
createTime: string | number;
|
||||||
|
/** 模板参数(部分模板支持跳转) */
|
||||||
|
templateParams?: NotifyMessageTemplateParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 我的站内信分页查询参数 */
|
/** 我的站内信分页查询参数 */
|
||||||
|
|||||||
10
src/typings/api/performance.d.ts
vendored
10
src/typings/api/performance.d.ts
vendored
@@ -21,6 +21,10 @@ declare namespace Api {
|
|||||||
actualScoreTotalCell?: string | null;
|
actualScoreTotalCell?: string | null;
|
||||||
baseScoreTotalCell?: string | null;
|
baseScoreTotalCell?: string | null;
|
||||||
extraScoreTotalCell?: string | null;
|
extraScoreTotalCell?: string | null;
|
||||||
|
resultDescriptionCells?: string[] | null;
|
||||||
|
actualScoreCells?: string[] | null;
|
||||||
|
baseScoreCells?: string[] | null;
|
||||||
|
extraScoreCells?: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Template {
|
interface Template {
|
||||||
@@ -172,6 +176,12 @@ declare namespace Api {
|
|||||||
extraScoreTotal?: string | number | null;
|
extraScoreTotal?: string | number | null;
|
||||||
statusCode?: Common.SheetStatusCode | null;
|
statusCode?: Common.SheetStatusCode | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PrefillData {
|
||||||
|
sourceSheetId?: string | null;
|
||||||
|
sourcePeriodMonth?: string | null;
|
||||||
|
cellValues: Record<string, string>;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
namespace Team {
|
namespace Team {
|
||||||
|
|||||||
5
src/typings/api/project.d.ts
vendored
5
src/typings/api/project.d.ts
vendored
@@ -239,7 +239,7 @@ declare namespace Api {
|
|||||||
type: string;
|
type: string;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
ownerNickname?: string | null;
|
ownerNickname?: string | null;
|
||||||
/** 所属执行的负责人 userId(按钮可见度公式用);跨执行查询永远为 null,按钮判定退化为只看权限码 */
|
/** 所属执行的负责人 userId(按钮可见度公式用);跨执行查询为空时,前端无法识别执行负责人身份 */
|
||||||
executionOwnerId: string | null;
|
executionOwnerId: string | null;
|
||||||
/** 父任务负责人 userId(一级任务为 null) */
|
/** 父任务负责人 userId(一级任务为 null) */
|
||||||
parentTaskOwnerId: string | null;
|
parentTaskOwnerId: string | null;
|
||||||
@@ -491,7 +491,7 @@ declare namespace Api {
|
|||||||
interface MyWorklogWeekResult {
|
interface MyWorklogWeekResult {
|
||||||
/** 归一后的周一日期 YYYY-MM-DD */
|
/** 归一后的周一日期 YYYY-MM-DD */
|
||||||
weekStart: string;
|
weekStart: string;
|
||||||
/** 周一~周五逐日工时(固定 5 元素;均摊推算值,周末份额归周五) */
|
/** 周一~周日逐日工时(固定 7 元素;按周填报时仅均摊到工作日,周末单天工时保留在周末当天) */
|
||||||
dailyHours: number[];
|
dailyHours: number[];
|
||||||
/** 本周工时按归属分布,hours 降序 */
|
/** 本周工时按归属分布,hours 降序 */
|
||||||
distribution: WorklogDistributionItem[];
|
distribution: WorklogDistributionItem[];
|
||||||
@@ -502,6 +502,7 @@ declare namespace Api {
|
|||||||
userId: string;
|
userId: string;
|
||||||
userNickname: string;
|
userNickname: string;
|
||||||
items: WorklogDistributionItem[];
|
items: WorklogDistributionItem[];
|
||||||
|
overtimeHours: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 工作台「团队工时周聚合」响应(GET /project/project/me/team-worklog-week;周标准工时后端不返回,前端落常量 35) */
|
/** 工作台「团队工时周聚合」响应(GET /project/project/me/team-worklog-week;周标准工时后端不返回,前端落常量 35) */
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ function canEditRow(row: Api.Feedback.FeedbackItem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getInitSearchParams(): Api.Feedback.FeedbackSearchParams {
|
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(
|
function transformPageResult(
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { FEEDBACK_STATUS_DICT_CODE } from '@/constants/dict';
|
import { FEEDBACK_STATUS_DICT_CODE } from '@/constants/dict';
|
||||||
import { fetchGetUserSimpleList } from '@/service/api';
|
import { fetchGetUserSimpleList } from '@/service/api';
|
||||||
import { useDict } from '@/hooks/business/dict';
|
|
||||||
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
|
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
|
||||||
|
|
||||||
defineOptions({ name: 'FeedbackSearch' });
|
defineOptions({ name: 'FeedbackSearch' });
|
||||||
@@ -14,9 +13,8 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const model = defineModel<Api.Feedback.FeedbackSearchParams>('model', { required: true });
|
const model = defineModel<Api.Feedback.FeedbackSearchParams>('model', { required: true });
|
||||||
|
|
||||||
// 提交人下拉选项(全员简易列表;value 取用户 id,ID 铁律 string)
|
// 提交人下拉选项(全员简表;value 使用用户 id,ID 铁律 string)
|
||||||
const userOptions = ref<{ label: string; value: string }[]>([]);
|
const userOptions = ref<{ label: string; value: string }[]>([]);
|
||||||
const { dictOptions: statusOptions } = useDict(FEEDBACK_STATUS_DICT_CODE);
|
|
||||||
|
|
||||||
// 分类保留在左侧分面;搜索区补充标题关键词、提交人、状态
|
// 分类保留在左侧分面;搜索区补充标题关键词、提交人、状态
|
||||||
const fields = computed<SearchField[]>(() => [
|
const fields = computed<SearchField[]>(() => [
|
||||||
@@ -32,9 +30,11 @@ const fields = computed<SearchField[]>(() => [
|
|||||||
{
|
{
|
||||||
key: 'status',
|
key: 'status',
|
||||||
label: '状态',
|
label: '状态',
|
||||||
type: 'select',
|
type: 'dict',
|
||||||
|
dictCode: FEEDBACK_STATUS_DICT_CODE,
|
||||||
placeholder: '请选择状态',
|
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() {
|
function search() {
|
||||||
// 输入期不实时 trim(避免受控 input 吃掉词间空格);提交前规整一次,空串归一为 undefined
|
// 输入期不实时 trim,避免受控 input 吃掉词间空格;提交前规整一次,空串归一为 undefined
|
||||||
model.value.title = model.value.title?.trim() || undefined;
|
model.value.title = model.value.title?.trim() || undefined;
|
||||||
emit('search');
|
emit('search');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
<script setup lang="tsx">
|
<script setup lang="tsx">
|
||||||
import { computed, reactive, ref } from 'vue';
|
import { computed, reactive, ref } from 'vue';
|
||||||
import { INFRA_OPERATE_TYPE_DICT_CODE } from '@/constants/dict';
|
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 { useAuth } from '@/hooks/business/auth';
|
||||||
import { useUIPaginatedTable } from '@/hooks/common/table';
|
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||||
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
|
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
|
||||||
@@ -59,6 +64,7 @@ const searchParams = reactive(createSearchParams());
|
|||||||
const detailVisible = ref(false);
|
const detailVisible = ref(false);
|
||||||
const currentRow = ref<Api.SystemLog.ApiAccess.Log | null>(null);
|
const currentRow = ref<Api.SystemLog.ApiAccess.Log | null>(null);
|
||||||
const exporting = ref(false);
|
const exporting = ref(false);
|
||||||
|
const userOptions = ref<Array<{ label: string; value: string }>>([]);
|
||||||
const canExport = computed(() => hasAuth(LogPermission.ApiAccessExport));
|
const canExport = computed(() => hasAuth(LogPermission.ApiAccessExport));
|
||||||
|
|
||||||
const detailSections: LogDetailSection[] = [
|
const detailSections: LogDetailSection[] = [
|
||||||
@@ -176,6 +182,22 @@ function handleSearch() {
|
|||||||
reloadTable(1);
|
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() {
|
async function handleExport() {
|
||||||
exporting.value = true;
|
exporting.value = true;
|
||||||
const { error, data: blob } = await fetchExportApiAccessLog(searchParams);
|
const { error, data: blob } = await fetchExportApiAccessLog(searchParams);
|
||||||
@@ -191,7 +213,12 @@ async function handleExport() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-full min-h-0 flex-col-stretch gap-16px overflow-hidden">
|
<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">
|
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">
|
||||||
<template #header>
|
<template #header>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { SYSTEM_USER_TYPE_DICT_CODE } from '@/constants/dict';
|
|
||||||
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
|
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
|
||||||
|
|
||||||
defineOptions({ name: 'ApiAccessLogSearch' });
|
defineOptions({ name: 'ApiAccessLogSearch' });
|
||||||
@@ -12,7 +11,19 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const model = defineModel<Api.SystemLog.ApiAccess.SearchParams>('model', { required: true });
|
const model = defineModel<Api.SystemLog.ApiAccess.SearchParams>('model', { required: true });
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
userOptions: Array<{ label: string; value: string }>;
|
||||||
|
}>();
|
||||||
|
|
||||||
const fields = computed<SearchField[]>(() => [
|
const fields = computed<SearchField[]>(() => [
|
||||||
|
{
|
||||||
|
key: 'userId',
|
||||||
|
label: '用户名',
|
||||||
|
type: 'select',
|
||||||
|
placeholder: '请选择用户名',
|
||||||
|
options: props.userOptions,
|
||||||
|
filterable: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'applicationName',
|
key: 'applicationName',
|
||||||
label: '应用名',
|
label: '应用名',
|
||||||
@@ -51,20 +62,14 @@ const fields = computed<SearchField[]>(() => [
|
|||||||
},
|
},
|
||||||
resolveValue: value => (value === null || value === undefined ? '' : String(value))
|
resolveValue: value => (value === null || value === undefined ? '' : String(value))
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
key: 'userId',
|
// key: 'userType',
|
||||||
label: '用户编号',
|
// label: '用户类型',
|
||||||
type: 'input',
|
// type: 'dict',
|
||||||
placeholder: '请输入用户编号'
|
// 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',
|
key: 'beginTime',
|
||||||
label: '请求时间',
|
label: '请求时间',
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
<script setup lang="tsx">
|
<script setup lang="tsx">
|
||||||
import { computed, reactive, ref } from 'vue';
|
import { computed, reactive, ref } from 'vue';
|
||||||
import { INFRA_API_ERROR_LOG_PROCESS_STATUS_DICT_CODE } from '@/constants/dict';
|
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 { useAuth } from '@/hooks/business/auth';
|
||||||
import { useUIPaginatedTable } from '@/hooks/common/table';
|
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||||
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
|
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
|
||||||
@@ -51,6 +56,7 @@ const searchParams = reactive(createSearchParams());
|
|||||||
const detailVisible = ref(false);
|
const detailVisible = ref(false);
|
||||||
const currentRow = ref<Api.SystemLog.ApiError.Log | null>(null);
|
const currentRow = ref<Api.SystemLog.ApiError.Log | null>(null);
|
||||||
const exporting = ref(false);
|
const exporting = ref(false);
|
||||||
|
const userOptions = ref<Array<{ label: string; value: string }>>([]);
|
||||||
const canExport = computed(() => hasAuth(LogPermission.ApiErrorExport));
|
const canExport = computed(() => hasAuth(LogPermission.ApiErrorExport));
|
||||||
|
|
||||||
const detailSections: LogDetailSection[] = [
|
const detailSections: LogDetailSection[] = [
|
||||||
@@ -168,6 +174,22 @@ function handleSearch() {
|
|||||||
reloadTable(1);
|
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() {
|
async function handleExport() {
|
||||||
exporting.value = true;
|
exporting.value = true;
|
||||||
const { error, data: blob } = await fetchExportApiErrorLog(searchParams);
|
const { error, data: blob } = await fetchExportApiErrorLog(searchParams);
|
||||||
@@ -183,7 +205,12 @@ async function handleExport() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-full min-h-0 flex-col-stretch gap-16px overflow-hidden">
|
<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">
|
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">
|
||||||
<template #header>
|
<template #header>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
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';
|
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
|
||||||
|
|
||||||
defineOptions({ name: 'ApiErrorLogSearch' });
|
defineOptions({ name: 'ApiErrorLogSearch' });
|
||||||
@@ -12,7 +12,19 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const model = defineModel<Api.SystemLog.ApiError.SearchParams>('model', { required: true });
|
const model = defineModel<Api.SystemLog.ApiError.SearchParams>('model', { required: true });
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
userOptions: Array<{ label: string; value: string }>;
|
||||||
|
}>();
|
||||||
|
|
||||||
const fields = computed<SearchField[]>(() => [
|
const fields = computed<SearchField[]>(() => [
|
||||||
|
{
|
||||||
|
key: 'userId',
|
||||||
|
label: '用户名',
|
||||||
|
type: 'select',
|
||||||
|
placeholder: '请选择用户名',
|
||||||
|
options: props.userOptions,
|
||||||
|
filterable: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'applicationName',
|
key: 'applicationName',
|
||||||
label: '应用名',
|
label: '应用名',
|
||||||
@@ -33,20 +45,14 @@ const fields = computed<SearchField[]>(() => [
|
|||||||
dictCode: INFRA_API_ERROR_LOG_PROCESS_STATUS_DICT_CODE,
|
dictCode: INFRA_API_ERROR_LOG_PROCESS_STATUS_DICT_CODE,
|
||||||
filterable: true
|
filterable: true
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
key: 'userId',
|
// key: 'userType',
|
||||||
label: '用户编号',
|
// label: '用户类型',
|
||||||
type: 'input',
|
// type: 'dict',
|
||||||
placeholder: '请输入用户编号'
|
// 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',
|
key: 'exceptionTime',
|
||||||
label: '异常时间',
|
label: '异常时间',
|
||||||
|
|||||||
@@ -466,7 +466,7 @@ async function handleExportSelected() {
|
|||||||
|
|
||||||
if (error || !blob) return;
|
if (error || !blob) return;
|
||||||
|
|
||||||
downloadBlob(blob, `绩效表_导出选中_${dayjs().format('YYYY-MM-DD')}.zip`);
|
downloadBlob(blob, `绩效表_导出选中_${dayjs().format('YYYY-MM-DD')}.xlsx`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleExportAll() {
|
async function handleExportAll() {
|
||||||
@@ -476,7 +476,7 @@ async function handleExportAll() {
|
|||||||
|
|
||||||
if (error || !blob) return;
|
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') {
|
async function handleExportCommand(command: 'selected' | 'all') {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
createPerformanceSheet,
|
createPerformanceSheet,
|
||||||
downloadFile,
|
downloadFile,
|
||||||
fetchPerformanceSheet,
|
fetchPerformanceSheet,
|
||||||
|
fetchPerformanceSheetPrefill,
|
||||||
fetchPerformanceTemplateCurrent,
|
fetchPerformanceTemplateCurrent,
|
||||||
sendPerformanceSheet,
|
sendPerformanceSheet,
|
||||||
updatePerformanceSheetExcel,
|
updatePerformanceSheetExcel,
|
||||||
@@ -27,19 +28,25 @@ interface Props {
|
|||||||
mode: 'view' | 'edit' | 'create';
|
mode: 'view' | 'edit' | 'create';
|
||||||
subordinateOptions?: SubordinateOption[];
|
subordinateOptions?: SubordinateOption[];
|
||||||
showApprovalFooter?: boolean;
|
showApprovalFooter?: boolean;
|
||||||
|
initialEmployeeId?: string;
|
||||||
|
initialPeriodMonth?: string;
|
||||||
|
hideDraftAction?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
rowData: null,
|
rowData: null,
|
||||||
subordinateOptions: () => [],
|
subordinateOptions: () => [],
|
||||||
showApprovalFooter: false
|
showApprovalFooter: false,
|
||||||
|
initialEmployeeId: '',
|
||||||
|
initialPeriodMonth: '',
|
||||||
|
hideDraftAction: false
|
||||||
});
|
});
|
||||||
|
|
||||||
const visible = defineModel<boolean>('visible', { default: false });
|
const visible = defineModel<boolean>('visible', { default: false });
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
saved: [];
|
saved: [payload: PerformanceSheetSavePayload];
|
||||||
savedAndSent: [];
|
savedAndSent: [payload: PerformanceSheetSavePayload];
|
||||||
startApproval: [];
|
startApproval: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -48,18 +55,29 @@ const { createRequiredRule } = useFormRules();
|
|||||||
|
|
||||||
const containerRef = ref<HTMLDivElement>();
|
const containerRef = ref<HTMLDivElement>();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const prefillLoading = ref(false);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const sending = ref(false);
|
const sending = ref(false);
|
||||||
const currentSheet = ref<Api.Performance.Sheet.Sheet | null>(null);
|
const currentSheet = ref<Api.Performance.Sheet.Sheet | null>(null);
|
||||||
const currentTemplate = ref<Api.Performance.Template.Template | null>(null);
|
const currentTemplate = ref<Api.Performance.Template.Template | null>(null);
|
||||||
const errorMessage = ref('');
|
const errorMessage = ref('');
|
||||||
|
const prefillSourcePeriodMonth = ref('');
|
||||||
const viewportWidth = ref(typeof window === 'undefined' ? 1920 : window.innerWidth);
|
const viewportWidth = ref(typeof window === 'undefined' ? 1920 : window.innerWidth);
|
||||||
|
const loadedCreateContextKey = ref('');
|
||||||
|
const createContextInitializing = ref(false);
|
||||||
|
|
||||||
const createForm = reactive({
|
const createForm = reactive({
|
||||||
periodMonth: createDefaultPeriodMonth(),
|
periodMonth: createDefaultPeriodMonth(),
|
||||||
employeeId: ''
|
employeeId: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
|
interface PerformanceSheetSavePayload {
|
||||||
|
sheet: Api.Performance.Sheet.Sheet;
|
||||||
|
actualScoreTotal: string;
|
||||||
|
baseScoreTotal: string;
|
||||||
|
extraScoreTotal: string;
|
||||||
|
}
|
||||||
|
|
||||||
const createFormRules = computed<FormRules>(() => ({
|
const createFormRules = computed<FormRules>(() => ({
|
||||||
periodMonth: [createRequiredRule('请选择绩效月份')],
|
periodMonth: [createRequiredRule('请选择绩效月份')],
|
||||||
employeeId: [createRequiredRule('请选择下属')]
|
employeeId: [createRequiredRule('请选择下属')]
|
||||||
@@ -367,6 +385,10 @@ function getActiveWorkbook() {
|
|||||||
return univerAPI?.getActiveWorkbook?.();
|
return univerAPI?.getActiveWorkbook?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCreateContextKey() {
|
||||||
|
return `${createForm.periodMonth || ''}::${createForm.employeeId || ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
function parseCellAddress(address?: string | null) {
|
function parseCellAddress(address?: string | null) {
|
||||||
const text = String(address || '')
|
const text = String(address || '')
|
||||||
.trim()
|
.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() {
|
function readScores() {
|
||||||
const mapping = currentTemplate.value?.scoreCellMapping;
|
const mapping = currentTemplate.value?.scoreCellMapping;
|
||||||
|
|
||||||
@@ -438,9 +483,36 @@ function createInitialFileName() {
|
|||||||
return '绩效表.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() {
|
async function loadWorkbook() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
prefillLoading.value = false;
|
||||||
errorMessage.value = '';
|
errorMessage.value = '';
|
||||||
|
prefillSourcePeriodMonth.value = '';
|
||||||
currentSheet.value = null;
|
currentSheet.value = null;
|
||||||
currentTemplate.value = null;
|
currentTemplate.value = null;
|
||||||
|
|
||||||
@@ -492,6 +564,14 @@ async function loadWorkbook() {
|
|||||||
const snapshot = await transformExcelToUniver(file);
|
const snapshot = await transformExcelToUniver(file);
|
||||||
await nextTick();
|
await nextTick();
|
||||||
createWorkbook(applyCreateEmployeeName(snapshot));
|
createWorkbook(applyCreateEmployeeName(snapshot));
|
||||||
|
|
||||||
|
if (isCreateMode.value) {
|
||||||
|
await nextTick();
|
||||||
|
await applyCreatePrefill();
|
||||||
|
loadedCreateContextKey.value = getCreateContextKey();
|
||||||
|
} else {
|
||||||
|
loadedCreateContextKey.value = '';
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = error instanceof Error ? error.message : 'Excel 解析失败';
|
errorMessage.value = error instanceof Error ? error.message : 'Excel 解析失败';
|
||||||
} finally {
|
} finally {
|
||||||
@@ -526,7 +606,17 @@ async function ensureCreatedSheet() {
|
|||||||
return sheetResult.data;
|
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();
|
const workbook = getActiveWorkbook();
|
||||||
if (!workbook) return null;
|
if (!workbook) return null;
|
||||||
|
|
||||||
@@ -573,18 +663,23 @@ async function executeSave(): Promise<Api.Performance.Sheet.Sheet | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return sheet;
|
return {
|
||||||
|
sheet,
|
||||||
|
actualScoreTotal: scores.actualScoreTotal,
|
||||||
|
baseScoreTotal: scores.baseScoreTotal,
|
||||||
|
extraScoreTotal: scores.extraScoreTotal
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSaveDraft() {
|
async function handleSaveDraft() {
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
const sheet = await executeSave();
|
const saveResult = await executeSave();
|
||||||
if (!sheet) return;
|
if (!saveResult) return;
|
||||||
|
|
||||||
window.$message?.success(isCreateMode.value ? '绩效表已保存为草稿' : '绩效 Excel 已保存');
|
window.$message?.success(isCreateMode.value ? '绩效表已保存为草稿' : '绩效 Excel 已保存');
|
||||||
visible.value = false;
|
visible.value = false;
|
||||||
emit('saved');
|
emit('saved', saveResult);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.$message?.error(error instanceof Error ? error.message : '绩效 Excel 保存失败');
|
window.$message?.error(error instanceof Error ? error.message : '绩效 Excel 保存失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -595,15 +690,15 @@ async function handleSaveDraft() {
|
|||||||
async function handleSaveAndSend() {
|
async function handleSaveAndSend() {
|
||||||
sending.value = true;
|
sending.value = true;
|
||||||
try {
|
try {
|
||||||
const sheet = await executeSave();
|
const saveResult = await executeSave();
|
||||||
if (!sheet) return;
|
if (!saveResult) return;
|
||||||
|
|
||||||
const sendResult = await sendPerformanceSheet(sheet.id);
|
const sendResult = await sendPerformanceSheet(saveResult.sheet.id);
|
||||||
if (sendResult.error) return;
|
if (sendResult.error) return;
|
||||||
|
|
||||||
window.$message?.success('绩效表已保存并发送');
|
window.$message?.success('绩效表已保存并发送');
|
||||||
visible.value = false;
|
visible.value = false;
|
||||||
emit('savedAndSent');
|
emit('savedAndSent', saveResult);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.$message?.error(error instanceof Error ? error.message : '绩效表发送失败');
|
window.$message?.error(error instanceof Error ? error.message : '绩效表发送失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -616,29 +711,48 @@ watch(visible, async isVisible => {
|
|||||||
disposeUniver();
|
disposeUniver();
|
||||||
currentSheet.value = null;
|
currentSheet.value = null;
|
||||||
currentTemplate.value = null;
|
currentTemplate.value = null;
|
||||||
// 重置创建表单
|
loadedCreateContextKey.value = '';
|
||||||
createForm.periodMonth = createDefaultPeriodMonth();
|
createContextInitializing.value = false;
|
||||||
createForm.employeeId = '';
|
prefillLoading.value = false;
|
||||||
|
prefillSourcePeriodMonth.value = '';
|
||||||
|
initializeCreateForm();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await nextTick();
|
createContextInitializing.value = isCreateMode.value;
|
||||||
await loadWorkbook();
|
|
||||||
|
try {
|
||||||
|
if (isCreateMode.value) {
|
||||||
|
initializeCreateForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
await loadWorkbook();
|
||||||
|
} finally {
|
||||||
|
createContextInitializing.value = false;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => createForm.employeeId,
|
() => [createForm.employeeId, createForm.periodMonth] as const,
|
||||||
async (employeeId, previousEmployeeId) => {
|
async ([employeeId, periodMonth], [previousEmployeeId, previousPeriodMonth]) => {
|
||||||
if (!visible.value || !isCreateMode.value || !employeeId || employeeId === previousEmployeeId) {
|
if (!visible.value || !isCreateMode.value || createContextInitializing.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const workbook = getActiveWorkbook();
|
const currentKey = `${periodMonth || ''}::${employeeId || ''}`;
|
||||||
if (!workbook) return;
|
const previousKey = `${previousPeriodMonth || ''}::${previousEmployeeId || ''}`;
|
||||||
|
if (
|
||||||
|
currentKey === previousKey ||
|
||||||
|
currentKey === loadedCreateContextKey.value ||
|
||||||
|
loading.value ||
|
||||||
|
prefillLoading.value
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const snapshot = workbook.save();
|
|
||||||
await nextTick();
|
await nextTick();
|
||||||
createWorkbook(applyCreateEmployeeName(snapshot));
|
await loadWorkbook();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -672,14 +786,24 @@ onMounted(() => {
|
|||||||
type="month"
|
type="month"
|
||||||
value-format="YYYY-MM"
|
value-format="YYYY-MM"
|
||||||
placeholder="选择绩效月份"
|
placeholder="选择绩效月份"
|
||||||
|
:disabled="loading || prefillLoading || saving || sending"
|
||||||
/>
|
/>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
<ElFormItem label="下属" prop="employeeId" class="performance-excel-editor__form-item">
|
<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" />
|
<ElOption v-for="opt in props.subordinateOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||||
</ElSelect>
|
</ElSelect>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
</ElForm>
|
</ElForm>
|
||||||
|
<div v-if="prefillLoading || prefillSourcePeriodMonth" class="performance-excel-editor__prefill-tip">
|
||||||
|
{{ prefillLoading ? '正在自动带出上一份绩效数据...' : `已自动带出 ${prefillSourcePeriodMonth} 的绩效数据` }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-loading="loading" class="performance-excel-editor">
|
<div v-loading="loading" class="performance-excel-editor">
|
||||||
@@ -698,12 +822,26 @@ onMounted(() => {
|
|||||||
<template v-if="canSave || showApprovalFooter" #footer>
|
<template v-if="canSave || showApprovalFooter" #footer>
|
||||||
<div class="performance-excel-editor__footer">
|
<div class="performance-excel-editor__footer">
|
||||||
<template v-if="canSave">
|
<template v-if="canSave">
|
||||||
<ElButton :loading="saving" :disabled="sending" @click="handleSaveDraft">保存草稿</ElButton>
|
<ElButton
|
||||||
<ElButton type="primary" :loading="sending" :disabled="saving" @click="handleSaveAndSend">发送绩效</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>
|
||||||
<template v-else-if="showApprovalFooter">
|
<template v-else-if="showApprovalFooter">
|
||||||
<ElButton @click="handleClose">退出审批</ElButton>
|
<ElButton @click="handleClose">退出</ElButton>
|
||||||
<ElButton type="primary" @click="handleStartApproval">开始审批</ElButton>
|
<ElButton type="primary" @click="handleStartApproval">审批</ElButton>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -732,6 +870,12 @@ onMounted(() => {
|
|||||||
margin-right: 24px;
|
margin-right: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.performance-excel-editor__prefill-tip {
|
||||||
|
margin-top: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.performance-excel-editor__footer {
|
.performance-excel-editor__footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import { computed, nextTick, reactive, ref, watch } from 'vue';
|
|||||||
import type { FormRules } from 'element-plus';
|
import type { FormRules } from 'element-plus';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { RDMS_REQ_PRIORITY_DICT_CODE, RDMS_TASK_ITEM_TYPE_DICT_CODE } from '@/constants/dict';
|
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 { useForm, useFormRules } from '@/hooks/common/form';
|
||||||
import { useDict } from '@/hooks/business/dict';
|
import { useDict } from '@/hooks/business/dict';
|
||||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||||
|
import PerformanceExcelEditorDrawer from '../../../my-performance/modules/performance-excel-editor-drawer.vue';
|
||||||
import {
|
import {
|
||||||
type WorkReportStructuredSection,
|
type WorkReportStructuredSection,
|
||||||
type WorkReportStructuredTask,
|
type WorkReportStructuredTask,
|
||||||
@@ -73,6 +75,10 @@ const EMPTY_TEXT = '';
|
|||||||
const { getLabel: getTaskItemTypeLabel } = useDict(RDMS_TASK_ITEM_TYPE_DICT_CODE);
|
const { getLabel: getTaskItemTypeLabel } = useDict(RDMS_TASK_ITEM_TYPE_DICT_CODE);
|
||||||
const { dictData: priorityDictData, getLabel: getPriorityLabel } = useDict(RDMS_REQ_PRIORITY_DICT_CODE);
|
const { dictData: priorityDictData, getLabel: getPriorityLabel } = useDict(RDMS_REQ_PRIORITY_DICT_CODE);
|
||||||
const todayText = computed(() => dayjs().format('YYYY-MM-DD'));
|
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 auditDialogVisible = ref(false);
|
||||||
const auditForm = reactive({
|
const auditForm = reactive({
|
||||||
@@ -90,7 +96,6 @@ const pageValidationModel = reactive({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
const pageRules: FormRules = {
|
const pageRules: FormRules = {
|
||||||
meetingDate: [createRequiredRule('请选择面谈时间')],
|
|
||||||
performanceResult: [
|
performanceResult: [
|
||||||
createRequiredRule('请输入绩效考核结果'),
|
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,最多一位小数 */
|
/** 绩效分数输入限制:0-100,最多一位小数 */
|
||||||
function handlePerformanceScoreInput(value: string) {
|
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 () => {
|
watch(rejectOpinionRequired, async () => {
|
||||||
if (!auditDialogVisible.value) return;
|
if (!auditDialogVisible.value) return;
|
||||||
|
|
||||||
@@ -614,10 +668,7 @@ watch(
|
|||||||
<ElInput v-model="mainForm.supervisor" disabled />
|
<ElInput v-model="mainForm.supervisor" disabled />
|
||||||
</div>
|
</div>
|
||||||
<div class="field field-form-item">
|
<div class="field field-form-item">
|
||||||
<label>
|
<label>面谈时间</label>
|
||||||
面谈时间
|
|
||||||
<span class="field-required-mark">*</span>
|
|
||||||
</label>
|
|
||||||
<ElFormItem class="field-inline-form-item" prop="meetingDate">
|
<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" style="width: 100%" />
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
@@ -730,12 +781,25 @@ watch(
|
|||||||
<span class="field-required-mark">*</span>
|
<span class="field-required-mark">*</span>
|
||||||
</div>
|
</div>
|
||||||
<ElFormItem class="performance-inline-form-item" prop="performanceResult">
|
<ElFormItem class="performance-inline-form-item" prop="performanceResult">
|
||||||
<ElInput
|
<div class="performance-input-group">
|
||||||
v-model="performanceForm.score"
|
<ElInput
|
||||||
class="performance-input"
|
v-model="performanceForm.score"
|
||||||
placeholder="请输入考核分数"
|
class="performance-input"
|
||||||
@input="handlePerformanceScoreInput"
|
placeholder="请输入考核分数"
|
||||||
/>
|
readonly
|
||||||
|
@input="handlePerformanceScoreInput"
|
||||||
|
/>
|
||||||
|
<ElButton
|
||||||
|
plain
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
:loading="performanceDrawerLoading"
|
||||||
|
:disabled="!performanceTargetOption.length"
|
||||||
|
@click="openPerformanceDrawer"
|
||||||
|
>
|
||||||
|
填写绩效
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -800,8 +864,8 @@ watch(
|
|||||||
</ElForm>
|
</ElForm>
|
||||||
|
|
||||||
<div class="form-actions approval-form-actions">
|
<div class="form-actions approval-form-actions">
|
||||||
<ElButton @click="emit('back')">退出审批</ElButton>
|
<ElButton @click="emit('back')">退出</ElButton>
|
||||||
<ElButton type="primary" @click="openAuditDialog">开始审批</ElButton>
|
<ElButton type="primary" @click="openAuditDialog">审批</ElButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BusinessFormDialog
|
<BusinessFormDialog
|
||||||
@@ -871,6 +935,18 @@ watch(
|
|||||||
</ElForm>
|
</ElForm>
|
||||||
</div>
|
</div>
|
||||||
</BusinessFormDialog>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -1794,13 +1870,19 @@ watch(
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.performance-input-group {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.performance-input {
|
.performance-input {
|
||||||
width: 200px;
|
width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.performance-input :deep(.el-input__wrapper) {
|
.performance-input :deep(.el-input__wrapper) {
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
background: transparent;
|
background: #f8fafc;
|
||||||
border-bottom: 1px solid #e2e8f0;
|
border-bottom: 1px solid #e2e8f0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
@@ -1812,6 +1894,10 @@ watch(
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.performance-input :deep(.el-input__inner[readonly]) {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.performance-input :deep(.el-input__inner::placeholder) {
|
.performance-input :deep(.el-input__inner::placeholder) {
|
||||||
color: #cbd5e1;
|
color: #cbd5e1;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|||||||
@@ -539,8 +539,8 @@ function notifyTitleSaved(item: WorkItem) {
|
|||||||
<ElButton type="primary" @click="emit('submit')">提交审批</ElButton>
|
<ElButton type="primary" @click="emit('submit')">提交审批</ElButton>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="scene === 'approval'" class="form-actions approval-form-actions">
|
<div v-else-if="scene === 'approval'" class="form-actions approval-form-actions">
|
||||||
<ElButton @click="emit('back')">退出审批</ElButton>
|
<ElButton @click="emit('back')">退出</ElButton>
|
||||||
<ElButton type="primary" @click="emit('requestApprove')">开始审批</ElButton>
|
<ElButton type="primary" @click="emit('requestApprove')">审批</ElButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -359,6 +359,7 @@ function resolveTaskItemTypeLabel(value?: string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const STRUCTURED_TASK_PREFIX_RE = /^(?:(?:\d+[..、])|(?:\d+\s+)|(?:[一二三四五六七八九十百千万]+[、..]))\s*/u;
|
const STRUCTURED_TASK_PREFIX_RE = /^(?:(?:\d+[..、])|(?:\d+\s+)|(?:[一二三四五六七八九十百千万]+[、..]))\s*/u;
|
||||||
|
const WORKLOG_DAY_SEPARATOR = '[[WR_DAY_SPLIT]]';
|
||||||
|
|
||||||
function stripStructuredTaskPrefixV2(value: string) {
|
function stripStructuredTaskPrefixV2(value: string) {
|
||||||
return value.trim().replace(STRUCTURED_TASK_PREFIX_RE, '');
|
return value.trim().replace(STRUCTURED_TASK_PREFIX_RE, '');
|
||||||
@@ -385,9 +386,9 @@ function formatStructuredTaskDisplayLine(task: StructuredTask, index: number, sh
|
|||||||
|
|
||||||
function getWorkLogEntries(detail: string): string[] {
|
function getWorkLogEntries(detail: string): string[] {
|
||||||
if (!detail) return [];
|
if (!detail) return [];
|
||||||
// 仅按中文分号切分,避免误伤文本中的其他标点;每段作为单独一天的工作日志展示。
|
// 仅按显式分隔标记切分,避免正文里的中文分号被误判成跨天分隔。
|
||||||
return detail
|
return detail
|
||||||
.split(';')
|
.split(WORKLOG_DAY_SEPARATOR)
|
||||||
.map(item => item.trim())
|
.map(item => item.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
@@ -1497,8 +1498,8 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
|||||||
<ElButton type="primary" @click="emit('submit')">提交审批</ElButton>
|
<ElButton type="primary" @click="emit('submit')">提交审批</ElButton>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="scene === 'approval'" class="form-actions approval-form-actions">
|
<div v-else-if="scene === 'approval'" class="form-actions approval-form-actions">
|
||||||
<ElButton @click="emit('back')">退出审批</ElButton>
|
<ElButton @click="emit('back')">退出</ElButton>
|
||||||
<ElButton type="primary" @click="emit('requestApprove')">开始审批</ElButton>
|
<ElButton type="primary" @click="emit('requestApprove')">审批</ElButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BusinessFormDialog
|
<BusinessFormDialog
|
||||||
|
|||||||
@@ -350,12 +350,24 @@ const columns = computed(() => [
|
|||||||
prop: 'title',
|
prop: 'title',
|
||||||
label: '需求名称',
|
label: '需求名称',
|
||||||
minWidth: 200,
|
minWidth: 200,
|
||||||
|
className: 'requirement-title-column',
|
||||||
formatter: (row: Api.Product.Requirement) => {
|
formatter: (row: Api.Product.Requirement) => {
|
||||||
return (
|
return (
|
||||||
<ElTooltip content={row.title} placement="top" show-after={300}>
|
<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}
|
{row.title}
|
||||||
</ElButton>
|
</span>
|
||||||
</ElTooltip>
|
</ElTooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1035,14 +1047,29 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
:deep(.requirement-title) {
|
:deep(.requirement-title) {
|
||||||
|
display: block;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
max-width: 180px;
|
color: var(--el-color-primary);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
line-height: 1.5;
|
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) {
|
:deep(.requirement-title--terminal) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ref, watch } from 'vue';
|
import { ref, watch } from 'vue';
|
||||||
import { fetchGetProjectRequirementPage } from '@/service/api/project';
|
import { fetchGetExecutionRequirementOptions } from '@/service/api/project';
|
||||||
|
|
||||||
export interface ProjectRequirementTreeNode {
|
export interface ProjectRequirementTreeNode {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -32,10 +32,10 @@ export function useProjectRequirementOptions(projectId: () => string | null) {
|
|||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const { data, error } = await fetchGetProjectRequirementPage({
|
const { data, error } = await fetchGetExecutionRequirementOptions({
|
||||||
projectId: id,
|
projectId: id,
|
||||||
pageNo: 1,
|
pageNo: 1,
|
||||||
pageSize: 100
|
pageSize: -1
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error || !data?.list) {
|
if (error || !data?.list) {
|
||||||
@@ -86,6 +86,7 @@ export function useProjectRequirementOptions(projectId: () => string | null) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pruneEmptyChildren(roots);
|
pruneEmptyChildren(roots);
|
||||||
|
|
||||||
return roots;
|
return roots;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { computed, markRaw } from 'vue';
|
import { markRaw } from 'vue';
|
||||||
import { useAuthStore } from '@/store/modules/auth';
|
import { useTaskPermissions } from './use-task-permissions';
|
||||||
import IconMdiClipboardEditOutline from '~icons/mdi/clipboard-edit-outline';
|
import IconMdiClipboardEditOutline from '~icons/mdi/clipboard-edit-outline';
|
||||||
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
|
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
|
||||||
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
|
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
|
||||||
@@ -25,18 +25,14 @@ export interface TaskActionEmits {
|
|||||||
/**
|
/**
|
||||||
* 任务行操作按钮的集中装配。
|
* 任务行操作按钮的集中装配。
|
||||||
*
|
*
|
||||||
* 只在这里收口任务行按钮的最终显示口径,不改动底层子任务 / 父任务负责人 / 执行负责人那套权限体系:
|
* 这里只收口任务行按钮的最终显示口径;编辑 / 删除身份规则统一落在 useTaskPermissions。
|
||||||
* - 当前用户是任务负责人(task.ownerId): 显示 填报 / 编辑 / 删除
|
* 权限码只做后端兜底,不作为前端按钮展示的单独放行条件。
|
||||||
* - 当前用户是协办人(task.assignees[].userId): 只显示 填报
|
|
||||||
* - 都不是: 也显示 填报
|
|
||||||
*/
|
*/
|
||||||
export function useTaskActions(emits: TaskActionEmits) {
|
export function useTaskActions(emits: TaskActionEmits) {
|
||||||
const authStore = useAuthStore();
|
const { canEditTask, canDeleteTask } = useTaskPermissions();
|
||||||
const currentUserId = computed(() => authStore.userInfo.userId || '');
|
|
||||||
|
|
||||||
function createActions(row: Api.Project.ProjectTask): TaskAction[] {
|
function createActions(row: Api.Project.ProjectTask): TaskAction[] {
|
||||||
const actions: TaskAction[] = [];
|
const actions: TaskAction[] = [];
|
||||||
const isOwner = Boolean(currentUserId.value) && row.ownerId === currentUserId.value;
|
|
||||||
|
|
||||||
actions.push({
|
actions.push({
|
||||||
key: 'report',
|
key: 'report',
|
||||||
@@ -46,11 +42,7 @@ export function useTaskActions(emits: TaskActionEmits) {
|
|||||||
onClick: () => emits.report(row)
|
onClick: () => emits.report(row)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!isOwner) {
|
if (canEditTask(row)) {
|
||||||
return actions;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (row.statusCode !== 'completed') {
|
|
||||||
actions.push({
|
actions.push({
|
||||||
key: 'edit',
|
key: 'edit',
|
||||||
tooltip: '编辑',
|
tooltip: '编辑',
|
||||||
@@ -60,13 +52,15 @@ export function useTaskActions(emits: TaskActionEmits) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
actions.push({
|
if (canDeleteTask(row)) {
|
||||||
key: 'delete',
|
actions.push({
|
||||||
tooltip: '删除',
|
key: 'delete',
|
||||||
icon: markRaw(IconMdiDeleteOutline),
|
tooltip: '删除',
|
||||||
type: 'danger',
|
icon: markRaw(IconMdiDeleteOutline),
|
||||||
onClick: () => emits.remove(row)
|
type: 'danger',
|
||||||
});
|
onClick: () => emits.remove(row)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return actions;
|
return actions;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export interface CascadeTriggerPayload {
|
|||||||
|
|
||||||
export interface UseTaskCompletionCascadeOptions {
|
export interface UseTaskCompletionCascadeOptions {
|
||||||
projectId: ComputedRef<string>;
|
projectId: ComputedRef<string>;
|
||||||
executionId: ComputedRef<string>;
|
|
||||||
/** 由调用方提供:打开 StatusActionDialog 的钩子;composable 不持有 dialog 实例 */
|
/** 由调用方提供:打开 StatusActionDialog 的钩子;composable 不持有 dialog 实例 */
|
||||||
openStatusActionDialog: (task: ProjectTask, action: TaskAction, fromCascade: boolean) => void;
|
openStatusActionDialog: (task: ProjectTask, action: TaskAction, fromCascade: boolean) => void;
|
||||||
/** 从 task 的 availableActions 里找出"完成"动作;找不到返回 null */
|
/** 从 task 的 availableActions 里找出"完成"动作;找不到返回 null */
|
||||||
@@ -74,7 +73,7 @@ export function useTaskCompletionCascade(options: UseTaskCompletionCascadeOption
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const siblings = await loadSiblings(completedTask.parentTaskId);
|
const siblings = await loadSiblings(completedTask.parentTaskId, completedTask.executionId);
|
||||||
if (siblings === null) {
|
if (siblings === null) {
|
||||||
window.$message?.warning('父任务级联检查失败');
|
window.$message?.warning('父任务级联检查失败');
|
||||||
return;
|
return;
|
||||||
@@ -84,7 +83,7 @@ export function useTaskCompletionCascade(options: UseTaskCompletionCascadeOption
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parent = await fetchTaskDetail(completedTask.parentTaskId);
|
const parent = await fetchTaskDetail(completedTask.parentTaskId, completedTask.executionId);
|
||||||
if (!parent) {
|
if (!parent) {
|
||||||
window.$message?.warning('父任务级联检查失败');
|
window.$message?.warning('父任务级联检查失败');
|
||||||
return;
|
return;
|
||||||
@@ -122,7 +121,8 @@ export function useTaskCompletionCascade(options: UseTaskCompletionCascadeOption
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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,
|
pageNo: 1,
|
||||||
pageSize: -1
|
pageSize: -1
|
||||||
});
|
});
|
||||||
@@ -153,10 +153,10 @@ export function useTaskCompletionCascade(options: UseTaskCompletionCascadeOption
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 拉父级下所有子任务;接口失败返回 null(与"空列表"区分,由调用方降级处理) */
|
/** 拉父级下所有子任务;接口失败返回 null(与"空列表"区分,由调用方降级处理)。父子任务同执行,executionId 取被完成任务自身的值 */
|
||||||
async function loadSiblings(parentTaskId: string): Promise<ProjectTask[] | null> {
|
async function loadSiblings(parentTaskId: string, executionId: string): Promise<ProjectTask[] | null> {
|
||||||
try {
|
try {
|
||||||
const result = await fetchGetProjectTaskPage(options.projectId.value, options.executionId.value, {
|
const result = await fetchGetProjectTaskPage(options.projectId.value, executionId, {
|
||||||
pageNo: 1,
|
pageNo: 1,
|
||||||
pageSize: -1,
|
pageSize: -1,
|
||||||
parentTaskId
|
parentTaskId
|
||||||
@@ -171,9 +171,9 @@ export function useTaskCompletionCascade(options: UseTaskCompletionCascadeOption
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 拉父任务详情;失败返回 null */
|
/** 拉父任务详情;失败返回 null */
|
||||||
async function fetchTaskDetail(taskId: string): Promise<ProjectTask | null> {
|
async function fetchTaskDetail(taskId: string, executionId: string): Promise<ProjectTask | null> {
|
||||||
try {
|
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) {
|
if (result.error || !result.data) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useAuthStore } from '@/store/modules/auth';
|
import { useAuthStore } from '@/store/modules/auth';
|
||||||
import { useObjectContextStore } from '@/store/modules/object-context';
|
import { useObjectContextStore } from '@/store/modules/object-context';
|
||||||
|
import {
|
||||||
|
canDeleteExecutionByIdentity,
|
||||||
|
canDeleteTaskByIdentity,
|
||||||
|
canEditExecutionByIdentity,
|
||||||
|
canEditTaskByIdentity,
|
||||||
|
isExecutionMutationActor,
|
||||||
|
isMutable
|
||||||
|
} from './task-permission-rules';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 任务 / 执行按钮可见度集中判定
|
* 任务 / 执行按钮可见度集中判定
|
||||||
*
|
*
|
||||||
* 关键领域规则:
|
* 关键领域规则:
|
||||||
* - 任务负责人本人不能编辑 / 删除自己负责的任务(增删改归上级 / 项目负责人裁决)
|
* - 任务编辑 / 删除入口先按身份收口:任务负责人、任务所属执行负责人、项目负责人
|
||||||
|
* - 权限码只作为后端最终校验的兜底,不单独决定前端按钮可见度
|
||||||
* - 本人能做的:状态推进(含 cancel "退出"任务)、加协办人、在自己任务下新增子任务
|
* - 本人能做的:状态推进(含 cancel "退出"任务)、加协办人、在自己任务下新增子任务
|
||||||
* - 执行负责人对子任务无编辑 / 删除权(子任务归父任务 owner 管)
|
|
||||||
* - 执行负责人能维护一级任务协办人(一级任务 `executionOwnerId` 通道;子任务不开此通道)
|
* - 执行负责人能维护一级任务协办人(一级任务 `executionOwnerId` 通道;子任务不开此通道)
|
||||||
* - 父任务负责人能改 / 删子任务,但不能给子任务加协办人 / 建孙任务 / 推进状态
|
|
||||||
*
|
*
|
||||||
* 权限码来源:`project:*` / `project:execution:*` / `project:task:*` 是**对象域权限码**,
|
* 权限码来源:`project:*` / `project:execution:*` / `project:task:*` 是**对象域权限码**,
|
||||||
* 挂在项目对象上下文里(项目负责人 / 项目协作者等角色),从 objectContextStore.buttonCodes 取,
|
* 挂在项目对象上下文里(项目负责人 / 项目协作者等角色),从 objectContextStore.buttonCodes 取,
|
||||||
@@ -22,46 +29,50 @@ export function useTaskPermissions() {
|
|||||||
|
|
||||||
const currentUserId = computed(() => authStore.userInfo.userId || '');
|
const currentUserId = computed(() => authStore.userInfo.userId || '');
|
||||||
const buttonCodeSet = computed(() => new Set(objectContextStore.buttonCodes));
|
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 {
|
function hasPermission(code: string): boolean {
|
||||||
return buttonCodeSet.value.has(code);
|
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 {
|
function canEditExecution(execution: Api.Project.ProjectExecution): boolean {
|
||||||
return (
|
return canEditExecutionByIdentity(execution, {
|
||||||
isMutable(execution) && (hasPermission('project:execution:update') || currentUserId.value === execution.ownerId)
|
currentUserId: currentUserId.value,
|
||||||
);
|
projectManagerUserId: currentProjectManagerUserId.value
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function canDeleteExecution(execution: Api.Project.ProjectExecution): boolean {
|
function canDeleteExecution(execution: Api.Project.ProjectExecution): boolean {
|
||||||
// completed 终态后端硬卡(不允许主动删,级联删除时由后端兜底),前端按钮也拦掉
|
// completed 终态后端硬卡(不允许主动删,级联删除时由后端兜底),前端按钮也拦掉
|
||||||
if (execution.statusCode === 'completed') return false;
|
return canDeleteExecutionByIdentity(execution, {
|
||||||
return hasPermission('project:execution:delete');
|
currentUserId: currentUserId.value,
|
||||||
|
projectManagerUserId: currentProjectManagerUserId.value
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 协办人弹窗内的写操作(设为负责人 / 失效 / 新增协办人)与编辑同一身份口径:执行负责人或项目负责人 */
|
||||||
function canChangeExecutionOwner(execution: Api.Project.ProjectExecution): boolean {
|
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 {
|
function canManageExecutionAssignee(execution: Api.Project.ProjectExecution): boolean {
|
||||||
return (
|
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 {
|
function canEditTask(task: Api.Project.ProjectTask): boolean {
|
||||||
if (!isMutable(task)) return false;
|
return canEditTaskByIdentity(task, {
|
||||||
if (hasPermission('project:task:update')) return true;
|
currentUserId: currentUserId.value,
|
||||||
return isTopLevelTask(task)
|
projectManagerUserId: currentProjectManagerUserId.value
|
||||||
? currentUserId.value === task.executionOwnerId
|
});
|
||||||
: currentUserId.value === task.parentTaskOwnerId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function canDeleteTask(task: Api.Project.ProjectTask): boolean {
|
function canDeleteTask(task: Api.Project.ProjectTask): boolean {
|
||||||
// completed 终态后端硬卡(不允许主动删,级联删除时由后端兜底),前端按钮也拦掉
|
return canDeleteTaskByIdentity(task, {
|
||||||
if (task.statusCode === 'completed') return false;
|
currentUserId: currentUserId.value,
|
||||||
if (hasPermission('project:task:delete')) return true;
|
projectManagerUserId: currentProjectManagerUserId.value
|
||||||
return isTopLevelTask(task)
|
});
|
||||||
? currentUserId.value === task.executionOwnerId
|
|
||||||
: currentUserId.value === task.parentTaskOwnerId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function canCreateTopLevelTask(execution: Api.Project.ProjectExecution): boolean {
|
function canCreateTopLevelTask(execution: Api.Project.ProjectExecution): boolean {
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ watch(visible, value => {
|
|||||||
<BusinessFormDialog
|
<BusinessFormDialog
|
||||||
v-model="visible"
|
v-model="visible"
|
||||||
:title="dialogTitle"
|
:title="dialogTitle"
|
||||||
preset="lg"
|
preset="xl"
|
||||||
:show-footer="false"
|
:show-footer="false"
|
||||||
:scrollbar="false"
|
:scrollbar="false"
|
||||||
@closed="handleClosed"
|
@closed="handleClosed"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
fetchCreateProjectTaskAssignee,
|
fetchCreateProjectTaskAssignee,
|
||||||
fetchDeleteProjectTask,
|
fetchDeleteProjectTask,
|
||||||
fetchGetProjectExecutionAssignees,
|
fetchGetProjectExecutionAssignees,
|
||||||
|
fetchGetProjectMembers,
|
||||||
fetchGetProjectTask,
|
fetchGetProjectTask,
|
||||||
fetchGetProjectTaskAssignees,
|
fetchGetProjectTaskAssignees,
|
||||||
fetchGetProjectTaskBoardPageCross,
|
fetchGetProjectTaskBoardPageCross,
|
||||||
@@ -292,7 +293,7 @@ const { data, loading, getData, getDataByPage, mobilePagination } = useUIPaginat
|
|||||||
} as unknown as TaskPageResponse);
|
} as unknown as TaskPageResponse);
|
||||||
}
|
}
|
||||||
// 统一走跨执行接口:身份维度(my/all)与范围维度(全部/状态/具体执行)自由组合
|
// 统一走跨执行接口:身份维度(my/all)与范围维度(全部/状态/具体执行)自由组合
|
||||||
// 单执行场景下 executionOwnerId 等字段会是 null,按钮权限通过全局权限码 RBAC 控制
|
// 单执行场景下任务详情会带 executionOwnerId;跨执行接口缺失时前端只能识别任务/项目负责人身份
|
||||||
return fetchGetProjectTaskPageCross(props.projectId, buildCrossSearchParams());
|
return fetchGetProjectTaskPageCross(props.projectId, buildCrossSearchParams());
|
||||||
},
|
},
|
||||||
transform: response => transformTaskPage(response, pageNo.value, pageSize.value),
|
transform: response => transformTaskPage(response, pageNo.value, pageSize.value),
|
||||||
@@ -538,7 +539,6 @@ async function handleOperateSubmit(payload: Api.Project.SaveProjectTaskParams) {
|
|||||||
|
|
||||||
const cascade = useTaskCompletionCascade({
|
const cascade = useTaskCompletionCascade({
|
||||||
projectId: computed(() => props.projectId),
|
projectId: computed(() => props.projectId),
|
||||||
executionId,
|
|
||||||
openStatusActionDialog: async (task, action, fromCascade) => {
|
openStatusActionDialog: async (task, action, fromCascade) => {
|
||||||
currentTask.value = task;
|
currentTask.value = task;
|
||||||
currentStatusAction.value = action;
|
currentStatusAction.value = action;
|
||||||
@@ -667,11 +667,31 @@ async function refreshAssigneesAfterMutation() {
|
|||||||
|
|
||||||
async function loadExecutionAssigneeOptions(specificExecutionId?: string) {
|
async function loadExecutionAssigneeOptions(specificExecutionId?: string) {
|
||||||
const targetId = specificExecutionId || executionId.value;
|
const targetId = specificExecutionId || executionId.value;
|
||||||
if (!props.projectId || !targetId) {
|
|
||||||
|
if (!props.projectId) {
|
||||||
executionAssigneeOptions.value = [];
|
executionAssigneeOptions.value = [];
|
||||||
return;
|
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);
|
const { error, data: assignees } = await fetchGetProjectExecutionAssignees(props.projectId, targetId);
|
||||||
|
|
||||||
if (error || !assignees) {
|
if (error || !assignees) {
|
||||||
@@ -802,13 +822,10 @@ watch(
|
|||||||
// execution 锚定变化 → 拉/清"执行协办人选项"(操作弹层用),任务列表的重拉由 scopedExecutionIds watch 统一处理
|
// execution 锚定变化 → 拉/清"执行协办人选项"(操作弹层用),任务列表的重拉由 scopedExecutionIds watch 统一处理
|
||||||
watch(
|
watch(
|
||||||
() => props.execution?.id,
|
() => props.execution?.id,
|
||||||
async value => {
|
async () => {
|
||||||
if (!value) {
|
|
||||||
executionAssigneeOptions.value = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await loadExecutionAssigneeOptions();
|
await loadExecutionAssigneeOptions();
|
||||||
}
|
},
|
||||||
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
// 范围维度变化(scopedExecutionIds / scopedExecutionInvolveUserId / scopedExecutionStatusCodes 引用变:
|
// 范围维度变化(scopedExecutionIds / scopedExecutionInvolveUserId / scopedExecutionStatusCodes 引用变:
|
||||||
@@ -832,9 +849,10 @@ watch(
|
|||||||
if (!canLoadAnyTasks.value) {
|
if (!canLoadAnyTasks.value) {
|
||||||
data.value = [];
|
data.value = [];
|
||||||
taskStatusBoard.value = null;
|
taskStatusBoard.value = null;
|
||||||
|
executionAssigneeOptions.value = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await Promise.all([getDataByPage(1), loadTaskStatusBoard()]);
|
await Promise.all([getDataByPage(1), loadTaskStatusBoard(), loadExecutionAssigneeOptions()]);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -352,11 +352,23 @@ const columns = computed(() => [
|
|||||||
prop: 'title',
|
prop: 'title',
|
||||||
label: '需求名称',
|
label: '需求名称',
|
||||||
minWidth: 220,
|
minWidth: 220,
|
||||||
|
className: 'requirement-title-column',
|
||||||
formatter: (row: Api.Project.ProjectRequirement) => (
|
formatter: (row: Api.Project.ProjectRequirement) => (
|
||||||
<ElTooltip content={row.title} placement="top" show-after={300}>
|
<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}
|
{row.title}
|
||||||
</ElButton>
|
</span>
|
||||||
</ElTooltip>
|
</ElTooltip>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -1020,14 +1032,29 @@ Promise.all([loadStatusOptions()]);
|
|||||||
}
|
}
|
||||||
|
|
||||||
:deep(.requirement-title) {
|
:deep(.requirement-title) {
|
||||||
|
display: block;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
max-width: 200px;
|
color: var(--el-color-primary);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
line-height: 1.5;
|
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) {
|
:deep(.requirement-table-card-body) {
|
||||||
|
|||||||
@@ -253,12 +253,18 @@ export interface WorkbenchWorklogDistributionItem {
|
|||||||
/** 周标准工时:系统无配置项,后端不返回,产品确认前端落常量 35(不是 40) */
|
/** 周标准工时:系统无配置项,后端不返回,产品确认前端落常量 35(不是 40) */
|
||||||
export const WORKBENCH_WEEK_TARGET_HOURS = 35;
|
export const WORKBENCH_WEEK_TARGET_HOURS = 35;
|
||||||
|
|
||||||
/** 单周工时视图(builder 衍生;逐日工时为后端按填报日期段均摊到工作日的推算值) */
|
/** 单周工时视图(builder 衍生;接口返回周一~周日逐日工时,按周填报时仅均摊到工作日) */
|
||||||
export interface WorkbenchWeekWorklogView {
|
export interface WorkbenchWeekWorklogView {
|
||||||
weekStart: string;
|
weekStart: string;
|
||||||
weekLabel: string;
|
weekLabel: string;
|
||||||
/** 周一~周五逐日工时(5 长度,均摊推算值,周末份额归周五) */
|
/** 周一~周日逐日工时(7 长度;周末单天工时保留在周末当天) */
|
||||||
dailyHours: number[];
|
dailyHours: number[];
|
||||||
|
/** 是否展示周末两列;仅当周六或周日任一天 > 0 时展示 */
|
||||||
|
showWeekend: boolean;
|
||||||
|
/** 图表/逐日展示实际使用的横轴标签 */
|
||||||
|
visibleDayLabels: string[];
|
||||||
|
/** 图表/逐日展示实际使用的逐日工时数据 */
|
||||||
|
visibleDailyHours: number[];
|
||||||
/** 本周累计 */
|
/** 本周累计 */
|
||||||
totalHours: number;
|
totalHours: number;
|
||||||
target: number;
|
target: number;
|
||||||
@@ -269,7 +275,10 @@ export interface WorkbenchWeekWorklogView {
|
|||||||
distribution: WorkbenchWorklogDistributionItem[];
|
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) {
|
function roundHours(value: number) {
|
||||||
return Math.round(value * 10) / 10;
|
return Math.round(value * 10) / 10;
|
||||||
@@ -291,7 +300,12 @@ export function buildWorkbenchWeekWorklogView(source: Api.Project.MyWorklogWeekR
|
|||||||
const start = dayjs(source.weekStart);
|
const start = dayjs(source.weekStart);
|
||||||
const weekLabel = start.isValid() ? `${start.isoWeekYear()}年第${start.isoWeek()}周` : 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 totalHours = roundHours(dailyHours.reduce((s, h) => s + h, 0));
|
||||||
const target = WORKBENCH_WEEK_TARGET_HOURS;
|
const target = WORKBENCH_WEEK_TARGET_HOURS;
|
||||||
const delta = roundHours(totalHours - target);
|
const delta = roundHours(totalHours - target);
|
||||||
@@ -301,6 +315,9 @@ export function buildWorkbenchWeekWorklogView(source: Api.Project.MyWorklogWeekR
|
|||||||
weekStart: source.weekStart,
|
weekStart: source.weekStart,
|
||||||
weekLabel,
|
weekLabel,
|
||||||
dailyHours,
|
dailyHours,
|
||||||
|
showWeekend,
|
||||||
|
visibleDayLabels,
|
||||||
|
visibleDailyHours,
|
||||||
totalHours,
|
totalHours,
|
||||||
target,
|
target,
|
||||||
delta,
|
delta,
|
||||||
@@ -320,8 +337,6 @@ export function getGreeting(hour: number = dayjs().hour()) {
|
|||||||
|
|
||||||
// === 团队工时分布(D16 团队 tab,原 C12 teamWorklog) ===
|
// === 团队工时分布(D16 团队 tab,原 C12 teamWorklog) ===
|
||||||
|
|
||||||
export type WorkbenchTeamWorklogItemKind = 'project' | 'personal' | 'other';
|
|
||||||
|
|
||||||
/** 团队工时分布视图(builder 衍生) */
|
/** 团队工时分布视图(builder 衍生) */
|
||||||
export interface WorkbenchTeamWorklogView {
|
export interface WorkbenchTeamWorklogView {
|
||||||
weekStart: string;
|
weekStart: string;
|
||||||
@@ -330,11 +345,8 @@ export interface WorkbenchTeamWorklogView {
|
|||||||
memberId: string;
|
memberId: string;
|
||||||
memberName: string;
|
memberName: string;
|
||||||
totalHours: number;
|
totalHours: number;
|
||||||
|
overtimeHours: number;
|
||||||
}>;
|
}>;
|
||||||
/** 项目/个人/其他 列(保序,去重) */
|
|
||||||
categories: Array<{ key: string; label: string; kind: WorkbenchTeamWorklogItemKind }>;
|
|
||||||
/** 每个 category 一个 series,data[i] = 第 i 个成员该 category 的工时(缺则 0) */
|
|
||||||
seriesMatrix: Array<{ key: string; label: string; kind: WorkbenchTeamWorklogItemKind; data: number[] }>;
|
|
||||||
/** 团队总工时 */
|
/** 团队总工时 */
|
||||||
totalHours: number;
|
totalHours: number;
|
||||||
/** 团队人均工时 */
|
/** 团队人均工时 */
|
||||||
@@ -345,10 +357,6 @@ export interface WorkbenchTeamWorklogView {
|
|||||||
fillRate: number;
|
fillRate: number;
|
||||||
/** 偏低人数:< 团队均值 × 0.8 */
|
/** 偏低人数:< 团队均值 × 0.8 */
|
||||||
lowCount: number;
|
lowCount: number;
|
||||||
/** 加班人数:> 周标准 × 1.125 */
|
|
||||||
highCount: number;
|
|
||||||
/** 加班判定阈值(小时),KPI 文案展示用 */
|
|
||||||
overtimeThreshold: number;
|
|
||||||
/** 工时最低成员(用于底部小结) */
|
/** 工时最低成员(用于底部小结) */
|
||||||
lowest: { memberName: string; hours: number } | null;
|
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;
|
const weekLabel = start.isValid() ? `${start.isoWeekYear()}年第${start.isoWeek()}周` : source.weekStart;
|
||||||
|
|
||||||
// 契约:members[0] 恒为当前用户,展示加「(我)」后缀
|
// 契约:members[0] 恒为当前用户,展示加「(我)」后缀
|
||||||
const members = source.members.map((member, index) => ({
|
const members = source.members.map((member, index) => {
|
||||||
memberId: member.userId,
|
const items = member.items.map(toWorklogDistributionItem);
|
||||||
memberName: index === 0 ? `${member.userNickname}(我)` : member.userNickname,
|
const totalHours = roundHours(items.reduce((sum, item) => sum + item.hours, 0));
|
||||||
items: member.items.map(toWorklogDistributionItem)
|
const overtimeHours = roundHours(member.overtimeHours ?? 0);
|
||||||
}));
|
|
||||||
|
|
||||||
// 列保序去重:按成员遍历顺序首次出现即入列;项目优先、个人/其他按出现顺序追加
|
return {
|
||||||
const categoryMap = new Map<string, { key: string; label: string; kind: WorkbenchTeamWorklogItemKind }>();
|
memberId: member.userId,
|
||||||
for (const m of members) {
|
memberName: index === 0 ? `${member.userNickname}(我)` : member.userNickname,
|
||||||
for (const it of m.items) {
|
totalHours,
|
||||||
if (!categoryMap.has(it.key)) {
|
overtimeHours
|
||||||
categoryMap.set(it.key, { key: it.key, label: it.label, kind: it.kind });
|
};
|
||||||
}
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
const categories = Array.from(categoryMap.values());
|
|
||||||
|
|
||||||
const memberView = members.map(m => ({
|
const totalHours = roundHours(members.reduce((sum, member) => sum + member.totalHours, 0));
|
||||||
memberId: m.memberId,
|
const memberCount = members.length;
|
||||||
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 averageHours = memberCount > 0 ? roundHours(totalHours / memberCount) : 0;
|
const averageHours = memberCount > 0 ? roundHours(totalHours / memberCount) : 0;
|
||||||
const expectedTotalHours = memberCount * WORKBENCH_WEEK_TARGET_HOURS;
|
const expectedTotalHours = memberCount * WORKBENCH_WEEK_TARGET_HOURS;
|
||||||
const fillRate = expectedTotalHours > 0 ? clampPercent((totalHours / expectedTotalHours) * 100) : 0;
|
const fillRate = expectedTotalHours > 0 ? clampPercent((totalHours / expectedTotalHours) * 100) : 0;
|
||||||
|
|
||||||
const lowThreshold = averageHours * 0.8;
|
const lowThreshold = averageHours * 0.8;
|
||||||
const highThreshold = WORKBENCH_WEEK_TARGET_HOURS * 1.125;
|
const lowCount = members.filter(member => member.totalHours < lowThreshold).length;
|
||||||
const lowCount = memberView.filter(m => m.totalHours < lowThreshold).length;
|
|
||||||
const highCount = memberView.filter(m => m.totalHours > highThreshold).length;
|
|
||||||
|
|
||||||
let lowest: { memberName: string; hours: number } | null = null;
|
let lowest: { memberName: string; hours: number } | null = null;
|
||||||
let highest: { memberName: string; hours: number } | null = null;
|
let highest: { memberName: string; hours: number } | null = null;
|
||||||
for (const m of memberView) {
|
for (const member of members) {
|
||||||
if (!lowest || m.totalHours < lowest.hours) lowest = { memberName: m.memberName, hours: m.totalHours };
|
if (!lowest || member.totalHours < lowest.hours)
|
||||||
if (!highest || m.totalHours > highest.hours) highest = { memberName: m.memberName, hours: m.totalHours };
|
lowest = { memberName: member.memberName, hours: member.totalHours };
|
||||||
|
if (!highest || member.totalHours > highest.hours)
|
||||||
|
highest = { memberName: member.memberName, hours: member.totalHours };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
weekStart: source.weekStart,
|
weekStart: source.weekStart,
|
||||||
weekLabel,
|
weekLabel,
|
||||||
members: memberView,
|
members,
|
||||||
categories,
|
|
||||||
seriesMatrix,
|
|
||||||
totalHours,
|
totalHours,
|
||||||
averageHours,
|
averageHours,
|
||||||
expectedTotalHours,
|
expectedTotalHours,
|
||||||
fillRate,
|
fillRate,
|
||||||
lowCount,
|
lowCount,
|
||||||
highCount,
|
|
||||||
overtimeThreshold: roundHours(highThreshold),
|
|
||||||
lowest,
|
lowest,
|
||||||
highest
|
highest
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ function buildMyBarOption(): ECOption {
|
|||||||
grid: { left: 28, right: 8, top: 16, bottom: 24, containLabel: false },
|
grid: { left: 28, right: 8, top: 16, bottom: 24, containLabel: false },
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
data: ['一', '二', '三', '四', '五'],
|
data: v?.visibleDayLabels ?? ['一', '二', '三', '四', '五'],
|
||||||
axisTick: { show: false },
|
axisTick: { show: false },
|
||||||
axisLine: { lineStyle: { color: '#e5e7eb' } },
|
axisLine: { lineStyle: { color: '#e5e7eb' } },
|
||||||
axisLabel: { color: '#6b7280', fontSize: 11 }
|
axisLabel: { color: '#6b7280', fontSize: 11 }
|
||||||
@@ -221,7 +221,7 @@ function buildMyBarOption(): ECOption {
|
|||||||
name: '每日工时',
|
name: '每日工时',
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
barWidth: 18,
|
barWidth: 18,
|
||||||
data: v?.dailyHours ?? [],
|
data: v?.visibleDailyHours ?? [],
|
||||||
itemStyle: { color: DAY_BAR_COLOR, borderRadius: [2, 2, 0, 0] }
|
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 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 {
|
function buildTeamBarOption(): ECOption {
|
||||||
const v = teamView.value;
|
const v = teamView.value;
|
||||||
if (!v) return {};
|
if (!v) return {};
|
||||||
const colored = teamSeriesWithColor.value;
|
|
||||||
return {
|
return {
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
@@ -259,15 +254,15 @@ function buildTeamBarOption(): ECOption {
|
|||||||
const params = Array.isArray(rawParams) ? rawParams : [rawParams];
|
const params = Array.isArray(rawParams) ? rawParams : [rawParams];
|
||||||
if (!params.length) return '';
|
if (!params.length) return '';
|
||||||
const name = params[0].axisValue as string;
|
const name = params[0].axisValue as string;
|
||||||
const total = params.reduce((s: number, p: any) => s + (Number(p.value) || 0), 0);
|
const totalHours = Number(params.find((p: any) => p.seriesName === '总工时')?.value ?? 0);
|
||||||
const lines = params
|
const overtimeHours = Number(params.find((p: any) => p.seriesName === '加班工时')?.value ?? 0);
|
||||||
.filter((p: any) => Number(p.value) > 0)
|
return [
|
||||||
.map((p: any) => `${p.marker}${p.seriesName} <b style="margin-left:6px">${p.value}h</b>`)
|
`<div style="font-weight:600;margin-bottom:4px">${name}</div>`,
|
||||||
.join('<br/>');
|
`总工时:${totalHours}h`,
|
||||||
return `<div style="font-weight:600;margin-bottom:4px">${name} · ${total}h</div>${lines}`;
|
`加班工时:${overtimeHours}h`
|
||||||
|
].join('<br/>');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// 不显示项目图例:团队参与项目繁杂,图例会很长很乱;项目明细由 tooltip 悬浮呈现
|
|
||||||
grid: { left: 32, right: 12, top: 16, bottom: 24, containLabel: false },
|
grid: { left: 32, right: 12, top: 16, bottom: 24, containLabel: false },
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
@@ -281,17 +276,28 @@ function buildTeamBarOption(): ECOption {
|
|||||||
splitLine: { lineStyle: { color: '#f3f4f6' } },
|
splitLine: { lineStyle: { color: '#f3f4f6' } },
|
||||||
axisLabel: { color: '#9ca3af', fontSize: 10, formatter: '{value}h' }
|
axisLabel: { color: '#9ca3af', fontSize: 10, formatter: '{value}h' }
|
||||||
},
|
},
|
||||||
series: colored.map((s, i) => ({
|
series: [
|
||||||
name: s.label,
|
{
|
||||||
type: 'bar',
|
name: '总工时',
|
||||||
stack: 'member',
|
type: 'bar',
|
||||||
barWidth: 22,
|
barWidth: 16,
|
||||||
data: s.data,
|
data: v.members.map(member => member.totalHours),
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
color: s.color,
|
color: '#409EFF',
|
||||||
borderRadius: i === colored.length - 1 ? [3, 3, 0, 0] : 0
|
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">
|
<div class="ww-section-title">
|
||||||
<SvgIcon icon="mdi:calendar-week" class="ww-section-icon" />
|
<SvgIcon icon="mdi:calendar-week" class="ww-section-icon" />
|
||||||
<span>每日工时</span>
|
<span>每日工时</span>
|
||||||
<ElTooltip content="系统按填报日期段均摊到工作日的推算值(周末份额计入周五),非逐日实填" placement="top">
|
<ElTooltip
|
||||||
|
content="接口返回周一~周日逐日工时;按周填报时仅均摊到工作日,周末单天工时保留在周末当天展示"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
<span class="ww-section-info-wrap">
|
<span class="ww-section-info-wrap">
|
||||||
<SvgIcon icon="mdi:information-outline" class="ww-section-info" />
|
<SvgIcon icon="mdi:information-outline" class="ww-section-info" />
|
||||||
</span>
|
</span>
|
||||||
@@ -417,14 +426,6 @@ watch(activeTab, async tab => {
|
|||||||
</span>
|
</span>
|
||||||
<span class="tw-kpi__sub">低于均值 80%</span>
|
<span class="tw-kpi__sub">低于均值 80%</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div ref="teamBarRef" class="tw-bar" />
|
<div ref="teamBarRef" class="tw-bar" />
|
||||||
@@ -594,7 +595,7 @@ watch(activeTab, async tab => {
|
|||||||
/* ============ 团队工时 ============ */
|
/* ============ 团队工时 ============ */
|
||||||
.tw-kpis {
|
.tw-kpis {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -621,9 +622,6 @@ watch(activeTab, async tab => {
|
|||||||
.tw-kpi__value.is-danger {
|
.tw-kpi__value.is-danger {
|
||||||
color: var(--el-color-danger);
|
color: var(--el-color-danger);
|
||||||
}
|
}
|
||||||
.tw-kpi__value.is-warn {
|
|
||||||
color: var(--el-color-warning);
|
|
||||||
}
|
|
||||||
.tw-kpi__unit {
|
.tw-kpi__unit {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { type Component, computed, markRaw, onActivated, ref, watch } from 'vue';
|
import { type Component, computed, markRaw, onActivated, ref, watch } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
import type { RouteKey } from '@elegant-router/types';
|
import type { RouteKey } from '@elegant-router/types';
|
||||||
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
|
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
|
||||||
import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict';
|
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)
|
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) {
|
function getTaskActionButtonType(code: Api.Project.ProjectTaskActionCode) {
|
||||||
if (code === 'complete') return 'success' as const;
|
if (code === 'complete') return 'success' as const;
|
||||||
if (code === 'cancel') return 'danger' as const;
|
if (code === 'cancel') return 'danger' as const;
|
||||||
@@ -820,7 +831,7 @@ async function loadMyTaskItems() {
|
|||||||
category: 'task' as const,
|
category: 'task' as const,
|
||||||
title: task.taskTitle,
|
title: task.taskTitle,
|
||||||
createdTime: task.createTime,
|
createdTime: task.createTime,
|
||||||
deadline: task.plannedEndDate,
|
deadline: normalizeTodoDeadline(task.plannedEndDate),
|
||||||
source: task.projectName,
|
source: task.projectName,
|
||||||
progressRate: task.progressRate,
|
progressRate: task.progressRate,
|
||||||
priority: mapTaskTodoPriority(task.priority),
|
priority: mapTaskTodoPriority(task.priority),
|
||||||
@@ -850,7 +861,7 @@ async function loadPersonalTodoItems() {
|
|||||||
category: 'personal',
|
category: 'personal',
|
||||||
title: item.taskTitle,
|
title: item.taskTitle,
|
||||||
createdTime: item.createTime,
|
createdTime: item.createTime,
|
||||||
deadline: item.plannedEndDate,
|
deadline: normalizeTodoDeadline(item.plannedEndDate),
|
||||||
source: '我的事项',
|
source: '我的事项',
|
||||||
progressRate: item.progressRate,
|
progressRate: item.progressRate,
|
||||||
routeKey: 'personal-center_my-item'
|
routeKey: 'personal-center_my-item'
|
||||||
|
|||||||
179
tests/task-permission-rules.test.ts
Normal file
179
tests/task-permission-rules.test.ts
Normal 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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user