fix(产品管理、项目管理、登录密码校验、工作报告): 修复用户们提出的一系列问题。

This commit is contained in:
dk
2026-06-23 17:59:42 +08:00
parent 10418fea0a
commit b26a9c8a39
14 changed files with 157 additions and 50 deletions

View File

@@ -31,7 +31,7 @@ const props = withDefaults(defineProps<Props>(), {
const model = defineModel<Api.Project.AttachmentItem[]>({ default: () => [] });
/** 给用户看的简短分类hint 行展示) */
const ALLOWED_EXTENSIONS_HINT = '支持 PDF、Word、Excel、PPT、TXT/MD/CSV、图片、ZIP/RAR/7Z、MP3/MP4';
const ALLOWED_EXTENSIONS_HINT = '支持 PDF、Word、Excel、PPT、TXT/MD/CSV、图片、ZIP/RAR/7Z、MP3/MP4、SQL/JSON/XML';
// 与后端 AttachmentValidator 白/黑名单保持一致5.16
const ALLOWED_EXTENSIONS = new Set([
@@ -55,7 +55,10 @@ const ALLOWED_EXTENSIONS = new Set([
'rar',
'7z',
'mp4',
'mp3'
'mp3',
'sql',
'xml',
'json'
]);
const FORBIDDEN_EXTENSIONS = new Set([

View File

@@ -661,7 +661,7 @@ const local: App.I18n.Schema = {
},
pwd: {
required: '请输入密码',
invalid: '密码格式不正确,6-18位字符,包含字母、数字、下划线'
invalid: '密码格式不正确,4-30位字符,包含字母、数字、下划线'
},
confirmPwd: {
required: '请输入确认密码',

View File

@@ -18,6 +18,10 @@ type ProductResponse = Omit<Api.Product.Product, 'id' | 'managerUserId' | 'curre
currentUserRoles?: Api.Common.CurrentUserRole[] | null;
};
type ProductOptionResponse = Omit<Api.Product.ProductOption, 'id'> & {
id: string | number;
};
type ProductPageResponse = Api.Product.PageResult<ProductResponse>;
type ProductActivityTimelineItemResponse = Omit<
@@ -46,6 +50,13 @@ function normalizeProduct(product: ProductResponse): Api.Product.Product {
};
}
function normalizeProductOption(option: ProductOptionResponse): Api.Product.ProductOption {
return {
...option,
id: normalizeStringId(option.id)
};
}
function normalizeOccurredAt(occurredAt: number | string) {
const value = Number(occurredAt);
@@ -109,6 +120,19 @@ export async function fetchGetProductPage(params?: Api.Product.ProductSearchPara
}));
}
/** 获取可绑定产品下拉选项 */
export async function fetchGetProductOptions() {
const result = await request<ProductOptionResponse[]>({
...safeJsonRequestConfig,
url: `${PRODUCT_PREFIX}/options`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<ProductOptionResponse[]>, data =>
(data ?? []).map(normalizeProductOption)
);
}
type ProductOverviewSummaryResponse = Omit<Api.Product.ProductOverviewSummary, 'total' | 'items'> & {
/** 后端 overview-summary 升级total/items灰度期间可能缺省适配层兜底 */
total?: number | null;

View File

@@ -92,6 +92,17 @@ declare namespace Api {
lastStatusReason?: string | null;
}
interface ProductOption {
/** 产品 ID */
id: string;
/** 产品编码 */
code: string;
/** 产品名称 */
name: string;
/** 产品方向字典值 */
directionCode: string;
}
interface ProductLifecycleAction {
actionCode: ProductStatusActionCode;
actionName: string;
@@ -216,6 +227,7 @@ declare namespace Api {
interface DeleteProductParams {
id: string;
productName: string;
confirmText: string;
reason: string;
}

View File

@@ -558,7 +558,9 @@ function resolveTaskMetrics(metricsText: string, fallback?: Partial<StructuredTa
.filter(Boolean);
const priorityText = metricsText.match(/\bP\d+\b/iu)?.[0] || metricParts.find(item => /^P?\d+$/iu.test(item));
const priority = normalizePriorityCode(priorityText || (useFallback ? fallback?.priority : undefined));
const progressText = metricsText.match(/进度\s*(\d+(?:\.\d+)?)%/u)?.[1];
const progressText =
metricsText.match(/进度\s*(\d+(?:\.\d+)?)%/u)?.[1] ||
metricParts.find(item => /^\d+(?:\.\d+)?%$/u.test(item))?.replace(/%$/u, '');
const hoursText = metricsText.match(/(\d+(?:\.\d+)?)h/u)?.[1];
return {
@@ -591,7 +593,7 @@ function toReviewItem(item: Api.WorkReport.Common.PersonalReportReviewItem): Rev
: escapeHtml(item.contentText || '').replace(/\n/g, '<br>') || EMPTY_TEXT,
contentSections,
reflection: item.reflectionText || '',
removable: false,
removable: true,
source: item
};
}
@@ -728,6 +730,13 @@ function removePlanItem(index: number) {
if (item?.sourceIndex !== undefined) props.model.planItems.splice(item.sourceIndex, 1);
}
function removeReviewItem(index: number) {
const item = reviewItems.value[index];
if (!item?.source) return;
const sourceIndex = props.model.reviewItems.indexOf(item.source);
if (sourceIndex >= 0) props.model.reviewItems.splice(sourceIndex, 1);
}
function focusEditField(key: string) {
activeEditField.value = key;
}
@@ -736,9 +745,19 @@ function blurEditField(key: string) {
if (activeEditField.value === key) activeEditField.value = '';
}
function isStructuredEditorUnchanged(text: string, sections: StructuredSection[] | undefined, showHours = false) {
if (!sections?.length) return false;
return normalizeEditorText(text) === createStructuredTextV2(sections, showHours);
}
function syncRichContent(item: ReviewItem, event: Event) {
const target = event.currentTarget as HTMLElement;
if (!item.source) return;
if (isStructuredEditorUnchanged(target.innerText, item.contentSections, true)) {
item.source.contentJson = createSectionsJson(item.contentSections || []);
item.source.contentText = createStructuredTextV2(item.contentSections || [], true);
return;
}
const sections = parseStructuredSectionsFromEditorV2(
target,
item.contentSections || [],
@@ -751,6 +770,11 @@ function syncRichContent(item: ReviewItem, event: Event) {
function syncRichTarget(item: PlanItem, event: Event) {
const target = event.currentTarget as HTMLElement;
if (!item.source) return;
if (isStructuredEditorUnchanged(target.innerText, item.targetSections)) {
item.source.targetJson = createSectionsJson(item.targetSections || []);
item.source.targetText = createStructuredTextV2(item.targetSections || []);
return;
}
const sections = parseStructuredSectionsFromEditorV2(
target,
item.targetSections || [],
@@ -836,7 +860,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
confirm-button-text="删除"
cancel-button-text="取消"
width="220"
@confirm="reviewItems.splice(index, 1)"
@confirm="removeReviewItem(index)"
>
<template #reference>
<button class="item-remove-btn" aria-label="删除回顾">×</button>

View File

@@ -127,7 +127,7 @@ function toWorkItem(item: Api.WorkReport.Project.ProjectReportItem, index: numbe
}
const currentWorks = computed<WorkItem[]>(() => {
return (props.model.currentItems || []).map((item, index) => toWorkItem(item, index, false));
return (props.model.currentItems || []).map((item, index) => toWorkItem(item, index, true));
});
const nextPlans = computed<WorkItem[]>(() => {
@@ -273,6 +273,11 @@ function removePlanItem(index: number) {
if (item?.sourceIndex !== undefined) props.model.nextItems.splice(item.sourceIndex, 1);
}
function removeCurrentWorkItem(index: number) {
const item = currentWorks.value[index];
if (item?.sourceIndex !== undefined) props.model.currentItems.splice(item.sourceIndex, 1);
}
function startTitleEdit(item: WorkItem) {
titleEditSnapshot.value[item.id] = item.title;
}
@@ -288,9 +293,9 @@ function notifyTitleSaved(item: WorkItem) {
item.title = nextTitle;
if (item.source) item.source.itemTitle = nextTitle;
delete titleEditSnapshot.value[item.id];
if (nextTitle !== previousTitle) {
ElMessage.success('名称已成功修改');
}
// if (nextTitle !== previousTitle) {
// ElMessage.success('名称已成功修改');
// }
}
</script>
@@ -373,7 +378,7 @@ function notifyTitleSaved(item: WorkItem) {
<div class="review-grid layout-row">
<div v-if="!currentWorks.length">{{ EMPTY_TEXT }}</div>
<div v-for="(item, index) in currentWorks" :key="item.id" class="review-card compact-work-card">
<div class="review-card-head">
<div class="review-card-head" :class="{ 'no-remove': item.removable === false }">
<span class="row-index">{{ index + 1 }}</span>
<div class="review-title-readonly">
<div class="work-title-line">
@@ -390,6 +395,18 @@ function notifyTitleSaved(item: WorkItem) {
<span class="meta-chip"> {{ item.hours }}h</span>
</div>
</div>
<ElPopconfirm
v-if="item.removable !== false && !isReadonly"
title="确认删除这条工作内容吗?"
confirm-button-text="删除"
cancel-button-text="取消"
width="220"
@confirm="removeCurrentWorkItem(index)"
>
<template #reference>
<button class="item-remove-btn" aria-label="删除工作内容">×</button>
</template>
</ElPopconfirm>
</div>
</div>
</div>
@@ -727,10 +744,7 @@ function notifyTitleSaved(item: WorkItem) {
align-items: center;
}
.review-card-head {
grid-template-columns: 28px minmax(0, 1fr);
}
.review-card-head.no-remove,
.plan-card-head.no-remove {
grid-template-columns: 28px minmax(0, 1fr);
}

View File

@@ -725,7 +725,7 @@ function toReviewItem(item: Api.WorkReport.Common.PersonalReportReviewItem): Rev
contentSections,
contentTasks,
reflection: item.reflectionText || '',
removable: false,
removable: true,
source: item
};
}

View File

@@ -38,7 +38,7 @@ const DEFAULT_TOP_N = 5;
const PAGE_SIZE = 10;
/** 产品行多列数(名称/编码/经理/我的角色/状态/原因/更新),非产品行整行合并用 */
const COLUMN_COUNT = 7;
/** 产品描述副行最大展示字符数,超出截断并追加 …(完整内容走 title 悬浮) */
/** 产品描述副行长度阈值:超过时展示「详情」入口 */
const PRODUCT_DESC_MAX_LEN = 48;
interface DirectionGroup {
@@ -274,11 +274,9 @@ function formatDate(value?: string | null) {
return dayjs(value).format('YYYY-MM-DD');
}
/** 产品描述副行:控长截断,超出追加 …;完整内容由 title 悬浮展示 */
/** 产品描述副行:不再手工截断字符,交给布局省略;完整内容由 title 悬浮展示 */
function truncateDesc(text?: string | null) {
const trimmed = (text ?? '').trim();
return trimmed.length > PRODUCT_DESC_MAX_LEN ? `${trimmed.slice(0, PRODUCT_DESC_MAX_LEN)}` : trimmed;
return (text ?? '').trim();
}
/** 描述是否被截断(放不下);仅截断时才追加「详情」入口 */
@@ -510,15 +508,17 @@ function isDescTruncated(text?: string | null) {
// 描述副行:控长截断 + 末尾「详情」链接,整行可点(进入对象域)
.pg-prod-desc {
display: inline-flex;
display: flex;
align-items: center;
gap: 4px;
max-width: 100%;
width: 100%;
min-width: 0;
cursor: pointer;
}
.pg-prod-desc__text {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
color: var(--el-text-color-secondary);
font-size: 12px;

View File

@@ -22,17 +22,21 @@ const visible = defineModel<boolean>('visible', {
const model = reactive({
confirmName: '',
confirmText: '',
reason: ''
});
const confirmDisabled = computed(() => {
return !model.reason.trim() || model.confirmName.trim() !== props.productName;
return (
!model.reason.trim() || model.confirmName.trim() !== props.productName || model.confirmText.trim() !== 'DELETE'
);
});
function handleConfirm() {
emit('submit', {
id: props.productId,
productName: model.confirmName.trim(),
confirmText: model.confirmText.trim(),
reason: model.reason.trim()
});
}
@@ -45,6 +49,7 @@ watch(
}
model.confirmName = '';
model.confirmText = '';
model.reason = '';
}
);
@@ -60,7 +65,7 @@ watch(
@confirm="handleConfirm"
>
<ElAlert
:title="`请输入当前产品名称 ${productName || '--'} 完成二次确认,删除后将退出当前对象上下文。`"
:title="`请输入当前产品名称 ${productName || '--'} 和确认口令 DELETE,删除后将退出当前对象上下文。`"
type="error"
:closable="false"
class="mb-16px"
@@ -69,6 +74,9 @@ watch(
<ElFormItem label="删除确认名称">
<ElInput v-model="model.confirmName" placeholder="请输入当前产品名称" />
</ElFormItem>
<ElFormItem label="确认口令">
<ElInput v-model="model.confirmText" placeholder="请输入 DELETE" />
</ElFormItem>
<ElFormItem label="删除原因">
<ElInput
v-model="model.reason"

View File

@@ -3,7 +3,7 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { ElCol, ElDatePicker, ElFormItem, ElInput, ElOption, ElRow, ElSelect } from 'element-plus';
import dayjs from 'dayjs';
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/constants/dict';
import { fetchGetProductPage } from '@/service/api';
import { fetchGetProductOptions } from '@/service/api';
import { useDict } from '@/hooks/business/dict';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessUserPicker from '@/components/custom/business-user-picker.vue';
@@ -26,7 +26,8 @@ export interface ProjectCreateBaseForm {
interface ProductOption {
id: string;
name: string;
directionCode: string;
code?: string;
directionCode?: string;
}
interface Props {
@@ -151,16 +152,17 @@ const rules = computed(
);
async function loadProductOptions() {
const { error, data } = await fetchGetProductPage({ pageNo: 1, pageSize: 200 });
const { error, data } = await fetchGetProductOptions();
if (error || !data) {
productOptions.value = [];
return;
}
productOptions.value = data.list.map(item => ({
productOptions.value = data.map(item => ({
id: item.id,
name: item.name || item.code || item.id,
code: item.code || '',
directionCode: item.directionCode || ''
}));
}
@@ -175,8 +177,9 @@ watch([() => model.value.productId, productOptions], ([productId]) => {
const product = productOptions.value.find(p => p.id === productId);
if (product) {
if (model.value.directionCode !== product.directionCode) {
model.value.directionCode = product.directionCode;
const directionCode = product.directionCode || '';
if (directionCode && model.value.directionCode !== directionCode) {
model.value.directionCode = directionCode;
}
return;
}

View File

@@ -6,7 +6,7 @@ import { PROJECT_MANAGER_ROLE_CODE } from '@/constants/business';
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/constants/dict';
import {
fetchCreateProjectWithTeam,
fetchGetProductPage,
fetchGetProductOptions,
fetchGetRoleSimpleList,
fetchUpdateProject
} from '@/service/api';
@@ -66,7 +66,8 @@ const dialogTitle = computed(() => (isEditMode.value ? '编辑项目' : '新增
interface ProductOption {
id: string;
name: string;
directionCode: string;
code?: string;
directionCode?: string;
}
const productOptions = ref<ProductOption[]>([]);
@@ -209,16 +210,17 @@ function closeDialog() {
}
async function loadProductOptions() {
const { error, data } = await fetchGetProductPage({ pageNo: 1, pageSize: 200 });
const { error, data } = await fetchGetProductOptions();
if (error || !data) {
productOptions.value = [];
return;
}
productOptions.value = data.list.map(item => ({
productOptions.value = data.map(item => ({
id: item.id,
name: item.name || item.code || item.id,
code: item.code || '',
directionCode: item.directionCode || ''
}));
}

View File

@@ -508,6 +508,16 @@ watch(
<span v-else>{{ formatWorklogPeriod(row.startDate, row.endDate).display }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="时长" width="100" align="center">
<template #default="{ row }">
<span class="task-worklog-panel__duration">{{ formatHours(row.durationHours) }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="进度" width="100" align="center">
<template #default="{ row }">
<span class="task-worklog-panel__progress">{{ formatProgress(row.progressRate) }}</span>
</template>
</ElTableColumn>
<ElTableColumn v-if="showAssigneeColumn" label="填报人" width="120" align="center">
<template #header>
<div class="task-worklog-panel__user-header">
@@ -675,16 +685,6 @@ watch(
<DictTag :dict-code="RDMS_WORKLOG_DIFFICULTY_DICT_CODE" :value="row.difficulty" size="small" effect="light" />
</template>
</ElTableColumn>
<ElTableColumn label="时长" width="100" align="center">
<template #default="{ row }">
<span class="task-worklog-panel__duration">{{ formatHours(row.durationHours) }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="进度" width="100" align="center">
<template #default="{ row }">
<span class="task-worklog-panel__progress">{{ formatProgress(row.progressRate) }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="120" align="center" fixed="right">
<template #default="{ row }">
<div class="task-worklog-panel__actions" @click.stop>

View File

@@ -11,6 +11,10 @@ export type WorkbenchTodoPriority = 'high' | 'mid' | 'low';
export interface WorkbenchTodoItemSource {
id: string;
category: WorkbenchTodoCategory;
/** 左侧分类 tag 文案;不传时按 category 默认映射 */
categoryLabel?: string;
/** 左侧分类 tag 色调;不传时按 category 默认映射 */
categoryTone?: WorkbenchTodoItem['categoryTone'];
title: string;
/** 创建时间ISO 字符串。列表默认按这个升序 */
createdTime: string;
@@ -126,8 +130,8 @@ export function buildWorkbenchTodoItems(source: readonly WorkbenchTodoItemSource
...item,
deadlineLabel: formatDeadline(item.deadline),
remainingDays: getRemainingDays(item.deadline),
categoryLabel: meta.label,
categoryTone: meta.tone
categoryLabel: item.categoryLabel || meta.label,
categoryTone: item.categoryTone || meta.tone
} satisfies WorkbenchTodoItem;
});
}

View File

@@ -187,6 +187,14 @@ const OVERTIME_APPROVAL_ACTION_ICONS = {
detail: markRaw(IconMdiEyeOutline)
};
function getApprovalCategoryLabel(bizType: ApprovalBizType) {
if (bizType === 'weekly') return '周报';
if (bizType === 'monthly') return '月报';
if (bizType === 'project') return '项目半月报';
if (bizType === 'overtime_application') return '加班申请';
return '待审批';
}
const PERSONAL_ACTION_ICONS = {
detail: markRaw(IconMdiEyeOutline),
worklog: markRaw(IconMdiClipboardEditOutline),
@@ -789,6 +797,7 @@ async function loadOvertimeApprovalItems() {
data.list.map(item => ({
id: `overtime-application-${item.id}`,
category: 'approval',
categoryLabel: '加班申请',
title: `${item.applicantName} · ${item.overtimeDate.slice(5, 7)} 月加班 ${item.overtimeDuration} 申请待审批`,
createdTime: item.submitTime || item.createTime,
deadline: item.submitTime || item.createTime,
@@ -810,6 +819,7 @@ function buildWorkReportApprovalItems<T extends WorkReportRow>(
rows.map(item => ({
id: `${bizType}-${item.id}`,
category: 'approval',
categoryLabel: getApprovalCategoryLabel(bizType),
title: `${reportTypeLabel} · ${bizType === 'weekly' ? formatWeeklyPeriodLabel(item) : formatPeriod(item)} 待审批`,
createdTime: item.submitTime || item.createTime || '',
deadline: item.submitTime || item.createTime || null,
@@ -1439,7 +1449,7 @@ onActivated(refresh);
.workbench-todo__item {
display: grid;
grid-template-columns: 72px minmax(0, 1fr) auto;
grid-template-columns: max-content minmax(0, 1fr) auto;
align-items: center;
gap: 14px;
padding: 14px 16px;
@@ -1460,17 +1470,20 @@ onActivated(refresh);
display: flex;
align-items: center;
gap: 8px;
width: 72px;
min-width: 0;
min-width: max-content;
}
.workbench-todo__category {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
line-height: 1;
white-space: nowrap;
flex: 0 0 auto;
}
.workbench-todo__category--sky {