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