Commit 78c9be51 authored by David Yang's avatar David Yang

refactor: migrate record detail flow to problem rectification

parent 2a14cc6d
......@@ -6,6 +6,7 @@
"": {
"hasInstallScript": true,
"dependencies": {
"@headlessui/react": "^2.2.10",
"@heroicons/react": "^2.2.0",
"@tailwindcss/postcss": "^4.2.0",
"@umijs/max": "^4.5.3",
......@@ -2441,6 +2442,21 @@
"@floating-ui/core": "^0.6.2"
}
},
"node_modules/@floating-ui/react": {
"version": "0.26.28",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz",
"integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.1.2",
"@floating-ui/utils": "^0.2.8",
"tabbable": "^6.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-0.6.3.tgz",
......@@ -2468,6 +2484,44 @@
"use-isomorphic-layout-effect": "^1.1.1"
}
},
"node_modules/@floating-ui/react/node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/react/node_modules/@floating-ui/dom": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/react/node_modules/@floating-ui/react-dom": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
"integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.7.6"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
},
"node_modules/@formatjs/intl-displaynames": {
"version": "1.2.10",
"resolved": "https://registry.npmjs.org/@formatjs/intl-displaynames/-/intl-displaynames-1.2.10.tgz",
......@@ -2512,6 +2566,35 @@
"deprecated": "the package is rather renamed to @formatjs/ecma-abstract with some changes in functionality (primarily selectUnit is removed and we don't plan to make any further changes to this package",
"license": "MIT"
},
"node_modules/@headlessui/react": {
"version": "2.2.10",
"resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.10.tgz",
"integrity": "sha512-5pVLNK9wlpxTUTy9GpgbX/SdcRh+HBnPktjM2wbiLTH4p+2EPHBO1aoSryUCuKUIItdDWO9ITlhUL8UnUN/oIA==",
"license": "MIT",
"dependencies": {
"@floating-ui/react": "^0.26.16",
"@react-aria/focus": "^3.20.2",
"@react-aria/interactions": "^3.25.0",
"@tanstack/react-virtual": "^3.13.9",
"use-sync-external-store": "^1.5.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/@headlessui/react/node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@heroicons/react": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz",
......@@ -2604,6 +2687,33 @@
"local-pkg": "^0.4.2"
}
},
"node_modules/@internationalized/date": {
"version": "3.12.1",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.1.tgz",
"integrity": "sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@internationalized/number": {
"version": "3.6.6",
"resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.6.tgz",
"integrity": "sha512-iFgmQaXHE0vytNfpLZWOC2mEJCBRzcUxt53Xf/yCXG93lRvqas237i3r7X4RKMwO3txiyZD4mQjKAByFv6UGSQ==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@internationalized/string": {
"version": "3.2.8",
"resolved": "https://registry.npmjs.org/@internationalized/string/-/string-3.2.8.tgz",
"integrity": "sha512-NdbMQUSfXLYIQol5VyMtinm9pZDciiMfN7RtmSuSB78io1hqwJ0naYfxyW6vgxWBkzWymQa/3uLDlbfmshtCaA==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
......@@ -3458,6 +3568,44 @@
"react-dom": ">=16.9.0"
}
},
"node_modules/@react-aria/focus": {
"version": "3.22.0",
"resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.22.0.tgz",
"integrity": "sha512-ZfDOVuVhqDsM9mkNji3QUZ/d40JhlVgXrDkrfXylM1035QCrcTHN7m2DpbE95sU2A8EQb4wikvt5jM6K/73BPg==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0",
"react-aria": "3.48.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-aria/interactions": {
"version": "3.28.0",
"resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.28.0.tgz",
"integrity": "sha512-OXwdU1EWFdMxmr/K1CXNGJzmNlCClByb+PuCaqUyzBymHPCGVhawirLIon/CrIN5psh3AiWpHSh4H0WeJdVpng==",
"license": "Apache-2.0",
"dependencies": {
"@react-types/shared": "^3.34.0",
"@swc/helpers": "^0.5.0",
"react-aria": "3.48.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-types/shared": {
"version": "3.34.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.34.0.tgz",
"integrity": "sha512-gp6xo/s2lX54AlTjOiqwDnxA7UW79BNvI9dB9pr3LZTzRKCd1ZA+ZbgKw/ReIiWuvvVw/8QFJpnqeeFyLocMcQ==",
"license": "Apache-2.0",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@sinclair/typebox": {
"version": "0.27.10",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz",
......@@ -4034,6 +4182,33 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.25",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.25.tgz",
"integrity": "sha512-bmNoqMu6gcAW9JGrKVB0Q1tN1i5RONZF8r1fW0bbE4Oyf3DwEGnzzQJ2OW+Ozg1P4s8PyugkHg2ULZoFQN+cqw==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.15.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.15.0.tgz",
"integrity": "sha512-0AwPGx0I8QxPYjAxShT/+z+ZOe9u8mW5rsXvivCTjRfRmz9a43+3mRyi4wwlyoUqOC56q/jatKa0Bh9M99BEHQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
......@@ -17111,6 +17286,36 @@
"node": ">=0.10.0"
}
},
"node_modules/react-aria": {
"version": "3.48.0",
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.48.0.tgz",
"integrity": "sha512-jQjd4rBEIMqecBaAKYJbVGK6EqIHLa5znVQ7jwFyK5vCyljoj6KhgtiahmcIPsG5vG5vEDLw+ba+bEWn6A2P4w==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.12.1",
"@internationalized/number": "^3.6.6",
"@internationalized/string": "^3.2.8",
"@react-types/shared": "^3.34.0",
"@swc/helpers": "^0.5.0",
"aria-hidden": "^1.2.3",
"clsx": "^2.0.0",
"react-stately": "3.46.0",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/react-aria/node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
......@@ -17267,6 +17472,32 @@
"react": ">=15"
}
},
"node_modules/react-stately": {
"version": "3.46.0",
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.46.0.tgz",
"integrity": "sha512-OdxhWvHgs2L4OJGIs7hnuTr5WjjMM6enhNEAMRqiekhF8+ITvA2LRwNftOZwcogaoCslGYq5S2VQTQwnm0GbCA==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.12.1",
"@internationalized/number": "^3.6.6",
"@internationalized/string": "^3.2.8",
"@react-types/shared": "^3.34.0",
"@swc/helpers": "^0.5.0",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/react-stately/node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/reactcss": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz",
......@@ -19246,6 +19477,12 @@
"integrity": "sha512-Nk8c4lXvMB98MtbmjX7JwJRgJOL8fluecYCfCeYBznwmpOs8Bf15hLM6z4z71EDAhQVrQrI+wt1aLWSXZq+hXA==",
"license": "MIT"
},
"node_modules/tabbable": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
"integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==",
"license": "MIT"
},
"node_modules/table": {
"version": "6.9.0",
"resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz",
......
......@@ -8,6 +8,7 @@
"start": "npm run dev"
},
"dependencies": {
"@headlessui/react": "^2.2.10",
"@heroicons/react": "^2.2.0",
"@tailwindcss/postcss": "^4.2.0",
"@umijs/max": "^4.5.3",
......
import { useEffect, useMemo, useState } from 'react';
import { Input } from '@/catalyst-ui-kit/javascript/input';
import { queryRecords } from '@/services/DataService';
import {
getReferenceSubjectName,
normalizeRecordId,
normalizeRecordLabel,
toArray,
} from '@/utils/subjectView';
export default function ReferenceSearchSelect({
fieldDef,
value,
onChange,
placeholder,
}) {
const [keyword, setKeyword] = useState('');
const [options, setOptions] = useState([]);
const subjectName = getReferenceSubjectName(fieldDef);
const multiple = Boolean(fieldDef?.multiple);
const selectedIds = useMemo(
() => toArray(value).map(normalizeRecordId).filter(Boolean),
[value],
);
useEffect(() => {
let cancelled = false;
const loadOptions = async () => {
if (!subjectName) {
setOptions([]);
return;
}
try {
const result = await queryRecords({
subject: subjectName,
page: 0,
page_size: 20,
keyword: keyword.trim() || undefined,
mode: 'basic',
});
if (!cancelled) {
setOptions(result?.records || []);
}
} catch (requestError) {
if (!cancelled) {
setOptions([]);
}
}
};
loadOptions();
return () => {
cancelled = true;
};
}, [keyword, subjectName]);
const toggleRecord = (record, checked) => {
const recordId = normalizeRecordId(record);
if (!recordId) {
return;
}
if (!multiple) {
onChange(checked ? recordId : null);
return;
}
if (checked) {
onChange(Array.from(new Set([...selectedIds, recordId])));
return;
}
onChange(selectedIds.filter((item) => item !== recordId));
};
if (!subjectName) {
return (
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
引用字段缺少目标 Subject 配置,暂不能搜索选择。
</div>
);
}
return (
<div className="space-y-3">
<Input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder={placeholder || '搜索引用记录标题'}
/>
<div className="max-h-56 space-y-2 overflow-y-auto rounded-2xl border border-zinc-200 bg-zinc-50 p-2">
{options.length === 0 ? (
<div className="rounded-xl bg-white px-3 py-4 text-sm text-zinc-500">暂无可选记录</div>
) : (
options.map((record) => {
const recordId = normalizeRecordId(record);
const checked = selectedIds.includes(recordId);
return (
<label
key={recordId}
className="flex cursor-pointer items-center gap-3 rounded-xl bg-white px-3 py-2 text-sm text-zinc-700 shadow-sm transition hover:bg-rose-50"
>
<input
type={multiple ? 'checkbox' : 'radio'}
checked={checked}
onChange={(event) => toggleRecord(record, event.target.checked)}
/>
<span>
<span className="block font-medium text-zinc-950">{normalizeRecordLabel(record) || recordId}</span>
<span className="block text-xs text-zinc-500">{record.name || recordId}</span>
</span>
</label>
);
})
)}
</div>
</div>
);
}
import clsx from 'clsx';
import TailwindEditableField from './TailwindEditableField';
import {
buildViewColumns,
getFieldValue,
isFieldDisabledByView,
} from '@/utils/subjectView';
export default function SubjectViewContent({
view,
subject,
editableRecord,
onFieldUpdate,
onDataChange,
readonly,
isFieldDisabled,
}) {
const displayFields = buildViewColumns(view, subject);
const longFormFieldTypes = new Set(['long_text', 'rich_text', 'object', 'form', 'subject', 'file']);
const handleFieldUpdate = async (fieldPath, newValue, fieldDef) => {
if (onDataChange) {
onDataChange(true);
}
return onFieldUpdate(fieldPath, newValue, fieldDef);
};
if (!displayFields || displayFields.length === 0) {
return (
<div className="rounded-[28px] border border-dashed border-zinc-300 bg-white/70 px-6 py-12 text-center text-sm text-zinc-500">
暂无可显示的字段
</div>
);
}
return (
<section className="overflow-hidden rounded-[30px] border border-white/80 bg-white/88 shadow-[0_12px_32px_rgba(15,23,42,0.07)] ring-1 ring-zinc-950/5">
<div className="border-b border-zinc-100 bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(250,250,250,0.88))] px-5 py-4">
<div className="text-xs font-semibold uppercase tracking-[0.2em] text-zinc-400">字段详情</div>
<div className="mt-1 text-sm text-zinc-500">
当前视图共 {displayFields.length} 个字段,点击右侧内容可直接编辑。
</div>
</div>
<div className="divide-y divide-zinc-100">
{displayFields.map((field) => {
const value = getFieldValue(editableRecord, field.field);
const fieldDisabled =
isFieldDisabled?.(field.field, view) ||
isFieldDisabledByView(field.field, view);
const isLongForm = longFormFieldTypes.has(field.fieldDef?.type);
return (
<section
key={field.field}
className={clsx(
'grid gap-3 px-5 py-4',
isLongForm ? 'md:grid-cols-1' : 'md:grid-cols-[190px_minmax(0,1fr)] md:gap-6',
)}
>
<div className="min-w-0">
<div className="text-sm font-semibold text-zinc-950">{field.label}</div>
<div className="mt-1 text-xs text-zinc-400">
{field.fieldDef?.type || 'text'}
{field.fieldDef?.multiple ? ' · 多值' : ''}
</div>
</div>
<div className="min-w-0">
<TailwindEditableField
value={value}
fieldDef={field.fieldDef}
onSave={(newValue) => handleFieldUpdate(field.field, newValue, field.fieldDef)}
readonly={readonly}
disabled={fieldDisabled}
/>
</div>
</section>
);
})}
</div>
</section>
);
}
import { useEffect, useMemo, useState } from 'react';
import clsx from 'clsx';
import { Checkbox } from '@/catalyst-ui-kit/javascript/checkbox';
import { Input } from '@/catalyst-ui-kit/javascript/input';
import { Select } from '@/catalyst-ui-kit/javascript/select';
import { Textarea } from '@/catalyst-ui-kit/javascript/textarea';
import { queryUsers, uploadFile } from '@/services/DataService';
import ReferenceSearchSelect from './ReferenceSearchSelect';
import {
dateInputValue,
formatFieldValue,
getSelectOptions,
isReferenceFieldType,
normalizeUserLabel,
toArray,
} from '@/utils/subjectView';
function asSaveValue(value, fieldDef) {
if (fieldDef?.multiple) {
return toArray(value).filter((item) => item !== '');
}
if (value === '') {
return null;
}
return value;
}
function Pill({ children, tone = 'zinc' }) {
const toneClass =
tone === 'rose'
? 'bg-rose-50 text-rose-700 ring-rose-100'
: tone === 'emerald'
? 'bg-emerald-50 text-emerald-700 ring-emerald-100'
: tone === 'amber'
? 'bg-amber-50 text-amber-700 ring-amber-100'
: 'bg-zinc-100 text-zinc-700 ring-zinc-200';
return (
<span className={clsx('inline-flex rounded-full px-2.5 py-1 text-xs font-medium ring-1', toneClass)}>
{children}
</span>
);
}
function FieldValue({ value, fieldDef }) {
if (value === null || value === undefined || value === '' || (Array.isArray(value) && value.length === 0)) {
return <span className="text-zinc-400">未填写</span>;
}
if (fieldDef?.type === 'select') {
const text = formatFieldValue(value, fieldDef);
return (
<div className="flex flex-wrap gap-1.5">
{toArray(text.split('、')).filter(Boolean).map((item) => (
<Pill key={item}>{item}</Pill>
))}
</div>
);
}
if (fieldDef?.type === 'user') {
return (
<div className="flex flex-wrap gap-1.5">
{toArray(value).map((item, index) => (
<Pill key={`${normalizeUserLabel(item)}-${index}`} tone="emerald">
{normalizeUserLabel(item) || '-'}
</Pill>
))}
</div>
);
}
if (fieldDef?.type === 'file') {
return (
<div className="flex flex-wrap gap-2">
{toArray(value).map((file, index) => {
const label = file?.name || file?.title || file?.url || `附件 ${index + 1}`;
if (!file?.url) {
return <Pill key={`${label}-${index}`}>{label}</Pill>;
}
return (
<a
key={`${label}-${index}`}
href={file.url}
target="_blank"
rel="noreferrer"
className="inline-flex rounded-full bg-sky-50 px-2.5 py-1 text-xs font-medium text-sky-700 ring-1 ring-sky-100 transition hover:bg-sky-100"
>
{label}
</a>
);
})}
</div>
);
}
if (fieldDef?.type === 'object' || fieldDef?.type === 'form' || fieldDef?.type === 'subject') {
return (
<pre className="max-h-48 overflow-auto rounded-2xl bg-zinc-950 px-4 py-3 text-xs leading-5 text-zinc-100">
{JSON.stringify(value, null, 2)}
</pre>
);
}
const text = formatFieldValue(value, fieldDef);
return <span className="whitespace-pre-wrap text-zinc-800">{text || '-'}</span>;
}
function UserSelector({ value, onChange, multiple }) {
const [keyword, setKeyword] = useState('');
const [options, setOptions] = useState([]);
const selectedIds = useMemo(
() => toArray(value).map((item) => item?._id || item).filter(Boolean),
[value],
);
useEffect(() => {
let cancelled = false;
const loadOptions = async () => {
const filter = keyword.trim()
? {
$or: [
{ username: { $regex: keyword.trim(), $options: 'i' } },
{ display_name: { $regex: keyword.trim(), $options: 'i' } },
],
}
: {};
try {
const result = await queryUsers(0, 20, filter);
if (!cancelled) {
setOptions(result?.users || []);
}
} catch (error) {
if (!cancelled) {
setOptions([]);
}
}
};
loadOptions();
return () => {
cancelled = true;
};
}, [keyword]);
const toggleUser = (user, checked) => {
if (!multiple) {
onChange(checked ? user._id : null);
return;
}
if (checked) {
onChange(Array.from(new Set([...selectedIds, user._id])));
return;
}
onChange(selectedIds.filter((item) => item !== user._id));
};
return (
<div className="space-y-3">
<Input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索用户名或显示名" />
<div className="max-h-56 space-y-2 overflow-y-auto rounded-2xl border border-zinc-200 bg-zinc-50 p-2">
{options.length === 0 ? (
<div className="rounded-xl bg-white px-3 py-4 text-sm text-zinc-500">暂无用户</div>
) : (
options.map((user) => {
const checked = selectedIds.includes(user._id);
return (
<label
key={user._id}
className="flex cursor-pointer items-center gap-3 rounded-xl bg-white px-3 py-2 text-sm text-zinc-700 shadow-sm transition hover:bg-rose-50"
>
<input
type={multiple ? 'checkbox' : 'radio'}
checked={checked}
onChange={(event) => toggleUser(user, event.target.checked)}
/>
<span>
<span className="block font-medium text-zinc-950">{user.display_name || user.username}</span>
<span className="block text-xs text-zinc-500">{user.username}</span>
</span>
</label>
);
})
)}
</div>
</div>
);
}
export default function TailwindEditableField({
value,
fieldDef,
onSave,
readonly = false,
disabled = false,
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const [error, setError] = useState('');
const [uploading, setUploading] = useState(false);
useEffect(() => {
setDraft(value);
}, [value]);
useEffect(() => {
if (readonly || disabled) {
setEditing(false);
}
}, [readonly, disabled]);
const canEdit = !readonly && !disabled;
const fieldType = fieldDef?.type || 'text';
const isLongText = ['long_text', 'rich_text'].includes(fieldType);
const isJsonLike = ['object', 'form', 'subject'].includes(fieldType);
const save = async (nextValue = draft) => {
setError('');
if (fieldType === 'object' && typeof nextValue === 'string' && nextValue.trim()) {
try {
nextValue = JSON.parse(nextValue);
} catch (parseError) {
setError('JSON 格式不正确,请检查后再保存。');
return;
}
}
try {
await onSave(asSaveValue(nextValue, fieldDef));
setEditing(false);
} catch (saveError) {
setError(saveError?.message || '保存失败,请稍后重试。');
}
};
const cancel = () => {
setDraft(value);
setError('');
setEditing(false);
};
const handleFileUpload = async (event) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
setUploading(true);
setError('');
try {
const uploaded = await uploadFile(file);
const fileValue = uploaded?.file || uploaded?.data || uploaded;
const nextValue = fieldDef?.multiple ? [...toArray(value), fileValue] : fileValue;
await onSave(nextValue);
setEditing(false);
} catch (uploadError) {
setError(uploadError?.message || '附件上传失败,请稍后重试。');
} finally {
setUploading(false);
}
};
if (!editing) {
return (
<button
type="button"
disabled={!canEdit}
onClick={() => canEdit && setEditing(true)}
className={clsx(
'group min-h-10 w-full rounded-2xl px-3 py-2.5 text-left text-sm transition',
canEdit
? 'bg-transparent hover:bg-rose-50/70 hover:ring-1 hover:ring-rose-100'
: 'cursor-default bg-transparent opacity-80',
)}
>
<FieldValue value={value} fieldDef={fieldDef} />
<div className="mt-2 flex items-center justify-between gap-3">
{disabled ? <div className="text-xs text-amber-600">当前视图无编辑权限</div> : <span />}
{canEdit ? (
<div className="text-[11px] font-medium text-zinc-400 opacity-0 transition group-hover:opacity-100">
点击编辑
</div>
) : null}
</div>
</button>
);
}
if (fieldType === 'boolean') {
return (
<div className="flex items-center gap-3 rounded-2xl border border-zinc-200 bg-white px-3 py-2">
<Checkbox checked={Boolean(draft)} onChange={(checked) => setDraft(checked)} color="rose" />
<span className="text-sm text-zinc-700">{draft ? '是' : '否'}</span>
<button type="button" onClick={() => save(Boolean(draft))} className="ml-auto text-sm font-medium text-rose-700">
保存
</button>
<button type="button" onClick={cancel} className="text-sm text-zinc-500">
取消
</button>
{error ? <div className="basis-full text-sm text-rose-600">{error}</div> : null}
</div>
);
}
if (fieldType === 'select') {
const options = getSelectOptions(fieldDef);
return (
<div className="space-y-3">
<Select
multiple={fieldDef?.multiple}
value={fieldDef?.multiple ? toArray(draft) : draft || ''}
onChange={(event) => {
if (fieldDef?.multiple) {
setDraft(Array.from(event.target.selectedOptions).map((option) => option.value));
return;
}
setDraft(event.target.value);
}}
>
{!fieldDef?.multiple ? <option value="">请选择</option> : null}
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
<div className="flex justify-end gap-2">
<button type="button" onClick={cancel} className="text-sm text-zinc-500">取消</button>
<button type="button" onClick={() => save()} className="text-sm font-medium text-rose-700">保存</button>
</div>
{error ? <div className="text-sm text-rose-600">{error}</div> : null}
</div>
);
}
if (fieldType === 'user') {
return (
<div className="space-y-3">
<UserSelector value={draft} onChange={setDraft} multiple={fieldDef?.multiple} />
<div className="flex justify-end gap-2">
<button type="button" onClick={cancel} className="text-sm text-zinc-500">取消</button>
<button type="button" onClick={() => save()} className="text-sm font-medium text-rose-700">保存</button>
</div>
{error ? <div className="text-sm text-rose-600">{error}</div> : null}
</div>
);
}
if (isReferenceFieldType(fieldType)) {
return (
<div className="space-y-3">
<ReferenceSearchSelect
fieldDef={fieldDef}
value={draft}
onChange={setDraft}
/>
<div className="flex justify-end gap-2">
<button type="button" onClick={cancel} className="text-sm text-zinc-500">取消</button>
<button type="button" onClick={() => save()} className="text-sm font-medium text-rose-700">保存</button>
</div>
{error ? <div className="text-sm text-rose-600">{error}</div> : null}
</div>
);
}
if (fieldType === 'file') {
return (
<div className="space-y-3 rounded-2xl border border-zinc-200 bg-white p-3">
<FieldValue value={value} fieldDef={fieldDef} />
<input type="file" onChange={handleFileUpload} disabled={uploading} className="block w-full text-sm text-zinc-600" />
{error ? <div className="text-sm text-rose-600">{error}</div> : null}
<div className="flex justify-end gap-2">
<button type="button" onClick={cancel} className="text-sm text-zinc-500">取消</button>
<span className="text-sm text-zinc-400">{uploading ? '上传中...' : '选择文件后自动保存'}</span>
</div>
</div>
);
}
if (isJsonLike) {
const jsonValue = typeof draft === 'string' ? draft : JSON.stringify(draft ?? null, null, 2);
return (
<div className="space-y-3">
<Textarea rows={8} value={jsonValue} onChange={(event) => setDraft(event.target.value)} resizable />
{error ? <div className="text-sm text-rose-600">{error}</div> : null}
<div className="flex justify-end gap-2">
<button type="button" onClick={cancel} className="text-sm text-zinc-500">取消</button>
<button
type="button"
onClick={() => (fieldType === 'object' ? save() : cancel())}
className="text-sm font-medium text-rose-700"
>
{fieldType === 'object' ? '保存' : '关闭'}
</button>
</div>
</div>
);
}
if (isLongText) {
return (
<div className="space-y-3">
<Textarea rows={5} value={draft || ''} onChange={(event) => setDraft(event.target.value)} autoFocus />
<div className="flex justify-end gap-2">
<button type="button" onClick={cancel} className="text-sm text-zinc-500">取消</button>
<button type="button" onClick={() => save()} className="text-sm font-medium text-rose-700">保存</button>
</div>
{error ? <div className="text-sm text-rose-600">{error}</div> : null}
</div>
);
}
return (
<div className="space-y-3">
<Input
type={fieldType === 'number' ? 'number' : fieldType === 'date' ? 'date' : 'text'}
value={fieldType === 'date' ? dateInputValue(draft) : draft ?? ''}
onChange={(event) => {
const nextValue = fieldType === 'number'
? event.target.value === '' ? '' : Number(event.target.value)
: event.target.value;
setDraft(nextValue);
}}
autoFocus
/>
<div className="flex justify-end gap-2">
<button type="button" onClick={cancel} className="text-sm text-zinc-500">取消</button>
<button type="button" onClick={() => save()} className="text-sm font-medium text-rose-700">保存</button>
</div>
{error ? <div className="text-sm text-rose-600">{error}</div> : null}
</div>
);
}
import { useEffect, useRef, useState } from 'react';
import {
getSubjectView,
loadRecord,
loadSubject,
updateRecord,
} from '@/services/DataService';
import {
isFieldDisabledByView,
isReferenceFieldType,
setFieldValue,
toIsoDateString,
} from '@/utils/subjectView';
function transformSaveValue(newValue, fieldDef) {
if (newValue === null || newValue === undefined || newValue === '') {
return fieldDef?.multiple ? [] : null;
}
if (Array.isArray(newValue) && newValue.length === 0) {
return [];
}
if (fieldDef?.type === 'user' || isReferenceFieldType(fieldDef?.type)) {
if (Array.isArray(newValue)) {
return newValue.map((item) => item?._id || item);
}
return newValue?._id || newValue;
}
if (fieldDef?.type === 'date') {
return toIsoDateString(newValue);
}
if (fieldDef?.type === 'number') {
return newValue === '' ? null : Number(newValue);
}
return newValue;
}
export function useSubjectRecord(recordName, refreshKey, readonly) {
const [remoteRecord, setRemoteRecord] = useState(null);
const [editableRecord, setEditableRecord] = useState(null);
const [subject, setSubject] = useState(null);
const [views, setViews] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const requestIdRef = useRef(0);
useEffect(() => {
let cancelled = false;
const requestId = requestIdRef.current + 1;
requestIdRef.current = requestId;
const loadAll = async () => {
if (!recordName) {
setRemoteRecord(null);
setEditableRecord(null);
setSubject(null);
setViews([]);
return;
}
setLoading(true);
setError('');
try {
const record = await loadRecord(recordName);
const subjectName = record?.__t || record?.subject?.name || record?.subject;
const [subjectData, viewsData] = subjectName
? await Promise.all([loadSubject(subjectName), getSubjectView(subjectName)])
: [null, []];
if (!cancelled && requestIdRef.current === requestId) {
setRemoteRecord(record);
setEditableRecord(JSON.parse(JSON.stringify(record)));
setSubject(subjectData);
setViews(viewsData || []);
}
} catch (loadError) {
if (!cancelled) {
setError(loadError?.message || '详情加载失败,请稍后重试。');
setRemoteRecord(null);
setEditableRecord(null);
setSubject(null);
setViews([]);
}
} finally {
if (!cancelled && requestIdRef.current === requestId) {
setLoading(false);
}
}
};
loadAll();
return () => {
cancelled = true;
};
}, [recordName, refreshKey]);
const handleFieldUpdate = async (fieldPath, newValue, fieldDef) => {
if (readonly || !editableRecord) {
return;
}
const previousRecord = editableRecord;
const nextRecord = setFieldValue(editableRecord, fieldPath, newValue);
setEditableRecord(nextRecord);
try {
const saveValue = transformSaveValue(newValue, fieldDef);
const payload = fieldPath.split('.').reduceRight((value, key) => ({ [key]: value }), saveValue);
await updateRecord(recordName, payload);
setRemoteRecord(nextRecord);
} catch (updateError) {
setEditableRecord(previousRecord);
throw updateError;
}
};
const isFieldDisabled = (fieldPath, currentView) => {
return isFieldDisabledByView(fieldPath, currentView);
};
return {
editableRecord,
remoteRecord,
subject,
views,
error,
handleFieldUpdate,
isFieldDisabled,
loading,
};
}
import * as Headless from '@headlessui/react';
import clsx from 'clsx';
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useState,
} from 'react';
import { Button } from '@/catalyst-ui-kit/javascript/button';
import { useSubjectRecord } from './hooks/useSubjectRecord';
import SubjectViewContent from './components/SubjectViewContent';
import {
checkViewCondition,
isViewAccessible,
} from '@/utils/subjectView';
function formatDateTime(value) {
if (!value) {
return '未记录';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '未记录';
}
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
function CloseIcon() {
return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="size-5">
<path d="M5 5l10 10M15 5 5 15" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
</svg>
);
}
function ExpandIcon({ expanded }) {
return expanded ? (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="size-5">
<path d="M7.5 3.5v3a1 1 0 0 1-1 1h-3M12.5 16.5v-3a1 1 0 0 1 1-1h3M16.5 7.5h-3a1 1 0 0 1-1-1v-3M3.5 12.5h3a1 1 0 0 1 1 1v3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
) : (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="size-5">
<path d="M3.5 8V4.5a1 1 0 0 1 1-1H8M16.5 8V4.5a1 1 0 0 0-1-1H12M3.5 12v3.5a1 1 0 0 0 1 1H8M16.5 12v3.5a1 1 0 0 1-1 1H12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
const RecordDetailDrawer = (props, ref) => {
const {
width = 860,
open,
recordName,
setCustomComponentClose,
onDataChange,
readonly = false,
filterView = [],
} = props;
const [refreshKey, setRefreshKey] = useState(() => Date.now());
const [isFullscreen, setIsFullscreen] = useState(false);
const [activeTabKey, setActiveTabKey] = useState();
const {
editableRecord,
remoteRecord,
subject,
views,
error,
handleFieldUpdate,
isFieldDisabled,
loading,
} = useSubjectRecord(recordName, refreshKey, readonly);
useEffect(() => {
if (open) {
setRefreshKey(Date.now());
}
}, [open]);
const detailViews = useMemo(() => {
const filtered = (views || [])
.filter((view) => view.type === 'detail')
.filter((view) => (filterView.length === 0 ? true : filterView.includes(view.name)))
.filter((view) => isViewAccessible(view))
.filter((view) => checkViewCondition(view.settings?.visabled, remoteRecord));
if (filtered.length > 0) {
return filtered;
}
return [
{
title: '基础信息',
type: 'detail',
name: 'base',
settings: {},
},
];
}, [views, filterView, remoteRecord]);
useEffect(() => {
if (detailViews.length === 0) {
return;
}
const currentTab = detailViews.find((view) => view.name === activeTabKey);
if (!currentTab) {
setActiveTabKey(detailViews[0].name);
}
}, [detailViews, activeTabKey]);
useImperativeHandle(
ref,
() => ({
setActiveTab: (key) => setActiveTabKey(key),
getActiveTab: () => activeTabKey,
}),
[activeTabKey],
);
const activeView = detailViews.find((view) => view.name === activeTabKey) || detailViews[0];
const drawerWidth = isFullscreen ? '100vw' : typeof width === 'number' ? `${width}px` : width;
const summaryItems = [
{
label: '主题',
value: subject?.title || subject?.name || '未识别',
},
{
label: '当前视图',
value: activeView?.title || activeView?.name || '基础信息',
},
{
label: '最近更新',
value: formatDateTime(editableRecord?.updated_at),
},
];
const closeDrawer = () => {
setCustomComponentClose?.();
};
return (
<Headless.Dialog open={Boolean(open)} onClose={closeDrawer} className="relative z-50">
<Headless.DialogBackdrop
transition
className="fixed inset-0 bg-zinc-950/40 backdrop-blur-[2px] transition duration-200 data-closed:opacity-0"
/>
<div className="fixed inset-0 overflow-hidden">
<div className="absolute inset-0 overflow-hidden">
<div className="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-4 sm:pl-10">
<Headless.DialogPanel
transition
style={{ width: drawerWidth }}
className="pointer-events-auto h-full transform overflow-hidden border-l border-white/70 bg-[linear-gradient(180deg,#fffdf8_0%,#f8fafc_38%,#f4f4f5_100%)] shadow-[0_24px_80px_rgba(15,23,42,0.24)] ring-1 ring-zinc-950/5 transition duration-300 data-closed:translate-x-full data-enter:ease-out data-leave:ease-in"
>
<div className="flex h-full flex-col">
<header className="relative overflow-hidden border-b border-white/70 bg-[linear-gradient(135deg,rgba(255,255,255,0.95),rgba(255,247,237,0.9)_45%,rgba(244,244,245,0.95))] px-6 pb-5 pt-6 shadow-[0_8px_24px_rgba(15,23,42,0.06)] backdrop-blur">
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-rose-200 to-transparent" />
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="text-[11px] font-semibold uppercase tracking-[0.32em] text-rose-500">
Record Detail
</div>
<Headless.DialogTitle className="mt-2 truncate text-[28px] font-semibold tracking-tight text-zinc-950">
{editableRecord?.title || editableRecord?.name || '记录详情'}
</Headless.DialogTitle>
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-zinc-500">
<span className="inline-flex rounded-full bg-white/90 px-3 py-1 font-medium text-zinc-700 ring-1 ring-white shadow-sm">
{readonly ? '只读模式' : '可编辑'}
</span>
{editableRecord?.status ? (
<span className="inline-flex rounded-full bg-rose-50 px-3 py-1 font-medium text-rose-700 ring-1 ring-rose-100">
{editableRecord.status}
</span>
) : null}
</div>
<div className="mt-4 grid gap-3 md:grid-cols-3">
{summaryItems.map((item) => (
<div
key={item.label}
className="rounded-2xl border border-white/80 bg-white/72 px-4 py-3 shadow-sm ring-1 ring-zinc-950/5"
>
<div className="text-xs font-medium uppercase tracking-[0.16em] text-zinc-400">
{item.label}
</div>
<div className="mt-1 truncate text-sm font-semibold text-zinc-800">
{item.value}
</div>
</div>
))}
</div>
</div>
<div className="flex shrink-0 gap-2 rounded-full border border-white/80 bg-white/88 p-1.5 shadow-sm ring-1 ring-zinc-950/5 backdrop-blur">
<button
type="button"
onClick={() => setIsFullscreen((current) => !current)}
className="rounded-full p-2 text-zinc-600 transition hover:bg-rose-50 hover:text-rose-700"
title={isFullscreen ? '退出全屏' : '全屏'}
>
<ExpandIcon expanded={isFullscreen} />
</button>
<button
type="button"
onClick={closeDrawer}
className="rounded-full p-2 text-zinc-600 transition hover:bg-rose-50 hover:text-rose-700"
title="关闭"
>
<CloseIcon />
</button>
</div>
</div>
<div className="mt-5">
<div className="flex gap-2 overflow-x-auto">
{detailViews.map((view) => {
const selected = view.name === activeView?.name;
return (
<button
key={view.name}
type="button"
onClick={() => setActiveTabKey(view.name)}
className={clsx(
'shrink-0 rounded-full px-4 py-2 text-sm font-medium transition',
selected
? 'bg-[linear-gradient(135deg,#7f1d1d,#be123c)] text-white shadow-lg shadow-rose-200/60'
: 'bg-white/92 text-zinc-600 ring-1 ring-zinc-200/80 hover:bg-rose-50 hover:text-rose-700',
)}
>
{view.title || view.name}
</button>
);
})}
</div>
</div>
</header>
<main className="min-h-0 flex-1 overflow-y-auto bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.9),rgba(248,250,252,0.96)_32%,rgba(244,244,245,0.98)_100%)] px-6 py-5">
{loading ? (
<div className="rounded-[28px] border border-white/80 bg-white/88 px-6 py-14 text-center text-sm text-zinc-500 shadow-sm ring-1 ring-zinc-950/5">
正在加载详情...
</div>
) : error ? (
<div className="rounded-[28px] border border-rose-200 bg-rose-50 px-6 py-5 text-sm text-rose-700 shadow-sm">
{error}
</div>
) : (
<SubjectViewContent
view={activeView}
subject={subject}
editableRecord={editableRecord}
onFieldUpdate={handleFieldUpdate}
onDataChange={onDataChange}
readonly={readonly}
isFieldDisabled={isFieldDisabled}
/>
)}
</main>
<footer className="border-t border-white/70 bg-white/82 px-6 py-4 backdrop-blur">
<div className="flex items-center justify-between gap-3">
<div className="text-sm text-zinc-500">
长文本、附件和状态类字段支持在详情面板内直接维护
</div>
<Button outline onClick={closeDrawer}>
关闭
</Button>
</div>
</footer>
</div>
</Headless.DialogPanel>
</div>
</div>
</div>
</Headless.Dialog>
);
};
export default forwardRef(RecordDetailDrawer);
import dayjs from 'dayjs';
import { queryRecords } from '@/services/DataService';
import { DEPARTMENT_GROUPS } from './problemRectificationDashboardService';
import {
getSubjectView,
loadSubject,
queryRecords,
} from '@/services/DataService';
import {
buildViewColumns,
emptyValueForField,
formatFieldValue,
getFieldNameFromPath,
getFieldValue,
isViewAccessible,
normalizeValueForSubmit,
normalizeText,
recordMatchesRestrictions,
sortRecordsByView,
} from '@/utils/subjectView';
export const PROBLEM_RECTIFICATION_SUBJECT = 'problem_rectification';
function normalizeText(value) {
return String(value ?? '').trim();
}
function toArray(value) {
if (!value) {
return [];
......@@ -55,112 +66,113 @@ export function normalizeProblemRectificationRecord(record) {
name: record?.name || '',
title: normalizeText(record?.title) || normalizeText(metadata.project_name),
projectName: normalizeText(metadata.project_name) || '-',
inspectUnit: normalizeText(metadata.inspect_the_implementing_unit),
implementationTime: normalizeText(metadata.check_the_implementation_time),
inspectionReportDate: normalizeText(metadata.inspection_report_date),
brief: normalizeText(metadata.problem_brief),
detail: normalizeText(metadata.problem_discovery_situation),
responsibleDepartments: toArray(metadata.responsible_department)
.map((item) => normalizeText(item))
.filter(Boolean),
progress: normalizeText(metadata.rectification_progress),
rectificationSituation: normalizeText(metadata.rectification_situation),
problemFrom: normalizeText(metadata.problem_from),
problemType: normalizeText(metadata.problem_type),
feedbackUnit: normalizeText(metadata.feedback_unit),
relatedIssueDomain: normalizeText(metadata.related_issue_domain),
dueDateLabel: formatDateText(metadata.the_time_of_rectification) || '-',
auditStatus: normalizeText(metadata.audit_status),
finalAuditStatus: normalizeText(metadata.final_audit_status),
fillUsers: toArray(metadata.fill_in_the_people)
.map(normalizeUserLabel)
.filter(Boolean),
reviewUsers: toArray(metadata.review_people)
.map(normalizeUserLabel)
.filter(Boolean),
auditStatus: normalizeText(metadata.audit_status),
finalAuditStatus: normalizeText(metadata.final_audit_status),
remark: normalizeText(metadata.remark),
proofMaterial: metadata.rectification_proof_materials || null,
createdAt: record?.created_at || '',
updatedAt: record?.updated_at || '',
dueDateLabel: formatDateText(metadata.the_time_of_rectification) || '-',
raw: record,
};
}
function matchGroup(record, groupKey) {
if (groupKey === 'all') {
return true;
}
const group = DEPARTMENT_GROUPS.find((item) => item.key === groupKey);
if (!group) {
return true;
}
export async function loadProblemRectificationListData() {
const result = await queryRecords({
subject: PROBLEM_RECTIFICATION_SUBJECT,
page: 0,
page_size: 1000,
sort: '-updated_at -created_at',
mode: 'full',
});
return group.matchFeedbackUnits.includes(record.feedbackUnit);
const records = (result?.records || []).map(normalizeProblemRectificationRecord);
return records;
}
function matchSearch(record, keyword) {
const searchText = normalizeText(keyword).toLowerCase();
if (!searchText) {
return true;
}
export async function loadProblemRectificationListResources() {
const [records, subject, views] = await Promise.all([
loadProblemRectificationListData(),
loadSubject(PROBLEM_RECTIFICATION_SUBJECT),
getSubjectView(PROBLEM_RECTIFICATION_SUBJECT),
]);
return [
record.projectName,
record.inspectUnit,
record.brief,
record.detail,
record.problemType,
record.problemFrom,
record.feedbackUnit,
record.relatedIssueDomain,
]
.join(' ')
.toLowerCase()
.includes(searchText);
return {
records,
subject,
views: views || [],
};
}
function matchProgress(record, progress) {
const progressValue = normalizeText(progress);
if (!progressValue) {
return true;
}
return record.progress === progressValue;
}
export function buildGridViews(views = []) {
const gridViews = views
.filter((view) => view.type === 'grid')
.filter((view) => isViewAccessible(view));
function matchAuditStatus(record, auditStatus) {
const auditValue = normalizeText(auditStatus);
if (!auditValue) {
return true;
if (gridViews.length > 0) {
return gridViews;
}
return record.auditStatus === auditValue;
return [
{
title: '全部',
type: 'grid',
name: 'all',
settings: {},
},
];
}
export async function loadProblemRectificationListData() {
const result = await queryRecords({
subject: PROBLEM_RECTIFICATION_SUBJECT,
page: 0,
page_size: 1000,
sort: '-updated_at -created_at',
});
export function buildViewColumnsForList(view, subject) {
return buildViewColumns(view, subject).filter((column) => column.field !== 'title' || !column.hide);
}
const records = (result?.records || []).map(normalizeProblemRectificationRecord);
return records;
export function getRecordCellText(record, column) {
const rawRecord = record?.raw || record;
const value = getFieldValue(rawRecord, column.field);
return formatFieldValue(value, column.fieldDef) || '-';
}
export function buildProblemRectificationListView(records, filters) {
const nextGroupKey = filters?.groupKey || 'all';
export function buildProblemRectificationConfiguredListView(records, filters, gridView, subject) {
const nextKeyword = filters?.keyword || '';
const nextProgress = filters?.progress || '';
const nextAuditStatus = filters?.auditStatus || '';
const filteredRecords = records.filter(
(record) =>
matchGroup(record, nextGroupKey) &&
matchSearch(record, nextKeyword) &&
matchProgress(record, nextProgress) &&
matchAuditStatus(record, nextAuditStatus),
);
const columns = buildViewColumnsForList(gridView, subject);
const searchableFields = columns.map((column) => column.field);
const filteredRecords = records.filter((record) => {
const rawRecord = record.raw || record;
if (!recordMatchesRestrictions(rawRecord, gridView?.settings?.restrictions)) {
return false;
}
if (nextProgress && record.progress !== nextProgress) {
return false;
}
if (nextAuditStatus && record.auditStatus !== nextAuditStatus) {
return false;
}
if (!nextKeyword) {
return true;
}
const keyword = normalizeText(nextKeyword).toLowerCase();
return searchableFields
.map((fieldPath) => normalizeText(formatFieldValue(getFieldValue(rawRecord, fieldPath), columns.find((column) => column.field === fieldPath)?.fieldDef)))
.join(' ')
.toLowerCase()
.includes(keyword);
});
const sortedRecords = sortRecordsByView(filteredRecords, gridView);
const progressOptions = Array.from(
new Set(records.map((item) => item.progress).filter(Boolean)),
......@@ -170,41 +182,67 @@ export function buildProblemRectificationListView(records, filters) {
new Set(records.map((item) => item.auditStatus).filter(Boolean)),
);
const groupCounts = Object.fromEntries(
DEPARTMENT_GROUPS.map((group) => [
group.key,
records.filter((record) => matchGroup(record, group.key)).length,
]),
);
return {
filteredRecords,
filteredRecords: sortedRecords,
columns,
progressOptions,
auditStatusOptions,
groupCounts,
};
}
export function buildProblemRectificationCreatePayload(formValues) {
export function buildProblemRectificationCreateFormFields(gridView, subject) {
const columns = buildViewColumnsForList(gridView, subject)
.filter((column) => column.field !== 'title')
.filter((column) => column.field?.startsWith('metadata.'))
.filter((column) => column.fieldDef?.name);
const seen = new Set();
const fields = columns.filter((column) => {
const fieldName = getFieldNameFromPath(column.field);
if (!fieldName || seen.has(fieldName)) {
return false;
}
seen.add(fieldName);
return true;
});
if (fields.length > 0) {
return fields;
}
return (subject?.fields || []).map((field) => ({
field: `metadata.${field.name}`,
label: field.label || field.name,
fieldDef: field,
}));
}
export function buildInitialCreateValues(createFields) {
return createFields.reduce((values, field) => ({
...values,
[field.fieldDef.name]: emptyValueForField(field.fieldDef),
}), {});
}
export function buildProblemRectificationCreatePayload(formValues, createFields = []) {
const metadata = createFields.reduce((nextMetadata, field) => {
const fieldName = field.fieldDef?.name;
if (!fieldName) {
return nextMetadata;
}
return {
...nextMetadata,
[fieldName]: normalizeValueForSubmit(formValues[fieldName], field.fieldDef),
};
}, {});
const projectName = normalizeText(metadata.project_name);
const problemBrief = normalizeText(metadata.problem_brief);
return {
subject: PROBLEM_RECTIFICATION_SUBJECT,
title: formValues.projectName || formValues.brief || '问题整改记录',
metadata: {
project_name: formValues.projectName || '',
inspect_the_implementing_unit: formValues.inspectUnit || '',
check_the_implementation_time: formValues.implementationTime || '',
inspection_report_date: formValues.inspectionReportDate || '',
problem_brief: formValues.brief || '',
problem_discovery_situation: formValues.detail || '',
responsible_department: formValues.responsibleDepartments.filter(Boolean),
rectification_progress: formValues.progress || '',
rectification_situation: formValues.rectificationSituation || '',
problem_from: formValues.problemFrom || '',
problem_type: formValues.problemType || '',
feedback_unit: formValues.feedbackUnit || '',
related_issue_domain: formValues.relatedIssueDomain || '',
the_time_of_rectification: formValues.dueDate || '',
remark: formValues.remark || '',
},
title: projectName || problemBrief || '问题整改记录',
metadata,
};
}
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="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">
COMING SOON
</div>
<h2 className="mt-6 text-3xl font-semibold text-slate-900">{title}</h2>
<p className="mt-3 max-w-2xl text-sm leading-7 text-slate-500">
{description}
</p>
<div className="mt-10 grid gap-4 md:grid-cols-2">
<div className="rounded-3xl border border-slate-200 bg-slate-50 p-5">
<div className="text-sm font-semibold text-slate-800">当前状态</div>
<div className="mt-2 text-sm leading-6 text-slate-500">
页面入口、路由、整体布局、认证链路、请求封装和统一
`dataServices` 已完成接入。
</div>
</div>
<div className="rounded-3xl border border-slate-200 bg-slate-50 p-5">
<div className="text-sm font-semibold text-slate-800">下一步可做</div>
<div className="mt-2 text-sm leading-6 text-slate-500">
直接在当前页面补业务组件,调用 `src/services/DataService.js`
里的接口即可,不需要再单独封装请求层。
</div>
</div>
</div>
</div>
</section>
);
}
import xyLogo from '@/assets/xy-logo.png';
import xyLogoSmall from '@/assets/xy-logo_small.png';
import { UserContext } from '@/wrapper/Auth';
import {
CalendarDaysIcon,
ChartBarSquareIcon,
Bars3Icon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { useContext, useEffect, useMemo, useState } from 'react';
import { Link, Outlet, useLocation } from 'umi';
const APP_NAME = '项目管理系统';
const menuGroups = [
{
title: '项目资源管理',
items: [
{
name: '资源统计',
path: '/resource/stat',
icon: ChartBarSquareIcon,
},
{
name: '资源预定',
path: '/resource/booking',
icon: CalendarDaysIcon,
},
],
},
];
function NavbarItem({ className, ...props }) {
return (
<button
type="button"
className={clsx(
'inline-flex items-center justify-center rounded-lg p-2 text-zinc-500 transition hover:bg-zinc-100 hover:text-zinc-900',
className,
)}
{...props}
/>
);
}
function NavItem({ name, path, active, collapsed, icon: Icon, onNavigate }) {
return (
<Link
to={path}
onClick={onNavigate}
title={collapsed ? name : undefined}
aria-label={collapsed ? name : undefined}
className={clsx(
'group relative flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200',
active
? 'bg-[var(--pm-brand)] text-white shadow-lg'
: 'text-white/90 hover:bg-zinc-800 hover:text-white',
collapsed && 'justify-center px-2',
)}
>
{Icon ? (
<Icon
className={clsx(
'size-5 shrink-0 transition-colors',
active ? 'text-white' : 'text-white/80 group-hover:text-white',
)}
/>
) : null}
{!collapsed ? (
<span
className={clsx(
'min-w-0 flex-1 truncate',
active ? 'text-white' : 'text-white/90 group-hover:text-white',
)}
>
{name}
</span>
) : null}
{active && !collapsed ? (
<span className="absolute right-2 size-1.5 rounded-full bg-white/50" />
) : null}
</Link>
);
}
function MobileNavItem({ name, path, active, icon: Icon, onNavigate }) {
return (
<Link
to={path}
onClick={onNavigate}
className={clsx(
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200',
active
? 'bg-[var(--pm-brand)] text-white shadow-lg'
: 'text-white/90 hover:bg-zinc-800 hover:text-white',
)}
>
{Icon ? (
<Icon
className={clsx(
'size-5 shrink-0',
active ? 'text-white' : 'text-white/80',
)}
/>
) : null}
<span
className={clsx(
'min-w-0 flex-1 truncate',
active ? 'text-white' : 'text-white/90',
)}
>
{name}
</span>
</Link>
);
}
function CollapseSidebarIcon() {
return (
<svg
data-slot="icon"
viewBox="0 0 20 20"
aria-hidden="true"
fill="currentColor"
>
<path d="M12.78 15.53a.75.75 0 0 1-1.06 0l-4.5-4.5a.75.75 0 0 1 0-1.06l4.5-4.5a.75.75 0 1 1 1.06 1.06L8.81 10l3.97 3.97a.75.75 0 0 1 0 1.06Z" />
</svg>
);
}
function ExpandSidebarIcon() {
return (
<svg
data-slot="icon"
viewBox="0 0 20 20"
aria-hidden="true"
fill="currentColor"
>
<path d="M7.22 15.53a.75.75 0 0 0 1.06 0l4.5-4.5a.75.75 0 0 0 0-1.06l-4.5-4.5a.75.75 0 1 0-1.06 1.06L11.19 10l-3.97 3.97a.75.75 0 0 0 0 1.06Z" />
</svg>
);
}
function MobileSidebar({ open, close, children }) {
if (!open) {
return null;
}
return (
<div className="relative z-[140] lg:hidden">
<div
className="fixed inset-0 z-[140] bg-black/30"
onClick={close}
aria-hidden="true"
/>
<div className="fixed inset-y-0 z-[150] w-full max-w-80 p-2">
<div className="flex h-full flex-col rounded-lg bg-zinc-900 shadow-xs ring-1 ring-white/10">
<div className="-mb-3 flex justify-end px-4 pt-3">
<NavbarItem
onClick={close}
aria-label="Close navigation"
className="text-zinc-400 hover:text-white"
>
<XMarkIcon className="size-5" />
</NavbarItem>
</div>
{children}
</div>
</div>
</div>
);
}
export default function ProjectManagerLayout() {
const location = useLocation();
const [isMobile, setIsMobile] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const userInfo = useContext(UserContext);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const media = window.matchMedia('(max-width: 1023px)');
const sync = () => {
const mobile = !!media.matches;
setIsMobile(mobile);
if (mobile) {
setIsMobileSidebarOpen(false);
setIsSidebarCollapsed(true);
} else {
const storedCollapsed = window.localStorage?.getItem?.(
'pm_sidebar_collapsed',
);
if (storedCollapsed !== null && storedCollapsed !== undefined) {
setIsSidebarCollapsed(storedCollapsed === '1');
}
}
};
sync();
media.addEventListener?.('change', sync);
return () => {
media.removeEventListener?.('change', sync);
};
}, []);
const sidebarCollapsed = isMobile ? true : isSidebarCollapsed;
const currentGroupTitle = useMemo(() => {
return (
menuGroups.find((group) =>
group.items.some((item) => item.path === location.pathname),
)?.title || ''
);
}, [location.pathname]);
return (
<div className="flex h-screen overflow-hidden bg-zinc-50">
<aside
className={clsx(
'relative z-30 hidden shrink-0 flex-col border-r border-zinc-800 bg-zinc-900 p-5 lg:flex',
sidebarCollapsed ? 'w-20' : 'w-72',
)}
>
<div
className={clsx(
'mb-7 flex items-center',
sidebarCollapsed ? 'justify-center' : 'gap-3 px-1',
)}
>
<img
src={sidebarCollapsed ? xyLogoSmall : xyLogo}
alt="XY Logo"
className={clsx(
'shrink-0 object-contain',
sidebarCollapsed
? 'h-8 w-auto max-w-8'
: 'h-8 w-auto max-w-[140px]',
)}
/>
{!sidebarCollapsed ? (
<h1 className="truncate text-sm font-semibold tracking-[0.02em] text-white/95">
{APP_NAME}
</h1>
) : null}
</div>
<nav className="flex-1 space-y-5 overflow-y-auto">
{menuGroups.map((group) => (
<div key={group.title}>
{!sidebarCollapsed ? (
<h2 className="mb-2 px-1 text-xs font-medium tracking-wide text-zinc-500">
{group.title}
</h2>
) : null}
<div className="space-y-1">
{group.items.map((item) => (
<NavItem
key={item.path}
name={item.name}
path={item.path}
active={location.pathname === item.path}
collapsed={sidebarCollapsed}
icon={item.icon}
/>
))}
</div>
</div>
))}
</nav>
</aside>
<div className="relative z-30 hidden lg:flex">
<button
type="button"
onClick={() =>
setIsSidebarCollapsed((prev) => {
const next = !prev;
window?.localStorage?.setItem?.(
'pm_sidebar_collapsed',
next ? '1' : '0',
);
return next;
})
}
aria-label={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
className="absolute -left-3.5 top-16 z-10 flex size-7 items-center justify-center rounded-full border border-zinc-700 bg-zinc-800 text-zinc-400 shadow hover:bg-zinc-700 hover:text-white [&_svg]:size-4"
>
{sidebarCollapsed ? <ExpandSidebarIcon /> : <CollapseSidebarIcon />}
</button>
</div>
<MobileSidebar
open={isMobileSidebarOpen}
close={() => setIsMobileSidebarOpen(false)}
>
<div className="px-5 pt-6">
<div className="flex items-center gap-2">
<img
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}
</h1>
</div>
</div>
<nav className="space-y-5 overflow-y-auto px-5 pb-6 pt-6">
{menuGroups.map((group) => (
<div key={group.title}>
<h2 className="mb-2 px-1 text-xs font-medium tracking-wide text-zinc-600">
{group.title}
</h2>
<div className="space-y-1">
{group.items.map((item) => (
<MobileNavItem
key={item.path}
name={item.name}
path={item.path}
active={location.pathname === item.path}
icon={item.icon}
onNavigate={() => setIsMobileSidebarOpen(false)}
/>
))}
</div>
</div>
))}
</nav>
</MobileSidebar>
<main className="flex flex-1 flex-col overflow-hidden p-4 sm:p-6">
<div className="mb-4 flex items-center justify-between gap-3 lg:hidden">
<NavbarItem
onClick={() => setIsMobileSidebarOpen(true)}
aria-label="Open navigation"
>
<Bars3Icon className="size-5" />
</NavbarItem>
<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">
<Outlet />
</div>
</main>
</div>
);
}
import clsx from 'clsx';
import { useEffect, useMemo, useState } from 'react';
import {
buildTaskPayload,
fetchSystemUsersByIds,
buildUserMap,
enrichProjectTask,
fetchProjectTasks,
formatDateInput,
downloadProjectTaskImportTemplate,
mergeUserRecords,
normalizeWeeklyUpdatesValue,
PRIORITY_OPTIONS,
PRIORITY_LABEL_MAP,
PROJECT_STATUS_LABEL_MAP,
PROJECT_STATUS_OPTIONS,
RESULT_STATUS_OPTIONS,
resolveUserReferenceId,
readProjectTaskImportRows,
importRecords,
saveProjectTask,
searchSystemUsers,
sortWeeklyUpdates,
} from './projectTaskService';
function resolveCurrentDate() {
return new Date().toISOString().slice(0, 10);
}
function getTaskRecordId(task) {
return task?._id || task?.id || task?.name || '';
}
function formatUserLabel(user) {
if (!user) {
return '-';
}
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>
<div className="mt-2 text-sm leading-6 text-zinc-500">{description}</div>
</div>
);
}
function UserSearchSelect({
value,
onChange,
placeholder = '远程搜索用户',
}) {
const [keyword, setKeyword] = useState('');
const [options, setOptions] = useState([]);
const [selectedUser, setSelectedUser] = useState(null);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!value) {
setSelectedUser(null);
return;
}
let cancelled = false;
setLoading(true);
fetchSystemUsersByIds([value])
.then((records) => {
if (cancelled) {
return;
}
setSelectedUser(records[0] || null);
setOptions((current) => mergeUserRecords([...(current || []), ...(records || [])]));
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [value]);
useEffect(() => {
let cancelled = false;
setLoading(true);
searchSystemUsers(keyword)
.then((records) => {
if (!cancelled) {
setOptions(records);
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [keyword]);
return (
<div
className="relative"
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
setOpen(false);
setKeyword('');
}
}}
>
<input
value={open ? keyword : selectedUser ? formatUserLabel(selectedUser) : ''}
onFocus={() => setOpen(true)}
onChange={(event) => {
setKeyword(event.target.value);
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"
/>
{value ? (
<button
type="button"
onClick={() => {
onChange('');
setSelectedUser(null);
setKeyword('');
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-zinc-400 transition hover:text-zinc-600"
>
清空
</button>
) : null}
{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)]">
{loading ? (
<div className="px-3 py-4 text-sm text-zinc-400">搜索中...</div>
) : options.length > 0 ? (
options.map((user) => (
<button
key={user.id}
type="button"
onMouseDown={(event) => {
event.preventDefault();
onChange(user.id);
setSelectedUser(user);
setKeyword('');
setOpen(false);
}}
className="block w-full rounded-xl px-3 py-2 text-left transition 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>
</button>
))
) : (
<div className="px-3 py-4 text-sm text-zinc-400">未搜索到用户</div>
)}
</div>
) : null}
</div>
);
}
function UserMultiSearchSelect({
value,
onChange,
placeholder = '远程搜索并选择成员',
}) {
const selectedIds = Array.isArray(value) ? value.filter(Boolean) : [];
const [keyword, setKeyword] = useState('');
const [options, setOptions] = useState([]);
const [selectedUsers, setSelectedUsers] = useState([]);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
let cancelled = false;
if (selectedIds.length === 0) {
setSelectedUsers([]);
return;
}
setLoading(true);
fetchSystemUsersByIds(selectedIds)
.then((records) => {
if (!cancelled) {
setSelectedUsers(records);
setOptions((current) => mergeUserRecords([...(current || []), ...(records || [])]));
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [selectedIds.join('|')]);
useEffect(() => {
let cancelled = false;
setLoading(true);
searchSystemUsers(keyword)
.then((records) => {
if (!cancelled) {
setOptions(records);
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [keyword]);
const handleSelect = (user) => {
if (selectedIds.includes(user.id)) {
return;
}
onChange([...selectedIds, user.id]);
setSelectedUsers((current) => mergeUserRecords([...(current || []), user]));
setKeyword('');
};
const handleRemove = (userId) => {
onChange(selectedIds.filter((item) => item !== userId));
setSelectedUsers((current) => current.filter((item) => item.id !== userId));
};
return (
<div
className="space-y-3"
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
setOpen(false);
setKeyword('');
}
}}
>
<div className="relative">
<input
value={keyword}
onFocus={() => setOpen(true)}
onChange={(event) => {
setKeyword(event.target.value);
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"
/>
{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)]">
{loading ? (
<div className="px-3 py-4 text-sm text-zinc-400">搜索中...</div>
) : options.length > 0 ? (
options.map((user) => {
const checked = selectedIds.includes(user.id);
return (
<button
key={user.id}
type="button"
onMouseDown={(event) => {
event.preventDefault();
handleSelect(user);
}}
className={clsx(
'block w-full rounded-xl px-3 py-2 text-left transition',
checked ? 'bg-sky-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>
</button>
);
})
) : (
<div className="px-3 py-4 text-sm text-zinc-400">未搜索到用户</div>
)}
</div>
) : null}
</div>
<div className="flex flex-wrap gap-2">
{selectedUsers.length > 0 ? (
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"
>
{formatUserLabel(user)}
<button
type="button"
onClick={() => handleRemove(user.id)}
className="text-xs text-sky-500 transition hover:text-sky-700"
>
移除
</button>
</span>
))
) : (
<span className="text-sm text-zinc-400">尚未选择成员</span>
)}
</div>
</div>
);
}
function formatPercent(value) {
const numericValue = Number(value || 0);
if (!Number.isFinite(numericValue)) {
return '0%';
}
return `${Math.max(0, Math.min(100, Math.round(numericValue)))}%`;
}
function parseUserReference(value) {
return resolveUserReferenceId(value);
}
function parseImportMemberIds(value) {
return String(value || '')
.split(/[,\n,;]/)
.map((item) => item.trim())
.filter(Boolean);
}
function normalizeImportWeeklyUpdates(row) {
const weekStartDate = formatDateInput(row['周进度开始日期']);
const weekEndDate = formatDateInput(row['周进度结束日期']);
const weeklySummary = String(row['周进度内容'] || '').trim();
const progressPercent = String(row['周进度进度'] || '').trim();
const blockers = String(row['周进度阻塞'] || '').trim();
const nextWeekPlan = String(row['周进度下周计划'] || '').trim();
const updatedAt = formatDateInput(row['周进度记录日期']) || resolveCurrentDate();
if (!weeklySummary && !weekStartDate && !weekEndDate && !progressPercent && !blockers && !nextWeekPlan) {
return [];
}
return [
{
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,
},
];
}
function mapImportRowToTask(row) {
const projectName = String(row['项目名称'] || '').trim();
const projectCode = String(row['项目编号'] || '').trim();
const leader = parseUserReference(row['下发领导']);
const projectOwner = parseUserReference(row['项目负责人']);
const memberIds = parseImportMemberIds(row['参与成员']);
const plannedStartDate = formatDateInput(row['计划开始日期']);
const plannedEndDate = formatDateInput(row['计划结束日期']);
const actualStartDate = formatDateInput(row['实际开始日期']);
const actualFinishDate = formatDateInput(row['实际完成日期']);
const projectStatus = String(row['项目状态'] || '').trim();
const completionPercent = String(row['完成进度'] || '').trim();
const priority = String(row['优先级'] || '').trim();
const resultStatus = String(row['结果状态'] || '').trim();
const description = String(row['项目说明'] || '').trim();
const remark = String(row['备注'] || '').trim();
const delayReason = String(row['延期原因'] || '').trim();
const weeklyUpdates = normalizeImportWeeklyUpdates(row);
const metadata = {
project_name: projectName,
project_code: projectCode,
leader,
project_owner: projectOwner,
member_ids: memberIds,
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,
result_status: resultStatus,
description,
remark,
delay_reason: delayReason,
weekly_updates: weeklyUpdates,
};
return {
title: projectName || projectCode || '未命名项目',
subject: 'pm_project_task',
metadata,
};
}
function buildImportPreviewRow(row, rowIndex) {
const payload = mapImportRowToTask(row);
let error = '';
try {
buildTaskPayload({
project_name: payload.metadata.project_name,
project_code: payload.metadata.project_code,
leader: payload.metadata.leader,
project_owner: payload.metadata.project_owner,
member_ids: payload.metadata.member_ids,
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,
project_status: payload.metadata.project_status,
completion_percent: payload.metadata.completion_percent,
result_status: payload.metadata.result_status,
is_overtime: false,
overtime_days: 0,
delay_reason: payload.metadata.delay_reason,
weekly_updates: payload.metadata.weekly_updates,
});
} catch (requestError) {
error = requestError?.message || '数据格式错误';
}
return {
...payload,
__rowIndex: rowIndex,
error,
};
}
function getTaskTone(task) {
if (task.resultStatus === 'completed_early') {
return {
surface: 'bg-emerald-50 border-emerald-200',
accent: 'bg-emerald-500',
text: 'text-emerald-700',
};
}
if (task.resultStatus === 'completed_overdue' || task.resultStatus === 'overdue') {
return {
surface: 'bg-rose-50 border-rose-200',
accent: 'bg-rose-500',
text: 'text-rose-700',
};
}
if (task.projectStatus === 'blocked') {
return {
surface: 'bg-amber-50 border-amber-200',
accent: 'bg-amber-500',
text: 'text-amber-700',
};
}
if (task.projectStatus === 'completed') {
return {
surface: 'bg-sky-50 border-sky-200',
accent: 'bg-sky-500',
text: 'text-sky-700',
};
}
return {
surface: 'bg-zinc-50 border-zinc-200',
accent: 'bg-[var(--pm-brand)]',
text: 'text-zinc-700',
};
}
function getCalendarEventTone(task) {
if (task.resultStatus === 'completed_early') {
return 'bg-emerald-100 border-emerald-300 text-emerald-700';
}
if (task.resultStatus === 'completed_overdue' || task.resultStatus === 'overdue') {
return 'bg-rose-100 border-rose-300 text-rose-700';
}
if (task.projectStatus === 'completed') {
return 'bg-sky-100 border-sky-300 text-sky-700';
}
if (task.projectStatus === 'blocked') {
return 'bg-amber-100 border-amber-300 text-amber-700';
}
return 'bg-zinc-100 border-zinc-200 text-zinc-700';
}
function enumerateDateRange(startDate, endDate) {
const start = formatDateInput(startDate);
const end = formatDateInput(endDate);
if (!start || !end || start > end) {
return [];
}
const dates = [];
const cursor = new Date(`${start}T00:00:00`);
const endTime = new Date(`${end}T00:00:00`).getTime();
while (cursor.getTime() <= endTime) {
dates.push(cursor.toISOString().slice(0, 10));
cursor.setDate(cursor.getDate() + 1);
}
return dates;
}
const GANTT_VIEW_TYPES = {
WEEK: 'week',
MONTH: 'month',
YEAR: 'year',
};
const GANTT_VIEW_OPTIONS = [
{ value: GANTT_VIEW_TYPES.WEEK, label: '周' },
{ value: GANTT_VIEW_TYPES.MONTH, label: '月' },
{ value: GANTT_VIEW_TYPES.YEAR, label: '年' },
];
function padNumber(value) {
return String(value).padStart(2, '0');
}
function createDate(value) {
return new Date(`${value}T00:00:00`);
}
function formatDateKey(date) {
return `${date.getFullYear()}-${padNumber(date.getMonth() + 1)}-${padNumber(
date.getDate(),
)}`;
}
function addDays(date, days) {
const next = new Date(date);
next.setDate(next.getDate() + days);
return next;
}
function addMonths(date, months) {
const next = new Date(date);
next.setMonth(next.getMonth() + months);
return next;
}
function addYears(date, years) {
const next = new Date(date);
next.setFullYear(next.getFullYear() + years);
return next;
}
function startOfIsoWeek(date) {
const next = new Date(date);
const currentDay = next.getDay() || 7;
next.setDate(next.getDate() - currentDay + 1);
next.setHours(0, 0, 0, 0);
return next;
}
function endOfIsoWeek(date) {
return addDays(startOfIsoWeek(date), 6);
}
function startOfMonth(date) {
return new Date(date.getFullYear(), date.getMonth(), 1);
}
function endOfMonth(date) {
return new Date(date.getFullYear(), date.getMonth() + 1, 0);
}
function startOfYear(date) {
return new Date(date.getFullYear(), 0, 1);
}
function endOfYear(date) {
return new Date(date.getFullYear(), 11, 31);
}
function isSameDay(left, right) {
return (
left.getFullYear() === right.getFullYear() &&
left.getMonth() === right.getMonth() &&
left.getDate() === right.getDate()
);
}
function getWeekdayLabel(date) {
return ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][date.getDay()] || '';
}
function getMonthLabel(date) {
return `${date.getMonth() + 1}月`;
}
function buildGanttWindow(anchorDate, viewType) {
const anchor = createDate(anchorDate);
if (viewType === GANTT_VIEW_TYPES.WEEK) {
return {
start: startOfIsoWeek(anchor),
end: endOfIsoWeek(anchor),
};
}
if (viewType === GANTT_VIEW_TYPES.YEAR) {
return {
start: startOfYear(anchor),
end: endOfYear(anchor),
};
}
return {
start: startOfMonth(anchor),
end: endOfMonth(anchor),
};
}
function buildGanttColumns(anchorDate, viewType) {
const windowRange = buildGanttWindow(anchorDate, viewType);
const todayDate = createDate(resolveCurrentDate());
const columns = [];
if (viewType === GANTT_VIEW_TYPES.YEAR) {
const year = windowRange.start.getFullYear();
for (let monthIndex = 0; monthIndex < 12; monthIndex += 1) {
const start = new Date(year, monthIndex, 1);
const end = new Date(year, monthIndex + 1, 0);
columns.push({
key: `${year}-${padNumber(monthIndex + 1)}`,
startKey: formatDateKey(start),
endKey: formatDateKey(end),
primary: `${monthIndex + 1}月`,
secondary: String(year),
isToday: todayDate.getFullYear() === year && todayDate.getMonth() === monthIndex,
});
}
} else {
const cursor = new Date(windowRange.start);
const endTime = windowRange.end.getTime();
while (cursor.getTime() <= endTime) {
columns.push({
key: formatDateKey(cursor),
startKey: formatDateKey(cursor),
endKey: formatDateKey(cursor),
primary: String(cursor.getDate()),
secondary: getWeekdayLabel(cursor),
isToday: isSameDay(cursor, todayDate),
});
cursor.setDate(cursor.getDate() + 1);
}
}
return {
columns,
startKey: formatDateKey(windowRange.start),
endKey: formatDateKey(windowRange.end),
};
}
function formatGanttRangeTitle(anchorDate, viewType) {
const anchor = createDate(anchorDate);
if (viewType === GANTT_VIEW_TYPES.WEEK) {
const start = startOfIsoWeek(anchor);
const end = endOfIsoWeek(anchor);
return `${formatDateKey(start)} ~ ${formatDateKey(end)}`;
}
if (viewType === GANTT_VIEW_TYPES.YEAR) {
return `${anchor.getFullYear()}年`;
}
return `${anchor.getFullYear()}年${padNumber(anchor.getMonth() + 1)}月`;
}
function moveGanttAnchor(anchorDate, viewType, direction) {
const anchor = createDate(anchorDate);
if (viewType === GANTT_VIEW_TYPES.WEEK) {
return formatDateKey(addDays(anchor, direction * 7));
}
if (viewType === GANTT_VIEW_TYPES.YEAR) {
return formatDateKey(addYears(anchor, direction));
}
return formatDateKey(addMonths(anchor, direction));
}
function getGanttColumnMinWidth(viewType) {
if (viewType === GANTT_VIEW_TYPES.WEEK) {
return 110;
}
if (viewType === GANTT_VIEW_TYPES.YEAR) {
return 108;
}
return 64;
}
function buildMonthCalendarMatrix(anchorDate) {
const baseDate = new Date(`${anchorDate}-01T00:00:00`);
const year = baseDate.getFullYear();
const month = baseDate.getMonth();
const firstDay = new Date(year, month, 1);
const startOffset = (firstDay.getDay() + 6) % 7;
const cursor = new Date(year, month, 1 - startOffset);
const weeks = [];
for (let weekIndex = 0; weekIndex < 6; weekIndex += 1) {
const week = [];
for (let dayIndex = 0; dayIndex < 7; dayIndex += 1) {
week.push({
dateKey: cursor.toISOString().slice(0, 10),
isCurrentMonth: cursor.getMonth() === month,
});
cursor.setDate(cursor.getDate() + 1);
}
weeks.push(week);
}
return weeks;
}
function buildTaskFormValues(task) {
const metadata = task?.metadata || {};
return {
project_name: metadata.project_name || '',
project_code: metadata.project_code || '',
leader: resolveUserReferenceId(metadata.leader),
project_owner: resolveUserReferenceId(metadata.project_owner),
member_ids: Array.isArray(metadata.member_ids)
? metadata.member_ids
: metadata.member_ids
? [metadata.member_ids]
: [],
planned_start_date: formatDateInput(metadata.planned_start_date),
planned_end_date: formatDateInput(metadata.planned_end_date),
actual_start_date: formatDateInput(metadata.actual_start_date),
actual_finish_date: formatDateInput(metadata.actual_finish_date),
description: metadata.description || '',
remark: metadata.remark || '',
priority: metadata.priority || '',
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 buildWeeklyUpdateFormValues(update) {
return {
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">
{children}
</div>
);
}
function StatCard({ label, value, hint, tone = 'default' }) {
const toneClass =
tone === 'danger'
? 'from-rose-50 to-white border-rose-200'
: tone === 'success'
? 'from-emerald-50 to-white border-emerald-200'
: tone === 'primary'
? 'from-sky-50 to-white border-sky-200'
: 'from-zinc-50 to-white border-zinc-200';
return (
<div
className={clsx(
'rounded-2xl border bg-gradient-to-br p-5 shadow-sm',
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>
);
}
function FilterSelect({ value, onChange, options, placeholder = '全部' }) {
return (
<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"
>
<option value="">{placeholder}</option>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
}
function TabButton({ active, children, onClick }) {
return (
<button
type="button"
onClick={onClick}
className={clsx(
'rounded-full px-4 py-2 text-sm font-medium transition',
active
? 'bg-[var(--pm-brand)] text-white shadow-sm'
: 'bg-zinc-100 text-zinc-600 hover:bg-zinc-200',
)}
>
{children}
</button>
);
}
function GanttLegend() {
const items = [
{ label: '正常进行', className: 'bg-zinc-400' },
{ label: '提前完成', className: 'bg-emerald-500' },
{ label: '已超时', className: 'bg-rose-500' },
{ label: '已完成', className: 'bg-sky-500' },
{ label: '受阻', className: 'bg-amber-500' },
];
return (
<div className="flex flex-wrap items-center gap-3">
{items.map((item) => (
<div key={item.label} className="flex items-center gap-2 text-xs text-zinc-500">
<span className={clsx('h-2.5 w-2.5 rounded-full', item.className)} />
{item.label}
</div>
))}
</div>
);
}
function ProjectGanttBoard({
tasks,
selectedTaskId,
onSelectTask,
onEditTask,
}) {
const [viewType, setViewType] = useState(GANTT_VIEW_TYPES.MONTH);
const [viewAnchor, setViewAnchor] = useState(resolveCurrentDate());
const activeTasks = tasks.filter(
(task) => task.plannedStartDate && task.plannedEndDate,
);
const timelineMeta = useMemo(() => {
return buildGanttColumns(viewAnchor, viewType);
}, [viewAnchor, viewType]);
const visibleTasks = useMemo(() => {
return activeTasks.filter((task) => {
return timelineMeta.columns.some(
(column) =>
column.endKey >= task.plannedStartDate &&
column.startKey <= task.plannedEndDate,
);
});
}, [activeTasks, timelineMeta.columns]);
const columnMinWidth = useMemo(
() => getGanttColumnMinWidth(viewType),
[viewType],
);
const timelineGridTemplate = useMemo(
() =>
`280px repeat(${timelineMeta.columns.length}, minmax(${columnMinWidth}px, 1fr))`,
[columnMinWidth, timelineMeta.columns.length],
);
const rangeTitle = useMemo(
() => formatGanttRangeTitle(viewAnchor, viewType),
[viewAnchor, viewType],
);
return (
<div className="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm">
<div className="border-b border-zinc-200 px-5 py-4">
<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">
{GANTT_VIEW_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setViewType(option.value)}
className={clsx(
'rounded-full px-4 py-1.5 text-sm font-medium transition',
viewType === option.value
? 'bg-white text-zinc-900 shadow-sm'
: 'text-zinc-500 hover:text-zinc-800',
)}
>
{option.label}
</button>
))}
</div>
<GanttLegend />
</div>
</div>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setViewAnchor(moveGanttAnchor(viewAnchor, viewType, -1))}
className="rounded-lg border border-zinc-200 px-3 py-2 text-sm text-zinc-600 transition hover:bg-zinc-50"
>
上一段
</button>
<div className="min-w-[180px] text-center text-sm font-semibold text-zinc-900">
{rangeTitle}
</div>
<button
type="button"
onClick={() => setViewAnchor(moveGanttAnchor(viewAnchor, viewType, 1))}
className="rounded-lg border border-zinc-200 px-3 py-2 text-sm text-zinc-600 transition hover:bg-zinc-50"
>
下一段
</button>
</div>
<button
type="button"
onClick={() => setViewAnchor(resolveCurrentDate())}
className="rounded-lg border border-zinc-200 px-3 py-2 text-sm text-zinc-600 transition hover:bg-zinc-50"
>
回到今天
</button>
</div>
</div>
<div className="overflow-auto">
<div
className="grid min-w-[1080px]"
style={{
gridTemplateColumns: timelineGridTemplate,
}}
>
<div className="sticky left-0 z-10 border-b border-r border-zinc-200 bg-zinc-50 px-4 py-3 text-sm font-semibold text-zinc-600">
项目 / 状态
</div>
{timelineMeta.columns.map((column) => (
<div
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',
)}
>
<div className="text-sm font-semibold leading-none">{column.primary}</div>
<div className="mt-1 text-[11px] leading-none text-inherit/80">
{column.secondary}
</div>
</div>
))}
{visibleTasks.length > 0 ? (
visibleTasks.map((task) => {
const tone = getTaskTone(task);
const taskId = getTaskRecordId(task);
const visibleStartIndex = timelineMeta.columns.findIndex(
(column) => column.endKey >= task.plannedStartDate,
);
const visibleEndIndex = [...timelineMeta.columns]
.reverse()
.findIndex((column) => column.startKey <= task.plannedEndDate);
const startIndex = visibleStartIndex;
const endIndex =
visibleEndIndex === -1
? -1
: timelineMeta.columns.length - visibleEndIndex - 1;
const hasVisibleBar =
startIndex !== -1 && endIndex !== -1 && endIndex >= startIndex;
const span = hasVisibleBar ? endIndex - startIndex + 1 : 0;
return (
<div key={taskId} className="contents">
<button
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' : '',
)}
>
<span className="text-sm font-semibold text-zinc-900">
{task.projectName}
</span>
<span className="mt-1 text-xs text-zinc-500">
{task.projectCode} · 负责人 {formatUserLabel(task.projectOwnerUser)}
</span>
<span className={clsx('mt-2 text-xs font-medium', tone.text)}>
{task.projectStatusLabel} · {task.resultStatusLabel}
</span>
</button>
<div
className="relative h-[118px] border-b border-zinc-200"
style={{ gridColumn: `2 / span ${timelineMeta.columns.length}` }}
>
<div
className="absolute inset-0 grid"
style={{
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',
)}
/>
))}
</div>
{hasVisibleBar ? (
<div
className="absolute inset-0 grid"
style={{
gridTemplateColumns: `repeat(${timelineMeta.columns.length}, minmax(${columnMinWidth}px, 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',
)}
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>
<span>周进度 {task.weeklyUpdateCount} 条</span>
</div>
</div>
<span
className={clsx(
'rounded-full px-2.5 py-1 text-xs font-medium',
tone.surface,
tone.text,
)}
>
{task.resultStatusLabel}
</span>
</button>
</div>
) : null}
</div>
</div>
);
})
) : (
<div className="col-span-full px-5 py-16 text-center text-sm text-zinc-400">
当前时间窗口内没有可展示的项目进度
</div>
)}
</div>
</div>
</div>
);
}
function ProjectCalendarBoard({
tasks,
currentUserId,
monthAnchor,
onMonthAnchorChange,
onOpenTask,
}) {
const monthWeeks = useMemo(
() => buildMonthCalendarMatrix(monthAnchor),
[monthAnchor],
);
const currentUserTasks = useMemo(
() =>
tasks.filter((task) => currentUserId && task.memberIds.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 title = useMemo(() => {
const [year, month] = monthAnchor.split('-');
return `${year}年${month}月`;
}, [monthAnchor]);
const handleMoveMonth = (offset) => {
const date = new Date(`${monthAnchor}-01T00:00:00`);
date.setMonth(date.getMonth() + offset);
onMonthAnchorChange(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`);
};
return (
<div className="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm">
<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
type="button"
onClick={() => handleMoveMonth(-1)}
className="rounded-lg border border-zinc-200 px-3 py-2 text-sm text-zinc-600 transition hover:bg-zinc-50"
>
上月
</button>
<div className="min-w-24 text-center text-sm font-semibold text-zinc-900">
{title}
</div>
<button
type="button"
onClick={() => handleMoveMonth(1)}
className="rounded-lg border border-zinc-200 px-3 py-2 text-sm text-zinc-600 transition hover:bg-zinc-50"
>
下月
</button>
</div>
</div>
<div className="grid grid-cols-7 border-b border-zinc-200 bg-zinc-50">
{['周一', '周二', '周三', '周四', '周五', '周六', '周日'].map((label) => (
<div key={label} className="px-3 py-3 text-center text-xs font-semibold text-zinc-500">
{label}
</div>
))}
</div>
<div className="grid grid-cols-7">
{monthWeeks.flat().map((cell) => {
const events = eventsByDate.get(cell.dateKey) || [];
const isToday = cell.dateKey === resolveCurrentDate();
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',
)}
>
<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)}
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),
)}
>
<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>
</div>
);
})}
</div>
</div>
);
}
function ProjectListPanel({ tasks, selectedTaskId, onSelectTask, onEditTask }) {
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">项目列表</div>
<div className="mt-1 text-sm text-zinc-500">
统一查看当前项目状态、共享进度和超时情况。
</div>
</div>
<div className="divide-y divide-zinc-100">
{tasks.length > 0 ? (
tasks.map((task) => {
const taskId = getTaskRecordId(task);
return (
<div
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' : '',
)}
>
<button
type="button"
onClick={() => onSelectTask(taskId)}
className="min-w-0 flex-1 text-left"
>
<div className="truncate text-sm font-semibold text-zinc-900">
{task.projectName}
</div>
<div className="mt-1 text-xs text-zinc-500">
{task.projectCode} · 负责人 {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">
<div className="text-sm font-semibold text-zinc-900">
{formatPercent(task.progressPercent)}
</div>
<div className="mt-1 text-xs text-zinc-500">
{task.isOvertime
? `超时 ${task.overtimeDays} 天`
: `周进度 ${task.weeklyUpdateCount} 条`}
</div>
</div>
<button
type="button"
onClick={() => onEditTask(task)}
className="rounded-xl border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-600 transition hover:bg-zinc-100"
>
编辑
</button>
</div>
</div>
);
})
) : (
<div className="px-5 py-12 text-center text-sm text-zinc-400">
当前筛选条件下暂无项目
</div>
)}
</div>
</div>
);
}
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 className="mt-1 text-sm text-zinc-500">
逐周记录完成内容、累计进度和当前阻塞事项。
</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-sky-400"
/>
</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-sky-400"
/>
</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-sky-400"
/>
</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-sky-400"
/>
</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-sky-400"
/>
</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-sky-400"
/>
</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-sky-400"
/>
</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 className="mt-1 text-sm text-zinc-500">
先预览再导入,适合从现有表格一次性创建 100+ 项目。
</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.project_name || '未命名项目'}
</td>
<td className="px-4 py-3 text-zinc-600">{row.project_owner || '-'}</td>
<td className="px-4 py-3 text-zinc-600">{row.member_ids || '-'}</td>
<td className="px-4 py-3 text-zinc-600">
{row.planned_start_date || '-'} ~ {row.planned_end_date || '-'}
</td>
<td className="px-4 py-3 text-zinc-600">
{row.weekly_updates?.length ? `1 条` : '-'}
</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,
submitting,
submitError,
onClose,
onSubmit,
}) {
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) {
return null;
}
const updateField = (field, value) => {
setFormValues((current) => ({
...current,
[field]: value,
}));
};
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
: [];
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>
<div className="mt-1 text-sm text-zinc-500">
表单已从页面底部改为弹框,便于在甘特图和列表中随时维护项目。
</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"
>
<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"
required
/>
</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"
/>
</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="远程搜索领导用户"
/>
</label>
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">项目负责人</span>
<UserSearchSelect
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>
<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"
>
<option value="">请选择优先级</option>
{PRIORITY_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>
<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"
required
/>
</label>
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">计划结束日期</span>
<input
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"
required
/>
</label>
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">实际开始日期</span>
<input
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"
/>
</label>
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">实际完成日期</span>
<input
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"
/>
</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">
<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"
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) => (
<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>
<input
type="number"
min="0"
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"
/>
</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.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"
/>
</label>
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">备注</span>
<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"
/>
</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)}
placeholder="项目延期时补充原因,便于后续复盘。"
className="min-h-[110px] w-full rounded-2xl border border-zinc-200 px-3 py-3 outline-none transition focus:border-sky-400"
/>
</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>
<div className="text-sm font-medium text-zinc-900">周进度记录</div>
<div className="mt-1 text-xs text-zinc-500">
每周追加一条记录,前端会自动维护 `weekly_updates` 数组。
</div>
</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}
</div>
) : null}
<div className="mt-6 flex flex-wrap items-center justify-between gap-4 border-t border-zinc-100 pt-5">
<div className="text-sm text-zinc-500">
当前状态:{PROJECT_STATUS_LABEL_MAP[formValues.project_status] || '-'}
{' · '}
{PRIORITY_LABEL_MAP[formValues.priority] || '未设置优先级'}
</div>
<div className="flex items-center gap-3">
<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="submit"
disabled={submitting}
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"
>
{submitting ? '保存中...' : '保存项目'}
</button>
</div>
</div>
</form>
</div>
</div>
<WeeklyUpdateEditorDialog
open={weeklyUpdateDialogOpen}
value={weeklyUpdateDraft}
editing={editingWeeklyUpdateIndex >= 0}
error={weeklyUpdateError}
onChange={handleWeeklyUpdateFieldChange}
onCancel={handleWeeklyUpdateCancel}
onConfirm={handleWeeklyUpdateConfirm}
/>
</>
);
}
export default function ProjectBookingWorkspace({ currentUsername }) {
const [tasks, setTasks] = useState([]);
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [filters, setFilters] = useState({
keyword: '',
status: '',
memberId: '',
leaderId: '',
});
const [activeTab, setActiveTab] = useState('gantt');
const [monthAnchor, setMonthAnchor] = useState(resolveCurrentDate().slice(0, 7));
const [selectedTaskId, setSelectedTaskId] = useState('');
const [dialogOpen, setDialogOpen] = useState(false);
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 loadWorkspace = async () => {
setLoading(true);
setError('');
try {
const taskRecords = await fetchProjectTasks(filters);
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 = resolveUserReferenceId(metadata.leader);
const projectOwnerId = resolveUserReferenceId(metadata.project_owner);
return [...memberIds, leaderId, projectOwnerId].filter(Boolean);
}),
),
);
if (currentUsername) {
userIds.push(currentUsername);
}
const normalizedUsers = await fetchSystemUsersByIds(userIds);
const userMap = buildUserMap(normalizedUsers);
const enrichedTasks = taskRecords.map((task) =>
enrichProjectTask(task, userMap),
);
setUsers(normalizedUsers);
setTasks(enrichedTasks);
if (enrichedTasks.length === 0) {
setError('当前未读取到真实项目记录,请先创建或导入 `pm_project_task` 数据。');
}
} catch (requestError) {
setUsers([]);
setTasks([]);
setError(requestError?.message || '加载项目管理工作台失败,请稍后重试。');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadWorkspace();
}, [filters.status, filters.memberId, filters.leaderId]);
const userMap = useMemo(() => buildUserMap(users), [users]);
const currentUser = useMemo(() => {
return (
users.find(
(user) => user.username === currentUsername || user.id === currentUsername,
) || users[0] || null
);
}, [currentUsername, users]);
const visibleTasks = useMemo(() => {
const keyword = filters.keyword.trim().toLowerCase();
return tasks.filter((task) => {
if (
keyword &&
!task.projectName.toLowerCase().includes(keyword) &&
!String(task.projectCode || '').toLowerCase().includes(keyword)
) {
return false;
}
return true;
});
}, [filters.keyword, tasks]);
useEffect(() => {
if (!selectedTaskId && visibleTasks.length > 0) {
setSelectedTaskId(getTaskRecordId(visibleTasks[0]));
return;
}
if (
selectedTaskId &&
!visibleTasks.some((task) => getTaskRecordId(task) === selectedTaskId)
) {
setSelectedTaskId(getTaskRecordId(visibleTasks[0]) || '');
}
}, [selectedTaskId, visibleTasks]);
const selectedTask = useMemo(
() =>
visibleTasks.find((task) => getTaskRecordId(task) === selectedTaskId) ||
visibleTasks[0] ||
null,
[selectedTaskId, visibleTasks],
);
const stats = useMemo(() => {
const total = visibleTasks.length;
const inProgress = visibleTasks.filter(
(task) => task.projectStatus === 'in_progress',
).length;
const overdue = visibleTasks.filter((task) => task.isOvertime).length;
const completed = visibleTasks.filter(
(task) => task.projectStatus === 'completed',
).length;
return {
total,
inProgress,
overdue,
completed,
};
}, [visibleTasks]);
const persistTask = async (values, currentTask) => {
await saveProjectTask(values, currentTask);
await loadWorkspace();
};
const handleImportTemplateDownload = () => {
downloadProjectTaskImportTemplate();
};
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);
try {
const rows = await readProjectTaskImportRows(file);
const preview = 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 handleConfirmImport = async () => {
const validRows = importPreviewRows.filter((row) => !row.error);
if (validRows.length === 0) {
setImportError('没有可导入的记录');
return;
}
setImportLoading(true);
setImportError('');
try {
await importRecords(
'pm_project_task',
validRows.map((row) => ({
title: row.metadata.project_name || row.metadata.project_code || '未命名项目',
metadata: row.metadata,
})),
);
await loadWorkspace();
handleImportDialogClose();
} catch (requestError) {
setImportError(requestError?.message || '导入失败,请稍后重试');
} finally {
setImportLoading(false);
}
};
const openCreateDialog = () => {
setEditingTask(null);
setDialogError('');
setDialogOpen(true);
};
const openEditDialog = (task) => {
setEditingTask(task || selectedTask || null);
setDialogError('');
setDialogOpen(true);
};
const handleDialogSubmit = async (values) => {
setDialogSubmitting(true);
setDialogError('');
try {
await persistTask(values, editingTask);
setDialogOpen(false);
setEditingTask(null);
} catch (requestError) {
setDialogError(requestError?.message || '保存失败,请稍后重试');
} finally {
setDialogSubmitting(false);
}
};
const handleOpenTaskFromCalendar = (task) => {
setSelectedTaskId(getTaskRecordId(task));
setActiveTab('gantt');
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>
<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,#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>
<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">
<input
value={filters.keyword}
onChange={(event) =>
setFilters((current) => ({
...current,
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"
/>
<FilterSelect
value={filters.status}
onChange={(value) =>
setFilters((current) => ({
...current,
status: value,
}))
}
options={PROJECT_STATUS_OPTIONS}
placeholder="全部状态"
/>
<div className="w-56">
<UserSearchSelect
value={filters.memberId}
onChange={(value) =>
setFilters((current) => ({
...current,
memberId: value,
}))
}
placeholder="远程搜索成员"
/>
</div>
<div className="w-56">
<UserSearchSelect
value={filters.leaderId}
onChange={(value) =>
setFilters((current) => ({
...current,
leaderId: value,
}))
}
placeholder="远程搜索领导"
/>
</div>
</div>
<div className="mt-5 flex flex-wrap items-center justify-between gap-4 border-t border-zinc-100 pt-4">
<div className="flex flex-wrap items-center gap-2">
<TabButton active={activeTab === 'gantt'} onClick={() => setActiveTab('gantt')}>
项目甘特图
</TabButton>
<TabButton active={activeTab === 'calendar'} onClick={() => setActiveTab('calendar')}>
个人日历图
</TabButton>
<TabButton active={activeTab === 'list'} onClick={() => setActiveTab('list')}>
项目清单
</TabButton>
</div>
<div className="rounded-full bg-zinc-100 px-3 py-1 text-sm text-zinc-600">
当前用户:{formatUserLabel(currentUser)}
</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">
{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>
) : null}
</ToolbarCard>
<div className="grid gap-4 md:grid-cols-4">
<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' && hasVisibleTasks ? (
<ProjectGanttBoard
tasks={visibleTasks}
selectedTaskId={selectedTaskId}
onSelectTask={setSelectedTaskId}
onEditTask={openEditDialog}
/>
) : null}
{activeTab === 'calendar' && hasVisibleTasks ? (
<ProjectCalendarBoard
tasks={visibleTasks}
currentUserId={currentUser?.id || ''}
monthAnchor={monthAnchor}
onMonthAnchorChange={setMonthAnchor}
onOpenTask={handleOpenTaskFromCalendar}
/>
) : null}
{activeTab === 'list' && hasVisibleTasks ? (
<ProjectListPanel
tasks={visibleTasks}
selectedTaskId={selectedTaskId}
onSelectTask={setSelectedTaskId}
onEditTask={openEditDialog}
/>
) : null}
{!loading && !hasVisibleTasks ? (
<EmptyWorkspaceNotice
title="当前没有可展示的真实项目"
description="页面现在只展示来自 `pm_project_task` 的真实 Record 数据。请先新建项目,或通过批量导入写入对象记录后再查看甘特图、日历图和项目列表。"
/>
) : null}
<ProjectTaskDialog
open={dialogOpen}
task={editingTask}
submitting={dialogSubmitting}
submitError={dialogError}
onClose={() => {
if (dialogSubmitting) {
return;
}
setDialogOpen(false);
setEditingTask(null);
setDialogError('');
}}
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>
);
}
# 项目管理流程文档
## 1. 模块目标
本模块用于项目领导在甘特图中下发项目任务,并让参与成员在个人日历图中查看与自己关联的项目安排、进度与超时情况。
当前版本以单对象 `pm_project_task` 为核心,适用于:
- 一个项目通常只有 1-2 名参与成员
- 多名成员共享同一项目进度与状态
- 由项目负责人持续维护周进度、状态与延期原因
---
## 2. 功能入口
- 左侧菜单:`资源统计`
- 左侧菜单:`资源预定`
后续项目管理页面可沿用 `project_manager_new` 的甘特图与日历图组件样式:
- 甘特图:保持与 `ResourceStatusGanttScheduler.jsx` 一致
- 日历图:保持与 `ReservationLeaveCalendar.jsx` / `TimesheetCalendarFullCalendar.jsx` 一致
---
## 3. 核心业务对象
- Subject:`pm_project_task`
一条记录表示一个项目任务主单。
关键字段:
- `project_name`:项目名称
- `project_code`:项目编号
- `leader`:下发领导,直接关联系统用户
- `project_owner`:项目负责人,直接关联系统用户
- `member_ids`:参与成员,直接关联系统用户,多值
- `planned_start_date` / `planned_end_date`:计划时间
- `actual_start_date` / `actual_finish_date`:实际时间
- `project_status`:项目当前业务状态
- `completion_percent`:共享完成进度
- `weekly_updates`:按周维护的进度记录数组
- `delay_reason`:延期原因说明
- `result_status`:结果状态
- `is_overtime` / `overtime_days`:超时标记
---
## 4. 业务流程
### 4.1 领导下发项目
1. 领导创建一条 `pm_project_task`
2. 手工填写项目名称、项目编号、计划开始日期、计划结束日期
3. 选择下发领导 `leader`
4. 选择项目负责人 `project_owner`
5. 多选参与成员 `member_ids`
6. 初始化项目状态为 `assigned` 或 `draft`
### 4.2 成员查看项目
1. 成员登录系统
2. 日历图查询 `member_ids` 包含当前用户的项目记录
3. 甘特图按项目展示所有任务
4. 可在甘特图或日历图中查看项目名称、计划时间、共享进度、状态
### 4.3 成员更新项目进度
1. 由项目负责人或指定更新人修改项目记录
2. 更新 `completion_percent`
3. 更新 `project_status`
4. 如项目开始,填写 `actual_start_date`
5. 如项目完成,填写 `actual_finish_date`
6. 在项目详情弹窗中通过“新增本周进度”录入一条周进度记录
7. 若已超过计划结束日期仍未完成,补充 `delay_reason`
### 4.4 状态结果判定
建议按以下规则计算:
- 未开始:当前日期 < `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`
对应字段建议:
- `project_status`:业务状态
- `result_status`:结果状态
- `is_overtime`:是否超时
- `overtime_days`:超时天数
- `delay_reason`:延期原因
---
## 5. 图表展示规则
### 5.0 资源统计
- `资源统计` 菜单用于承接项目管理统计看板
- 当前版本建议至少展示:
- 项目总数
- 进行中数量
- 已完成数量
- 已超时数量
- 受阻数量
- 平均进度
- 建议补充统计维度:
- 项目状态分布
- 项目优先级分布
- 成员负载分布
- 即将到期项目
- 超时项目清单
### 5.1 甘特图
- 以 `pm_project_task` 为数据源
- 展示项目名称、项目负责人、计划时间、实际时间、项目状态、结果状态
- 已完成、超时、提前完成等状态需要有明显视觉区分
- 可展示最近一条周进度摘要或周进度记录数量,辅助判断项目是否持续更新
- 周进度明细通过项目编辑弹窗内的记录列表和新增/编辑弹窗维护
- 后续可按状态映射颜色,不必先存颜色字段
### 5.2 个人日历图
- 当前用户只看 `member_ids` 包含自己的项目
- 展示项目名称、计划时间、项目状态、共享进度
- 共享进度与共享状态对所有参与成员一致
- 如项目存在最新周进度,可在详情或弹窗中查看周记录内容
---
## 6. 约束与限制
- 当前版本不区分成员个人独立进度
- 当前版本不拆分子任务
- 当前版本一个项目只维护一条主记录
- 当前版本默认 1-2 名成员协作并共享进度
- 周进度记录当前存放在 `pm_project_task.metadata.weekly_updates` 中,暂不拆独立日志对象
- 前端页面仅展示真实 `pm_project_task` Record 数据;当查询结果为空或接口失败时,只允许显示空状态或错误提示,禁止再回退演示数据
---
## 6.1 系统用户远程搜索示例
`leader`、`project_owner` 和 `member_ids` 使用系统用户,不单独维护业务用户对象。
前端远程搜索用户时,统一调用 `queryUsers`,按 `display_name` 和 `username` 模糊匹配:
```js
const result = await queryUsers(0, 20, {
$or: [
{
username: {
$regex: searchText.trim(),
},
},
{
display_name: {
$regex: searchText.trim(),
},
},
],
});
```
返回的用户对象当前按以下字段使用:
- `id` / `_id`:作为用户引用值保存
- `display_name`:界面主展示名称
- `username`:辅助展示与搜索匹配
---
## 6.2 周进度记录维护约定
- `weekly_updates` 采用对象数组,按时间顺序追加
- 每条记录建议至少填写:`week_start_date`、`week_end_date`、`weekly_summary`
- 如需要同步累计进度,建议同时填写 `progress_percent_after_update`
- 如存在阻塞事项,建议在周记录的 `blockers` 中体现;如果项目已延期,再同步补充主记录字段 `delay_reason`
- 当前版本周进度记录由项目负责人统一维护,不拆分到成员个人视角
- 当前前端交互不要求用户直接填写 JSON,而是通过结构化表单维护周进度数组
---
## 7. 后续可扩展方向
- 增加项目日志对象,记录状态变更历史
- 增加项目附件、评论、提醒
- 增加拖拽调整计划时间
- 增加项目分组、筛选、统计看板
- 如未来成员进度不再共享,再拆分为“项目主单 + 人员分配单”
import {
buildUserMap,
enrichProjectTask,
fetchProjectTasks,
fetchSystemUsersByIds,
} from './projectTaskService';
export async function loadProjectDashboardData(currentUsername = '') {
try {
const taskRecords = await fetchProjectTasks({});
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 ||
'';
return [...memberIds, leaderId].filter(Boolean);
}),
),
);
if (currentUsername) {
userIds.push(currentUsername);
}
const users = await fetchSystemUsersByIds(userIds);
const userMap = buildUserMap(users);
const tasks = taskRecords.map((task) => enrichProjectTask(task, userMap));
if (tasks.length > 0) {
return {
tasks,
users,
isDemoMode: false,
error: '',
};
}
return {
tasks: [],
users,
isDemoMode: false,
error: '当前未读取到真实项目记录,请先创建或导入 `pm_project_task` 数据。',
};
} catch (error) {
return {
tasks: [],
users: [],
isDemoMode: false,
error: error?.message || '统计数据加载失败,请稍后重试。',
};
}
}
import {
createRecord,
importRecords,
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 PRIORITY_OPTIONS = [
{ label: '高', value: 'high' },
{ label: '中', value: 'medium' },
{ label: '低', value: 'low' },
];
export const RESULT_STATUS_OPTIONS = [
{ label: '未开始', value: 'not_started' },
{ label: '正常进行', value: 'on_track' },
{ label: '已超时', value: 'overdue' },
{ label: '提前完成', value: 'completed_early' },
{ label: '准时完成', value: 'completed_on_time' },
{ label: '超时完成', value: 'completed_overdue' },
];
export const PROJECT_STATUS_LABEL_MAP = PROJECT_STATUS_OPTIONS.reduce(
(accumulator, item) => {
accumulator[item.value] = item.label;
return accumulator;
},
{},
);
export const RESULT_STATUS_LABEL_MAP = RESULT_STATUS_OPTIONS.reduce(
(accumulator, item) => {
accumulator[item.value] = item.label;
return accumulator;
},
{},
);
export const PRIORITY_LABEL_MAP = PRIORITY_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 formatDateInput(value) {
if (!value) {
return '';
}
const text = normalizeText(value);
if (/^\d{4}-\d{2}-\d{2}$/.test(text)) {
return text;
}
const date = new Date(text);
if (Number.isNaN(date.getTime())) {
return '';
}
return date.toISOString().slice(0, 10);
}
export function formatDateLabel(value) {
const dateText = formatDateInput(value);
if (!dateText) {
return '-';
}
const [year, month, day] = dateText.split('-');
return `${year}-${month}-${day}`;
}
export function calculateDiffDays(startDate, endDate) {
const start = formatDateInput(startDate);
const end = formatDateInput(endDate);
if (!start || !end) {
return 0;
}
const startTime = new Date(`${start}T00:00:00`).getTime();
const endTime = new Date(`${end}T00:00:00`).getTime();
return Math.floor((endTime - startTime) / 86400000);
}
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 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 === 'string') {
const text = normalizeText(value);
if (!text) {
return [];
}
try {
const parsed = JSON.parse(text);
if (!Array.isArray(parsed)) {
throw new Error('周进度记录必须是 JSON 数组');
}
return sortWeeklyUpdates(parsed.filter((item) => item && typeof item === 'object'));
} catch (error) {
if (throwOnInvalid) {
throw new Error('周进度记录需填写为合法的 JSON 数组');
}
return [];
}
}
if (throwOnInvalid) {
throw new Error('周进度记录需填写为合法的 JSON 数组');
}
return [];
}
export const PROJECT_TASK_IMPORT_HEADERS = [
'项目名称',
'项目编号',
'下发领导',
'项目负责人',
'参与成员',
'计划开始日期',
'计划结束日期',
'实际开始日期',
'实际完成日期',
'项目状态',
'完成进度',
'优先级',
'结果状态',
'项目说明',
'备注',
'延期原因',
'周进度开始日期',
'周进度结束日期',
'周进度内容',
'周进度进度',
'周进度阻塞',
'周进度下周计划',
'周进度记录日期',
];
export function buildProjectTaskImportWorkbook() {
const workbook = XLSX.utils.book_new();
const templateSheet = XLSX.utils.aoa_to_sheet([PROJECT_TASK_IMPORT_HEADERS]);
const guideSheet = XLSX.utils.aoa_to_sheet([
['字段', '填写说明'],
['下发领导 / 项目负责人', '填写系统用户的 username 或 _id'],
['参与成员', '多个用户用英文逗号分隔,填写 username 或 _id'],
['项目状态', '草稿 / 已下发 / 进行中 / 已完成 / 受阻 / 已取消'],
['结果状态', '未开始 / 正常进行 / 已超时 / 提前完成 / 准时完成 / 超时完成'],
['周进度字段', '如填写,会自动生成第一条周进度记录'],
]);
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';
const plannedStartDate = formatDateInput(metadata.planned_start_date);
const plannedEndDate = formatDateInput(metadata.planned_end_date);
const actualFinishDate = formatDateInput(metadata.actual_finish_date);
const today = new Date().toISOString().slice(0, 10);
if (projectStatus === 'completed' && actualFinishDate && plannedEndDate) {
if (actualFinishDate < plannedEndDate) {
return 'completed_early';
}
if (actualFinishDate > plannedEndDate) {
return 'completed_overdue';
}
return 'completed_on_time';
}
if (
projectStatus !== 'completed' &&
plannedEndDate &&
today > plannedEndDate
) {
return 'overdue';
}
if (plannedStartDate && today < plannedStartDate) {
return 'not_started';
}
return 'on_track';
}
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
? calculateDiffDays(plannedEndDate, actualFinishDate)
: !actualFinishDate && plannedEndDate
? Math.max(calculateDiffDays(plannedEndDate, new Date().toISOString().slice(0, 10)), 0)
: 0;
const leaderId = resolveUserReferenceId(metadata.leader);
const projectOwnerId = resolveUserReferenceId(metadata.project_owner);
const weeklyUpdates = normalizeWeeklyUpdatesValue(metadata.weekly_updates);
const latestWeeklyUpdate =
weeklyUpdates.length > 0 ? weeklyUpdates[weeklyUpdates.length - 1] : null;
return {
...task,
metadata: {
...metadata,
result_status: resultStatus,
is_overtime: isOvertime,
overtime_days: overtimeDays,
weekly_updates: weeklyUpdates,
},
projectName: normalizeText(metadata.project_name) || task?.title || '未命名项目',
projectCode: normalizeText(metadata.project_code) || '-',
projectStatus: metadata.project_status || 'draft',
projectStatusLabel:
PROJECT_STATUS_LABEL_MAP[metadata.project_status] || metadata.project_status || '-',
resultStatus,
resultStatusLabel: RESULT_STATUS_LABEL_MAP[resultStatus] || resultStatus,
plannedStartDate,
plannedEndDate,
actualStartDate: formatDateInput(metadata.actual_start_date),
actualFinishDate,
progressPercent: Number(metadata.completion_percent || 0),
priority: metadata.priority || '',
priorityLabel: PRIORITY_LABEL_MAP[metadata.priority] || '-',
memberIds,
memberUsers: memberIds
.map((id) => userMap.get(id))
.filter(Boolean),
leaderUser: userMap.get(leaderId) || metadata.leader || null,
projectOwnerUser: userMap.get(projectOwnerId) || metadata.project_owner || null,
delayReason: normalizeText(metadata.delay_reason),
weeklyUpdates,
latestWeeklyUpdate,
latestWeeklySummary: normalizeText(
latestWeeklyUpdate?.weekly_summary || latestWeeklyUpdate?.summary,
),
weeklyUpdateCount: weeklyUpdates.length,
isOvertime,
overtimeDays,
dateSpanDays:
plannedStartDate && plannedEndDate
? Math.max(calculateDiffDays(plannedStartDate, plannedEndDate) + 1, 1)
: 0,
};
}
export function buildTaskFilter({
keyword = '',
status = '',
memberId = '',
leaderId = '',
}) {
const conditions = [];
if (keyword) {
conditions.push({
$or: [
{
'metadata.project_name': {
$regex: keyword,
$options: 'i',
},
},
{
'metadata.project_code': {
$regex: keyword,
$options: 'i',
},
},
],
});
}
if (status) {
conditions.push({
'metadata.project_status': status,
});
}
if (memberId) {
conditions.push({
'metadata.member_ids': memberId,
});
}
if (leaderId) {
conditions.push({
'metadata.leader': leaderId,
});
}
if (conditions.length === 0) {
return {};
}
if (conditions.length === 1) {
return conditions[0];
}
return {
$and: conditions,
};
}
export async function fetchProjectTasks(filters = {}) {
const response = await queryRecords({
subject: PROJECT_TASK_SUBJECT,
page: 0,
page_size: 500,
mode: 'full',
sort: '-updated_at',
filter: buildTaskFilter(filters),
});
return response?.records || [];
}
export function normalizeUserRecord(user) {
return {
id: user?._id || user?.id || user?.username || '',
username: user?.username || '',
displayName:
user?.display_name || user?.title || user?.username || user?._id || '',
raw: user,
};
}
export function mergeUserRecords(users = []) {
const userMap = new Map();
users.map(normalizeUserRecord).forEach((user) => {
if (!user.id) {
return;
}
userMap.set(user.id, user);
});
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 [];
}
export async function searchSystemUsers(keyword = '') {
const normalizedKeyword = normalizeText(keyword);
const response = await queryUsers(
0,
20,
normalizedKeyword
? {
$or: [
{
username: {
$regex: normalizedKeyword,
},
},
{
display_name: {
$regex: normalizedKeyword,
},
},
],
}
: '',
null,
);
return mergeUserRecords(extractUserQueryResults(response));
}
export async function fetchSystemUsersByIds(ids = []) {
const uniqueIds = Array.from(
new Set((Array.isArray(ids) ? ids : []).filter(Boolean)),
);
if (uniqueIds.length === 0) {
return [];
}
const records = await Promise.all(
uniqueIds.map(async (id) => {
try {
return await loadUser(id);
} catch (error) {
try {
return await loadUser(String(id));
} catch {
return null;
}
}
}),
);
return mergeUserRecords(records.filter(Boolean));
}
export function buildUserMap(users = []) {
return new Map(
users
.map(normalizeUserRecord)
.filter((user) => user.id)
.map((user) => [user.id, user]),
);
}
export function buildTaskPayload(values) {
const projectName = normalizeText(values.project_name);
const leader = values.leader || '';
const projectOwner = values.project_owner || '';
const memberIds = Array.isArray(values.member_ids) ? values.member_ids.filter(Boolean) : [];
const plannedStartDate = formatDateInput(values.planned_start_date);
const plannedEndDate = formatDateInput(values.planned_end_date);
if (!projectName) {
throw new Error('请填写项目名称');
}
if (!leader) {
throw new Error('请选择下发领导');
}
if (!projectOwner) {
throw new Error('请选择项目负责人');
}
if (memberIds.length === 0) {
throw new Error('请至少选择一位参与成员');
}
if (!plannedStartDate || !plannedEndDate) {
throw new Error('请选择计划开始日期和计划结束日期');
}
if (plannedStartDate > plannedEndDate) {
throw new Error('计划结束日期不能早于计划开始日期');
}
return {
title: projectName || '未命名项目',
metadata: {
project_name: projectName,
project_code: normalizeText(values.project_code),
leader,
project_owner: projectOwner,
member_ids: memberIds,
planned_start_date: plannedStartDate,
planned_end_date: plannedEndDate,
actual_start_date: formatDateInput(values.actual_start_date),
actual_finish_date: formatDateInput(values.actual_finish_date),
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),
delay_reason: normalizeText(values.delay_reason),
weekly_updates: normalizeWeeklyUpdatesValue(values.weekly_updates, {
throwOnInvalid: true,
}),
},
};
}
export async function saveProjectTask(values, currentTask) {
const payload = buildTaskPayload(values);
const recordName =
currentTask?.name || currentTask?._id || currentTask?.id || '';
if (recordName) {
return updateRecord(recordName, payload);
}
return createRecord({
subject: PROJECT_TASK_SUBJECT,
...payload,
});
}
export function buildDemoTasks() {
return [
{
_id: 'demo-task-1',
title: '容诚年度项目',
subject: PROJECT_TASK_SUBJECT,
metadata: {
project_name: '容诚年度项目',
project_code: 'PM-2026-001',
leader: 'yang_admin',
project_owner: 'yang_admin',
member_ids: ['yang_admin', 'system'],
planned_start_date: '2026-05-01',
planned_end_date: '2026-06-10',
actual_start_date: '2026-05-02',
actual_finish_date: '',
description: '领导在甘特图中下发项目,成员共享进度推进。',
remark: '甘特图与日历图采用 project_manager_new 风格组织。',
priority: 'high',
project_status: 'in_progress',
completion_percent: 68,
result_status: 'on_track',
is_overtime: false,
overtime_days: 0,
delay_reason: '',
weekly_updates: [
{
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,
},
],
},
},
{
_id: 'demo-task-2',
title: '客户并购专项',
subject: PROJECT_TASK_SUBJECT,
metadata: {
project_name: '客户并购专项',
project_code: 'PM-2026-002',
leader: 'yang_admin',
project_owner: 'system',
member_ids: ['system'],
planned_start_date: '2026-04-15',
planned_end_date: '2026-05-25',
actual_start_date: '2026-04-16',
actual_finish_date: '',
description: '共享进度型项目,单条记录承载 1-2 名成员。',
remark: '当前展示为已超时。',
priority: 'medium',
project_status: 'in_progress',
completion_percent: 82,
result_status: 'overdue',
is_overtime: true,
overtime_days: 14,
delay_reason: '客户补充资料延后,导致复核节点顺延。',
weekly_updates: [
{
week_start_date: '2026-05-18',
week_end_date: '2026-05-24',
weekly_summary: '完成初稿沟通,但等待客户补材料,无法进入最终复核。',
blockers: '客户补充资料尚未齐备',
progress_percent_after_update: 82,
},
],
},
},
{
_id: 'demo-task-3',
title: '海外审计补充工作',
subject: PROJECT_TASK_SUBJECT,
metadata: {
project_name: '海外审计补充工作',
project_code: 'PM-2026-003',
leader: 'system',
project_owner: 'yang_admin',
member_ids: ['yang_admin'],
planned_start_date: '2026-03-10',
planned_end_date: '2026-04-20',
actual_start_date: '2026-03-11',
actual_finish_date: '2026-04-16',
description: '提前完成案例,用于甘特图状态展示。',
remark: '',
priority: 'low',
project_status: 'completed',
completion_percent: 100,
result_status: 'completed_early',
is_overtime: false,
overtime_days: 0,
delay_reason: '',
weekly_updates: [
{
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 { useContext } from 'react';
import ProjectBookingWorkspace from '../ProjectBookingWorkspace';
export default function ResourceBookingPage() {
const userInfo = useContext(UserContext);
return (
<ProjectBookingWorkspace
currentUsername={userInfo?.username || userInfo?._id || ''}
/>
);
}
import clsx from 'clsx';
import { useEffect, useMemo, useState } from 'react';
import {
PRIORITY_LABEL_MAP,
PROJECT_STATUS_LABEL_MAP,
} from '../projectTaskService';
import { loadProjectDashboardData } from '../projectStatisticsService';
function formatUserLabel(user) {
if (!user) {
return '-';
}
return user.displayName || user.username || user.id || '-';
}
function formatPercent(value) {
const numericValue = Number(value || 0);
if (!Number.isFinite(numericValue)) {
return '0%';
}
return `${Math.max(0, Math.min(100, Math.round(numericValue)))}%`;
}
function StatCard({ label, value, hint, tone = 'default' }) {
const toneClass =
tone === 'danger'
? 'from-rose-50 to-white border-rose-200'
: tone === 'success'
? 'from-emerald-50 to-white border-emerald-200'
: tone === 'primary'
? 'from-sky-50 to-white border-sky-200'
: tone === 'warning'
? 'from-amber-50 to-white border-amber-200'
: 'from-zinc-50 to-white border-zinc-200';
return (
<div
className={clsx(
'rounded-2xl border bg-gradient-to-br p-5 shadow-sm',
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>
);
}
function SectionCard({ title, description, children }) {
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}
</div>
<div className="px-5 py-5">{children}</div>
</div>
);
}
export default function ResourceStatPage({ currentUsername = '' }) {
const [tasks, setTasks] = useState([]);
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
const result = await loadProjectDashboardData(currentUsername);
if (cancelled) {
return;
}
setTasks(result.tasks || []);
setUsers(result.users || []);
setError(result.error || '');
setLoading(false);
})();
return () => {
cancelled = true;
};
}, [currentUsername]);
const dashboard = useMemo(() => {
const total = tasks.length;
const inProgress = tasks.filter((task) => task.projectStatus === 'in_progress').length;
const completed = tasks.filter((task) => task.projectStatus === 'completed').length;
const overdue = tasks.filter((task) => task.isOvertime).length;
const blocked = tasks.filter((task) => task.projectStatus === 'blocked').length;
const avgProgress =
total > 0
? Math.round(
tasks.reduce((sum, task) => sum + Number(task.progressPercent || 0), 0) /
total,
)
: 0;
const statusGroups = Object.entries(
tasks.reduce((accumulator, task) => {
const key = task.projectStatus || 'draft';
accumulator[key] = (accumulator[key] || 0) + 1;
return accumulator;
}, {}),
)
.map(([key, count]) => ({
key,
label: PROJECT_STATUS_LABEL_MAP[key] || key,
count,
}))
.sort((left, right) => right.count - left.count);
const priorityGroups = Object.entries(
tasks.reduce((accumulator, task) => {
const key = task.priority || 'unset';
accumulator[key] = (accumulator[key] || 0) + 1;
return accumulator;
}, {}),
).map(([key, count]) => ({
key,
label: key === 'unset' ? '未设置' : PRIORITY_LABEL_MAP[key] || key,
count,
}));
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 }),
totalProjects: 0,
inProgressProjects: 0,
overdueProjects: 0,
};
}
accumulator[memberId].totalProjects += 1;
if (task.projectStatus === 'in_progress') {
accumulator[memberId].inProgressProjects += 1;
}
if (task.isOvertime) {
accumulator[memberId].overdueProjects += 1;
}
});
return accumulator;
}, {}),
).sort((left, right) => right.totalProjects - left.totalProjects);
const upcoming = [...tasks]
.filter((task) => task.plannedEndDate)
.sort((left, right) =>
String(left.plannedEndDate).localeCompare(String(right.plannedEndDate)),
)
.slice(0, 5);
const overdueList = tasks
.filter((task) => task.isOvertime)
.sort((left, right) => right.overtimeDays - left.overtimeDays)
.slice(0, 5);
return {
total,
inProgress,
completed,
overdue,
blocked,
avgProgress,
statusGroups,
priorityGroups,
memberLoad,
upcoming,
overdueList,
};
}, [tasks, users]);
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">
这里承接项目管理的统计看板,除了总量卡片,还补充了状态分布、优先级、成员负载和超时清单,便于领导快速判断资源压力。
</div>
{error ? (
<div className="mt-4 rounded-xl 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>
) : null}
</div>
<div className="grid gap-4 md:grid-cols-3 xl:grid-cols-6">
<StatCard
label="项目总数"
value={dashboard.total}
hint="全部项目记录"
tone="primary"
/>
<StatCard
label="进行中"
value={dashboard.inProgress}
hint="仍在推进中的项目"
/>
<StatCard
label="已完成"
value={dashboard.completed}
hint="已进入 completed 状态"
tone="success"
/>
<StatCard
label="已超时"
value={dashboard.overdue}
hint="计划结束后未完成或超时完成"
tone="danger"
/>
<StatCard
label="受阻项目"
value={dashboard.blocked}
hint="当前状态为 blocked"
tone="warning"
/>
<StatCard
label="平均进度"
value={formatPercent(dashboard.avgProgress)}
hint="全部项目平均完成度"
/>
</div>
<div className="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
<SectionCard
title="状态分布"
description="观察当前项目状态结构,判断项目池是否过度集中在进行中或超时状态。"
>
<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>
</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>
</SectionCard>
</div>
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<SectionCard
title="成员负载"
description="统计每位成员参与的项目数量、进行中项目数量和超时项目数量,适合观察资源压力。"
>
<div className="overflow-hidden rounded-2xl border border-zinc-200">
<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>
{dashboard.memberLoad.length > 0 ? (
dashboard.memberLoad.map((member) => (
<div
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">
{member.overdueProjects}
</div>
</div>
))
) : (
<div className="px-4 py-10 text-center text-sm text-zinc-400">
暂无成员负载数据
</div>
)}
</div>
</SectionCard>
<div className="space-y-6">
<SectionCard
title="即将到期"
description="优先关注计划结束日期最近的项目。"
>
<div className="space-y-3">
{dashboard.upcoming.length > 0 ? (
dashboard.upcoming.map((task) => (
<div
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>
</div>
))
) : (
<div className="text-sm text-zinc-400">暂无到期项目</div>
)}
</div>
</SectionCard>
<SectionCard
title="超时清单"
description="快速查看超时最明显的项目,便于优先处理。"
>
<div className="space-y-3">
{dashboard.overdueList.length > 0 ? (
dashboard.overdueList.map((task) => (
<div
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>
</div>
))
) : (
<div className="text-sm text-zinc-400">当前没有超时项目</div>
)}
</div>
</SectionCard>
</div>
</div>
</div>
);
}
import dayjs from 'dayjs';
export function normalizeText(value) {
return String(value ?? '').trim();
}
export function toArray(value) {
if (value === null || value === undefined || value === '') {
return [];
}
return Array.isArray(value) ? value : [value];
}
const REFERENCE_FIELD_TYPES = new Set([
'reference',
'document',
'view',
'template',
'task',
'tag',
'group',
'category',
]);
export function getCurrentUserFromStorage() {
if (typeof window === 'undefined') {
return null;
}
try {
return JSON.parse(window.localStorage.getItem('current_user') || 'null');
} catch (error) {
return null;
}
}
function collectUserGroups(user) {
return toArray(user?.groups)
.flatMap((group) => [group?._id, group?.name, group?.display_name, group])
.map(normalizeText)
.filter(Boolean);
}
function collectUserRoles(user) {
const directRoles = toArray(user?.roles);
const appRoles = toArray(user?.applications).flatMap((app) => app?.roles || []);
return [...directRoles, ...appRoles].map(normalizeText).filter(Boolean);
}
function collectUserPermissions(user) {
const directPermissions = toArray(user?.permissions);
const appPermissions = toArray(user?.applications).flatMap((app) => app?.permissions || []);
return [...directPermissions, ...appPermissions].map(normalizeText).filter(Boolean);
}
function intersects(left, right) {
const rightSet = new Set(toArray(right).map(normalizeText).filter(Boolean));
return toArray(left).some((item) => rightSet.has(normalizeText(item)));
}
export function hasAuthAccess(authRule, user = getCurrentUserFromStorage()) {
if (!authRule) {
return true;
}
if (!user) {
return false;
}
if (user.type === 'admin') {
return true;
}
if (authRule.type === 'group') {
return intersects(collectUserGroups(user), authRule.groups);
}
if (authRule.type === 'role') {
return intersects(collectUserRoles(user), authRule.roles);
}
if (authRule.type === 'permission') {
return intersects(collectUserPermissions(user), authRule.permissions);
}
if (authRule.type === 'username') {
return intersects([user.username, user.display_name, user._id], authRule.usernames);
}
return true;
}
export function getFieldValue(record, fieldPath) {
if (!record || !fieldPath) {
return null;
}
return fieldPath.split('.').reduce((current, key) => current?.[key], record);
}
export function setFieldValue(record, fieldPath, value) {
const nextRecord = JSON.parse(JSON.stringify(record || {}));
const keys = fieldPath.split('.');
let cursor = nextRecord;
keys.slice(0, -1).forEach((key) => {
if (!cursor[key] || typeof cursor[key] !== 'object') {
cursor[key] = {};
}
cursor = cursor[key];
});
cursor[keys[keys.length - 1]] = value;
return nextRecord;
}
export function getFieldDefinition(fieldPath, subject) {
if (!subject?.fields) {
return null;
}
if (fieldPath === 'title') {
return { name: 'title', label: '标题', type: 'text' };
}
const fieldName = fieldPath?.startsWith('metadata.')
? fieldPath.replace(/^metadata\./, '')
: fieldPath?.split('.').pop();
return subject.fields.find((field) => field.name === fieldName) || null;
}
export function getFieldPath(fieldDef) {
if (!fieldDef?.name) {
return '';
}
return fieldDef.name === 'title' ? 'title' : `metadata.${fieldDef.name}`;
}
export function getFieldNameFromPath(fieldPath) {
if (!fieldPath) {
return '';
}
if (fieldPath === 'title') {
return 'title';
}
return fieldPath.startsWith('metadata.')
? fieldPath.replace(/^metadata\./, '')
: fieldPath.split('.').pop();
}
function pickReferenceSubjectName(value) {
if (!value) {
return '';
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'object') {
return value.name || value.subject || value.subjectName || '';
}
return '';
}
export function getReferenceSubjectName(fieldDef) {
const settings = fieldDef?.settings || {};
return [
fieldDef?.subjectName,
fieldDef?.subject,
fieldDef?.refSubject,
fieldDef?.referenceSubject,
fieldDef?.targetSubject,
fieldDef?.reference_subject,
fieldDef?.target_subject,
settings.subjectName,
settings.subject,
settings.refSubject,
settings.referenceSubject,
settings.targetSubject,
settings.reference_subject,
settings.target_subject,
]
.map(pickReferenceSubjectName)
.find(Boolean) || '';
}
export function isReferenceFieldType(fieldType) {
return REFERENCE_FIELD_TYPES.has(fieldType);
}
export function normalizeRecordId(value) {
if (!value) {
return '';
}
if (typeof value === 'object') {
return value._id || value.id || value.name || '';
}
return normalizeText(value);
}
export function normalizeRecordLabel(value) {
if (!value) {
return '';
}
if (typeof value === 'object') {
return value.title || value.display_name || value.displayName || value.name || value.username || value._id || '';
}
return normalizeText(value);
}
export function buildViewColumns(view, subject, { includeHidden = false } = {}) {
const configuredColumns = view?.settings?.columns || [];
if (configuredColumns.length > 0) {
return configuredColumns
.filter((column) => includeHidden || !column.hide)
.map((column) => {
const fieldDef = getFieldDefinition(column.field, subject);
return {
...column,
width: column.width || 180,
label: fieldDef?.label || (column.field === 'title' ? '标题' : column.field),
fieldDef: fieldDef || { name: column.field, label: column.field, type: 'text' },
};
});
}
const defaultColumns = [];
defaultColumns.push({
field: 'title',
width: 180,
label: '标题',
fieldDef: { name: 'title', label: '标题', type: 'text' },
});
toArray(subject?.fields).forEach((field) => {
defaultColumns.push({
field: `metadata.${field.name}`,
width: 180,
label: field.label || field.name,
fieldDef: field,
});
});
return defaultColumns;
}
function compareRuleValue(recordValue, comparator, expectedValue) {
const expectedValues = toArray(expectedValue).map(normalizeText);
const recordValues = toArray(recordValue).map((item) => {
if (item && typeof item === 'object') {
return normalizeText(item._id || item.name || item.title || item.display_name || item.username);
}
return normalizeText(item);
});
if (comparator === '$in') {
return recordValues.some((item) => expectedValues.includes(item));
}
if (comparator === '$nin') {
return !recordValues.some((item) => expectedValues.includes(item));
}
if (comparator === '$ne') {
return !recordValues.some((item) => expectedValues.includes(item));
}
if (comparator === '$exists') {
return Boolean(expectedValue) ? recordValue !== undefined && recordValue !== null : recordValue === undefined || recordValue === null;
}
if (comparator === '$contains') {
const target = expectedValues.join(' ');
return recordValues.some((item) => item.includes(target));
}
return recordValues.some((item) => expectedValues.includes(item));
}
export function checkViewCondition(condition, record) {
if (condition === undefined || condition === null) {
return true;
}
if (typeof condition === 'boolean') {
return condition;
}
const rules = Array.isArray(condition?.rules) ? condition.rules : [];
if (rules.length === 0) {
return true;
}
const relationship = condition.relationship || 'all';
const results = rules.map((rule) => {
const recordValue = getFieldValue(record, rule.field);
return compareRuleValue(recordValue, rule.comparator || '$eq', rule.value);
});
return relationship === 'any' || relationship === 'or'
? results.some(Boolean)
: results.every(Boolean);
}
export function isViewAccessible(view, user = getCurrentUserFromStorage()) {
return hasAuthAccess(view?.settings?.viewAuth, user);
}
export function isFieldDisabledByView(fieldPath, view, user = getCurrentUserFromStorage()) {
const disabledColumn = toArray(view?.settings?.disableColumns).some((column) => column.field === fieldPath);
if (disabledColumn) {
return true;
}
const fieldAuthRule = toArray(view?.settings?.auth).find((rule) => rule.field === fieldPath);
return fieldAuthRule ? !hasAuthAccess(fieldAuthRule, user) : false;
}
export function recordMatchesRestrictions(record, restrictions = []) {
return toArray(restrictions).every((restriction) => {
if (restriction?.type !== 'field') {
return true;
}
return compareRuleValue(
getFieldValue(record, restriction.field),
restriction.comparator || '$in',
restriction.value,
);
});
}
export function mergeRestrictionsIntoFilter(baseFilter = {}, restrictions = []) {
return toArray(restrictions).reduce((filter, restriction) => {
if (restriction?.type !== 'field' || !restriction.field) {
return filter;
}
const values = toArray(restriction.value);
return {
...filter,
[restriction.field]: values.length > 1 ? { $in: values } : values[0],
};
}, { ...baseFilter });
}
export function sortRecordsByView(records, view) {
const sortColumns = toArray(view?.settings?.columns)
.filter((column) => column.sort)
.sort((left, right) => (left.sortIndex ?? 0) - (right.sortIndex ?? 0));
if (sortColumns.length === 0) {
return records;
}
return [...records].sort((left, right) => {
for (const column of sortColumns) {
const leftValue = normalizeText(getFieldValue(left.raw || left, column.field));
const rightValue = normalizeText(getFieldValue(right.raw || right, column.field));
const direction = column.sort === 'desc' ? -1 : 1;
if (leftValue > rightValue) return direction;
if (leftValue < rightValue) return -direction;
}
return 0;
});
}
export function getSelectOptions(fieldDef) {
return toArray(fieldDef?.settings?.options || fieldDef?.options).map((option) => {
if (typeof option === 'string') {
return { label: option, value: option };
}
return {
...option,
label: option.label || option.title || option.value,
value: option.value ?? option.label ?? option.title,
};
});
}
export function normalizeUserLabel(value) {
if (!value) {
return '';
}
if (typeof value === 'string') {
return value;
}
return value.display_name || value.displayName || value.username || value.name || value.title || value._id || '';
}
export function formatDateText(value) {
if (!value) {
return '';
}
const parsed = dayjs(value);
if (parsed.isValid()) {
return parsed.format('YYYY-MM-DD');
}
const text = normalizeText(value);
return /^\d{4}-\d{2}-\d{2}/.test(text) ? text.slice(0, 10) : text;
}
export function formatFieldValue(value, fieldDef) {
if (value === null || value === undefined || value === '') {
return '';
}
if (Array.isArray(value) && value.length === 0) {
return '';
}
if (fieldDef?.type === 'date') {
return formatDateText(value);
}
if (fieldDef?.type === 'boolean') {
return value ? '是' : '否';
}
if (fieldDef?.type === 'number') {
return typeof value === 'number' ? value.toLocaleString() : normalizeText(value);
}
if (fieldDef?.type === 'select') {
const options = getSelectOptions(fieldDef);
const labelForValue = (item) => {
const rawValue = typeof item === 'object' && item !== null ? item.value : item;
const option = options.find((candidate) => candidate.value === rawValue);
return option?.label || normalizeText(rawValue);
};
return toArray(value).map(labelForValue).filter(Boolean).join('、');
}
if (fieldDef?.type === 'user') {
return toArray(value).map(normalizeUserLabel).filter(Boolean).join('、');
}
if (isReferenceFieldType(fieldDef?.type)) {
return toArray(value).map(normalizeRecordLabel).filter(Boolean).join('、');
}
if (fieldDef?.type === 'file') {
return toArray(value)
.map((file) => file?.name || file?.title || file?.url || normalizeText(file))
.filter(Boolean)
.join('、');
}
if (typeof value === 'object') {
return JSON.stringify(value);
}
return normalizeText(value);
}
export function dateInputValue(value) {
const formatted = formatDateText(value);
return /^\d{4}-\d{2}-\d{2}$/.test(formatted) ? formatted : '';
}
export function toIsoDateString(value) {
if (!value) {
return null;
}
const parsed = dayjs(value);
return parsed.isValid() ? parsed.toISOString() : value;
}
export function emptyValueForField(fieldDef) {
if (fieldDef?.multiple) {
return [];
}
if (fieldDef?.type === 'boolean') {
return false;
}
return '';
}
export function normalizeValueForSubmit(value, fieldDef) {
if (fieldDef?.multiple) {
const values = toArray(value).filter((item) => item !== '' && item !== null && item !== undefined);
if (fieldDef?.type === 'user' || isReferenceFieldType(fieldDef?.type)) {
return values.map((item) => item?._id || item);
}
return values;
}
if (value === '' || value === undefined) {
return null;
}
if (fieldDef?.type === 'date') {
return toIsoDateString(value);
}
if (fieldDef?.type === 'number') {
return value === null ? null : Number(value);
}
if (fieldDef?.type === 'user' || isReferenceFieldType(fieldDef?.type)) {
return value?._id || value;
}
if (fieldDef?.type === 'object' && typeof value === 'string') {
try {
return value.trim() ? JSON.parse(value) : null;
} catch (error) {
return value;
}
}
return value;
}
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