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

refactor: migrate record detail flow to problem rectification

parent 2a14cc6d
This diff is collapsed.
......@@ -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, 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,
};
}
This diff is collapsed.
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>
);
}
This diff is collapsed.
# 项目管理流程文档
## 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 || '统计数据加载失败,请稍后重试。',
};
}
}
This diff is collapsed.
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 || ''}
/>
);
}
This diff is collapsed.
This diff is collapsed.
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