Commit ca0cbf30 authored by David Yang's avatar David Yang

feat: 完善项目管理工作台与资源统计

parent 70e6803d
......@@ -12,11 +12,14 @@
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.20",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.6",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwindcss": "^4.2.0",
"umi-request": "^1.4.0",
"umi-request-progress": "^1.1.0"
"umi-request-progress": "^1.1.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/react": "^18.0.33",
......@@ -7306,6 +7309,15 @@
"node": ">=8.9.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/ajv": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
......@@ -8512,6 +8524,19 @@
],
"license": "CC-BY-4.0"
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
......@@ -8677,6 +8702,15 @@
"node": ">=6"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
......@@ -8929,6 +8963,18 @@
"node": ">=10"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/create-ecdh": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz",
......@@ -10055,6 +10101,36 @@
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT"
},
"node_modules/echarts": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.1.0"
}
},
"node_modules/echarts-for-react": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.6.tgz",
"integrity": "sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"size-sensor": "^1.0.1"
},
"peerDependencies": {
"echarts": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0",
"react": "^15.0.0 || >=16.0.0"
}
},
"node_modules/echarts/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
......@@ -11384,6 +11460,15 @@
"node": ">= 0.6"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fraction.js": {
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
......@@ -18260,6 +18345,12 @@
"integrity": "sha512-9SQdmsyz4HSP+3gs6PJzhkaMEg+6zTlu9oxIghnwUX3eq+ajq4ft5egl0iyR55LAmO/UwvU8NgIWs/ZyQMa6dw==",
"license": "MIT"
},
"node_modules/size-sensor": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.3.tgz",
"integrity": "sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==",
"license": "ISC"
},
"node_modules/slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
......@@ -18493,6 +18584,18 @@
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stable": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz",
......@@ -20739,6 +20842,24 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
......@@ -20823,6 +20944,27 @@
}
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
......@@ -20924,6 +21066,21 @@
"peerDependencies": {
"zod": "^3.18.0"
}
},
"node_modules/zrender": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
"integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zrender/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
}
}
}
......@@ -14,11 +14,14 @@
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.20",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.6",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwindcss": "^4.2.0",
"umi-request": "^1.4.0",
"umi-request-progress": "^1.1.0"
"umi-request-progress": "^1.1.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/react": "^18.0.33",
......
:root {
--pm-brand: #009cd9;
--pm-brand-strong: #007bb8;
--pm-brand: #b42318;
--pm-brand-strong: #991b1b;
--pm-surface: #ffffff;
--pm-bg: #eef5f9;
--pm-bg: #f8fafc;
--pm-sidebar: #0f172a;
--pm-sidebar-soft: #1e293b;
--pm-text: #0f172a;
......@@ -17,7 +17,10 @@ body,
body {
margin: 0;
background: linear-gradient(180deg, #f8fbfd 0%, #eef5f9 100%);
background:
radial-gradient(circle at top left, rgba(251, 191, 188, 0.22), transparent 26%),
radial-gradient(circle at bottom right, rgba(251, 207, 232, 0.2), transparent 30%),
#f8fafc;
color: var(--pm-text);
font-family:
"PingFang SC",
......
export default function EmptyStatePage({ title, description }) {
return (
<section className="relative overflow-hidden rounded-[32px] border border-white/70 bg-white/90 p-8 shadow-[0_30px_80px_rgba(15,23,42,0.08)]">
<div className="absolute -right-20 -top-20 h-56 w-56 rounded-full bg-sky-100 blur-3xl" />
<div className="absolute bottom-0 left-0 h-40 w-40 rounded-full bg-cyan-50 blur-3xl" />
<div className="absolute -right-20 -top-20 h-56 w-56 rounded-full bg-rose-100 blur-3xl" />
<div className="absolute bottom-0 left-0 h-40 w-40 rounded-full bg-amber-50 blur-3xl" />
<div className="relative">
<div className="inline-flex rounded-full border border-sky-200 bg-sky-50 px-3 py-1 text-xs font-semibold tracking-[0.2em] text-sky-700">
<div className="inline-flex rounded-full border border-rose-200 bg-rose-50 px-3 py-1 text-xs font-semibold tracking-[0.2em] text-rose-700">
COMING SOON
</div>
<h2 className="mt-6 text-3xl font-semibold text-slate-900">{title}</h2>
......
import rsmLogo from '@/assets/rsm_logo.svg';
import rsmLogoSmall from '@/assets/rsm_logo_small.svg';
import xyLogo from '@/assets/xy-logo.png';
import xyLogoSmall from '@/assets/xy-logo_small.png';
import { UserContext } from '@/wrapper/Auth';
import {
CalendarDaysIcon,
......@@ -220,7 +220,7 @@ export default function ProjectManagerLayout() {
}, [location.pathname]);
return (
<div className="flex h-screen overflow-hidden bg-zinc-50">
<div className="flex h-screen overflow-hidden bg-[radial-gradient(circle_at_top_left,_rgba(251,191,188,0.22),_transparent_26%),radial-gradient(circle_at_bottom_right,_rgba(251,207,232,0.2),_transparent_30%),#f8fafc]">
<aside
className={clsx(
'relative z-30 hidden shrink-0 flex-col border-r border-zinc-800 bg-zinc-900 p-5 lg:flex',
......@@ -234,13 +234,13 @@ export default function ProjectManagerLayout() {
)}
>
<img
src={sidebarCollapsed ? rsmLogoSmall : rsmLogo}
alt="RSM Logo"
src={sidebarCollapsed ? xyLogoSmall : xyLogo}
alt="XY Logo"
className={clsx(
'shrink-0 object-contain',
sidebarCollapsed
? 'h-9 w-auto max-w-[52px]'
: 'h-5 w-auto max-w-[128px]',
? 'h-8 w-auto max-w-8'
: 'h-8 w-auto max-w-[140px]',
)}
/>
{!sidebarCollapsed ? (
......@@ -302,9 +302,9 @@ export default function ProjectManagerLayout() {
<div className="px-5 pt-6">
<div className="flex items-center gap-2">
<img
src={rsmLogo}
alt="RSM Logo"
className="h-5 w-auto max-w-[128px] shrink-0 object-contain"
src={xyLogo}
alt="XY Logo"
className="h-8 w-auto max-w-[140px] shrink-0 object-contain"
/>
<h1 className="text-base font-semibold tracking-[0.02em] text-white">
{APP_NAME}
......@@ -346,7 +346,7 @@ export default function ProjectManagerLayout() {
<div className="text-sm text-zinc-500">{currentGroupTitle}</div>
</div>
<div className="flex-1 overflow-y-auto rounded-xl border border-zinc-200 bg-white p-4 sm:p-6">
<div className="flex-1 overflow-y-auto rounded-xl border border-zinc-200 bg-white/95 p-4 shadow-[0_20px_50px_rgba(15,23,42,0.06)] sm:p-6">
<Outlet />
</div>
</main>
......
import clsx from 'clsx';
import { useEffect, useMemo, useState } from 'react';
import {
buildDemoTasks,
buildImportTaskPayload,
buildTaskPayload,
OCCURRENCE_FREQUENCY_OPTIONS,
fetchSystemUsersByIds,
buildUserMap,
enrichProjectTask,
fetchProjectTasks,
formatDateInput,
downloadProjectTaskImportTemplate,
loadProjectTaskFieldOptions,
mergeUserRecords,
PRIORITY_OPTIONS,
normalizeWeeklyUpdatesValue,
PRIORITY_LABEL_MAP,
PROJECT_STATUS_LABEL_MAP,
PROJECT_STATUS_OPTIONS,
RESULT_STATUS_OPTIONS,
resolveUserReferenceId,
readProjectTaskImportRows,
resolveImportUserReferenceList,
importRecords,
saveProjectTask,
searchSystemUsers,
sortWeeklyUpdates,
} from './projectTaskService';
function resolveCurrentDate() {
return new Date().toISOString().slice(0, 10);
}
function canStartProject(projectStatus) {
return projectStatus === 'draft';
}
function canCompleteProject(projectStatus) {
return projectStatus === 'in_progress';
}
function getTaskRecordId(task) {
return task?._id || task?.id || task?.name || '';
}
......@@ -32,6 +48,17 @@ function formatUserLabel(user) {
return user.displayName || user.username || user.id || '-';
}
function EmptyWorkspaceNotice({ title, description }) {
return (
<div className="rounded-2xl border border-dashed border-zinc-200 bg-white px-6 py-12 text-center shadow-sm">
<div className="text-lg font-semibold text-zinc-900">{title}</div>
{description ? (
<div className="mt-2 text-sm leading-6 text-zinc-500">{description}</div>
) : null}
</div>
);
}
function UserSearchSelect({
value,
onChange,
......@@ -110,7 +137,7 @@ function UserSearchSelect({
setOpen(true);
}}
placeholder={placeholder}
className="h-11 w-full rounded-xl border border-zinc-200 bg-white px-3 text-sm text-zinc-700 outline-none transition focus:border-sky-400"
className="h-11 w-full rounded-xl border border-zinc-200 bg-white px-3 text-sm text-zinc-700 outline-none transition focus:border-rose-300"
/>
{value ? (
<button
......@@ -161,7 +188,7 @@ function UserSearchSelect({
function UserMultiSearchSelect({
value,
onChange,
placeholder = '远程搜索并选择成员',
placeholder = '远程搜索用户',
}) {
const selectedIds = Array.isArray(value) ? value.filter(Boolean) : [];
const [keyword, setKeyword] = useState('');
......@@ -251,7 +278,7 @@ function UserMultiSearchSelect({
setOpen(true);
}}
placeholder={placeholder}
className="h-11 w-full rounded-xl border border-zinc-200 bg-white px-3 text-sm text-zinc-700 outline-none transition focus:border-sky-400"
className="h-11 w-full rounded-xl border border-zinc-200 bg-white px-3 text-sm text-zinc-700 outline-none transition focus:border-rose-300"
/>
{open ? (
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-auto rounded-2xl border border-zinc-200 bg-white p-2 shadow-[0_20px_50px_rgba(15,23,42,0.12)]">
......@@ -270,15 +297,13 @@ function UserMultiSearchSelect({
}}
className={clsx(
'block w-full rounded-xl px-3 py-2 text-left transition',
checked ? 'bg-sky-50' : 'hover:bg-zinc-50',
checked ? 'bg-rose-50' : 'hover:bg-zinc-50',
)}
>
<div className="text-sm font-medium text-zinc-900">
{formatUserLabel(user)}
</div>
<div className="mt-1 text-xs text-zinc-500">
{user.username || user.id}
</div>
<div className="mt-1 text-xs text-zinc-500">{user.username || user.id}</div>
</button>
);
})
......@@ -294,20 +319,20 @@ function UserMultiSearchSelect({
selectedUsers.map((user) => (
<span
key={user.id}
className="inline-flex items-center gap-2 rounded-full bg-sky-50 px-3 py-1.5 text-sm text-sky-700"
className="inline-flex items-center gap-2 rounded-full bg-rose-50 px-3 py-1.5 text-sm text-rose-700"
>
{formatUserLabel(user)}
<button
type="button"
onClick={() => handleRemove(user.id)}
className="text-xs text-sky-500 transition hover:text-sky-700"
className="text-xs text-rose-500 transition hover:text-rose-700"
>
移除
</button>
</span>
))
) : (
<span className="text-sm text-zinc-400">尚未选择成员</span>
<span className="text-sm text-zinc-400">尚未选择用户</span>
)}
</div>
</div>
......@@ -322,6 +347,300 @@ function formatPercent(value) {
return `${Math.max(0, Math.min(100, Math.round(numericValue)))}%`;
}
function normalizeImportText(value) {
return String(value ?? '').trim();
}
function parseLooseDateInput(value) {
const normalizedValue = normalizeImportText(value);
if (!normalizedValue) {
return '';
}
const directDate = formatDateInput(normalizedValue);
if (directDate) {
return directDate;
}
const excelDate = Number(normalizedValue);
if (Number.isFinite(excelDate) && excelDate > 59) {
const parsedExcelDate = new Date(Math.round((excelDate - 25569) * 86400 * 1000));
const normalizedExcelDate = formatDateInput(parsedExcelDate.toISOString());
if (normalizedExcelDate) {
return normalizedExcelDate;
}
}
const sanitizedValue = normalizedValue
.replace(/\s+/g, '')
.replace(/[.]/g, '-')
.replace(/[\/]/g, '-')
.replace(/年/g, '-')
.replace(/月/g, '-')
.replace(/日/g, '');
const fullDateMatch = sanitizedValue.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
if (fullDateMatch) {
const [, year, month, day] = fullDateMatch;
return formatDateInput(
`${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`,
);
}
const monthDayMatch = normalizedValue.match(/^(\d{1,2})月(\d{1,2})日?$/);
if (monthDayMatch) {
const currentYear = new Date().getFullYear();
const [, month, day] = monthDayMatch;
return formatDateInput(
`${currentYear}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`,
);
}
return '';
}
function parsePercentInput(value) {
const normalizedValue = normalizeImportText(value);
if (!normalizedValue) {
return '';
}
const numericText = normalizedValue.endsWith('%')
? normalizedValue.slice(0, -1).trim()
: normalizedValue;
const numericValue = Number(numericText);
if (!Number.isFinite(numericValue)) {
return '';
}
return Math.max(0, Math.min(100, numericValue));
}
function parseOccurrenceFrequency(value) {
const normalizedValue = normalizeImportText(value);
if (!normalizedValue) {
return '';
}
const valueMap = {
高: '3',
中: '2',
低: '1',
3: '3',
2: '2',
1: '1',
};
return valueMap[normalizedValue] || normalizedValue;
}
function parseProjectStatus(value) {
const normalizedValue = normalizeImportText(value);
if (!normalizedValue) {
return '';
}
const valueMap = {
草稿: 'draft',
进行中: 'in_progress',
已完成: 'completed',
受阻: 'blocked',
已取消: 'cancelled',
draft: 'draft',
in_progress: 'in_progress',
completed: 'completed',
blocked: 'blocked',
cancelled: 'cancelled',
};
return valueMap[normalizedValue] || normalizedValue;
}
function parseBooleanInput(value) {
const normalizedValue = normalizeImportText(value).toLowerCase();
if (!normalizedValue) {
return false;
}
if (
['是', 'true', '1', 'y', 'yes', '有', '属于'].includes(normalizedValue)
) {
return true;
}
if (
['否', 'false', '0', 'n', 'no', '无', '不属于'].includes(normalizedValue)
) {
return false;
}
return false;
}
function buildImportRemarkSegments({
baseRemark,
plannedProgress,
}) {
const segments = [];
const normalizedRemark = normalizeImportText(baseRemark);
if (normalizedRemark) {
segments.push(normalizedRemark);
}
if (plannedProgress) {
segments.push(`导入时间进度:${plannedProgress}`);
}
return segments.join(';');
}
function normalizeImportWeeklyUpdates(row) {
const weekStartDate = parseLooseDateInput(row['周进度开始日期']);
const weekEndDate = parseLooseDateInput(row['周进度结束日期']);
const weeklySummary = normalizeImportText(row['周进度内容']);
const progressPercent = parsePercentInput(row['周进度进度']);
const blockers = normalizeImportText(row['周进度阻塞']);
const nextWeekPlan = normalizeImportText(row['周进度下周计划']);
const updatedAt = parseLooseDateInput(row['周进度记录日期']) || resolveCurrentDate();
if (!weeklySummary && !weekStartDate && !weekEndDate && !progressPercent && !blockers && !nextWeekPlan) {
return [];
}
return {
items: [
{
week_start_date: weekStartDate,
week_end_date: weekEndDate,
weekly_summary: weeklySummary,
progress_percent_after_update: progressPercent === '' ? '' : Number(progressPercent),
blockers,
next_week_plan: nextWeekPlan,
updated_at: updatedAt,
},
],
};
}
async function mapImportRowToTask(row) {
const projectName = normalizeImportText(row['任务名称'] || row['项目名称']);
const bigCategory = normalizeImportText(row['大类']);
const project = normalizeImportText(row['项目']);
const projectOwnerSource = normalizeImportText(row['主办人'] || row['项目负责人']);
const projectCollaboratorSource = normalizeImportText(row['协办人1'] || row['项目协作人']);
const projectOwner = await resolveImportUserReferenceList(projectOwnerSource, '主办人');
const projectCollaborator = await resolveImportUserReferenceList(
projectCollaboratorSource,
'协办人',
);
const taskSource = normalizeImportText(row['任务来源']);
const taskNature = normalizeImportText(row['任务性质']);
const occurrenceFrequency = parseOccurrenceFrequency(row['发生频率']);
const plannedStartDate = parseLooseDateInput(row['计划启动时间'] || row['计划开始日期']);
const plannedEndDate = parseLooseDateInput(row['计划完成时间'] || row['计划结束日期']);
const actualStartDate = parseLooseDateInput(row['实际开始日期']);
const actualFinishDate = parseLooseDateInput(row['实际完成日期']);
const projectStatus = parseProjectStatus(row['任务状态'] || row['项目状态']);
const completionPercent = parsePercentInput(row['任务进度'] || row['完成进度']);
const priority = normalizeImportText(row['优先级']);
const description = normalizeImportText(row['项目说明']);
const annualAuditPlan = parseBooleanInput(row['是否属于年度审计计划']);
const plannedProgress = normalizeImportText(row['时间进度']);
const remark = buildImportRemarkSegments({
baseRemark: row['备注'],
plannedProgress,
});
const delayReason = normalizeImportText(row['延期原因']);
const weeklyUpdates = normalizeImportWeeklyUpdates(row);
const metadata = {
project_name: projectName,
big_category: bigCategory,
project,
project_owner: projectOwner,
project_collaborator: projectCollaborator,
task_source: taskSource,
task_nature: taskNature,
occurrence_frequency: occurrenceFrequency,
planned_start_date: plannedStartDate,
planned_end_date: plannedEndDate,
actual_start_date: actualStartDate,
actual_finish_date: actualFinishDate,
project_status: projectStatus,
completion_percent: completionPercent === '' ? 0 : Number(completionPercent),
priority,
is_annual_audit_plan: annualAuditPlan,
description,
remark,
delay_reason: delayReason,
weekly_updates: weeklyUpdates,
};
return {
title: projectName || project || '未命名项目',
subject: 'pm_project_task',
metadata,
__display: {
project_owner: projectOwnerSource,
project_collaborator: projectCollaboratorSource,
},
};
}
async function buildImportPreviewRow(row, rowIndex) {
let payload = null;
let error = '';
try {
payload = await mapImportRowToTask(row);
buildTaskPayload({
project_name: payload.metadata.project_name,
big_category: payload.metadata.big_category,
project: payload.metadata.project,
project_owner: payload.metadata.project_owner,
project_collaborator: payload.metadata.project_collaborator,
task_source: payload.metadata.task_source,
task_nature: payload.metadata.task_nature,
occurrence_frequency: payload.metadata.occurrence_frequency,
planned_start_date: payload.metadata.planned_start_date,
planned_end_date: payload.metadata.planned_end_date,
actual_start_date: payload.metadata.actual_start_date,
actual_finish_date: payload.metadata.actual_finish_date,
description: payload.metadata.description,
remark: payload.metadata.remark,
priority: payload.metadata.priority,
is_annual_audit_plan: payload.metadata.is_annual_audit_plan,
project_status: payload.metadata.project_status,
completion_percent: payload.metadata.completion_percent,
delay_reason: payload.metadata.delay_reason,
weekly_updates: payload.metadata.weekly_updates,
});
} catch (requestError) {
error = requestError?.message || '数据格式错误';
}
return {
...(payload || {
title: normalizeImportText(row['任务名称'] || row['项目名称']) || '未命名项目',
subject: 'pm_project_task',
metadata: {
project_name: normalizeImportText(row['任务名称'] || row['项目名称']),
project_owner: [],
project_collaborator: [],
planned_start_date: parseLooseDateInput(row['计划启动时间'] || row['计划开始日期']),
planned_end_date: parseLooseDateInput(row['计划完成时间'] || row['计划结束日期']),
weekly_updates: normalizeImportWeeklyUpdates(row),
},
__display: {
project_owner: normalizeImportText(row['主办人'] || row['项目负责人']),
project_collaborator: normalizeImportText(row['协办人1'] || row['项目协作人']),
},
}),
__rowIndex: rowIndex,
error,
};
}
function getTaskTone(task) {
if (task.resultStatus === 'completed_early') {
return {
......@@ -346,9 +665,9 @@ function getTaskTone(task) {
}
if (task.projectStatus === 'completed') {
return {
surface: 'bg-sky-50 border-sky-200',
accent: 'bg-sky-500',
text: 'text-sky-700',
surface: 'bg-rose-50 border-rose-200',
accent: 'bg-rose-500',
text: 'text-rose-700',
};
}
return {
......@@ -366,7 +685,7 @@ function getCalendarEventTone(task) {
return 'bg-rose-100 border-rose-300 text-rose-700';
}
if (task.projectStatus === 'completed') {
return 'bg-sky-100 border-sky-300 text-sky-700';
return 'bg-rose-100 border-rose-300 text-rose-700';
}
if (task.projectStatus === 'blocked') {
return 'bg-amber-100 border-amber-300 text-amber-700';
......@@ -610,22 +929,89 @@ function buildMonthCalendarMatrix(anchorDate) {
return weeks;
}
function buildCalendarRangeRows(monthWeeks, tasks) {
return monthWeeks.map((week) => {
const weekStartKey = week[0]?.dateKey || '';
const weekEndKey = week[6]?.dateKey || '';
const segments = tasks
.filter(
(task) =>
task.plannedStartDate &&
task.plannedEndDate &&
task.plannedStartDate <= weekEndKey &&
task.plannedEndDate >= weekStartKey,
)
.sort((left, right) => {
if (left.plannedStartDate === right.plannedStartDate) {
if (left.plannedEndDate === right.plannedEndDate) {
return left.projectName.localeCompare(right.projectName);
}
return right.plannedEndDate.localeCompare(left.plannedEndDate);
}
return left.plannedStartDate.localeCompare(right.plannedStartDate);
})
.map((task) => {
const visibleStartKey =
task.plannedStartDate > weekStartKey ? task.plannedStartDate : weekStartKey;
const visibleEndKey =
task.plannedEndDate < weekEndKey ? task.plannedEndDate : weekEndKey;
const startColumn = week.findIndex((day) => day.dateKey === visibleStartKey) + 1;
const endColumn = week.findIndex((day) => day.dateKey === visibleEndKey) + 1;
return {
task,
startKey: visibleStartKey,
endKey: visibleEndKey,
startColumn,
span: Math.max(endColumn - startColumn + 1, 1),
};
})
.filter((segment) => segment.startColumn > 0);
const laneEndKeys = [];
const positionedSegments = segments.map((segment) => {
let laneIndex = laneEndKeys.findIndex((endKey) => segment.startKey > endKey);
if (laneIndex < 0) {
laneIndex = laneEndKeys.length;
laneEndKeys.push(segment.endKey);
} else {
laneEndKeys[laneIndex] = segment.endKey;
}
return {
...segment,
lane: laneIndex + 1,
};
});
return {
week,
segments: positionedSegments,
laneCount: Math.max(laneEndKeys.length, 1),
};
});
}
function buildTaskFormValues(task) {
const metadata = task?.metadata || {};
return {
project_name: metadata.project_name || '',
project_code: metadata.project_code || '',
leader:
metadata.leader?._id ||
metadata.leader?.id ||
metadata.leader?.username ||
metadata.leader ||
'',
member_ids: Array.isArray(metadata.member_ids)
? metadata.member_ids
: metadata.member_ids
? [metadata.member_ids]
big_category: metadata.big_category || '',
project: metadata.project || '',
project_owner: Array.isArray(metadata.project_owner)
? metadata.project_owner.map(resolveUserReferenceId).filter(Boolean)
: metadata.project_owner
? [resolveUserReferenceId(metadata.project_owner)].filter(Boolean)
: [],
project_collaborator: Array.isArray(metadata.project_collaborator)
? metadata.project_collaborator.map(resolveUserReferenceId).filter(Boolean)
: metadata.project_collaborator
? [resolveUserReferenceId(metadata.project_collaborator)].filter(Boolean)
: [],
task_source: metadata.task_source || '',
task_nature: metadata.task_nature || '',
occurrence_frequency: String(metadata.occurrence_frequency ?? '').trim(),
planned_start_date: formatDateInput(metadata.planned_start_date),
planned_end_date: formatDateInput(metadata.planned_end_date),
actual_start_date: formatDateInput(metadata.actual_start_date),
......@@ -633,41 +1019,46 @@ function buildTaskFormValues(task) {
description: metadata.description || '',
remark: metadata.remark || '',
priority: metadata.priority || '',
is_annual_audit_plan: Boolean(metadata.is_annual_audit_plan),
project_status: metadata.project_status || 'draft',
completion_percent: metadata.completion_percent ?? 0,
result_status: metadata.result_status || '',
is_overtime: Boolean(metadata.is_overtime),
overtime_days: metadata.overtime_days ?? 0,
delay_reason: metadata.delay_reason || '',
weekly_updates: normalizeWeeklyUpdatesValue(metadata.weekly_updates),
};
}
function buildLocalTaskRecord(values, currentTask) {
function buildWeeklyUpdateFormValues(update) {
return {
...(currentTask || {}),
_id: getTaskRecordId(currentTask) || `demo-task-${Date.now()}`,
title: values.project_name || currentTask?.title || '未命名项目',
subject: currentTask?.subject || 'pm_project_task',
metadata: {
project_name: values.project_name || '',
project_code: values.project_code || '',
leader: values.leader || '',
member_ids: Array.isArray(values.member_ids) ? values.member_ids : [],
planned_start_date: formatDateInput(values.planned_start_date),
planned_end_date: formatDateInput(values.planned_end_date),
actual_start_date: formatDateInput(values.actual_start_date),
actual_finish_date: formatDateInput(values.actual_finish_date),
description: values.description || '',
remark: values.remark || '',
priority: values.priority || '',
project_status: values.project_status || 'draft',
completion_percent: Number(values.completion_percent || 0),
result_status: values.result_status || '',
is_overtime: Boolean(values.is_overtime),
overtime_days: Number(values.overtime_days || 0),
},
week_start_date: formatDateInput(update?.week_start_date),
week_end_date: formatDateInput(update?.week_end_date),
weekly_summary: update?.weekly_summary || '',
progress_percent_after_update:
update?.progress_percent_after_update ?? update?.progress ?? '',
blockers: update?.blockers || '',
next_week_plan: update?.next_week_plan || '',
updated_at: formatDateInput(update?.updated_at) || resolveCurrentDate(),
};
}
function formatWeeklyUpdateRange(update) {
const startDate = formatDateInput(update?.week_start_date);
const endDate = formatDateInput(update?.week_end_date);
if (startDate && endDate) {
return `${startDate} ~ ${endDate}`;
}
if (startDate) {
return startDate;
}
if (endDate) {
return endDate;
}
return '未填写周期';
}
function ToolbarCard({ children }) {
return (
<div className="rounded-2xl border border-zinc-200/80 bg-white p-4 shadow-sm">
......@@ -683,7 +1074,7 @@ function StatCard({ label, value, hint, tone = 'default' }) {
: tone === 'success'
? 'from-emerald-50 to-white border-emerald-200'
: tone === 'primary'
? 'from-sky-50 to-white border-sky-200'
? 'from-rose-50 to-white border-rose-200'
: 'from-zinc-50 to-white border-zinc-200';
return (
......@@ -695,7 +1086,7 @@ function StatCard({ label, value, hint, tone = 'default' }) {
>
<div className="text-sm font-medium text-zinc-500">{label}</div>
<div className="mt-3 text-3xl font-semibold text-zinc-900">{value}</div>
<div className="mt-2 text-sm text-zinc-500">{hint}</div>
{hint ? <div className="mt-2 text-sm text-zinc-500">{hint}</div> : null}
</div>
);
}
......@@ -705,7 +1096,7 @@ function FilterSelect({ value, onChange, options, placeholder = '全部' }) {
<select
value={value}
onChange={(event) => onChange(event.target.value)}
className="h-10 rounded-xl border border-zinc-200 bg-white px-3 text-sm text-zinc-700 outline-none transition focus:border-sky-400"
className="h-10 rounded-xl border border-zinc-200 bg-white px-3 text-sm text-zinc-700 outline-none transition focus:border-rose-300"
>
<option value="">{placeholder}</option>
{options.map((option) => (
......@@ -739,7 +1130,7 @@ function GanttLegend() {
{ label: '正常进行', className: 'bg-zinc-400' },
{ label: '提前完成', className: 'bg-emerald-500' },
{ label: '已超时', className: 'bg-rose-500' },
{ label: '已完成', className: 'bg-sky-500' },
{ label: '已完成', className: 'bg-rose-500' },
{ label: '受阻', className: 'bg-amber-500' },
];
......@@ -803,9 +1194,6 @@ function ProjectGanttBoard({
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="text-lg font-semibold text-zinc-900">项目甘特图</div>
<div className="mt-1 text-sm text-zinc-500">
参考 `project_manager_new` 的时间轴结构,支持周、月、年视图切换,默认按月查看。
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="inline-flex rounded-full border border-zinc-200 bg-zinc-100/80 p-1">
......@@ -875,7 +1263,7 @@ function ProjectGanttBoard({
key={column.key}
className={clsx(
'border-b border-r border-zinc-200 px-2 py-3 text-center',
column.isToday ? 'bg-sky-50 text-sky-700' : 'bg-zinc-50 text-zinc-500',
column.isToday ? 'bg-rose-50 text-rose-700' : 'bg-zinc-50 text-zinc-500',
)}
>
<div className="text-sm font-semibold leading-none">{column.primary}</div>
......@@ -910,23 +1298,30 @@ function ProjectGanttBoard({
type="button"
onClick={() => onSelectTask(taskId)}
className={clsx(
'sticky left-0 z-[5] flex h-[118px] flex-col items-start justify-center border-b border-r border-zinc-200 bg-white px-4 py-4 text-left transition hover:bg-zinc-50',
selectedTaskId === taskId ? 'bg-sky-50' : '',
'sticky left-0 z-[5] flex h-full min-h-[104px] flex-col items-start justify-center border-b border-r border-zinc-200 bg-white px-4 py-2 text-left transition hover:bg-zinc-50',
selectedTaskId === taskId ? 'bg-rose-50' : '',
)}
>
<span className="text-sm font-semibold text-zinc-900">
{task.projectName}
</span>
<span className="mt-1 text-xs text-zinc-500">
{task.projectCode} · {task.projectStatusLabel}
</span>
<span className={clsx('mt-2 text-xs font-medium', tone.text)}>
{task.resultStatusLabel}
</span>
<div className="w-full">
<div className="truncate text-sm font-semibold text-zinc-900">
{task.projectName}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] leading-4 text-zinc-500">
<span className="truncate">
{task.bigCategory || task.projectCategory || '未分类'}
</span>
<span className="truncate">
负责人 {formatUserLabel(task.projectOwnerUser)}
</span>
<span className={clsx('font-medium', tone.text)}>
{task.projectStatusLabel} / {task.resultStatusLabel}
</span>
</div>
</div>
</button>
<div
className="relative h-[118px] border-b border-zinc-200"
className="relative h-[104px] border-b border-zinc-200"
style={{ gridColumn: `2 / span ${timelineMeta.columns.length}` }}
>
<div
......@@ -935,62 +1330,69 @@ function ProjectGanttBoard({
gridTemplateColumns: `repeat(${timelineMeta.columns.length}, minmax(${columnMinWidth}px, 1fr))`,
}}
>
{timelineMeta.columns.map((column) => (
<div
key={`${taskId}-${column.key}`}
className={clsx(
'h-[118px] border-r border-zinc-200',
column.isToday ? 'bg-sky-50/55' : 'bg-white',
)}
/>
))}
{timelineMeta.columns.map((column) => (
<div
key={`${taskId}-${column.key}`}
className={clsx(
'h-[104px] border-r border-zinc-200',
column.isToday ? 'bg-rose-50/60' : 'bg-white',
)}
/>
))}
</div>
{hasVisibleBar ? (
<div
className="absolute inset-0 grid"
className="absolute inset-0 grid items-center"
style={{
gridTemplateColumns: `repeat(${timelineMeta.columns.length}, minmax(${columnMinWidth}px, 1fr))`,
gridTemplateRows: '1fr',
}}
>
<button
type="button"
onClick={() => {
onSelectTask(taskId);
onEditTask(task);
}}
className={clsx(
'relative flex items-center self-center rounded-[22px] border px-4 py-3 text-left shadow-sm transition hover:brightness-[0.98]',
tone.surface,
selectedTaskId === taskId ? 'ring-2 ring-sky-300' : 'ring-1 ring-transparent',
)}
<div
className="flex h-full items-center"
style={{
gridColumn: `${startIndex + 1} / span ${span}`,
minHeight: viewType === GANTT_VIEW_TYPES.YEAR ? '64px' : '72px',
}}
>
<div className={clsx('mr-3 h-5 w-1 rounded-full', tone.accent)} />
<div className="min-w-0 flex-1 text-left">
<div className="truncate text-[15px] font-semibold text-zinc-900">
{task.projectName}
</div>
<div className="mt-1 flex flex-wrap items-center gap-3 text-xs text-zinc-500">
<span>{formatPercent(task.progressPercent)}</span>
<span>
{task.plannedStartDate} ~ {task.plannedEndDate}
</span>
</div>
</div>
<span
>
<button
type="button"
onClick={() => {
onSelectTask(taskId);
onEditTask(task);
}}
className={clsx(
'rounded-full px-2.5 py-1 text-xs font-medium',
'relative flex h-auto w-full items-center rounded-[16px] border px-3 py-1.5 text-left shadow-sm transition hover:brightness-[0.98]',
tone.surface,
tone.text,
selectedTaskId === taskId ? 'ring-2 ring-rose-300' : 'ring-1 ring-transparent',
)}
>
{task.resultStatusLabel}
</span>
</button>
style={{
height: viewType === GANTT_VIEW_TYPES.YEAR ? '56px' : '62px',
}}
>
<div className={clsx('mr-2.5 h-4 w-1 shrink-0 rounded-full', tone.accent)} />
<div className="min-w-0 flex-1 text-left">
<div className="flex items-center justify-between gap-2">
<div className="truncate text-sm font-semibold text-zinc-900">
{task.projectName}
</div>
<span
className={clsx(
'ml-3 shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium',
tone.surface,
tone.text,
)}
>
{task.resultStatusLabel}
</span>
</div>
<div className="mt-1 truncate text-[11px] leading-4 text-zinc-500">
时间 {formatPercent(task.plannedProgressPercent)} · 实际 {formatPercent(task.progressPercent)} · {task.plannedStartDate} ~ {task.plannedEndDate} · 周进度 {task.weeklyUpdateCount} 条
{task.latestWeeklySummary ? ` · ${task.latestWeeklySummary}` : ''}
</div>
</div>
</button>
</div>
</div>
) : null}
</div>
......@@ -1022,23 +1424,14 @@ function ProjectCalendarBoard({
const currentUserTasks = useMemo(
() =>
tasks.filter((task) => currentUserId && task.memberIds.includes(currentUserId)),
tasks.filter((task) => currentUserId && task.relatedUserIds.includes(currentUserId)),
[currentUserId, tasks],
);
const eventsByDate = useMemo(() => {
const map = new Map();
currentUserTasks.forEach((task) => {
enumerateDateRange(task.plannedStartDate, task.plannedEndDate).forEach(
(dateKey) => {
const current = map.get(dateKey) || [];
current.push(task);
map.set(dateKey, current);
},
);
});
return map;
}, [currentUserTasks]);
const calendarRows = useMemo(
() => buildCalendarRangeRows(monthWeeks, currentUserTasks),
[currentUserTasks, monthWeeks],
);
const title = useMemo(() => {
const [year, month] = monthAnchor.split('-');
......@@ -1056,9 +1449,6 @@ function ProjectCalendarBoard({
<div className="flex items-center justify-between border-b border-zinc-200 px-5 py-4">
<div>
<div className="text-lg font-semibold text-zinc-900">个人项目日历</div>
<div className="mt-1 text-sm text-zinc-500">
当前用户只看 `member_ids` 包含自己的项目,和甘特图共享同一条项目记录。
</div>
</div>
<div className="flex items-center gap-2">
<button
......@@ -1089,53 +1479,96 @@ function ProjectCalendarBoard({
))}
</div>
<div className="grid grid-cols-7">
{monthWeeks.flat().map((cell) => {
const events = eventsByDate.get(cell.dateKey) || [];
const isToday = cell.dateKey === resolveCurrentDate();
<div className="divide-y divide-zinc-200">
{calendarRows.map((row) => (
<div key={row.week[0]?.dateKey} className="overflow-hidden">
<div className="grid grid-cols-7">
{row.week.map((cell) => {
const isToday = cell.dateKey === resolveCurrentDate();
return (
<div
key={cell.dateKey}
className={clsx(
'min-h-[58px] border-r border-zinc-200 px-3 py-3 last:border-r-0',
cell.isCurrentMonth ? 'bg-white' : 'bg-zinc-50/70',
)}
>
<div
className={clsx(
'flex items-center justify-between text-xs',
isToday ? 'font-semibold text-rose-700' : 'text-zinc-500',
)}
>
<span>{cell.dateKey.slice(8)}</span>
{isToday ? <span>今天</span> : null}
</div>
</div>
);
})}
</div>
return (
<div
key={cell.dateKey}
className={clsx(
'min-h-[148px] border-b border-r border-zinc-200 p-3',
cell.isCurrentMonth ? 'bg-white' : 'bg-zinc-50/60',
)}
className="relative border-t border-zinc-200 bg-white px-2 py-3"
style={{
minHeight: `${row.laneCount * 36 + 20}px`,
}}
>
<div
className={clsx(
'mb-3 flex items-center justify-between text-xs',
isToday ? 'font-semibold text-sky-700' : 'text-zinc-500',
)}
>
<span>{cell.dateKey.slice(8)}</span>
{events.length > 0 ? <span>{events.length} 项</span> : null}
</div>
<div className="space-y-2">
{events.slice(0, 3).map((task) => (
<button
key={`${cell.dateKey}-${getTaskRecordId(task)}`}
type="button"
onClick={() => onOpenTask(task)}
<div className="grid grid-cols-7 gap-x-0">
{row.week.map((cell) => (
<div
key={`${cell.dateKey}-lane-guide`}
className={clsx(
'block w-full rounded-xl border px-3 py-2 text-left text-xs shadow-sm transition hover:brightness-[0.98]',
getCalendarEventTone(task),
'h-full min-h-[36px] border-r border-dashed border-zinc-100 last:border-r-0',
cell.isCurrentMonth ? 'bg-white' : 'bg-zinc-50/40',
)}
>
<div className="truncate font-semibold">{task.projectName}</div>
<div className="mt-1 truncate opacity-80">
{task.projectStatusLabel} · {formatPercent(task.progressPercent)}
</div>
</button>
/>
))}
{events.length > 3 ? (
<div className="text-xs text-zinc-400">还有 {events.length - 3} 项</div>
) : null}
</div>
{row.segments.length > 0 ? (
<div
className="absolute inset-x-2 top-3 grid gap-y-2"
style={{
gridTemplateColumns: 'repeat(7, minmax(0, 1fr))',
gridTemplateRows: `repeat(${row.laneCount}, minmax(28px, 28px))`,
}}
>
{row.segments.map((segment) => {
const taskId = getTaskRecordId(segment.task);
return (
<button
key={`${row.week[0]?.dateKey}-${taskId}`}
type="button"
onClick={() => onOpenTask(segment.task)}
className={clsx(
'flex min-w-0 items-center rounded-full border px-3 text-left text-xs shadow-sm transition hover:brightness-[0.98]',
getCalendarEventTone(segment.task),
)}
style={{
gridColumn: `${segment.startColumn} / span ${segment.span}`,
gridRow: String(segment.lane),
}}
>
<span className="min-w-0 truncate font-semibold">
{segment.task.projectName}
</span>
{segment.span >= 2 ? (
<span className="ml-2 shrink-0 opacity-80">
{segment.task.projectStatusLabel} · {formatPercent(segment.task.progressPercent)}
</span>
) : null}
</button>
);
})}
</div>
) : (
<div className="flex min-h-[36px] items-center px-2 text-xs text-zinc-400">
本周暂无项目安排
</div>
)}
</div>
);
})}
</div>
))}
</div>
</div>
);
......@@ -1146,9 +1579,6 @@ function ProjectListPanel({ tasks, selectedTaskId, onSelectTask, onEditTask }) {
<div className="rounded-2xl border border-zinc-200 bg-white shadow-sm">
<div className="border-b border-zinc-200 px-5 py-4">
<div className="text-lg font-semibold text-zinc-900">项目列表</div>
<div className="mt-1 text-sm text-zinc-500">
统一查看当前项目状态、共享进度和超时情况。
</div>
</div>
<div className="divide-y divide-zinc-100">
{tasks.length > 0 ? (
......@@ -1159,7 +1589,7 @@ function ProjectListPanel({ tasks, selectedTaskId, onSelectTask, onEditTask }) {
key={taskId}
className={clsx(
'flex items-center justify-between gap-4 px-5 py-4 transition hover:bg-zinc-50',
selectedTaskId === taskId ? 'bg-sky-50' : '',
selectedTaskId === taskId ? 'bg-rose-50' : '',
)}
>
<button
......@@ -1171,8 +1601,18 @@ function ProjectListPanel({ tasks, selectedTaskId, onSelectTask, onEditTask }) {
{task.projectName}
</div>
<div className="mt-1 text-xs text-zinc-500">
{task.projectCode} · {task.projectStatusLabel} · {task.resultStatusLabel}
{task.bigCategory || '未分类'} · 负责人 {formatUserLabel(task.projectOwnerUser)} ·{' '}
{task.projectStatusLabel}
</div>
{task.latestWeeklySummary ? (
<div className="mt-1 truncate text-xs text-zinc-400">
最近周进展:{task.latestWeeklySummary}
</div>
) : task.delayReason ? (
<div className="mt-1 truncate text-xs text-rose-500">
延期原因:{task.delayReason}
</div>
) : null}
</button>
<div className="flex items-center gap-3">
<div className="text-right">
......@@ -1180,7 +1620,9 @@ function ProjectListPanel({ tasks, selectedTaskId, onSelectTask, onEditTask }) {
{formatPercent(task.progressPercent)}
</div>
<div className="mt-1 text-xs text-zinc-500">
{task.isOvertime ? `超时 ${task.overtimeDays} 天` : '正常'}
{task.isOvertime
? `超时 ${task.overtimeDays} 天`
: `周进度 ${task.weeklyUpdateCount} 条`}
</div>
</div>
<button
......@@ -1204,6 +1646,276 @@ function ProjectListPanel({ tasks, selectedTaskId, onSelectTask, onEditTask }) {
);
}
function WeeklyUpdateEditorDialog({
open,
value,
editing,
error,
onChange,
onCancel,
onConfirm,
}) {
if (!open) {
return null;
}
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-slate-950/50 px-4 py-6">
<div className="w-full max-w-3xl rounded-[28px] bg-white shadow-[0_32px_100px_rgba(15,23,42,0.32)]">
<div className="flex items-center justify-between border-b border-zinc-200 px-6 py-5">
<div>
<div className="text-lg font-semibold text-zinc-900">
{editing ? '编辑周进度' : '新增周进度'}
</div>
</div>
<button
type="button"
onClick={onCancel}
className="rounded-full border border-zinc-200 px-3 py-2 text-sm text-zinc-500 transition hover:bg-zinc-100"
>
关闭
</button>
</div>
<div className="grid gap-4 px-6 py-5 md:grid-cols-2">
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">本周开始日期</span>
<input
type="date"
value={value.week_start_date}
onChange={(event) => onChange('week_start_date', event.target.value)}
className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-rose-300"
/>
</label>
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">本周结束日期</span>
<input
type="date"
value={value.week_end_date}
onChange={(event) => onChange('week_end_date', event.target.value)}
className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-rose-300"
/>
</label>
<label className="space-y-2 text-sm text-zinc-600 md:col-span-2">
<span className="font-medium">本周完成内容</span>
<textarea
value={value.weekly_summary}
onChange={(event) => onChange('weekly_summary', event.target.value)}
className="min-h-[120px] w-full rounded-2xl border border-zinc-200 px-3 py-3 outline-none transition focus:border-rose-300"
/>
</label>
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">更新后累计进度</span>
<input
type="number"
min="0"
max="100"
value={value.progress_percent_after_update}
onChange={(event) =>
onChange('progress_percent_after_update', event.target.value)
}
className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-rose-300"
/>
</label>
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">记录日期</span>
<input
type="date"
value={value.updated_at}
onChange={(event) => onChange('updated_at', event.target.value)}
className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-rose-300"
/>
</label>
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">当前阻塞事项</span>
<textarea
value={value.blockers}
onChange={(event) => onChange('blockers', event.target.value)}
className="min-h-[110px] w-full rounded-2xl border border-zinc-200 px-3 py-3 outline-none transition focus:border-rose-300"
/>
</label>
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">下周计划</span>
<textarea
value={value.next_week_plan}
onChange={(event) => onChange('next_week_plan', event.target.value)}
className="min-h-[110px] w-full rounded-2xl border border-zinc-200 px-3 py-3 outline-none transition focus:border-rose-300"
/>
</label>
</div>
{error ? (
<div className="px-6 pb-2 text-sm text-rose-600">{error}</div>
) : null}
<div className="flex items-center justify-end gap-3 border-t border-zinc-100 px-6 py-5">
<button
type="button"
onClick={onCancel}
className="rounded-xl border border-zinc-200 bg-white px-5 py-2.5 text-sm font-medium text-zinc-600 transition hover:bg-zinc-100"
>
取消
</button>
<button
type="button"
onClick={onConfirm}
className="rounded-xl bg-[var(--pm-brand)] px-5 py-2.5 text-sm font-medium text-white transition hover:bg-[var(--pm-brand-strong)]"
>
保存周进度
</button>
</div>
</div>
</div>
);
}
function ProjectImportDialog({
open,
loading,
error,
previewRows,
fileName,
successRows,
onClose,
onDownloadTemplate,
onFileChange,
onImport,
}) {
if (!open) {
return null;
}
return (
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-slate-950/55 px-4 py-6">
<div className="max-h-[92vh] w-full max-w-5xl overflow-hidden rounded-[28px] bg-white shadow-[0_32px_100px_rgba(15,23,42,0.32)]">
<div className="flex items-center justify-between border-b border-zinc-200 px-6 py-5">
<div>
<div className="text-xl font-semibold text-zinc-900">Excel 批量导入</div>
</div>
<button
type="button"
onClick={onClose}
className="rounded-full border border-zinc-200 px-3 py-2 text-sm text-zinc-500 transition hover:bg-zinc-100"
>
关闭
</button>
</div>
<div className="space-y-4 px-6 py-5">
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={onDownloadTemplate}
className="rounded-xl border border-zinc-200 bg-white px-4 py-2 text-sm font-medium text-zinc-700 transition hover:bg-zinc-100"
>
下载模板
</button>
<label className="inline-flex cursor-pointer items-center rounded-xl border border-zinc-200 bg-white px-4 py-2 text-sm font-medium text-zinc-700 transition hover:bg-zinc-100">
选择 Excel 文件
<input
type="file"
accept=".xlsx,.xls"
className="hidden"
onChange={onFileChange}
/>
</label>
<div className="text-sm text-zinc-500">
{fileName ? `文件:${fileName} · ` : ''}
识别到 {previewRows.length} 行,可导入 {successRows.length} 行
</div>
</div>
{error ? (
<div className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-600">
{error}
</div>
) : null}
<div className="max-h-[50vh] overflow-auto rounded-2xl border border-zinc-200">
<table className="min-w-full divide-y divide-zinc-100 text-sm">
<thead className="sticky top-0 bg-zinc-50">
<tr>
<th className="px-4 py-3 text-left font-medium text-zinc-500">#</th>
<th className="px-4 py-3 text-left font-medium text-zinc-500">项目名称</th>
<th className="px-4 py-3 text-left font-medium text-zinc-500">负责人</th>
<th className="px-4 py-3 text-left font-medium text-zinc-500">协作人</th>
<th className="px-4 py-3 text-left font-medium text-zinc-500">计划时间</th>
<th className="px-4 py-3 text-left font-medium text-zinc-500">周进度</th>
<th className="px-4 py-3 text-left font-medium text-zinc-500">结果</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 bg-white">
{previewRows.length > 0 ? (
previewRows.map((row, index) => (
<tr key={`${row.__rowIndex}-${index}`}>
<td className="px-4 py-3 text-zinc-500">{row.__rowIndex}</td>
<td className="px-4 py-3 font-medium text-zinc-900">
{row.metadata?.project_name || '未命名项目'}
</td>
<td className="px-4 py-3 text-zinc-600">
{row.__display?.project_owner || '-'}
</td>
<td className="px-4 py-3 text-zinc-600">
{row.__display?.project_collaborator || '-'}
</td>
<td className="px-4 py-3 text-zinc-600">
{row.metadata?.planned_start_date || '-'} ~ {row.metadata?.planned_end_date || '-'}
</td>
<td className="px-4 py-3 text-zinc-600">
{Array.isArray(row.metadata?.weekly_updates?.items) &&
row.metadata.weekly_updates.items.length > 0
? `${row.metadata.weekly_updates.items.length} 条`
: '-'}
</td>
<td className="px-4 py-3 text-zinc-600">
{row.error ? (
<span className="text-rose-600">{row.error}</span>
) : (
<span className="text-emerald-600">可导入</span>
)}
</td>
</tr>
))
) : (
<tr>
<td colSpan={7} className="px-4 py-10 text-center text-zinc-400">
请选择 Excel 文件后查看预览
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="flex items-center justify-end gap-3 border-t border-zinc-100 pt-4">
<button
type="button"
onClick={onClose}
className="rounded-xl border border-zinc-200 bg-white px-5 py-2.5 text-sm font-medium text-zinc-600 transition hover:bg-zinc-100"
>
取消
</button>
<button
type="button"
onClick={onImport}
disabled={loading || successRows.length === 0}
className="rounded-xl bg-[var(--pm-brand)] px-5 py-2.5 text-sm font-medium text-white transition hover:bg-[var(--pm-brand-strong)] disabled:cursor-not-allowed disabled:opacity-70"
>
{loading ? '导入中...' : `确认导入 ${successRows.length} 条`}
</button>
</div>
</div>
</div>
</div>
);
}
function ProjectTaskDialog({
open,
task,
......@@ -1211,14 +1923,25 @@ function ProjectTaskDialog({
submitError,
onClose,
onSubmit,
fieldOptions,
}) {
const [formValues, setFormValues] = useState(buildTaskFormValues(null));
const [weeklyUpdateDialogOpen, setWeeklyUpdateDialogOpen] = useState(false);
const [editingWeeklyUpdateIndex, setEditingWeeklyUpdateIndex] = useState(-1);
const [weeklyUpdateDraft, setWeeklyUpdateDraft] = useState(
buildWeeklyUpdateFormValues(null),
);
const [weeklyUpdateError, setWeeklyUpdateError] = useState('');
useEffect(() => {
if (!open) {
return;
}
setFormValues(buildTaskFormValues(task));
setWeeklyUpdateDialogOpen(false);
setEditingWeeklyUpdateIndex(-1);
setWeeklyUpdateDraft(buildWeeklyUpdateFormValues(null));
setWeeklyUpdateError('');
}, [open, task]);
if (!open) {
......@@ -1232,72 +1955,277 @@ function ProjectTaskDialog({
}));
};
const handleStartProject = () => {
onSubmit({
...formValues,
project_status: 'in_progress',
actual_start_date: formValues.actual_start_date || resolveCurrentDate(),
});
};
const handleCompleteProject = () => {
onSubmit({
...formValues,
project_status: 'completed',
actual_start_date: formValues.actual_start_date || resolveCurrentDate(),
actual_finish_date: formValues.actual_finish_date || resolveCurrentDate(),
completion_percent:
Number(formValues.completion_percent || 0) >= 100
? formValues.completion_percent
: 100,
});
};
const openCreateWeeklyUpdate = () => {
setEditingWeeklyUpdateIndex(-1);
setWeeklyUpdateDraft(
buildWeeklyUpdateFormValues({
progress_percent_after_update: formValues.completion_percent,
}),
);
setWeeklyUpdateError('');
setWeeklyUpdateDialogOpen(true);
};
const openEditWeeklyUpdate = (update, index) => {
setEditingWeeklyUpdateIndex(index);
setWeeklyUpdateDraft(buildWeeklyUpdateFormValues(update));
setWeeklyUpdateError('');
setWeeklyUpdateDialogOpen(true);
};
const handleWeeklyUpdateFieldChange = (field, value) => {
setWeeklyUpdateDraft((current) => ({
...current,
[field]: value,
}));
};
const handleWeeklyUpdateConfirm = () => {
const weeklySummary = String(weeklyUpdateDraft.weekly_summary || '').trim();
if (!weeklySummary) {
setWeeklyUpdateError('请填写本周完成内容');
return;
}
const weekStartDate = formatDateInput(weeklyUpdateDraft.week_start_date);
const weekEndDate = formatDateInput(weeklyUpdateDraft.week_end_date);
if (weekStartDate && weekEndDate && weekStartDate > weekEndDate) {
setWeeklyUpdateError('本周结束日期不能早于本周开始日期');
return;
}
const nextEntry = {
week_start_date: weekStartDate,
week_end_date: weekEndDate,
weekly_summary: weeklySummary,
progress_percent_after_update:
weeklyUpdateDraft.progress_percent_after_update === ''
? ''
: Number(weeklyUpdateDraft.progress_percent_after_update),
blockers: String(weeklyUpdateDraft.blockers || '').trim(),
next_week_plan: String(weeklyUpdateDraft.next_week_plan || '').trim(),
updated_at: formatDateInput(weeklyUpdateDraft.updated_at) || resolveCurrentDate(),
};
setFormValues((current) => {
const currentUpdates = Array.isArray(current.weekly_updates)
? [...current.weekly_updates]
: [];
if (
editingWeeklyUpdateIndex >= 0 &&
editingWeeklyUpdateIndex < currentUpdates.length
) {
currentUpdates[editingWeeklyUpdateIndex] = nextEntry;
} else {
currentUpdates.push(nextEntry);
}
const sortedUpdates = sortWeeklyUpdates(currentUpdates);
return {
...current,
completion_percent:
nextEntry.progress_percent_after_update === ''
? current.completion_percent
: nextEntry.progress_percent_after_update,
weekly_updates: sortedUpdates,
};
});
setWeeklyUpdateError('');
setWeeklyUpdateDialogOpen(false);
setEditingWeeklyUpdateIndex(-1);
setWeeklyUpdateDraft(buildWeeklyUpdateFormValues(null));
};
const handleWeeklyUpdateDelete = (index) => {
setFormValues((current) => ({
...current,
weekly_updates: current.weekly_updates.filter((_, itemIndex) => itemIndex !== index),
}));
};
const handleWeeklyUpdateCancel = () => {
setWeeklyUpdateDialogOpen(false);
setEditingWeeklyUpdateIndex(-1);
setWeeklyUpdateDraft(buildWeeklyUpdateFormValues(null));
setWeeklyUpdateError('');
};
const weeklyUpdates = Array.isArray(formValues.weekly_updates)
? formValues.weekly_updates
: [];
const priorityOptions = fieldOptions?.priorityOptions || [];
const projectStatusOptions =
fieldOptions?.projectStatusOptions?.length > 0
? fieldOptions.projectStatusOptions
: PROJECT_STATUS_OPTIONS;
const occurrenceFrequencyOptions =
fieldOptions?.occurrenceFrequencyOptions?.length > 0
? fieldOptions.occurrenceFrequencyOptions
: OCCURRENCE_FREQUENCY_OPTIONS;
const hasPersistedTask = Boolean(getTaskRecordId(task));
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/45 px-4 py-6">
<div className="max-h-[92vh] w-full max-w-5xl overflow-hidden rounded-[28px] bg-white shadow-[0_32px_100px_rgba(15,23,42,0.28)]">
<div className="flex items-center justify-between border-b border-zinc-200 px-6 py-5">
<div>
<div className="text-xl font-semibold text-zinc-900">
{task ? '编辑项目' : '新建项目'}
<>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/45 px-4 py-6">
<div className="max-h-[92vh] w-full max-w-5xl overflow-hidden rounded-[28px] bg-white shadow-[0_32px_100px_rgba(15,23,42,0.28)]">
<div className="flex items-center justify-between border-b border-zinc-200 px-6 py-5">
<div>
<div className="text-xl font-semibold text-zinc-900">
{task ? '编辑项目' : '新建项目'}
</div>
</div>
<div className="mt-1 text-sm text-zinc-500">
表单已从页面底部改为弹框,便于在甘特图和列表中随时维护项目。
<div className="flex items-center gap-2">
{hasPersistedTask && canStartProject(formValues.project_status) ? (
<button
type="button"
onClick={handleStartProject}
disabled={submitting}
className="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-2 text-sm font-medium text-emerald-700 transition hover:bg-emerald-100"
>
{submitting ? '启动中...' : '启动项目'}
</button>
) : null}
{hasPersistedTask && canCompleteProject(formValues.project_status) ? (
<button
type="button"
onClick={handleCompleteProject}
disabled={submitting}
className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-2 text-sm font-medium text-rose-700 transition hover:bg-rose-100"
>
{submitting ? '完成中...' : '完成项目'}
</button>
) : null}
<button
type="button"
onClick={onClose}
disabled={submitting}
className="rounded-full border border-zinc-200 px-3 py-2 text-sm text-zinc-500 transition hover:bg-zinc-100"
>
关闭
</button>
</div>
</div>
<button
type="button"
onClick={onClose}
className="rounded-full border border-zinc-200 px-3 py-2 text-sm text-zinc-500 transition hover:bg-zinc-100"
>
关闭
</button>
</div>
<form
onSubmit={(event) => {
event.preventDefault();
onSubmit(formValues);
}}
className="max-h-[calc(92vh-84px)] overflow-auto px-6 py-5"
>
<form
onSubmit={(event) => {
event.preventDefault();
onSubmit(formValues);
}}
className="max-h-[calc(92vh-84px)] overflow-auto px-6 py-5"
>
<div className="grid gap-4 md:grid-cols-2">
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">项目名称</span>
<input
value={formValues.project_name}
onChange={(event) => updateField('project_name', event.target.value)}
className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-sky-400"
className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-rose-300"
required
/>
</label>
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">项目编号</span>
<span className="font-medium">大类</span>
<input
value={formValues.big_category}
onChange={(event) => updateField('big_category', event.target.value)}
className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-rose-300"
/>
</label>
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">项目</span>
<input
value={formValues.project}
onChange={(event) => updateField('project', event.target.value)}
className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-rose-300"
/>
</label>
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">项目负责人</span>
<UserMultiSearchSelect
value={formValues.project_owner}
onChange={(value) => updateField('project_owner', value)}
placeholder="远程搜索项目负责人"
/>
</label>
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">项目协作人</span>
<UserMultiSearchSelect
value={formValues.project_collaborator}
onChange={(value) => updateField('project_collaborator', value)}
placeholder="远程搜索项目协作人"
/>
</label>
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">任务来源</span>
<input
value={formValues.project_code}
onChange={(event) => updateField('project_code', event.target.value)}
className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-sky-400"
value={formValues.task_source}
onChange={(event) => updateField('task_source', event.target.value)}
className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-rose-300"
/>
</label>
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">下发领导</span>
<UserSearchSelect
value={formValues.leader}
onChange={(value) => updateField('leader', value)}
placeholder="远程搜索领导用户"
<span className="font-medium">任务性质</span>
<input
value={formValues.task_nature}
onChange={(event) => updateField('task_nature', event.target.value)}
className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-rose-300"
/>
</label>
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">发生频率</span>
<select
value={formValues.occurrence_frequency}
onChange={(event) => updateField('occurrence_frequency', event.target.value)}
className="h-11 w-full rounded-xl border border-zinc-200 bg-white px-3 outline-none transition focus:border-rose-300"
>
<option value="">请选择发生频率</option>
{occurrenceFrequencyOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">优先级</span>
<select
value={formValues.priority}
onChange={(event) => updateField('priority', event.target.value)}
className="h-11 w-full rounded-xl border border-zinc-200 bg-white px-3 outline-none transition focus:border-sky-400"
className="h-11 w-full rounded-xl border border-zinc-200 bg-white px-3 outline-none transition focus:border-rose-300"
>
<option value="">请选择优先级</option>
{PRIORITY_OPTIONS.map((option) => (
{priorityOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
......@@ -1305,13 +2233,32 @@ function ProjectTaskDialog({
</select>
</label>
<div className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">是否属于年度审计计划</span>
<div className="rounded-2xl border border-zinc-200 bg-white px-3 py-3">
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={formValues.is_annual_audit_plan}
onChange={(event) =>
updateField('is_annual_audit_plan', event.target.checked)
}
className="mt-0.5 h-4 w-4 rounded border-zinc-300 text-rose-600 focus:ring-rose-500"
/>
<div>
<div className="text-sm font-medium text-zinc-900">属于年度审计计划</div>
</div>
</label>
</div>
</div>
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">计划开始日期</span>
<input
type="date"
value={formValues.planned_start_date}
onChange={(event) => updateField('planned_start_date', event.target.value)}
className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-sky-400"
className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-rose-300"
required
/>
</label>
......@@ -1322,7 +2269,7 @@ function ProjectTaskDialog({
type="date"
value={formValues.planned_end_date}
onChange={(event) => updateField('planned_end_date', event.target.value)}
className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-sky-400"
className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-rose-300"
required
/>
</label>
......@@ -1333,7 +2280,7 @@ function ProjectTaskDialog({
type="date"
value={formValues.actual_start_date}
onChange={(event) => updateField('actual_start_date', event.target.value)}
className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-sky-400"
className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-rose-300"
/>
</label>
......@@ -1343,51 +2290,29 @@ function ProjectTaskDialog({
type="date"
value={formValues.actual_finish_date}
onChange={(event) => updateField('actual_finish_date', event.target.value)}
className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-sky-400"
className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-rose-300"
/>
</label>
</div>
<div className="mt-5 space-y-3">
<div className="text-sm font-medium text-zinc-700">参与成员</div>
<UserMultiSearchSelect
value={formValues.member_ids}
onChange={(nextValue) => updateField('member_ids', nextValue)}
placeholder="远程搜索并选择参与成员"
/>
</div>
<div className="mt-5 grid gap-4 md:grid-cols-3">
<div className="mt-5 grid gap-4 md:grid-cols-2">
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">项目状态</span>
<select
value={formValues.project_status}
onChange={(event) => updateField('project_status', event.target.value)}
className="h-11 w-full rounded-xl border border-zinc-200 bg-white px-3 outline-none transition focus:border-sky-400"
className="h-11 w-full rounded-xl border border-zinc-200 bg-white px-3 outline-none transition focus:border-rose-300"
required
>
{PROJECT_STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">结果状态</span>
<select
value={formValues.result_status}
onChange={(event) => updateField('result_status', event.target.value)}
className="h-11 w-full rounded-xl border border-zinc-200 bg-white px-3 outline-none transition focus:border-sky-400"
>
<option value="">自动判定 / 手动补充</option>
{RESULT_STATUS_OPTIONS.map((option) => (
{projectStatusOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<div className="text-xs text-zinc-400">
工作流:草稿 {'->'} 进行中 {'->'} 已完成
</div>
</label>
<label className="space-y-2 text-sm text-zinc-600">
......@@ -1398,7 +2323,7 @@ function ProjectTaskDialog({
max="100"
value={formValues.completion_percent}
onChange={(event) => updateField('completion_percent', event.target.value)}
className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-sky-400"
className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-rose-300"
/>
</label>
</div>
......@@ -1409,7 +2334,7 @@ function ProjectTaskDialog({
<textarea
value={formValues.description}
onChange={(event) => updateField('description', event.target.value)}
className="min-h-[110px] w-full rounded-2xl border border-zinc-200 px-3 py-3 outline-none transition focus:border-sky-400"
className="min-h-[110px] w-full rounded-2xl border border-zinc-200 px-3 py-3 outline-none transition focus:border-rose-300"
/>
</label>
......@@ -1418,11 +2343,94 @@ function ProjectTaskDialog({
<textarea
value={formValues.remark}
onChange={(event) => updateField('remark', event.target.value)}
className="min-h-[110px] w-full rounded-2xl border border-zinc-200 px-3 py-3 outline-none transition focus:border-sky-400"
className="min-h-[110px] w-full rounded-2xl border border-zinc-200 px-3 py-3 outline-none transition focus:border-rose-300"
/>
</label>
</div>
<div className="mt-5 grid gap-4 md:grid-cols-2">
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">延期原因</span>
<textarea
value={formValues.delay_reason}
onChange={(event) => updateField('delay_reason', event.target.value)}
className="min-h-[110px] w-full rounded-2xl border border-zinc-200 px-3 py-3 outline-none transition focus:border-rose-300"
/>
</label>
</div>
<div className="mt-5 rounded-2xl border border-zinc-200/90 bg-zinc-50/60 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="text-sm font-medium text-zinc-900">周进度记录</div>
<button
type="button"
onClick={openCreateWeeklyUpdate}
className="rounded-xl border border-zinc-200 bg-white px-4 py-2 text-sm font-medium text-zinc-700 transition hover:bg-zinc-100"
>
新增本周进度
</button>
</div>
<div className="mt-4 space-y-3">
{weeklyUpdates.length > 0 ? (
weeklyUpdates.map((update, index) => (
<div
key={`${update.week_start_date || 'week'}-${index}`}
className="rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-zinc-900">
{formatWeeklyUpdateRange(update)}
</div>
<div className="mt-1 text-xs text-zinc-500">
累计进度{' '}
{update.progress_percent_after_update === ''
? '未填写'
: `${update.progress_percent_after_update}%`}
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => openEditWeeklyUpdate(update, index)}
className="rounded-lg border border-zinc-200 px-3 py-1.5 text-xs text-zinc-600 transition hover:bg-zinc-100"
>
编辑
</button>
<button
type="button"
onClick={() => handleWeeklyUpdateDelete(index)}
className="rounded-lg border border-rose-200 px-3 py-1.5 text-xs text-rose-600 transition hover:bg-rose-50"
>
删除
</button>
</div>
</div>
<div className="mt-3 text-sm text-zinc-700">{update.weekly_summary}</div>
{update.blockers ? (
<div className="mt-3 text-xs text-amber-700">
阻塞事项:{update.blockers}
</div>
) : null}
{update.next_week_plan ? (
<div className="mt-2 text-xs text-zinc-500">
下周计划:{update.next_week_plan}
</div>
) : null}
</div>
))
) : (
<div className="rounded-2xl border border-dashed border-zinc-200 bg-white px-4 py-8 text-center text-sm text-zinc-400">
暂无周进度记录
</div>
)}
</div>
</div>
{submitError ? (
<div className="mt-5 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-600">
{submitError}
......@@ -1452,9 +2460,20 @@ function ProjectTaskDialog({
</button>
</div>
</div>
</form>
</form>
</div>
</div>
</div>
<WeeklyUpdateEditorDialog
open={weeklyUpdateDialogOpen}
value={weeklyUpdateDraft}
editing={editingWeeklyUpdateIndex >= 0}
error={weeklyUpdateError}
onChange={handleWeeklyUpdateFieldChange}
onCancel={handleWeeklyUpdateCancel}
onConfirm={handleWeeklyUpdateConfirm}
/>
</>
);
}
......@@ -1463,12 +2482,11 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [isDemoMode, setIsDemoMode] = useState(false);
const [filters, setFilters] = useState({
keyword: '',
status: '',
memberId: '',
leaderId: '',
ownerId: '',
collaboratorId: '',
});
const [activeTab, setActiveTab] = useState('gantt');
const [monthAnchor, setMonthAnchor] = useState(resolveCurrentDate().slice(0, 7));
......@@ -1477,6 +2495,16 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
const [editingTask, setEditingTask] = useState(null);
const [dialogSubmitting, setDialogSubmitting] = useState(false);
const [dialogError, setDialogError] = useState('');
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [importPreviewRows, setImportPreviewRows] = useState([]);
const [importError, setImportError] = useState('');
const [importLoading, setImportLoading] = useState(false);
const [importFileName, setImportFileName] = useState('');
const [fieldOptions, setFieldOptions] = useState({
projectStatusOptions: PROJECT_STATUS_OPTIONS,
priorityOptions: [],
occurrenceFrequencyOptions: OCCURRENCE_FREQUENCY_OPTIONS,
});
const loadWorkspace = async () => {
setLoading(true);
......@@ -1488,18 +2516,13 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
new Set(
taskRecords.flatMap((task) => {
const metadata = task?.metadata || {};
const memberIds = Array.isArray(metadata.member_ids)
? metadata.member_ids
: metadata.member_ids
? [metadata.member_ids]
: [];
const leaderId =
metadata.leader?._id ||
metadata.leader?.id ||
metadata.leader?.username ||
metadata.leader ||
'';
return [...memberIds, leaderId].filter(Boolean);
const projectOwnerIds = Array.isArray(metadata.project_owner)
? metadata.project_owner.map(resolveUserReferenceId)
: [resolveUserReferenceId(metadata.project_owner)].filter(Boolean);
const projectCollaboratorIds = Array.isArray(metadata.project_collaborator)
? metadata.project_collaborator.map(resolveUserReferenceId)
: [resolveUserReferenceId(metadata.project_collaborator)].filter(Boolean);
return [...projectOwnerIds, ...projectCollaboratorIds].filter(Boolean);
}),
),
);
......@@ -1513,25 +2536,14 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
);
setUsers(normalizedUsers);
setTasks(
enrichedTasks.length > 0
? enrichedTasks
: buildDemoTasks().map((task) => enrichProjectTask(task, userMap)),
);
setIsDemoMode(enrichedTasks.length === 0);
setTasks(enrichedTasks);
if (enrichedTasks.length === 0) {
setError('当前未读取到项目记录,已展示演示数据,可继续预览页面交互。');
setError('当前未读取到真实项目记录,请先创建或导入 `pm_project_task` 数据。');
}
} catch (requestError) {
const demoUsers = [
{ id: 'yang_admin', username: 'yang_admin', displayName: 'yang' },
{ id: 'system', username: 'system', displayName: '系统' },
];
const userMap = buildUserMap(demoUsers);
setUsers(demoUsers);
setTasks(buildDemoTasks().map((task) => enrichProjectTask(task, userMap)));
setIsDemoMode(true);
setError(requestError?.message || '加载项目管理工作台失败,已切换为演示数据。');
setUsers([]);
setTasks([]);
setError(requestError?.message || '加载项目管理工作台失败,请稍后重试。');
} finally {
setLoading(false);
}
......@@ -1539,7 +2551,21 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
useEffect(() => {
loadWorkspace();
}, [filters.status, filters.memberId, filters.leaderId]);
}, [filters.status, filters.ownerId, filters.collaboratorId]);
useEffect(() => {
let cancelled = false;
loadProjectTaskFieldOptions().then((options) => {
if (!cancelled) {
setFieldOptions(options);
}
});
return () => {
cancelled = true;
};
}, []);
const userMap = useMemo(() => buildUserMap(users), [users]);
......@@ -1557,7 +2583,10 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
if (
keyword &&
!task.projectName.toLowerCase().includes(keyword) &&
!String(task.projectCode || '').toLowerCase().includes(keyword)
!String(task.bigCategory || '').toLowerCase().includes(keyword) &&
!String(task.projectCategory || '').toLowerCase().includes(keyword) &&
!String(task.taskSource || '').toLowerCase().includes(keyword) &&
!String(task.taskNature || '').toLowerCase().includes(keyword)
) {
return false;
}
......@@ -1606,34 +2635,98 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
};
}, [visibleTasks]);
const applyLocalTask = (values, currentTask) => {
const nextTask = enrichProjectTask(
buildLocalTaskRecord(values, currentTask),
userMap,
);
const persistTask = async (values, currentTask) => {
await saveProjectTask(values, currentTask);
await loadWorkspace();
};
setTasks((current) => {
const currentId = getTaskRecordId(currentTask);
if (!currentId) {
return [nextTask, ...current];
}
const handleImportTemplateDownload = () => {
downloadProjectTaskImportTemplate();
};
return current.map((item) =>
getTaskRecordId(item) === currentId ? nextTask : item,
);
});
const handleImportDialogClose = () => {
if (importLoading) {
return;
}
setImportDialogOpen(false);
setImportPreviewRows([]);
setImportError('');
setImportFileName('');
};
const handleImportFileChange = async (event) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
setImportLoading(true);
setImportError('');
setImportFileName(file.name);
setSelectedTaskId(getTaskRecordId(nextTask));
try {
const rows = await readProjectTaskImportRows(file);
const preview = await Promise.all(
rows.map((row, index) => buildImportPreviewRow(row, index + 2)),
);
setImportPreviewRows(preview);
setImportDialogOpen(true);
} catch (requestError) {
setImportError(requestError?.message || '解析 Excel 失败');
setImportPreviewRows([]);
setImportDialogOpen(true);
} finally {
setImportLoading(false);
event.target.value = '';
}
};
const persistTask = async (values, currentTask) => {
if (isDemoMode) {
applyLocalTask(values, currentTask);
const handleConfirmImport = async () => {
const validRows = importPreviewRows.filter((row) => !row.error);
if (validRows.length === 0) {
setImportError('没有可导入的记录');
return;
}
await saveProjectTask(values, currentTask);
await loadWorkspace();
setImportLoading(true);
setImportError('');
try {
await importRecords(
'pm_project_task',
validRows.map((row) => ({
...buildImportTaskPayload({
project_name: row.metadata.project_name,
big_category: row.metadata.big_category,
project: row.metadata.project,
project_owner: row.metadata.project_owner,
project_collaborator: row.metadata.project_collaborator,
task_source: row.metadata.task_source,
task_nature: row.metadata.task_nature,
occurrence_frequency: row.metadata.occurrence_frequency,
planned_start_date: row.metadata.planned_start_date,
planned_end_date: row.metadata.planned_end_date,
actual_start_date: row.metadata.actual_start_date,
actual_finish_date: row.metadata.actual_finish_date,
description: row.metadata.description,
remark: row.metadata.remark,
priority: row.metadata.priority,
is_annual_audit_plan: row.metadata.is_annual_audit_plan,
project_status: row.metadata.project_status,
completion_percent: row.metadata.completion_percent,
delay_reason: row.metadata.delay_reason,
weekly_updates: row.metadata.weekly_updates,
}),
})),
);
await loadWorkspace();
handleImportDialogClose();
} catch (requestError) {
setImportError(requestError?.message || '导入失败,请稍后重试');
} finally {
setImportLoading(false);
}
};
const openCreateDialog = () => {
......@@ -1669,24 +2762,33 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
openEditDialog(task);
};
const hasVisibleTasks = visibleTasks.length > 0;
return (
<div className="space-y-6">
<ToolbarCard>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="text-lg font-semibold text-zinc-900">资源预定 / 项目管理工作台</div>
<div className="mt-1 text-sm text-zinc-500">
基于 `pm_project_task` 单对象模型,支持领导下发项目、成员共享进度、甘特图与个人日历联动。
</div>
</div>
<button
type="button"
onClick={openCreateDialog}
className="rounded-2xl bg-[linear-gradient(135deg,#0b75b6_0%,#1095d3_55%,#18b6f6_100%)] px-5 py-2.5 text-sm font-medium text-white shadow-[0_16px_40px_rgba(17,128,196,0.2)] transition hover:brightness-105"
>
新建项目
</button>
<div className="flex w-full flex-wrap items-center justify-start gap-3 sm:w-auto sm:justify-end">
<button
type="button"
onClick={openCreateDialog}
className="rounded-2xl bg-[linear-gradient(135deg,#991b1b_0%,#b42318_55%,#e11d48_100%)] px-5 py-2.5 text-sm font-medium text-white shadow-[0_16px_40px_rgba(180,35,24,0.22)] transition hover:brightness-105"
>
新建项目
</button>
<button
type="button"
onClick={() => setImportDialogOpen(true)}
className="rounded-2xl border border-zinc-200 bg-white px-5 py-2.5 text-sm font-medium text-zinc-700 transition hover:bg-zinc-100"
>
批量导入
</button>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-3">
......@@ -1698,8 +2800,8 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
keyword: event.target.value,
}))
}
placeholder="搜索项目名称 / 项目编号"
className="h-10 w-64 rounded-xl border border-zinc-200 px-3 text-sm text-zinc-700 outline-none transition focus:border-sky-400"
placeholder="搜索项目名称 / 大类 / 项目 / 任务来源"
className="h-10 w-64 rounded-xl border border-zinc-200 px-3 text-sm text-zinc-700 outline-none transition focus:border-rose-300"
/>
<FilterSelect
......@@ -1716,27 +2818,27 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
<div className="w-56">
<UserSearchSelect
value={filters.memberId}
value={filters.ownerId}
onChange={(value) =>
setFilters((current) => ({
...current,
memberId: value,
ownerId: value,
}))
}
placeholder="远程搜索成员"
placeholder="远程搜索负责人"
/>
</div>
<div className="w-56">
<UserSearchSelect
value={filters.leaderId}
value={filters.collaboratorId}
onChange={(value) =>
setFilters((current) => ({
...current,
leaderId: value,
collaboratorId: value,
}))
}
placeholder="远程搜索领导"
placeholder="远程搜索协作人"
/>
</div>
</div>
......@@ -1766,8 +2868,8 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
) : null}
{loading ? (
<div className="mt-4 rounded-xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-700">
正在加载项目管理工作台...
<div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
正在加载...
</div>
) : null}
</ToolbarCard>
......@@ -1776,29 +2878,25 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
<StatCard
label="项目总数"
value={stats.total}
hint="当前筛选条件下的全部项目"
tone="primary"
/>
<StatCard
label="进行中"
value={stats.inProgress}
hint="共享进度仍在推进中的项目"
/>
<StatCard
label="已完成"
value={stats.completed}
hint="已经进入 completed 状态的项目"
tone="success"
/>
<StatCard
label="已超时"
value={stats.overdue}
hint="已超出计划结束日期或超时完成"
tone="danger"
/>
</div>
{activeTab === 'gantt' ? (
{activeTab === 'gantt' && hasVisibleTasks ? (
<ProjectGanttBoard
tasks={visibleTasks}
selectedTaskId={selectedTaskId}
......@@ -1807,7 +2905,7 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
/>
) : null}
{activeTab === 'calendar' ? (
{activeTab === 'calendar' && hasVisibleTasks ? (
<ProjectCalendarBoard
tasks={visibleTasks}
currentUserId={currentUser?.id || ''}
......@@ -1817,7 +2915,7 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
/>
) : null}
{activeTab === 'list' ? (
{activeTab === 'list' && hasVisibleTasks ? (
<ProjectListPanel
tasks={visibleTasks}
selectedTaskId={selectedTaskId}
......@@ -1826,11 +2924,18 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
/>
) : null}
{!loading && !hasVisibleTasks ? (
<EmptyWorkspaceNotice
title="当前暂无项目"
/>
) : null}
<ProjectTaskDialog
open={dialogOpen}
task={editingTask}
submitting={dialogSubmitting}
submitError={dialogError}
fieldOptions={fieldOptions}
onClose={() => {
if (dialogSubmitting) {
return;
......@@ -1841,6 +2946,19 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
}}
onSubmit={handleDialogSubmit}
/>
<ProjectImportDialog
open={importDialogOpen}
loading={importLoading}
error={importError}
previewRows={importPreviewRows}
fileName={importFileName}
successRows={importPreviewRows.filter((row) => !row.error)}
onClose={handleImportDialogClose}
onDownloadTemplate={handleImportTemplateDownload}
onFileChange={handleImportFileChange}
onImport={handleConfirmImport}
/>
</div>
);
}
......@@ -8,7 +8,7 @@
- 一个项目通常只有 1-2 名参与成员
- 多名成员共享同一项目进度与状态
- 只会由 1 名成员更新项目进度或状态
- 由项目负责人持续维护周进度、状态与延期原因
---
......@@ -33,15 +33,21 @@
关键字段:
- `project_name`:项目名称
- `project_code`:项目编号
- `leader`:下发领导,直接关联系统用户
- `member_ids`:参与成员,直接关联系统用户,多值
- `big_category`:大类
- `project`:项目归属或项目分类补充字段
- `project_owner`:项目负责人,直接关联系统用户,多值
- `project_collaborator`:项目协作人,直接关联系统用户,多值
- `task_source`:任务来源
- `task_nature`:任务性质
- `occurrence_frequency`:发生频率
- `planned_start_date` / `planned_end_date`:计划时间
- `actual_start_date` / `actual_finish_date`:实际时间
- `project_status`:项目当前业务状态
- `completion_percent`:共享完成进度
- `result_status`:结果状态
- `is_overtime` / `overtime_days`:超时标记
- `weekly_updates`:按周维护的进度记录对象
- `delay_reason`:延期原因说明
- `is_annual_audit_plan`:是否属于年度审计计划
- 结果状态:页面按计划时间、实际完成时间和当前日期实时派生的展示状态,不单独存字段
---
......@@ -50,25 +56,30 @@
### 4.1 领导下发项目
1. 领导创建一条 `pm_project_task`
2. 手工填写项目名称、项目编号、计划开始日期、计划结束日期
3. 选择下发领导 `leader`
4. 多选参与成员 `member_ids`
5. 初始化项目状态为 `assigned` 或 `draft`
2. 填写项目名称、计划开始日期、计划结束日期
3. 按需要补充大类、项目、任务来源、任务性质、发生频率
4. 按需勾选是否属于年度审计计划 `is_annual_audit_plan`
5. 选择一个或多个项目负责人 `project_owner`
6. 选择一个或多个项目协作人 `project_collaborator`
7. 初始化项目状态为 `draft`
### 4.2 成员查看项目
1. 成员登录系统
2. 日历图查询 `member_ids` 包含当前用户的项目记录
2. 日历图查询当前用户包含在 `project_owner` 或 `project_collaborator` 数组中的项目记录
3. 甘特图按项目展示所有任务
4. 可在甘特图或日历图中查看项目名称、计划时间、共享进度、状态
### 4.3 成员更新项目进度
1. 由项目指定更新人修改项目记录
2. 更新 `completion_percent`
3. 更新 `project_status`
4. 如项目开始,填写 `actual_start_date`
5. 如项目完成,填写 `actual_finish_date`
1. 由项目负责人或指定更新人修改项目记录
2. 初始状态默认为 `draft`
3. 新建弹窗在项目尚未保存落库前,只允许编辑基础字段并点击“保存项目”;此时不展示“启动任务”或“完成任务”
4. 点击“启动项目”后,前端直接调用保存接口,状态切换为 `in_progress`,并自动带入 `actual_start_date=当天`;保存成功后关闭弹窗,如需调整实际开始日期可再次打开编辑
5. 点击“完成项目”后,前端直接调用保存接口,状态切换为 `completed`,并自动带入 `actual_finish_date=当天`;如 `actual_start_date` 为空也会一并补当天,完成进度不足 100 时自动补到 100,保存成功后关闭弹窗
6. 项目完成时,如当前完成进度小于 100,可默认补到 100
7. 在项目详情弹窗中通过“新增本周进度”录入一条周进度记录;如填写了周进度里的累计项目进度,保存时自动同步覆盖主记录的 `completion_percent`
8. 若已超过计划结束日期仍未完成,补充 `delay_reason`
### 4.4 状态结果判定
......@@ -76,17 +87,21 @@
- 未开始:当前日期 < `planned_start_date` 且未进入完成状态
- 正常进行:当前日期在计划区间内,且 `project_status` 为 `in_progress`
- 已超时:当前日期 > `planned_end_date` 且 `project_status` 不为 `completed`
- 提前完成:`actual_finish_date` < `planned_end_date`
- 准时完成:`actual_finish_date` = `planned_end_date`
- 超时完成:`actual_finish_date` > `planned_end_date`
- 已超时:存在 `planned_end_date`,且当前日期 > `planned_end_date`、同时 `actual_finish_date` 为空
- 提前完成:存在 `planned_end_date` 与 `actual_finish_date`,且 `actual_finish_date` < `planned_end_date`
- 准时完成:存在 `planned_end_date` 与 `actual_finish_date`,且 `actual_finish_date` = `planned_end_date`
- 超时完成:存在 `planned_end_date` 与 `actual_finish_date`,且 `actual_finish_date` > `planned_end_date`
补充约定:
- 结果状态为派生结果,前端展示与统计时按 `planned_end_date`、`actual_finish_date`、当前日期实时计算,不以手工录入值作为最终口径
- `is_overtime` 与 `overtime_days` 也按上述时间字段实时推导
- 因此“超时”判断的核心依据是“计划完成时间”和“实际完成时间”的对比;仅在任务尚未完成时,才使用“当前日期是否超过计划完成时间”判断是否处于进行中的超时状态
对应字段建议:
- `project_status`:业务状态
- `result_status`:结果状态
- `is_overtime`:是否超时
- `overtime_days`:超时天数
- `delay_reason`:延期原因
---
......@@ -95,6 +110,8 @@
### 5.0 资源统计
- `资源统计` 菜单用于承接项目管理统计看板
- 页面布局采用“顶部概览区 + 指标卡片 + ECharts 图表面板 + 风险清单”的工作台结构
- 顶部概览区展示:当前看板说明、我的关联项目数、需优先关注数量、受阻项目数量
- 当前版本建议至少展示:
- 项目总数
- 进行中数量
......@@ -102,25 +119,55 @@
- 已超时数量
- 受阻数量
- 平均进度
- 图表区建议固定展示:
- 项目状态分布环图
- 结果状态分布柱状图
- 进度区间分布柱状图
- 成员负载 Top N 横向柱状图
- 维度概览区保留列表化统计:
- 发生频率
- 大类
- 任务来源
- 风险清单区展示:
- 即将到期项目
- 超时项目清单
- 建议补充统计维度:
- 项目状态分布
- 项目优先级分布
- 成员负载分布
- 结果状态分布(按时间规则实时派生)
- 发生频率分布
- 大类分布
- 任务来源分布
- 负责人/协作人负载分布
- 即将到期项目
- 超时项目清单
- 统计口径以 `pm_project_task.metadata` 的当前字段为准,优先展示:
- `project_status`
- `occurrence_frequency`
- `big_category`
- `task_source`
### 5.1 甘特图
- 以 `pm_project_task` 为数据源
- 展示项目名称、计划时间、实际时间、项目状态、结果状态
- 展示项目名称、项目负责人、计划时间、实际时间、项目状态,以及按时间规则派生的结果状态
- 同时展示两个百分比:
- 时间进度百分比:按 `planned_start_date` 到 `planned_end_date` 与当前日期的关系自动计算
- 实际进度百分比:读取 `completion_percent`
- 已完成、超时、提前完成等状态需要有明显视觉区分
- 甘特图条内展示最新一周的周进度总结;如存在多条周记录,以最新一条摘要为准
- 周进度记录数量可作为辅助信息一并展示,帮助判断项目是否持续更新
- 周进度明细通过项目编辑弹窗内的记录列表和新增/编辑弹窗维护
- 后续可按状态映射颜色,不必先存颜色字段
### 5.2 个人日历图
- 当前用户只看 `member_ids` 包含自己的项目
- 当前用户只看自己出现在 `project_owner` 或 `project_collaborator` 数组中的项目
- 展示项目名称、计划时间、项目状态、共享进度
- 共享进度与共享状态对所有参与成员一致
- 日历图按 `planned_start_date` 到 `planned_end_date` 生成连续区间条,不再按天重复拆成多个独立卡片
- 当项目跨周时,区间条在对应周内自动拆段显示,但视觉上保持连续排期的阅读方式
- 共享进度与共享状态对负责人和协作人一致
- 如项目存在最新周进度,可在详情或弹窗中查看周记录内容
---
......@@ -129,13 +176,15 @@
- 当前版本不区分成员个人独立进度
- 当前版本不拆分子任务
- 当前版本一个项目只维护一条主记录
- 当前版本默认 1-2 名成员协作并共享进度
- 当前版本默认负责人 + 协作人协同维护共享进度
- 周进度记录当前存放在 `pm_project_task.metadata.weekly_updates` 中,暂不拆独立日志对象
- 前端页面仅展示真实 `pm_project_task` Record 数据;当查询结果为空或接口失败时,只允许显示空状态或错误提示,禁止再回退演示数据
---
## 6.1 系统用户远程搜索示例
`leader` 和 `member_ids` 使用系统用户,不单独维护业务用户对象。
`project_owner` 和 `project_collaborator` 使用系统用户,不单独维护业务用户对象,两个字段都支持多值数组。
前端远程搜索用户时,统一调用 `queryUsers`,按 `display_name` 和 `username` 模糊匹配:
......@@ -164,6 +213,28 @@ const result = await queryUsers(0, 20, {
---
## 6.2 周进度记录维护约定
- `weekly_updates.items` 采用对象数组,按时间顺序追加
- 每条记录建议至少填写:`week_start_date`、`week_end_date`、`weekly_summary`
- 如填写了 `progress_percent_after_update`,保存时自动同步到主记录的 `completion_percent`,以最后一条含进度值的周记录为准
- 如存在阻塞事项,建议在周记录的 `blockers` 中体现;如果项目已延期,再同步补充主记录字段 `delay_reason`
- 当前版本周进度记录由项目负责人统一维护,不拆分到成员个人视角
- 当前前端交互不要求用户直接填写 JSON,而是通过结构化表单维护周进度记录
## 6.3 Excel 批量导入口径
- Excel 模板采用业务列头:`大类`、`项目`、`是否属于年度审计计划`、`发生频率`、`任务名称`、`任务来源`、`任务性质`、`计划启动时间`、`计划完成时间`、`时间进度`、`任务进度`、`任务状态`、`主办人`、`协办人1`
- `任务名称` 导入到 `metadata.project_name`
- `是否属于年度审计计划` 导入到 `metadata.is_annual_audit_plan`,支持 `是/否`、`true/false`、`1/0`
- `主办人` 导入到 `metadata.project_owner`,`协办人1` 导入到 `metadata.project_collaborator`;支持填写系统用户 `display_name` 或 `username`,前端会先按 `username/display_name` 的 `$regex` 条件调用 `queryUsers` 查询,并最终保存为用户 `_id` 数组
- `计划启动时间` / `计划完成时间` 支持 Excel 日期、`YYYY-MM-DD`、`YYYY/MM/DD`、`2026年5月1日`、`5月1日`;入库时统一转换为 ISO 8601
- `任务进度` 支持 `20` 或 `20%`,导入到 `metadata.completion_percent`
- `任务状态` 支持中文状态值,导入时转换为 `draft` / `in_progress` / `completed` / `blocked` / `cancelled`
- `时间进度` 属于前端派生展示值,不参与系统计算;为避免丢失,导入时仅保留在 `remark`
---
## 7. 后续可扩展方向
- 增加项目日志对象,记录状态变更历史
......
import {
buildDemoTasks,
buildUserMap,
enrichProjectTask,
fetchProjectTasks,
fetchSystemUsersByIds,
loadProjectTaskFieldOptions,
resolveUserReferenceId,
} from './projectTaskService';
export async function loadProjectDashboardData(currentUsername = '') {
try {
const taskRecords = await fetchProjectTasks({});
const [taskRecords, fieldOptions] = await Promise.all([
fetchProjectTasks({}),
loadProjectTaskFieldOptions(),
]);
const userIds = Array.from(
new Set(
taskRecords.flatMap((task) => {
const metadata = task?.metadata || {};
const memberIds = Array.isArray(metadata.member_ids)
? metadata.member_ids
: metadata.member_ids
? [metadata.member_ids]
: [];
const leaderId =
metadata.leader?._id ||
metadata.leader?.id ||
metadata.leader?.username ||
metadata.leader ||
'';
const projectOwnerIds = Array.isArray(metadata.project_owner)
? metadata.project_owner.map(resolveUserReferenceId)
: [resolveUserReferenceId(metadata.project_owner)].filter(Boolean);
const projectCollaboratorIds = Array.isArray(metadata.project_collaborator)
? metadata.project_collaborator.map(resolveUserReferenceId)
: [resolveUserReferenceId(metadata.project_collaborator)].filter(Boolean);
return [...memberIds, leaderId].filter(Boolean);
return [...projectOwnerIds, ...projectCollaboratorIds].filter(Boolean);
}),
),
);
......@@ -42,29 +41,26 @@ export async function loadProjectDashboardData(currentUsername = '') {
return {
tasks,
users,
fieldOptions,
isDemoMode: false,
error: '',
};
}
return {
tasks: buildDemoTasks().map((task) => enrichProjectTask(task, userMap)),
tasks: [],
users,
isDemoMode: true,
error: '当前未读取到项目记录,已展示演示数据,可继续浏览统计看板。',
fieldOptions,
isDemoMode: false,
error: '当前未读取到真实项目记录,请先创建或导入 `pm_project_task` 数据。',
};
} catch (error) {
const fallbackUsers = [
{ id: 'yang_admin', username: 'yang_admin', displayName: 'yang' },
{ id: 'system', username: 'system', displayName: '系统' },
];
const userMap = buildUserMap(fallbackUsers);
return {
tasks: buildDemoTasks().map((task) => enrichProjectTask(task, userMap)),
users: fallbackUsers,
isDemoMode: true,
error: error?.message || '统计数据加载失败,已切换为演示数据。',
tasks: [],
users: [],
fieldOptions: {},
isDemoMode: false,
error: error?.message || '统计数据加载失败,请稍后重试。',
};
}
}
import {
createRecord,
importRecords,
loadSubject,
loadUser,
queryRecords,
queryUsers,
updateRecord,
} from '@/services/DataService';
import * as XLSX from 'xlsx';
export { importRecords };
export const PROJECT_TASK_SUBJECT = 'pm_project_task';
export const PROJECT_STATUS_OPTIONS = [
{ label: '草稿', value: 'draft' },
{ label: '已下发', value: 'assigned' },
{ label: '进行中', value: 'in_progress' },
{ label: '已完成', value: 'completed' },
{ label: '受阻', value: 'blocked' },
{ label: '已取消', value: 'cancelled' },
];
export const OCCURRENCE_FREQUENCY_OPTIONS = [
{ label: '高', value: '3' },
{ label: '中', value: '2' },
{ label: '低', value: '1' },
];
export const PRIORITY_OPTIONS = [
{ label: '高', value: 'high' },
{ label: '中', value: 'medium' },
......@@ -56,10 +66,28 @@ export const PRIORITY_LABEL_MAP = PRIORITY_OPTIONS.reduce(
{},
);
export const OCCURRENCE_FREQUENCY_LABEL_MAP = OCCURRENCE_FREQUENCY_OPTIONS.reduce(
(accumulator, item) => {
accumulator[item.value] = item.label;
return accumulator;
},
{},
);
export function normalizeText(value) {
return String(value ?? '').trim();
}
export function resolveUserReferenceId(value) {
return value?._id || value?.id || value?.username || value || '';
}
export function normalizeUserReferenceList(value) {
const values = Array.isArray(value) ? value : value ? [value] : [];
return values.map(resolveUserReferenceId).filter(Boolean);
}
export function formatDateInput(value) {
if (!value) {
return '';
......@@ -88,6 +116,15 @@ export function formatDateLabel(value) {
return `${year}-${month}-${day}`;
}
export function formatDateValueForPayload(value) {
const dateText = formatDateInput(value);
if (!dateText) {
return '';
}
return new Date(`${dateText}T00:00:00.000Z`).toISOString();
}
export function calculateDiffDays(startDate, endDate) {
const start = formatDateInput(startDate);
const end = formatDateInput(endDate);
......@@ -100,6 +137,325 @@ export function calculateDiffDays(startDate, endDate) {
return Math.floor((endTime - startTime) / 86400000);
}
export function calculatePlannedProgressPercent(startDate, endDate, currentDate = '') {
const plannedStartDate = formatDateInput(startDate);
const plannedEndDate = formatDateInput(endDate);
const today = formatDateInput(currentDate) || new Date().toISOString().slice(0, 10);
if (!plannedStartDate || !plannedEndDate) {
return 0;
}
if (plannedStartDate >= plannedEndDate) {
if (today < plannedStartDate) {
return 0;
}
return 100;
}
if (today <= plannedStartDate) {
return 0;
}
if (today >= plannedEndDate) {
return 100;
}
const totalDays = Math.max(calculateDiffDays(plannedStartDate, plannedEndDate), 1);
const elapsedDays = Math.max(calculateDiffDays(plannedStartDate, today), 0);
return Math.max(0, Math.min(100, Math.round((elapsedDays / totalDays) * 100)));
}
export function sortWeeklyUpdates(updates = []) {
return [...updates].sort((left, right) => {
const leftKey =
formatDateInput(left?.week_start_date) ||
formatDateInput(left?.updated_at) ||
'9999-12-31';
const rightKey =
formatDateInput(right?.week_start_date) ||
formatDateInput(right?.updated_at) ||
'9999-12-31';
if (leftKey === rightKey) {
const leftUpdatedAt = formatDateInput(left?.updated_at) || '';
const rightUpdatedAt = formatDateInput(right?.updated_at) || '';
return leftUpdatedAt.localeCompare(rightUpdatedAt);
}
return leftKey.localeCompare(rightKey);
});
}
export function wrapWeeklyUpdatesValue(updates = []) {
return {
items: sortWeeklyUpdates(
(Array.isArray(updates) ? updates : []).filter((item) => item && typeof item === 'object'),
),
};
}
export function normalizeWeeklyUpdatesValue(value, options = {}) {
const { throwOnInvalid = false } = options;
if (!value) {
return [];
}
if (Array.isArray(value)) {
return sortWeeklyUpdates(value.filter((item) => item && typeof item === 'object'));
}
if (typeof value === 'object') {
const items = Array.isArray(value.items)
? value.items
: Array.isArray(value.records)
? value.records
: [];
if (items.length > 0) {
return sortWeeklyUpdates(items.filter((item) => item && typeof item === 'object'));
}
if (throwOnInvalid) {
throw new Error('周进度记录需填写为合法的对象或数组');
}
return [];
}
if (typeof value === 'string') {
const text = normalizeText(value);
if (!text) {
return [];
}
try {
const parsed = JSON.parse(text);
if (Array.isArray(parsed)) {
return sortWeeklyUpdates(parsed.filter((item) => item && typeof item === 'object'));
}
if (parsed && typeof parsed === 'object' && Array.isArray(parsed.items)) {
return sortWeeklyUpdates(parsed.items.filter((item) => item && typeof item === 'object'));
}
throw new Error('周进度记录必须是 JSON 对象或数组');
} catch (error) {
if (throwOnInvalid) {
throw new Error('周进度记录需填写为合法的 JSON 对象或数组');
}
return [];
}
}
if (throwOnInvalid) {
throw new Error('周进度记录需填写为合法的 JSON 对象或数组');
}
return [];
}
export function serializeWeeklyUpdatesValue(value, options = {}) {
const updates = normalizeWeeklyUpdatesValue(value, options).map((item) => ({
...item,
week_start_date: formatDateValueForPayload(item.week_start_date),
week_end_date: formatDateValueForPayload(item.week_end_date),
progress_percent_after_update:
item.progress_percent_after_update === '' || item.progress_percent_after_update == null
? ''
: Number(item.progress_percent_after_update),
blockers: normalizeText(item.blockers),
next_week_plan: normalizeText(item.next_week_plan),
weekly_summary: normalizeText(item.weekly_summary),
updated_by: resolveUserReferenceId(item.updated_by),
updated_at: formatDateValueForPayload(item.updated_at),
}));
return wrapWeeklyUpdatesValue(updates);
}
export function resolveCompletionPercentFromWeeklyUpdates(weeklyUpdates, fallbackValue = 0) {
const updates = normalizeWeeklyUpdatesValue(weeklyUpdates);
const latestProgressUpdate = [...updates]
.reverse()
.find(
(item) =>
item?.progress_percent_after_update !== '' &&
item?.progress_percent_after_update != null &&
Number.isFinite(Number(item.progress_percent_after_update)),
);
if (latestProgressUpdate) {
return Number(latestProgressUpdate.progress_percent_after_update);
}
return Number(fallbackValue || 0);
}
export function normalizeSelectOptions(options = []) {
if (!Array.isArray(options)) {
return [];
}
return options
.map((option) => ({
label: normalizeText(option?.label),
value: String(option?.value ?? '').trim(),
}))
.filter((option) => option.label && option.value);
}
function extractSubjectFields(subject) {
if (Array.isArray(subject?.fields)) {
return subject.fields;
}
if (Array.isArray(subject?.data?.fields)) {
return subject.data.fields;
}
if (Array.isArray(subject?.subject?.fields)) {
return subject.subject.fields;
}
return [];
}
function extractSubjectField(subject, fieldName) {
return extractSubjectFields(subject).find((field) => field?.name === fieldName) || null;
}
export async function loadProjectTaskFieldOptions() {
try {
const subject = await loadSubject(PROJECT_TASK_SUBJECT);
const projectStatusField = extractSubjectField(subject, 'project_status');
const priorityField = extractSubjectField(subject, 'priority');
const occurrenceFrequencyField = extractSubjectField(subject, 'occurrence_frequency');
return {
projectStatusOptions:
normalizeSelectOptions(projectStatusField?.settings?.options).length > 0
? normalizeSelectOptions(projectStatusField?.settings?.options)
: PROJECT_STATUS_OPTIONS,
priorityOptions: normalizeSelectOptions(priorityField?.settings?.options),
occurrenceFrequencyOptions:
normalizeSelectOptions(occurrenceFrequencyField?.settings?.options).length > 0
? normalizeSelectOptions(occurrenceFrequencyField?.settings?.options)
: OCCURRENCE_FREQUENCY_OPTIONS,
};
} catch (error) {
return {
projectStatusOptions: PROJECT_STATUS_OPTIONS,
priorityOptions: [],
occurrenceFrequencyOptions: OCCURRENCE_FREQUENCY_OPTIONS,
};
}
}
export const PROJECT_TASK_IMPORT_HEADERS = [
'大类',
'项目',
'是否属于年度审计计划',
'发生频率',
'任务名称',
'任务来源',
'任务性质',
'计划启动时间',
'计划完成时间',
'时间进度',
'任务进度',
'任务状态',
'主办人',
'协办人1',
];
export const PROJECT_TASK_IMPORT_EXAMPLE_ROW = [
'审计工作',
'内部专项审计审查',
'是',
'1',
'反洗钱专项审计(覆盖子公司)',
'监管规定',
'项目性',
'5月1日',
'6月30日',
'33%',
'20%',
'进行中',
'李依敏',
'牟文博',
];
export function buildProjectTaskImportWorkbook() {
const workbook = XLSX.utils.book_new();
const templateSheet = XLSX.utils.aoa_to_sheet([
PROJECT_TASK_IMPORT_HEADERS,
PROJECT_TASK_IMPORT_EXAMPLE_ROW,
]);
templateSheet['!cols'] = [
{ wch: 14 },
{ wch: 18 },
{ wch: 16 },
{ wch: 10 },
{ wch: 28 },
{ wch: 14 },
{ wch: 12 },
{ wch: 14 },
{ wch: 14 },
{ wch: 10 },
{ wch: 10 },
{ wch: 10 },
{ wch: 14 },
{ wch: 14 },
];
const guideSheet = XLSX.utils.aoa_to_sheet([
['字段', '填写说明'],
['模板示例行', '第 2 行是示例数据,正式导入前可直接覆盖或删除'],
['任务名称', '导入为项目名称 metadata.project_name'],
['主办人 / 协办人1', '填写系统用户的 display_name、username 或 _id;多人可用逗号、顿号、分号、斜杠分隔,导入时自动查询并保存为用户 _id 数组'],
['发生频率', '支持 1 / 2 / 3,也支持 高 / 中 / 低'],
['计划启动时间 / 计划完成时间', '支持 Excel 日期、YYYY-MM-DD、YYYY/MM/DD、2026年5月1日、5月1日;无年份时默认当前年份'],
['时间进度', '系统内为派生字段,导入时仅保留到备注中,不参与系统计算'],
['任务进度', '支持 20 或 20% 格式,导入为 completion_percent'],
['任务状态', '支持 草稿 / 进行中 / 已完成 / 受阻 / 已取消'],
['是否属于年度审计计划', '支持 是/否、true/false、1/0;导入到 metadata.is_annual_audit_plan'],
]);
guideSheet['!cols'] = [{ wch: 24 }, { wch: 88 }];
XLSX.utils.book_append_sheet(workbook, templateSheet, '导入模板');
XLSX.utils.book_append_sheet(workbook, guideSheet, '说明');
return workbook;
}
export function downloadProjectTaskImportTemplate(filename = '项目管理导入模板.xlsx') {
XLSX.writeFile(buildProjectTaskImportWorkbook(), filename);
}
export async function readProjectTaskImportRows(file) {
const buffer = await file.arrayBuffer();
const workbook = XLSX.read(buffer, { type: 'array' });
const firstSheetName = workbook.SheetNames[0];
if (!firstSheetName) {
return [];
}
const sheet = workbook.Sheets[firstSheetName];
if (!sheet) {
return [];
}
return XLSX.utils
.sheet_to_json(sheet, {
defval: '',
raw: false,
})
.filter((row) =>
Object.values(row || {}).some((value) => String(value ?? '').trim() !== ''),
);
}
export function resolveResultStatus(task) {
const metadata = task?.metadata || {};
const projectStatus = metadata.project_status || 'draft';
......@@ -108,7 +464,7 @@ export function resolveResultStatus(task) {
const actualFinishDate = formatDateInput(metadata.actual_finish_date);
const today = new Date().toISOString().slice(0, 10);
if (projectStatus === 'completed' && actualFinishDate && plannedEndDate) {
if (actualFinishDate && plannedEndDate) {
if (actualFinishDate < plannedEndDate) {
return 'completed_early';
}
......@@ -118,15 +474,15 @@ export function resolveResultStatus(task) {
return 'completed_on_time';
}
if (
projectStatus !== 'completed' &&
plannedEndDate &&
today > plannedEndDate
) {
if (plannedEndDate && !actualFinishDate && today > plannedEndDate) {
return 'overdue';
}
if (plannedStartDate && today < plannedStartDate) {
if (projectStatus === 'completed') {
return plannedEndDate ? 'completed_on_time' : 'on_track';
}
if (plannedStartDate && !actualFinishDate && today < plannedStartDate) {
return 'not_started';
}
......@@ -135,45 +491,51 @@ export function resolveResultStatus(task) {
export function enrichProjectTask(task, userMap = new Map()) {
const metadata = task?.metadata || {};
const memberIds = Array.isArray(metadata.member_ids)
? metadata.member_ids.filter(Boolean)
: metadata.member_ids
? [metadata.member_ids]
: [];
const plannedStartDate = formatDateInput(metadata.planned_start_date);
const plannedEndDate = formatDateInput(metadata.planned_end_date);
const actualFinishDate = formatDateInput(metadata.actual_finish_date);
const resultStatus = metadata.result_status || resolveResultStatus(task);
const isOvertime =
typeof metadata.is_overtime === 'boolean'
? metadata.is_overtime
: resultStatus === 'overdue' || resultStatus === 'completed_overdue';
const overtimeDays =
Number.isFinite(Number(metadata.overtime_days)) && metadata.overtime_days !== ''
? Number(metadata.overtime_days)
: actualFinishDate && plannedEndDate && actualFinishDate > plannedEndDate
const plannedProgressPercent = calculatePlannedProgressPercent(
plannedStartDate,
plannedEndDate,
);
const resultStatus = resolveResultStatus(task);
const isOvertime = resultStatus === 'overdue' || resultStatus === 'completed_overdue';
const overtimeDays = isOvertime
? actualFinishDate && plannedEndDate && actualFinishDate > plannedEndDate
? calculateDiffDays(plannedEndDate, actualFinishDate)
: !actualFinishDate && plannedEndDate
? Math.max(calculateDiffDays(plannedEndDate, new Date().toISOString().slice(0, 10)), 0)
: 0;
: 0
: 0;
const leaderId =
metadata.leader?._id ||
metadata.leader?.id ||
metadata.leader?.username ||
metadata.leader ||
'';
const projectOwnerIds = normalizeUserReferenceList(metadata.project_owner);
const projectCollaboratorIds = normalizeUserReferenceList(metadata.project_collaborator);
const weeklyUpdates = normalizeWeeklyUpdatesValue(metadata.weekly_updates);
const latestWeeklyUpdate =
weeklyUpdates.length > 0 ? weeklyUpdates[weeklyUpdates.length - 1] : null;
const legacyUserIds = [
...normalizeUserReferenceList(metadata.member_ids),
resolveUserReferenceId(metadata.leader),
];
const relatedUserIds = Array.from(
new Set(
[...projectOwnerIds, ...projectCollaboratorIds, ...legacyUserIds].filter(Boolean),
),
);
return {
...task,
metadata: {
...metadata,
result_status: resultStatus,
is_overtime: isOvertime,
overtime_days: overtimeDays,
weekly_updates: wrapWeeklyUpdatesValue(weeklyUpdates),
},
projectName: normalizeText(metadata.project_name) || task?.title || '未命名项目',
projectCode: normalizeText(metadata.project_code) || '-',
bigCategory: normalizeText(metadata.big_category),
projectCategory: normalizeText(metadata.project),
taskSource: normalizeText(metadata.task_source),
taskNature: normalizeText(metadata.task_nature),
projectStatus: metadata.project_status || 'draft',
projectStatusLabel:
PROJECT_STATUS_LABEL_MAP[metadata.project_status] || metadata.project_status || '-',
......@@ -183,14 +545,38 @@ export function enrichProjectTask(task, userMap = new Map()) {
plannedEndDate,
actualStartDate: formatDateInput(metadata.actual_start_date),
actualFinishDate,
plannedProgressPercent,
progressPercent: Number(metadata.completion_percent || 0),
priority: metadata.priority || '',
priorityLabel: PRIORITY_LABEL_MAP[metadata.priority] || '-',
memberIds,
memberUsers: memberIds
occurrenceFrequency: String(metadata.occurrence_frequency ?? '').trim(),
occurrenceFrequencyLabel:
OCCURRENCE_FREQUENCY_LABEL_MAP[String(metadata.occurrence_frequency ?? '').trim()] || '-',
projectOwnerIds,
projectCollaboratorIds,
relatedUserIds,
relatedUsers: relatedUserIds
.map((id) => userMap.get(id))
.filter(Boolean),
leaderUser: userMap.get(leaderId) || metadata.leader || null,
projectOwnerUsers: projectOwnerIds
.map((id) => userMap.get(id) || { id })
.filter(Boolean),
projectCollaboratorUsers: projectCollaboratorIds
.map((id) => userMap.get(id) || { id })
.filter(Boolean),
projectOwnerUser:
projectOwnerIds.length > 0 ? userMap.get(projectOwnerIds[0]) || { id: projectOwnerIds[0] } : null,
projectCollaboratorUser:
projectCollaboratorIds.length > 0
? userMap.get(projectCollaboratorIds[0]) || { id: projectCollaboratorIds[0] }
: null,
delayReason: normalizeText(metadata.delay_reason),
weeklyUpdates,
latestWeeklyUpdate,
latestWeeklySummary: normalizeText(
latestWeeklyUpdate?.weekly_summary || latestWeeklyUpdate?.summary,
),
weeklyUpdateCount: weeklyUpdates.length,
isOvertime,
overtimeDays,
dateSpanDays:
......@@ -203,8 +589,8 @@ export function enrichProjectTask(task, userMap = new Map()) {
export function buildTaskFilter({
keyword = '',
status = '',
memberId = '',
leaderId = '',
ownerId = '',
collaboratorId = '',
}) {
const conditions = [];
......@@ -218,7 +604,25 @@ export function buildTaskFilter({
},
},
{
'metadata.project_code': {
'metadata.big_category': {
$regex: keyword,
$options: 'i',
},
},
{
'metadata.project': {
$regex: keyword,
$options: 'i',
},
},
{
'metadata.task_source': {
$regex: keyword,
$options: 'i',
},
},
{
'metadata.task_nature': {
$regex: keyword,
$options: 'i',
},
......@@ -233,15 +637,15 @@ export function buildTaskFilter({
});
}
if (memberId) {
if (ownerId) {
conditions.push({
'metadata.member_ids': memberId,
'metadata.project_owner': ownerId,
});
}
if (leaderId) {
if (collaboratorId) {
conditions.push({
'metadata.leader': leaderId,
'metadata.project_collaborator': collaboratorId,
});
}
......@@ -276,8 +680,17 @@ export function normalizeUserRecord(user) {
id: user?._id || user?.id || user?.username || '',
username: user?.username || '',
displayName:
user?.display_name || user?.title || user?.username || user?._id || '',
raw: user,
user?.display_name ||
user?.displayName ||
user?.title ||
user?.username ||
user?._id ||
'',
status: user?.status || '',
type: user?.type || '',
updatedAt: user?.updated_at || user?.updatedAt || '',
createdAt: user?.created_at || user?.createdAt || '',
raw: user?.raw || user,
};
}
......@@ -294,6 +707,189 @@ export function mergeUserRecords(users = []) {
return Array.from(userMap.values());
}
function extractUserQueryResults(response) {
if (Array.isArray(response?.users)) {
return response.users;
}
if (Array.isArray(response?.records)) {
return response.records;
}
return [];
}
function buildImportUserQueryFilter(keyword = '') {
const normalizedKeyword = normalizeText(keyword);
if (!normalizedKeyword) {
return '';
}
return {
$or: [
{
username: {
$regex: normalizedKeyword,
},
},
{
display_name: {
$regex: normalizedKeyword,
},
},
],
};
}
function buildBatchImportUserQueryFilter(keywords = []) {
const normalizedKeywords = Array.from(
new Set((Array.isArray(keywords) ? keywords : []).map(normalizeText).filter(Boolean)),
);
if (normalizedKeywords.length === 0) {
return '';
}
return {
$or: [
{
username: {
$in: normalizedKeywords,
},
},
{
display_name: {
$in: normalizedKeywords,
},
},
],
};
}
function normalizeImportUserCompareValue(value = '') {
return String(value ?? '')
.trim()
.toLowerCase()
.replace(/\s+/g, '')
.replace(/[((]/g, '(')
.replace(/[))]/g, ')');
}
function compareImportUserPriority(leftUser, rightUser) {
const left = normalizeUserRecord(leftUser);
const right = normalizeUserRecord(rightUser);
if (left.status !== right.status) {
if (left.status === 'active') {
return -1;
}
if (right.status === 'active') {
return 1;
}
}
const leftUpdatedAt = new Date(left.updatedAt || 0).getTime();
const rightUpdatedAt = new Date(right.updatedAt || 0).getTime();
if (leftUpdatedAt !== rightUpdatedAt) {
return rightUpdatedAt - leftUpdatedAt;
}
const leftCreatedAt = new Date(left.createdAt || 0).getTime();
const rightCreatedAt = new Date(right.createdAt || 0).getTime();
if (leftCreatedAt !== rightCreatedAt) {
return rightCreatedAt - leftCreatedAt;
}
return left.displayName.localeCompare(right.displayName, 'zh-CN');
}
function findExactImportUserMatch(users = [], keyword = '') {
const normalizedKeyword = normalizeText(keyword);
if (!normalizedKeyword) {
return null;
}
const normalizedCompareKeyword = normalizeImportUserCompareValue(normalizedKeyword);
const exactDisplayNameMatches = users.filter((user) => {
const normalizedUser = normalizeUserRecord(user);
return (
normalizeImportUserCompareValue(normalizedUser.displayName) ===
normalizedCompareKeyword
);
});
if (exactDisplayNameMatches.length > 0) {
return normalizeUserRecord(
[...exactDisplayNameMatches].sort(compareImportUserPriority)[0],
);
}
const exactUsernameMatches = users.filter((user) => {
const normalizedUser = normalizeUserRecord(user);
return (
normalizeImportUserCompareValue(normalizedUser.username) ===
normalizedCompareKeyword
);
});
if (exactUsernameMatches.length > 0) {
return normalizeUserRecord(
[...exactUsernameMatches].sort(compareImportUserPriority)[0],
);
}
const exactIdMatches = users.filter((user) => {
const normalizedUser = normalizeUserRecord(user);
return normalizeImportUserCompareValue(normalizedUser.id) === normalizedCompareKeyword;
});
if (exactIdMatches.length > 0) {
return normalizeUserRecord([...exactIdMatches].sort(compareImportUserPriority)[0]);
}
if (users.length === 1) {
return normalizeUserRecord(users[0]);
}
return null;
}
async function resolveImportUserReference(keyword = '') {
const normalizedKeyword = normalizeText(keyword);
if (!normalizedKeyword) {
return {
user: null,
candidates: [],
};
}
const users = await searchSystemUsers(normalizedKeyword);
const matchedUser = findExactImportUserMatch(users, normalizedKeyword);
return {
user: matchedUser,
candidates: users,
};
}
async function searchSystemUsersByExactValues(keywords = []) {
const normalizedKeywords = Array.from(
new Set((Array.isArray(keywords) ? keywords : []).map(normalizeText).filter(Boolean)),
);
if (normalizedKeywords.length === 0) {
return [];
}
const response = await queryUsers(
0,
Math.max(normalizedKeywords.length * 5, 20),
buildBatchImportUserQueryFilter(normalizedKeywords),
null,
);
return mergeUserRecords(extractUserQueryResults(response));
}
export async function searchSystemUsers(keyword = '') {
const normalizedKeyword = normalizeText(keyword);
const response = await queryUsers(
......@@ -317,7 +913,7 @@ export async function searchSystemUsers(keyword = '') {
: '',
null,
);
return mergeUserRecords(response?.records || []);
return mergeUserRecords(extractUserQueryResults(response));
}
export async function fetchSystemUsersByIds(ids = []) {
......@@ -355,30 +951,219 @@ export function buildUserMap(users = []) {
);
}
export function splitImportUserReferences(value) {
const normalizedValue = normalizeText(value);
if (!normalizedValue) {
return [];
}
return normalizedValue
.split(/[,,、;;\/\n]+/)
.map((item) => normalizeText(item))
.filter(Boolean);
}
export async function resolveImportUserReferenceList(value, fieldLabel = '用户') {
return resolveImportUserReferenceListWithCandidates(value, fieldLabel);
}
export async function resolveImportUserReferenceListWithCandidates(
value,
fieldLabel = '用户',
candidateUsers = [],
) {
const referenceValues = splitImportUserReferences(value);
if (referenceValues.length === 0) {
return [];
}
const resolvedUsers = await Promise.all(
referenceValues.map(async (referenceValue) => ({
referenceValue,
...(Array.isArray(candidateUsers) && candidateUsers.length > 0
? {
user: findExactImportUserMatch(candidateUsers, referenceValue),
candidates: candidateUsers.filter((candidate) => {
const normalizedCandidate = normalizeUserRecord(candidate);
const compareValue = normalizeImportUserCompareValue(referenceValue);
return (
normalizeImportUserCompareValue(normalizedCandidate.displayName).includes(compareValue) ||
normalizeImportUserCompareValue(normalizedCandidate.username).includes(compareValue) ||
normalizeImportUserCompareValue(normalizedCandidate.id).includes(compareValue)
);
}),
}
: await resolveImportUserReference(referenceValue)),
})),
);
const unresolvedValues = resolvedUsers.filter((item) => !item.user?.id);
if (unresolvedValues.length > 0) {
const unresolvedMessage = unresolvedValues
.map((item) => {
const candidateLabels = (Array.isArray(item.candidates) ? item.candidates : [])
.map((candidate) => normalizeUserRecord(candidate))
.slice(0, 3)
.map((candidate) =>
candidate.displayName && candidate.username
? `${candidate.displayName}(${candidate.username})`
: candidate.displayName || candidate.username || candidate.id,
)
.filter(Boolean);
return candidateLabels.length > 0
? `${item.referenceValue},可选:${candidateLabels.join('、')}`
: item.referenceValue;
})
.join(';');
throw new Error(`${fieldLabel}未匹配到系统用户:${unresolvedMessage}`);
}
return Array.from(
new Set(resolvedUsers.map((item) => item.user.id).filter(Boolean)),
);
}
export async function resolveImportUsersForRows(rows = []) {
const allReferenceValues = Array.from(
new Set(
(Array.isArray(rows) ? rows : []).flatMap((row) => [
...splitImportUserReferences(row?.['主办人'] || row?.['项目负责人']),
...splitImportUserReferences(row?.['协办人1'] || row?.['项目协作人']),
]),
),
);
return searchSystemUsersByExactValues(allReferenceValues);
}
export function buildTaskPayload(values) {
const projectName = normalizeText(values.project_name);
const projectOwner = normalizeUserReferenceList(values.project_owner);
const projectCollaborator = normalizeUserReferenceList(values.project_collaborator);
const plannedStartDate = formatDateInput(values.planned_start_date);
const plannedEndDate = formatDateInput(values.planned_end_date);
const projectStatus = values.project_status || 'draft';
const actualStartDateInput = formatDateInput(values.actual_start_date);
const actualFinishDateInput = formatDateInput(values.actual_finish_date);
const today = new Date().toISOString().slice(0, 10);
const serializedWeeklyUpdates = serializeWeeklyUpdatesValue(values.weekly_updates, {
throwOnInvalid: true,
});
const completionPercent = resolveCompletionPercentFromWeeklyUpdates(
serializedWeeklyUpdates,
values.completion_percent,
);
const actualStartDate =
projectStatus === 'in_progress' || projectStatus === 'completed'
? actualStartDateInput || today
: actualStartDateInput;
const actualFinishDate =
projectStatus === 'completed'
? actualFinishDateInput || today
: actualFinishDateInput;
const normalizedCompletionPercent =
projectStatus === 'completed' && Number(completionPercent || 0) < 100
? 100
: completionPercent;
if (!projectName) {
throw new Error('请填写项目名称');
}
if (!plannedStartDate || !plannedEndDate) {
throw new Error('请选择计划开始日期和计划结束日期');
}
if (plannedStartDate > plannedEndDate) {
throw new Error('计划结束日期不能早于计划开始日期');
}
return {
title: normalizeText(values.project_name) || '未命名项目',
title: projectName || '未命名项目',
metadata: {
project_name: normalizeText(values.project_name),
project_code: normalizeText(values.project_code),
leader: values.leader || '',
member_ids: Array.isArray(values.member_ids) ? values.member_ids : [],
planned_start_date: formatDateInput(values.planned_start_date),
planned_end_date: formatDateInput(values.planned_end_date),
actual_start_date: formatDateInput(values.actual_start_date),
actual_finish_date: formatDateInput(values.actual_finish_date),
project_name: projectName,
big_category: normalizeText(values.big_category),
project: normalizeText(values.project),
project_owner: projectOwner,
project_collaborator: projectCollaborator,
task_source: normalizeText(values.task_source),
task_nature: normalizeText(values.task_nature),
occurrence_frequency: values.occurrence_frequency || '',
planned_start_date: formatDateValueForPayload(plannedStartDate),
planned_end_date: formatDateValueForPayload(plannedEndDate),
actual_start_date: formatDateValueForPayload(actualStartDate),
actual_finish_date: formatDateValueForPayload(actualFinishDate),
description: normalizeText(values.description),
remark: normalizeText(values.remark),
priority: values.priority || '',
project_status: values.project_status || 'draft',
completion_percent: Number(values.completion_percent || 0),
result_status: values.result_status || '',
is_overtime: Boolean(values.is_overtime),
overtime_days: Number(values.overtime_days || 0),
is_annual_audit_plan: Boolean(values.is_annual_audit_plan),
project_status: projectStatus,
completion_percent: normalizedCompletionPercent,
delay_reason: normalizeText(values.delay_reason),
weekly_updates: serializedWeeklyUpdates,
},
};
}
function omitEmptyImportMetadata(metadata = {}) {
return Object.entries(metadata).reduce((accumulator, [key, value]) => {
if (value === '') {
return accumulator;
}
if (value === null || value === undefined) {
return accumulator;
}
if (Array.isArray(value) && value.length === 0) {
return accumulator;
}
if (
typeof value === 'object' &&
!Array.isArray(value) &&
value &&
Object.keys(value).length === 0
) {
return accumulator;
}
accumulator[key] = value;
return accumulator;
}, {});
}
export function buildImportTaskPayload(values) {
const payload = buildTaskPayload(values);
const metadata = payload?.metadata || {};
return {
...payload,
metadata: omitEmptyImportMetadata({
...metadata,
actual_start_date: metadata.actual_start_date || null,
actual_finish_date: metadata.actual_finish_date || null,
description: metadata.description || null,
remark: metadata.remark || null,
priority: metadata.priority || null,
delay_reason: metadata.delay_reason || null,
occurrence_frequency: metadata.occurrence_frequency || null,
task_source: metadata.task_source || null,
task_nature: metadata.task_nature || null,
big_category: metadata.big_category || null,
project: metadata.project || null,
weekly_updates:
metadata.weekly_updates &&
Array.isArray(metadata.weekly_updates.items) &&
metadata.weekly_updates.items.length > 0
? metadata.weekly_updates
: null,
}),
};
}
export async function saveProjectTask(values, currentTask) {
const payload = buildTaskPayload(values);
const recordName =
......@@ -402,9 +1187,13 @@ export function buildDemoTasks() {
subject: PROJECT_TASK_SUBJECT,
metadata: {
project_name: '容诚年度项目',
project_code: 'PM-2026-001',
leader: 'yang_admin',
member_ids: ['yang_admin', 'system'],
big_category: '审计',
project: '年度专项',
project_owner: ['yang_admin'],
project_collaborator: ['system'],
task_source: '客户年度项目',
task_nature: '常规',
occurrence_frequency: '2',
planned_start_date: '2026-05-01',
planned_end_date: '2026-06-10',
actual_start_date: '2026-05-02',
......@@ -414,9 +1203,21 @@ export function buildDemoTasks() {
priority: 'high',
project_status: 'in_progress',
completion_percent: 68,
result_status: 'on_track',
is_overtime: false,
overtime_days: 0,
delay_reason: '',
weekly_updates: wrapWeeklyUpdatesValue([
{
week_start_date: '2026-05-04',
week_end_date: '2026-05-10',
weekly_summary: '完成项目启动会与需求拆解,确认负责人和交付排期。',
progress_percent_after_update: 25,
},
{
week_start_date: '2026-05-11',
week_end_date: '2026-05-17',
weekly_summary: '完成核心资料收集和初版底稿整理,进入执行阶段。',
progress_percent_after_update: 68,
},
]),
},
},
{
......@@ -425,9 +1226,13 @@ export function buildDemoTasks() {
subject: PROJECT_TASK_SUBJECT,
metadata: {
project_name: '客户并购专项',
project_code: 'PM-2026-002',
leader: 'yang_admin',
member_ids: ['system'],
big_category: '咨询',
project: '并购专项',
project_owner: ['system'],
project_collaborator: ['yang_admin'],
task_source: '客户专项需求',
task_nature: '专项',
occurrence_frequency: '1',
planned_start_date: '2026-04-15',
planned_end_date: '2026-05-25',
actual_start_date: '2026-04-16',
......@@ -437,9 +1242,16 @@ export function buildDemoTasks() {
priority: 'medium',
project_status: 'in_progress',
completion_percent: 82,
result_status: 'overdue',
is_overtime: true,
overtime_days: 14,
delay_reason: '客户补充资料延后,导致复核节点顺延。',
weekly_updates: wrapWeeklyUpdatesValue([
{
week_start_date: '2026-05-18',
week_end_date: '2026-05-24',
weekly_summary: '完成初稿沟通,但等待客户补材料,无法进入最终复核。',
blockers: '客户补充资料尚未齐备',
progress_percent_after_update: 82,
},
]),
},
},
{
......@@ -448,9 +1260,13 @@ export function buildDemoTasks() {
subject: PROJECT_TASK_SUBJECT,
metadata: {
project_name: '海外审计补充工作',
project_code: 'PM-2026-003',
leader: 'system',
member_ids: ['yang_admin'],
big_category: '审计',
project: '海外补充',
project_owner: ['yang_admin'],
project_collaborator: [],
task_source: '内部安排',
task_nature: '补充工作',
occurrence_frequency: '3',
planned_start_date: '2026-03-10',
planned_end_date: '2026-04-20',
actual_start_date: '2026-03-11',
......@@ -460,9 +1276,21 @@ export function buildDemoTasks() {
priority: 'low',
project_status: 'completed',
completion_percent: 100,
result_status: 'completed_early',
is_overtime: false,
overtime_days: 0,
delay_reason: '',
weekly_updates: wrapWeeklyUpdatesValue([
{
week_start_date: '2026-04-07',
week_end_date: '2026-04-13',
weekly_summary: '完成补充抽样与差异复核,准备结项。',
progress_percent_after_update: 92,
},
{
week_start_date: '2026-04-14',
week_end_date: '2026-04-20',
weekly_summary: '提前完成全部交付并归档。',
progress_percent_after_update: 100,
},
]),
},
},
];
......
import { UserContext } from '@/wrapper/Auth';
import clsx from 'clsx';
import { useEffect, useMemo, useState } from 'react';
import { useContext, useEffect, useMemo, useState } from 'react';
import ReactECharts from 'echarts-for-react';
import {
PRIORITY_LABEL_MAP,
formatDateLabel,
OCCURRENCE_FREQUENCY_LABEL_MAP,
PROJECT_STATUS_LABEL_MAP,
RESULT_STATUS_LABEL_MAP,
} from '../projectTaskService';
import { loadProjectDashboardData } from '../projectStatisticsService';
const CHART_COLORS = ['#b42318', '#f97316', '#f59e0b', '#e11d48', '#7c3aed', '#0f766e'];
const RESULT_STATUS_COLORS = {
not_started: '#94a3b8',
on_track: '#b42318',
overdue: '#e11d48',
completed_early: '#0f766e',
completed_on_time: '#22c55e',
completed_overdue: '#f97316',
unset: '#d4d4d8',
};
const PROGRESS_BUCKETS = [
{ label: '0-25%', min: 0, max: 25 },
{ label: '26-50%', min: 26, max: 50 },
{ label: '51-75%', min: 51, max: 75 },
{ label: '76-99%', min: 76, max: 99 },
{ label: '100%', min: 100, max: 100 },
];
function formatUserLabel(user) {
if (!user) {
return '-';
......@@ -21,47 +43,290 @@ function formatPercent(value) {
return `${Math.max(0, Math.min(100, Math.round(numericValue)))}%`;
}
function StatCard({ label, value, hint, tone = 'default' }) {
function buildDonutOption(data, total) {
return {
color: CHART_COLORS,
tooltip: {
trigger: 'item',
formatter: '{b}<br/>{c} 个 ({d}%)',
},
series: [
{
type: 'pie',
radius: ['56%', '78%'],
center: ['50%', '52%'],
itemStyle: {
borderColor: '#ffffff',
borderWidth: 4,
},
label: {
formatter: '{b}\n{c}',
color: '#334155',
fontSize: 12,
},
labelLine: {
length: 16,
length2: 10,
},
emphasis: {
scale: true,
scaleSize: 6,
},
data,
},
{
type: 'pie',
silent: true,
radius: ['0%', '42%'],
center: ['50%', '52%'],
label: {
position: 'center',
formatter: `{count|${total}}\n{name|项目总数}`,
rich: {
count: {
fontSize: 28,
fontWeight: 700,
color: '#0f172a',
lineHeight: 34,
},
name: {
fontSize: 12,
color: '#64748b',
lineHeight: 16,
},
},
},
data: [{ value: 1 }],
},
],
};
}
function buildVerticalBarOption(data, colors = []) {
return {
color: colors.length > 0 ? colors : ['#b42318'],
grid: {
top: 20,
left: 24,
right: 16,
bottom: 36,
containLabel: true,
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
xAxis: {
type: 'category',
axisTick: {
show: false,
},
axisLine: {
lineStyle: {
color: '#e4e4e7',
},
},
axisLabel: {
color: '#52525b',
interval: 0,
},
data: data.map((item) => item.label),
},
yAxis: {
type: 'value',
minInterval: 1,
splitLine: {
lineStyle: {
color: '#f1f5f9',
},
},
axisLabel: {
color: '#71717a',
},
},
series: [
{
type: 'bar',
data: data.map((item, index) => ({
value: item.count,
itemStyle: colors[index]
? {
color: colors[index],
borderRadius: [12, 12, 0, 0],
}
: {
borderRadius: [12, 12, 0, 0],
},
})),
barWidth: 28,
label: {
show: true,
position: 'top',
color: '#334155',
},
},
],
};
}
function buildHorizontalBarOption(data) {
return {
color: ['#b42318'],
grid: {
top: 12,
left: 96,
right: 24,
bottom: 20,
containLabel: true,
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
xAxis: {
type: 'value',
minInterval: 1,
splitLine: {
lineStyle: {
color: '#f1f5f9',
},
},
axisLabel: {
color: '#71717a',
},
},
yAxis: {
type: 'category',
axisTick: {
show: false,
},
axisLine: {
show: false,
},
axisLabel: {
color: '#334155',
width: 88,
overflow: 'truncate',
},
data: data.map((item) => item.label),
},
series: [
{
type: 'bar',
barWidth: 18,
data: data.map((item) => ({
value: item.totalProjects,
itemStyle: {
borderRadius: [0, 10, 10, 0],
},
})),
label: {
show: true,
position: 'right',
color: '#0f172a',
},
},
],
};
}
function StatCard({ label, value, helper, tone = 'default' }) {
const toneClass =
tone === 'danger'
? 'from-rose-50 to-white border-rose-200'
? 'from-rose-50 via-white to-white border-rose-200'
: tone === 'success'
? 'from-emerald-50 to-white border-emerald-200'
? 'from-emerald-50 via-white to-white border-emerald-200'
: tone === 'primary'
? 'from-sky-50 to-white border-sky-200'
? 'from-rose-50 via-white to-white border-rose-200'
: tone === 'warning'
? 'from-amber-50 to-white border-amber-200'
: 'from-zinc-50 to-white border-zinc-200';
? 'from-amber-50 via-white to-white border-amber-200'
: 'from-zinc-50 via-white to-white border-zinc-200';
return (
<div
className={clsx(
'rounded-2xl border bg-gradient-to-br p-5 shadow-sm',
'rounded-3xl border bg-gradient-to-br p-5 shadow-sm shadow-zinc-200/40',
toneClass,
)}
>
<div className="text-sm font-medium text-zinc-500">{label}</div>
<div className="mt-3 text-3xl font-semibold text-zinc-900">{value}</div>
<div className="mt-2 text-sm text-zinc-500">{hint}</div>
<div className="mt-3 text-3xl font-semibold tracking-tight text-zinc-950">{value}</div>
<div className="mt-2 text-xs text-zinc-500">{helper}</div>
</div>
);
}
function SectionCard({ title, description, children }) {
function SectionCard({ title, description = '', children, className = '', bodyClassName = '' }) {
return (
<div className="rounded-2xl border border-zinc-200 bg-white shadow-sm">
<div className="border-b border-zinc-200 px-5 py-4">
<div className="text-lg font-semibold text-zinc-900">{title}</div>
{description ? (
<div className="mt-1 text-sm text-zinc-500">{description}</div>
) : null}
<section
className={clsx(
'rounded-[28px] border border-zinc-200/80 bg-white shadow-sm shadow-zinc-200/40',
className,
)}
>
<div className="border-b border-zinc-200/80 px-5 py-4">
<div className="text-lg font-semibold text-zinc-950">{title}</div>
{description ? <div className="mt-1 text-sm text-zinc-500">{description}</div> : null}
</div>
<div className="px-5 py-5">{children}</div>
<div className={clsx('px-5 py-5', bodyClassName)}>{children}</div>
</section>
);
}
function ChartPanel({ title, description, option, hasData }) {
return (
<SectionCard title={title} description={description}>
{hasData ? (
<ReactECharts
option={option}
notMerge
lazyUpdate
opts={{ renderer: 'svg' }}
style={{ height: 320, width: '100%' }}
/>
) : (
<div className="flex h-80 items-center justify-center rounded-3xl border border-dashed border-zinc-200 bg-zinc-50 text-sm text-zinc-400">
暂无可展示的统计数据
</div>
)}
</SectionCard>
);
}
function DimensionList({ items, emptyText }) {
if (items.length === 0) {
return <div className="text-sm text-zinc-400">{emptyText}</div>;
}
return (
<div className="space-y-3">
{items.map((item) => (
<div
key={item.key}
className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4"
>
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 text-sm font-medium text-zinc-800">{item.label}</div>
<div className="shrink-0 text-lg font-semibold text-zinc-950">{item.count}</div>
</div>
</div>
))}
</div>
);
}
export default function ResourceStatPage({ currentUsername = '' }) {
const userInfo = useContext(UserContext);
const effectiveCurrentUsername = currentUsername || userInfo?.username || userInfo?._id || '';
const currentUserAliases = useMemo(
() => Array.from(new Set([effectiveCurrentUsername, userInfo?._id, userInfo?.username].filter(Boolean))),
[effectiveCurrentUsername, userInfo?._id, userInfo?.username],
);
const [tasks, setTasks] = useState([]);
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
......@@ -72,7 +337,7 @@ export default function ResourceStatPage({ currentUsername = '' }) {
(async () => {
setLoading(true);
const result = await loadProjectDashboardData(currentUsername);
const result = await loadProjectDashboardData(effectiveCurrentUsername);
if (cancelled) {
return;
}
......@@ -85,7 +350,7 @@ export default function ResourceStatPage({ currentUsername = '' }) {
return () => {
cancelled = true;
};
}, [currentUsername]);
}, [effectiveCurrentUsername]);
const dashboard = useMemo(() => {
const total = tasks.length;
......@@ -96,11 +361,17 @@ export default function ResourceStatPage({ currentUsername = '' }) {
const avgProgress =
total > 0
? Math.round(
tasks.reduce((sum, task) => sum + Number(task.progressPercent || 0), 0) /
total,
tasks.reduce((sum, task) => sum + Number(task.progressPercent || 0), 0) / total,
)
: 0;
const currentUserTaskCount =
currentUserAliases.length > 0
? tasks.filter((task) =>
task.relatedUserIds.some((userId) => currentUserAliases.includes(userId)),
).length
: 0;
const statusGroups = Object.entries(
tasks.reduce((accumulator, task) => {
const key = task.projectStatus || 'draft';
......@@ -115,37 +386,89 @@ export default function ResourceStatPage({ currentUsername = '' }) {
}))
.sort((left, right) => right.count - left.count);
const priorityGroups = Object.entries(
const resultStatusGroups = Object.entries(
tasks.reduce((accumulator, task) => {
const key = task.resultStatus || 'unset';
accumulator[key] = (accumulator[key] || 0) + 1;
return accumulator;
}, {}),
)
.map(([key, count]) => ({
key,
label: key === 'unset' ? '未设置' : RESULT_STATUS_LABEL_MAP[key] || key,
count,
}))
.sort((left, right) => right.count - left.count);
const frequencyGroups = Object.entries(
tasks.reduce((accumulator, task) => {
const key = task.occurrenceFrequency || 'unset';
accumulator[key] = (accumulator[key] || 0) + 1;
return accumulator;
}, {}),
)
.map(([key, count]) => ({
key,
label: key === 'unset' ? '未设置' : OCCURRENCE_FREQUENCY_LABEL_MAP[key] || key,
count,
}))
.sort((left, right) => right.count - left.count);
const categoryGroups = Object.entries(
tasks.reduce((accumulator, task) => {
const key = task.priority || 'unset';
const key = task.bigCategory || 'unset';
accumulator[key] = (accumulator[key] || 0) + 1;
return accumulator;
}, {}),
).map(([key, count]) => ({
key,
label: key === 'unset' ? '未设置' : PRIORITY_LABEL_MAP[key] || key,
count,
)
.map(([key, count]) => ({
key,
label: key === 'unset' ? '未设置' : key,
count,
}))
.sort((left, right) => right.count - left.count);
const taskSourceGroups = Object.entries(
tasks.reduce((accumulator, task) => {
const key = task.taskSource || 'unset';
accumulator[key] = (accumulator[key] || 0) + 1;
return accumulator;
}, {}),
)
.map(([key, count]) => ({
key,
label: key === 'unset' ? '未设置' : key,
count,
}))
.sort((left, right) => right.count - left.count);
const progressBuckets = PROGRESS_BUCKETS.map((bucket) => ({
...bucket,
count: tasks.filter((task) => {
const progress = Number(task.progressPercent || 0);
return progress >= bucket.min && progress <= bucket.max;
}).length,
}));
const memberLoad = Object.values(
tasks.reduce((accumulator, task) => {
task.memberIds.forEach((memberId) => {
if (!accumulator[memberId]) {
const user = users.find((item) => item.id === memberId);
accumulator[memberId] = {
id: memberId,
label: formatUserLabel(user || { id: memberId }),
task.relatedUserIds.forEach((userId) => {
if (!accumulator[userId]) {
const user = users.find((item) => item.id === userId);
accumulator[userId] = {
id: userId,
label: formatUserLabel(user || { id: userId }),
totalProjects: 0,
inProgressProjects: 0,
overdueProjects: 0,
};
}
accumulator[memberId].totalProjects += 1;
accumulator[userId].totalProjects += 1;
if (task.projectStatus === 'in_progress') {
accumulator[memberId].inProgressProjects += 1;
accumulator[userId].inProgressProjects += 1;
}
if (task.isOvertime) {
accumulator[memberId].overdueProjects += 1;
accumulator[userId].overdueProjects += 1;
}
});
return accumulator;
......@@ -154,15 +477,13 @@ export default function ResourceStatPage({ currentUsername = '' }) {
const upcoming = [...tasks]
.filter((task) => task.plannedEndDate)
.sort((left, right) =>
String(left.plannedEndDate).localeCompare(String(right.plannedEndDate)),
)
.slice(0, 5);
.sort((left, right) => String(left.plannedEndDate).localeCompare(String(right.plannedEndDate)))
.slice(0, 6);
const overdueList = tasks
const overdueList = [...tasks]
.filter((task) => task.isOvertime)
.sort((left, right) => right.overtimeDays - left.overtimeDays)
.slice(0, 5);
.slice(0, 6);
return {
total,
......@@ -171,142 +492,196 @@ export default function ResourceStatPage({ currentUsername = '' }) {
overdue,
blocked,
avgProgress,
currentUserTaskCount,
statusGroups,
priorityGroups,
resultStatusGroups,
frequencyGroups,
categoryGroups,
taskSourceGroups,
progressBuckets,
memberLoad,
upcoming,
overdueList,
};
}, [tasks, users]);
}, [currentUserAliases, tasks, users]);
const statusOption = useMemo(
() =>
buildDonutOption(
dashboard.statusGroups.map((item) => ({
name: item.label,
value: item.count,
})),
dashboard.total,
),
[dashboard.statusGroups, dashboard.total],
);
const resultStatusOption = useMemo(
() =>
buildVerticalBarOption(
dashboard.resultStatusGroups,
dashboard.resultStatusGroups.map((item) => RESULT_STATUS_COLORS[item.key] || '#b42318'),
),
[dashboard.resultStatusGroups],
);
const progressOption = useMemo(
() => buildVerticalBarOption(dashboard.progressBuckets, ['#fef2f2', '#fdba74', '#fb7185', '#b42318', '#0f766e']),
[dashboard.progressBuckets],
);
const memberLoadOption = useMemo(
() => buildHorizontalBarOption(dashboard.memberLoad.slice(0, 8)),
[dashboard.memberLoad],
);
return (
<div className="space-y-6">
<div className="rounded-2xl border border-zinc-200 bg-white px-5 py-5 shadow-sm">
<div className="text-xl font-semibold text-zinc-900">资源统计</div>
<div className="mt-2 text-sm leading-6 text-zinc-500">
这里承接项目管理的统计看板,除了总量卡片,还补充了状态分布、优先级、成员负载和超时清单,便于领导快速判断资源压力。
<section className="overflow-hidden rounded-[32px] border border-zinc-200 bg-[radial-gradient(circle_at_top_left,_rgba(251,191,188,0.22),_transparent_26%),radial-gradient(circle_at_top_right,_rgba(251,207,232,0.18),_transparent_28%),linear-gradient(135deg,_#ffffff_0%,_#f8fafc_46%,_#fff1f2_100%)] p-6 shadow-sm shadow-zinc-200/50">
<div className="flex flex-col gap-5 xl:flex-row xl:items-end xl:justify-between">
<div className="max-w-3xl">
<div className="inline-flex items-center rounded-full border border-rose-200 bg-white/80 px-3 py-1 text-xs font-medium text-rose-700">
项目管理工作台
</div>
<div className="mt-4 text-3xl font-semibold tracking-tight text-zinc-950">
资源统计
</div>
<div className="mt-3 text-sm leading-6 text-zinc-600">
统计口径基于 `pm_project_task.metadata` 实时聚合,结果状态按计划日期、实际完成日期与当前日期前端派生。
</div>
</div>
<div className="grid gap-3 sm:grid-cols-3 xl:min-w-[480px]">
<div className="rounded-2xl border border-white/80 bg-white/90 px-4 py-4 shadow-sm">
<div className="text-xs font-medium text-zinc-500">我的关联项目</div>
<div className="mt-2 text-2xl font-semibold text-zinc-950">
{dashboard.currentUserTaskCount}
</div>
</div>
<div className="rounded-2xl border border-white/80 bg-white/90 px-4 py-4 shadow-sm">
<div className="text-xs font-medium text-zinc-500">需优先关注</div>
<div className="mt-2 text-2xl font-semibold text-rose-600">{dashboard.overdue}</div>
</div>
<div className="rounded-2xl border border-white/80 bg-white/90 px-4 py-4 shadow-sm">
<div className="text-xs font-medium text-zinc-500">受阻项目</div>
<div className="mt-2 text-2xl font-semibold text-amber-600">{dashboard.blocked}</div>
</div>
</div>
</div>
{error ? (
<div className="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
<div className="mt-5 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
{error}
</div>
) : null}
{loading ? (
<div className="mt-4 rounded-xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-700">
<div className="mt-5 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
正在加载资源统计看板...
</div>
) : null}
</div>
</section>
<div className="grid gap-4 md:grid-cols-3 xl:grid-cols-6">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
<StatCard
label="项目总数"
value={dashboard.total}
hint="全部项目记录"
helper="当前已聚合的项目任务"
tone="primary"
/>
<StatCard
label="进行中"
value={dashboard.inProgress}
hint="仍在推进中的项目"
helper="业务状态为进行中的项目"
/>
<StatCard
label="已完成"
value={dashboard.completed}
hint="已进入 completed 状态"
helper="业务状态为已完成的项目"
tone="success"
/>
<StatCard
label="已超时"
value={dashboard.overdue}
hint="计划结束后未完成或超时完成"
helper="按计划/实际日期派生的超时结果"
tone="danger"
/>
<StatCard
label="受阻项目"
value={dashboard.blocked}
hint="当前状态为 blocked"
helper="需要重点跟进的阻塞项目"
tone="warning"
/>
<StatCard
label="平均进度"
value={formatPercent(dashboard.avgProgress)}
hint="全部项目平均完成度"
helper="completion_percent 平均值"
/>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<ChartPanel
title="项目状态分布"
description="按 `metadata.project_status` 聚合,帮助快速识别进行中、完成与受阻占比。"
option={statusOption}
hasData={dashboard.statusGroups.length > 0}
/>
<ChartPanel
title="结果状态分布"
description="按计划完成时间、实际完成时间与当前日期派生结果状态。"
option={resultStatusOption}
hasData={dashboard.resultStatusGroups.length > 0}
/>
</div>
<div className="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
<div className="grid gap-6 xl:grid-cols-[1fr_1fr_0.9fr]">
<ChartPanel
title="进度区间分布"
description="按 `completion_percent` 分桶,快速查看项目推进层级。"
option={progressOption}
hasData={dashboard.progressBuckets.some((item) => item.count > 0)}
/>
<ChartPanel
title="成员负载 Top 8"
description="按负责人和协作人的关联项目数汇总,便于观察资源分配。"
option={memberLoadOption}
hasData={dashboard.memberLoad.length > 0}
/>
<SectionCard
title="状态分布"
description="观察当前项目状态结构,判断项目池是否过度集中在进行中或超时状态。"
title="维度概览"
description="保留发生频率、大类、任务来源三类核心统计维度。"
bodyClassName="space-y-5"
>
<div className="space-y-3">
{dashboard.statusGroups.length > 0 ? (
dashboard.statusGroups.map((item) => {
const percentage =
dashboard.total > 0
? Math.round((item.count / dashboard.total) * 100)
: 0;
return (
<div key={item.key}>
<div className="mb-2 flex items-center justify-between text-sm text-zinc-600">
<span>{item.label}</span>
<span>
{item.count} 个 / {percentage}%
</span>
</div>
<div className="h-3 rounded-full bg-zinc-100">
<div
className="h-3 rounded-full bg-[var(--pm-brand)]"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
})
) : (
<div className="text-sm text-zinc-400">暂无状态统计数据</div>
)}
<div>
<div className="mb-3 text-sm font-medium text-zinc-500">发生频率</div>
<DimensionList items={dashboard.frequencyGroups.slice(0, 3)} emptyText="暂无发生频率统计数据" />
</div>
</SectionCard>
<SectionCard
title="优先级分布"
description="用于判断高优先级项目是否过多,以及是否存在大量未设置优先级的项目。"
>
<div className="grid gap-3 sm:grid-cols-2">
{dashboard.priorityGroups.length > 0 ? (
dashboard.priorityGroups.map((item) => (
<div
key={item.key}
className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4"
>
<div className="text-sm text-zinc-500">{item.label}</div>
<div className="mt-2 text-2xl font-semibold text-zinc-900">
{item.count}
</div>
</div>
))
) : (
<div className="text-sm text-zinc-400">暂无优先级统计数据</div>
)}
<div>
<div className="mb-3 text-sm font-medium text-zinc-500">大类</div>
<DimensionList items={dashboard.categoryGroups.slice(0, 3)} emptyText="暂无大类统计数据" />
</div>
<div>
<div className="mb-3 text-sm font-medium text-zinc-500">任务来源</div>
<DimensionList items={dashboard.taskSourceGroups.slice(0, 3)} emptyText="暂无任务来源统计数据" />
</div>
</SectionCard>
</div>
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<SectionCard
title="成员负载"
description="统计每位成员参与的项目数量、进行中项目数量和超时项目数量,适合观察资源压力。"
title="成员负载明细"
description="展示每位成员的关联项目、进行中项目和超时项目数量。"
bodyClassName="px-0 py-0"
>
<div className="overflow-hidden rounded-2xl border border-zinc-200">
<div className="overflow-hidden rounded-[28px]">
<div className="grid grid-cols-[1.4fr_0.8fr_0.8fr_0.8fr] bg-zinc-50 text-sm font-medium text-zinc-500">
<div className="px-4 py-3">成员</div>
<div className="px-4 py-3 text-center">参与项目</div>
<div className="px-4 py-3 text-center">进行中</div>
<div className="px-4 py-3 text-center">超时</div>
<div className="px-5 py-3">成员</div>
<div className="px-5 py-3 text-center">参与项目</div>
<div className="px-5 py-3 text-center">进行中</div>
<div className="px-5 py-3 text-center">超时</div>
</div>
{dashboard.memberLoad.length > 0 ? (
dashboard.memberLoad.map((member) => (
......@@ -314,16 +689,16 @@ export default function ResourceStatPage({ currentUsername = '' }) {
key={member.id}
className="grid grid-cols-[1.4fr_0.8fr_0.8fr_0.8fr] border-t border-zinc-100 text-sm text-zinc-700"
>
<div className="px-4 py-3 font-medium text-zinc-900">{member.label}</div>
<div className="px-4 py-3 text-center">{member.totalProjects}</div>
<div className="px-4 py-3 text-center">{member.inProgressProjects}</div>
<div className="px-4 py-3 text-center text-rose-600">
<div className="px-5 py-3.5 font-medium text-zinc-950">{member.label}</div>
<div className="px-5 py-3.5 text-center">{member.totalProjects}</div>
<div className="px-5 py-3.5 text-center">{member.inProgressProjects}</div>
<div className="px-5 py-3.5 text-center text-rose-600">
{member.overdueProjects}
</div>
</div>
))
) : (
<div className="px-4 py-10 text-center text-sm text-zinc-400">
<div className="px-5 py-12 text-center text-sm text-zinc-400">
暂无成员负载数据
</div>
)}
......@@ -333,7 +708,7 @@ export default function ResourceStatPage({ currentUsername = '' }) {
<div className="space-y-6">
<SectionCard
title="即将到期"
description="优先关注计划结束日期最近的项目。"
description="按计划完成日期升序展示,便于提前提醒负责人。"
>
<div className="space-y-3">
{dashboard.upcoming.length > 0 ? (
......@@ -342,9 +717,10 @@ export default function ResourceStatPage({ currentUsername = '' }) {
key={task._id || task.id || task.name}
className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4"
>
<div className="text-sm font-semibold text-zinc-900">{task.projectName}</div>
<div className="mt-1 text-xs text-zinc-500">
截止 {task.plannedEndDate} · {task.projectStatusLabel} · {formatPercent(task.progressPercent)}
<div className="text-sm font-semibold text-zinc-950">{task.projectName}</div>
<div className="mt-1 text-xs leading-5 text-zinc-500">
截止 {formatDateLabel(task.plannedEndDate)} · {task.projectStatusLabel} ·{' '}
{task.resultStatusLabel} · {formatPercent(task.progressPercent)}
</div>
</div>
))
......@@ -356,7 +732,7 @@ export default function ResourceStatPage({ currentUsername = '' }) {
<SectionCard
title="超时清单"
description="快速查看超时最明显的项目,便于优先处理。"
description="按超时天数倒序展示,帮助工作台聚焦风险项目。"
>
<div className="space-y-3">
{dashboard.overdueList.length > 0 ? (
......@@ -365,9 +741,10 @@ export default function ResourceStatPage({ currentUsername = '' }) {
key={task._id || task.id || task.name}
className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-4"
>
<div className="text-sm font-semibold text-zinc-900">{task.projectName}</div>
<div className="mt-1 text-xs text-rose-700">
超时 {task.overtimeDays} 天 · {task.projectStatusLabel}
<div className="text-sm font-semibold text-zinc-950">{task.projectName}</div>
<div className="mt-1 text-xs leading-5 text-rose-700">
超时 {task.overtimeDays} 天 · {task.projectStatusLabel} ·{' '}
{task.resultStatusLabel}
</div>
</div>
))
......
......@@ -55,9 +55,10 @@ type Record = {
```json
{
"metadata": {
"leader": "69b12a47e92b1de77c94a9f5",
"member_ids": [
"69b12a47e92b1de77c94a9f5",
"project_owner": [
"69b12a47e92b1de77c94a9f5"
],
"project_collaborator": [
"69b12a47e92b1de77c94a9f6"
]
}
......@@ -66,8 +67,8 @@ type Record = {
### 2.2 `date`
- 建议统一使用 `YYYY-MM-DD`
- 如后端要求完整时间,可扩展为 ISO 字符串
- 表单展示可使用 `YYYY-MM-DD` 或 `datetime-local`
- 写入 `metadata` 时,建议统一转换为 ISO 8601 字符串(`YYYY-MM-DDTHH:mm:ss.sssZ`)
### 2.3 其他常见类型
......@@ -75,6 +76,7 @@ type Record = {
- `number`:数字
- `boolean`:`true / false`
- `select`:传选项的存储值,不传显示文案
- `object`:对象类型时建议内部维护结构化子字段或 `items` 数组,前端优先使用结构化表单编辑,无现成交互时再退回 JSON 校验方案
---
......@@ -87,9 +89,13 @@ type Record = {
| 字段标签 | 字段别名 | 字段类型 | 是否主键 | 是否多值 | 是否必填 |
| --- | --- | --- | --- | --- | --- |
| 项目名称 | `project_name` | text | 否 | 否 | 是 |
| 项目编号 | `project_code` | text | 是 | 否 | 否 |
| 下发领导 | `leader` | user | 否 | 否 | 是 |
| 参与成员 | `member_ids` | user | 否 | 是 | 是 |
| 大类 | `big_category` | text | 否 | 否 | 否 |
| 项目 | `project` | text | 否 | 否 | 否 |
| 项目负责人 | `project_owner` | user | 否 | 是 | 否 |
| 项目协作人 | `project_collaborator` | user | 否 | 是 | 否 |
| 任务来源 | `task_source` | text | 否 | 否 | 否 |
| 任务性质 | `task_nature` | text | 否 | 否 | 否 |
| 发生频率 | `occurrence_frequency` | select | 否 | 否 | 否 |
| 计划开始日期 | `planned_start_date` | date | 否 | 否 | 是 |
| 计划结束日期 | `planned_end_date` | date | 否 | 否 | 是 |
| 实际开始日期 | `actual_start_date` | text | 否 | 否 | 否 |
......@@ -99,62 +105,95 @@ type Record = {
| 优先级 | `priority` | select | 否 | 否 | 否 |
| 项目状态 | `project_status` | select | 否 | 否 | 是 |
| 完成进度 | `completion_percent` | number | 否 | 否 | 否 |
| 结果状态 | `result_status` | select | 否 | 否 | 否 |
| 是否超时 | `is_overtime` | boolean | 否 | 否 | 否 |
| 超时天数 | `overtime_days` | number | 否 | 否 | 否 |
| 延期原因 | `delay_reason` | text | 否 | 否 | 否 |
| 周进度记录 | `weekly_updates` | object | 否 | 否 | 否 |
| 是否属于年度审计计划 | `is_annual_audit_plan` | boolean | 否 | 否 | 否 |
### 3.1.1 `project_status` 选项
| 显示值 | 存储值 |
| --- | --- |
| 草稿 | `draft` |
| 已下发 | `assigned` |
| 进行中 | `in_progress` |
| 已完成 | `completed` |
| 受阻 | `blocked` |
| 已取消 | `cancelled` |
### 3.1.2 `priority` 建议选项
### 3.1.2 `occurrence_frequency` 选项
| 显示值 | 存储值 |
| --- | --- |
| 高 | `high` |
| 中 | `medium` |
| 低 | `low` |
| 高 | `3` |
| 中 | `2` |
| 低 | `1` |
### 3.1.3 `result_status` 建议选项
### 3.1.3 `priority` 配置说明
| 显示值 | 存储值 |
| --- | --- |
| 未开始 | `not_started` |
| 正常进行 | `on_track` |
| 已超时 | `overdue` |
| 提前完成 | `completed_early` |
| 准时完成 | `completed_on_time` |
| 超时完成 | `completed_overdue` |
- 当前最新结构中,`priority` 为 `select` 类型
- 但字段定义内暂未提供 `settings.options`
- 前端生成表单时不得硬编码选项,需等对象配置或接口返回的 `settings.options`
### 3.1.4 `weekly_updates` 子结构建议
`weekly_updates` 当前字段类型为 `object`。如业务上继续沿用“周进度记录集合”方案,建议对象内维护 `items` 数组,每项至少包含以下字段:
| 字段标签 | 字段别名 | 字段类型 | 是否必填 | 说明 |
| --- | --- | --- | --- | --- |
| 周开始日期 | `week_start_date` | date | 否 | 本周记录起始日期 |
| 周结束日期 | `week_end_date` | date | 否 | 本周记录结束日期 |
| 本周完成内容 | `weekly_summary` | text | 是 | 本周实际完成事项 |
| 更新后累计进度 | `progress_percent_after_update` | number | 否 | 本次更新后的累计完成进度 |
| 当前阻塞事项 | `blockers` | text | 否 | 当前风险、卡点、等待项 |
| 下周计划 | `next_week_plan` | text | 否 | 下一周推进安排 |
| 更新人 | `updated_by` | user | 否 | 记录本次周进度的人 |
| 更新时间 | `updated_at` | date | 否 | 本次周进度的录入时间 |
### 3.1.4 metadata 示例
前端交互建议:
- 项目基础信息和周进度记录分开维护
- 周进度记录通过“新增本周进度 / 编辑周进度”弹窗录入
- 列表页或项目详情只展示最近一条摘要,不直接暴露底层 JSON
- 若后端最终确认 `weekly_updates` 直接存数组,可再将本节从“对象内 `items` 数组”调整为“字段直接存数组”
### 3.1.5 metadata 示例
```json
{
"project_name": "某客户 2026 年专项项目",
"project_code": "PM-2026-001",
"leader": "69b12a47e92b1de77c94a9f5",
"member_ids": [
"69b12a47e92b1de77c94a9f6",
"big_category": "审计",
"project": "2026 年专项项目",
"project_owner": [
"69b12a47e92b1de77c94a9f6"
],
"project_collaborator": [
"69b12a47e92b1de77c94a9f7"
],
"planned_start_date": "2026-05-01",
"planned_end_date": "2026-06-10",
"task_source": "客户年度项目",
"task_nature": "常规",
"occurrence_frequency": "2",
"planned_start_date": "2026-05-01T00:00:00.000Z",
"planned_end_date": "2026-06-10T00:00:00.000Z",
"actual_start_date": "2026-05-03",
"actual_finish_date": "",
"description": "领导在甘特图中下发项目,成员在日历图中查看关联项目。",
"remark": "一版默认 1-2 人,进度共享。",
"priority": "high",
"description": "项目管理对象最新字段示例。",
"remark": "结构已按最新字段同步。",
"priority": "",
"project_status": "in_progress",
"completion_percent": 60,
"result_status": "on_track",
"is_overtime": false,
"overtime_days": 0
"delay_reason": "",
"weekly_updates": {
"items": [
{
"week_start_date": "2026-05-04T00:00:00.000Z",
"week_end_date": "2026-05-10T00:00:00.000Z",
"weekly_summary": "完成项目启动会和资料清单确认。",
"progress_percent_after_update": 20,
"blockers": "",
"next_week_plan": "推进底稿准备并和客户确认时间表。",
"updated_by": "69b12a47e92b1de77c94a9f6",
"updated_at": "2026-05-10T00:00:00.000Z"
}
]
}
}
```
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment