From 62776aa33b46ca62db03353b8bb9de6589dfd470 Mon Sep 17 00:00:00 2001 From: fanhongcai Date: Thu, 20 Aug 2026 21:43:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A1=A8=E5=8D=95=E5=BA=93=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E6=94=B9=E6=A8=AA=E7=89=88=E7=BB=9F=E4=B8=80=E5=B0=BA?= =?UTF-8?q?=E5=AF=B8=20+=20=E6=97=A5=E7=A8=8B=E9=A1=B5=E5=B0=8F=E6=9C=88?= =?UTF-8?q?=E5=8E=86=E8=83=8C=E6=99=AF=E8=9E=8D=E5=85=A5=E4=B8=8E=E4=BC=91?= =?UTF-8?q?=E6=81=AF=E6=97=A5=E5=8C=BA=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 表单库:卡片网格改为固定 3 列等宽,aspectRatio 恢复 4/3 横版,缩略图字段数同步调回 2 - 日程页:小月历背景透明融入左侧容器,周六/周日表头与单元格红字、浅灰底区分休息日(选中保持蓝底白字) --- client/src/pages/Forms.tsx | 1121 ++++++++++++++++++++++++--------- client/src/pages/Schedule.tsx | 32 +- 2 files changed, 853 insertions(+), 300 deletions(-) diff --git a/client/src/pages/Forms.tsx b/client/src/pages/Forms.tsx index 9bfd9ff..e49e922 100644 --- a/client/src/pages/Forms.tsx +++ b/client/src/pages/Forms.tsx @@ -17,17 +17,22 @@ import { Select, Space, Switch, + Tabs, Tag, + Tooltip, Typography, message, } from 'antd'; import type { Dayjs } from 'dayjs'; import dayjs from 'dayjs'; import { + AppstoreOutlined, CalendarOutlined, CheckCircleOutlined, DeleteOutlined, + EyeOutlined, FormOutlined, + LayoutOutlined, PlusOutlined, ReloadOutlined, SendOutlined, @@ -67,44 +72,217 @@ const STATUS_META: Record done: { label: '已完成', color: 'green' }, }; +/** 表单市场内置模板(前端演示数据,可一键加入表单库) */ +interface MarketTemplate { + id: string; + name: string; + description: string; + category: string; + author: string; + installs: number; + fields: FormField[]; +} + +const MARKET_TEMPLATES: MarketTemplate[] = [ + { + id: 'm-leave', + name: '请假申请(标准版)', + description: '通用请假申请单,支持事假/病假/年假/调休', + category: '人事', + author: 'ChatOA 官方', + installs: 1286, + fields: [ + { name: 'start', label: '开始日期', type: 'date', required: true }, + { name: 'end', label: '结束日期', type: 'date', required: true }, + { + name: 'type', + label: '请假类型', + type: 'select', + required: true, + options: [ + { label: '事假', value: '事假' }, + { label: '病假', value: '病假' }, + { label: '年假', value: '年假' }, + { label: '调休', value: '调休' }, + ], + }, + { name: 'reason', label: '请假事由', type: 'textarea', required: true }, + { name: 'contact', label: '紧急联系人', type: 'text' }, + ], + }, + { + id: 'm-trip', + name: '出差申请', + description: '出差行程与预算审批单', + category: '人事', + author: 'ChatOA 官方', + installs: 952, + fields: [ + { name: 'city', label: '出差城市', type: 'text', required: true }, + { name: 'start', label: '出发日期', type: 'date', required: true }, + { name: 'end', label: '返回日期', type: 'date', required: true }, + { + name: 'vehicle', + label: '交通工具', + type: 'radio', + options: [ + { label: '高铁', value: '高铁' }, + { label: '飞机', value: '飞机' }, + { label: '自驾', value: '自驾' }, + ], + }, + { name: 'budget', label: '预算(元)', type: 'number' }, + { name: 'purpose', label: '出差目的', type: 'textarea', required: true }, + ], + }, + { + id: 'm-expense', + name: '费用报销', + description: '日常费用报销单(发票/票据明细)', + category: '财务', + author: 'ChatOA 官方', + installs: 1103, + fields: [ + { name: 'project', label: '所属项目', type: 'text' }, + { name: 'amount', label: '报销金额(元)', type: 'number', required: true }, + { + name: 'category', + label: '费用类型', + type: 'select', + options: [ + { label: '交通', value: '交通' }, + { label: '餐饮', value: '餐饮' }, + { label: '办公', value: '办公' }, + { label: '其他', value: '其他' }, + ], + }, + { name: 'detail', label: '费用明细', type: 'textarea', required: true }, + { name: 'urgent', label: '紧急报销', type: 'switch' }, + ], + }, + { + id: 'm-meeting', + name: '会议室预约', + description: '会议室预订登记表', + category: '行政', + author: 'ChatOA 官方', + installs: 765, + fields: [ + { name: 'room', label: '会议室名称', type: 'text', required: true }, + { name: 'date', label: '使用日期', type: 'date', required: true }, + { name: 'start', label: '开始时间', type: 'date', required: true }, + { name: 'end', label: '结束时间', type: 'date', required: true }, + { name: 'topic', label: '会议主题', type: 'text', required: true }, + { name: 'attendees', label: '参会人数', type: 'number' }, + ], + }, + { + id: 'm-supply', + name: '办公用品申领', + description: '办公用品/耗材申领单', + category: '行政', + author: 'ChatOA 官方', + installs: 641, + fields: [ + { name: 'item', label: '用品名称', type: 'text', required: true }, + { name: 'count', label: '数量', type: 'number', required: true }, + { + name: 'urgency', + label: '紧急程度', + type: 'radio', + options: [ + { label: '普通', value: '普通' }, + { label: '加急', value: '加急' }, + ], + }, + { name: 'remark', label: '用途说明', type: 'textarea' }, + ], + }, + { + id: 'm-overtime', + name: '加班申请', + description: '工作日/休息日加班审批单', + category: '人事', + author: 'ChatOA 官方', + installs: 587, + fields: [ + { name: 'date', label: '加班日期', type: 'date', required: true }, + { + name: 'type', + label: '加班类型', + type: 'select', + options: [ + { label: '工作日', value: '工作日' }, + { label: '周末', value: '周末' }, + { label: '节假日', value: '节假日' }, + ], + }, + { name: 'hours', label: '加班时长(小时)', type: 'number', required: true }, + { name: 'reason', label: '加班事由', type: 'textarea', required: true }, + ], + }, +]; + +/** 右侧工作区页签 */ +type RightTabKind = 'market' | 'instance' | 'template' | 'library'; +interface RightTab { + key: string; + kind: RightTabKind; + name: string; + instanceId?: string; + templateId?: string; +} +const RIGHT_MARKET_KEY = 'market'; +const RIGHT_LIBRARY_KEY = 'library'; + /** 表单在月历上的归属日期(优先处理期限,否则提交日期) */ const dayKey = (ins: FormInstance) => dayjs(ins.dueDate || ins.submittedAt).format('YYYY-MM-DD'); -function fieldControl(f: FormField) { +function fieldControl(f: FormField, disabled = false) { switch (f.type) { case 'textarea': - return ; + return ; case 'number': - return ; + return ( + + ); case 'select': return ( - ); case 'radio': return ( - ({ label: o.label, value: o.value }))} /> + ({ label: o.label, value: o.value }))} + disabled={disabled} + /> ); case 'date': - return ; + return ; case 'switch': - return ; + return ; default: - return ; + return ; } } +/** 表单库预览时的示例值:下拉/单选展示首个选项,开关默认关,其余留空 */ +function previewValue(f: FormField) { + if (f.type === 'select' || f.type === 'radio') return f.options?.[0]?.value; + if (f.type === 'switch') return false; + return undefined; +} + export default function FormsPage() { const [templates, setTemplates] = useState([]); const [instances, setInstances] = useState([]); const [loading, setLoading] = useState(true); // 左侧筛选 - const [tab, setTab] = useState<'active' | 'library'>('active'); const [keyword, setKeyword] = useState(''); const [selectedDate, setSelectedDate] = useState(null); const [selectedId, setSelectedId] = useState(null); - const [selectedTplId, setSelectedTplId] = useState(null); // 模板设计器 const [designOpen, setDesignOpen] = useState(false); @@ -141,15 +319,74 @@ export default function FormsPage() { return map; }, [templates]); - const selected = useMemo( - () => instances.find((i) => i.id === selectedId) ?? null, - [instances, selectedId], - ); + // 右侧工作区多页签:默认固定打开「表单库」(不可关闭),其余按需创建 + const [rightTabs, setRightTabs] = useState([ + { key: RIGHT_LIBRARY_KEY, kind: 'library', name: '表单库' }, + ]); + const [activeRightKey, setActiveRightKey] = useState(RIGHT_LIBRARY_KEY); - const selectedTpl = useMemo( - () => templates.find((t) => t.id === selectedTplId) ?? null, - [templates, selectedTplId], - ); + const openRightTab = (rt: RightTab) => { + setRightTabs((prev) => + prev.some((t) => t.key === rt.key) ? prev : [...prev, rt], + ); + setActiveRightKey(rt.key); + }; + + const closeRightTab = (key: string) => { + if (key === RIGHT_LIBRARY_KEY) return; + const idx = rightTabs.findIndex((t) => t.key === key); + const next = rightTabs.filter((t) => t.key !== key); + setRightTabs(next); + setActiveRightKey((cur) => { + if (cur !== key) return cur; + return next[Math.min(idx, next.length - 1)]?.key ?? ''; + }); + }; + + /** 渲染右侧页签内容 */ + const renderRightTab = (rt: RightTab) => { + if (rt.kind === 'instance') { + const ins = instances.find((i) => i.id === rt.instanceId); + if (!ins) return ; + return ( + + ); + } + if (rt.kind === 'template') { + const tpl = templates.find((t) => t.id === rt.templateId); + if (!tpl) return ; + return ; + } + if (rt.kind === 'library') { + return ( + + openRightTab({ + key: `tpl-${t.id}`, + kind: 'template', + templateId: t.id, + name: t.name, + }) + } + onFill={pickTemplate} + onOpenMarket={() => + openRightTab({ + key: RIGHT_MARKET_KEY, + kind: 'market', + name: '表单市场', + }) + } + /> + ); + } + return ; + }; // 月历红点:待处理/处理中的表单按日期统计 const markDates = useMemo(() => { @@ -173,10 +410,11 @@ export default function FormsPage() { ); }; - const activeCount = useMemo( - () => instances.filter((i) => i.status !== 'done').length, + const doneCount = useMemo( + () => instances.filter((i) => i.status === 'done').length, [instances], ); + const totalCount = instances.length; // 进行中:非 done 表单,支持日期 + 关键字过滤 const filteredInstances = useMemo(() => { @@ -196,18 +434,6 @@ export default function FormsPage() { }); }, [instances, keyword, selectedDate, tplMap]); - // 表单库:组织拥有的空表单模板合集 - const filteredTemplates = useMemo(() => { - const kw = keyword.trim().toLowerCase(); - return templates.filter((t) => { - if (!kw) return true; - return ( - t.name.toLowerCase().includes(kw) || - (t.description ?? '').toLowerCase().includes(kw) - ); - }); - }, [templates, keyword]); - // ===== 模板设计器 ===== const addField = () => { setTplFields((fs) => [ @@ -273,10 +499,10 @@ export default function FormsPage() { load(); }; - const removeSelected = async () => { - if (!selected) return; - await deleteInstance(selected.id); - setSelectedId(null); + const removeInstance = async (id: string) => { + await deleteInstance(id); + closeRightTab(`inst-${id}`); + if (selectedId === id) setSelectedId(null); load(); }; @@ -309,8 +535,8 @@ export default function FormsPage() { 表单 - {/* 小月历:红点标记有待处理表单的日期(样式与日程页一致) */} -
+ {/* 小月历:红点标记有待处理表单的日期;休息日(周六/周日)与工作日区分显示 */} +
)} /> +
- {/* 搜索 + Tab */} + {/* 搜索 + 进行中标题 */}
setKeyword(e.target.value)} /> - { - if (t === 'active') setSelectedTplId(null); - else setSelectedId(null); +
- {selectedDate && ( -
- setSelectedDate(null)} - style={{ marginRight: 0 }} - > - {selectedDate.format('YYYY-MM-DD')} - -
- )} + > + + {selectedDate ? selectedDate.format('M月D日 dddd') : '全部日期'} + + + {doneCount} + / + {totalCount} + +
- {/* 表单列表 */} + {/* 表单列表(进行中) */}
- {tab === 'active' ? ( - }} - renderItem={(ins) => { - const t = tplMap[ins.templateId]; - const meta = STATUS_META[ins.status] ?? STATUS_META.pending; - return ( - { - setSelectedId(ins.id); - setSelectedTplId(null); - }} - style={{ - cursor: 'pointer', - padding: '8px 10px', - borderRadius: 6, - background: selectedId === ins.id ? '#e6f4ff' : 'transparent', - }} - > -
-
- - {t?.name ?? '未知模板'} - - - {meta.label} - -
-
- - {dayjs(ins.dueDate || ins.submittedAt).format('MM-DD')} - - - {firstFieldValue(ins, t)} - -
-
-
- ); - }} - /> - ) : ( - - ), - }} - renderItem={(t) => ( + }} + renderItem={(ins) => { + const t = tplMap[ins.templateId]; + const meta = STATUS_META[ins.status] ?? STATUS_META.pending; + return ( { - setSelectedTplId(t.id); - setSelectedId(null); + setSelectedId(ins.id); + openRightTab({ + key: `inst-${ins.id}`, + kind: 'instance', + instanceId: ins.id, + name: t?.name ?? '表单', + }); }} style={{ cursor: 'pointer', padding: '8px 10px', borderRadius: 6, - background: selectedTplId === t.id ? '#e6f4ff' : 'transparent', + background: selectedId === ins.id ? '#e6f4ff' : 'transparent', }} >
- {t.name} + {t?.name ?? '未知模板'} - - {t.fields.length} 字段 + + {meta.label}
- - {t.description || '空表单模板'} - +
+ + {dayjs(ins.dueDate || ins.submittedAt).format('MM-DD')} + + + {firstFieldValue(ins, t)} + +
- )} - /> - )} + ); + }} + />
@@ -507,128 +714,37 @@ export default function FormsPage() { -
- {selected ? ( - - {tplMap[selected.templateId]?.name ?? '未知模板'} - - {STATUS_META[selected.status]?.label} - - - } - extra={ - - {selected.status !== 'done' ? ( - - ) : ( - - )} - - - } - > - - - {selected.submitterId} - - - {dayjs(selected.submittedAt).format('YYYY-MM-DD HH:mm')} - - - {selected.dueDate - ? dayjs(selected.dueDate).format('YYYY-MM-DD') - : '未设置'} - - - {tplMap[selected.templateId]?.description || '—'} - - - - 表单内容 - - - - ) : selectedTpl ? ( - - - {selectedTpl.name} - 空表单 - - } - extra={ - - } - > - - - {selectedTpl.description || '—'} - - - {selectedTpl.fields.length} - - - - 表单字段 - - }} - renderItem={(f) => ( - - - {f.label} - - {FIELD_TYPES.find((x) => x.value === f.type)?.label ?? f.type} - - {f.required && ( - - 必填 - - )} - - - )} - /> - - ) : ( -
- -
- )} +
+ { + if (action === 'remove' && key !== RIGHT_LIBRARY_KEY) closeRightTab(key as string); + }} + items={rightTabs.map((rt) => ({ + key: rt.key, + closable: rt.key !== RIGHT_LIBRARY_KEY, + label: + rt.kind === 'market' ? ( + + + {rt.name} + + ) : rt.kind === 'library' ? ( + + + {rt.name} + + ) : ( + rt.name + ), + children: renderRightTab(rt), + }))} + className="form-workspace-tabs" + /> +
@@ -801,48 +917,6 @@ export default function FormsPage() { ); } -function TabsBar({ - tab, - setTab, - activeCount, - templateCount, - onSwitch, -}: { - tab: 'active' | 'library'; - setTab: (t: 'active' | 'library') => void; - activeCount: number; - templateCount: number; - onSwitch?: (t: 'active' | 'library') => void; -}) { - return ( -
- {( - [ - { key: 'active', label: `进行中 (${activeCount})` }, - { key: 'library', label: `表单库 (${templateCount})` }, - ] as const - ).map((it) => ( -
{ - onSwitch?.(it.key); - setTab(it.key); - }} - style={{ - padding: '6px 14px', - fontSize: 13, - cursor: 'pointer', - color: tab === it.key ? '#1677ff' : '#666', - borderBottom: tab === it.key ? '2px solid #1677ff' : '2px solid transparent', - }} - > - {it.label} -
- ))} -
- ); -} - function firstFieldValue(ins: FormInstance, t?: FormTemplate) { if (!t || t.fields.length === 0) return ''; const first = t.fields[0]; @@ -867,3 +941,454 @@ function DescriptionsOf({ ); } + +/** 单据详情:右侧页签中展示单个表单实例,支持状态操作与删除 */ +function InstanceDetail({ + ins, + tpl, + onSetStatus, + onDelete, +}: { + ins: FormInstance; + tpl?: FormTemplate; + onSetStatus: (id: string, status: FormInstanceStatus) => void; + onDelete: (id: string) => void; +}) { + return ( + + {tpl?.name ?? '未知模板'} + {STATUS_META[ins.status]?.label} + + } + extra={ + + {ins.status !== 'done' ? ( + + ) : ( + + )} + + + } + > + + {ins.submitterId} + + {dayjs(ins.submittedAt).format('YYYY-MM-DD HH:mm')} + + + {ins.dueDate ? dayjs(ins.dueDate).format('YYYY-MM-DD') : '未设置'} + + {tpl?.description || '—'} + + + 表单内容 + + + + ); +} + +/** 模板详情:右侧页签中展示表单库模板,上部预览 + 下部详情 */ +function TemplateDetail({ + tpl, + onFill, +}: { + tpl: FormTemplate; + onFill: (t: FormTemplate) => void; +}) { + return ( +
+ {/* 上部:表单预览 */} + + + {tpl.name} + 空表单 + + } + extra={ + + } + > + + 预览:下方为填写该表单时的实际效果(控件为只读示例) + +
+
+ {tpl.fields.map((f) => ( + + {fieldControl(f, true)} + + ))} +
+
+
+ + {/* 下部:表单详情 */} + + 表单详情 + + } + > + + + {tpl.description || '—'} + + {tpl.fields.length} + + {dayjs(tpl.createdAt).format('YYYY-MM-DD HH:mm')} + + {tpl.ownerId || '—'} + + + 字段清单 + + }} + renderItem={(f) => ( + + + {f.label} + + {FIELD_TYPES.find((x) => x.value === f.type)?.label ?? f.type} + + {f.required && ( + + 必填 + + )} + + + )} + /> + +
+ ); +} + +/** 表单库:右侧页签,缩略图卡片展示组织模板合集,支持搜索、预览与填写 */ +function FormLibrary({ + templates, + onOpenTpl, + onFill, + onOpenMarket, +}: { + templates: FormTemplate[]; + onOpenTpl: (t: FormTemplate) => void; + onFill: (t: FormTemplate) => void; + onOpenMarket: () => void; +}) { + const [kw, setKw] = useState(''); + const filtered = templates.filter((t) => { + const k = kw.trim().toLowerCase(); + if (!k) return true; + return ( + t.name.toLowerCase().includes(k) || + (t.description ?? '').toLowerCase().includes(k) + ); + }); + + return ( +
+
+ setKw(e.target.value)} + /> + +
+ {filtered.length === 0 ? ( + + ) : ( +
+ {filtered.map((t) => ( + + + {t.name} + + + +
+ } + > + + {t.description || '空表单模板'} + + {/* 表单缩略图:真实控件只读渲染,占据剩余空间 */} +
+
+ {t.fields.slice(0, 2).map((f) => ( + + {fieldControl(f, true)} + + ))} +
+ {t.fields.length > 2 && ( +
+ … 共 {t.fields.length} 个字段 +
+ )} +
+ + ))} +
+ )} + + ); +} + +/** 表单市场:右侧页签,支持搜索并选择模板加入表单库 */ +function FormMarket({ onAdded }: { onAdded: () => void }) { + const [kw, setKw] = useState(''); + const [added, setAdded] = useState>({}); + const [submitting, setSubmitting] = useState(null); + + const filtered = MARKET_TEMPLATES.filter( + (m) => + !kw.trim() || + m.name.toLowerCase().includes(kw.trim().toLowerCase()) || + m.description.toLowerCase().includes(kw.trim().toLowerCase()), + ); + + const addToLibrary = async (m: MarketTemplate) => { + setSubmitting(m.id); + try { + await createTemplate({ + name: m.name, + description: m.description, + fields: m.fields, + }); + setAdded((prev) => ({ ...prev, [m.id]: true })); + message.success(`「${m.name}」已加入表单库`); + onAdded(); + } catch (e: any) { + message.error(e.response?.data?.message || e.message || '加入失败'); + } finally { + setSubmitting(null); + } + }; + + return ( +
+
+
+ + 表单市场 + + + 精选常用表单模板,一键加入你的表单库 + +
+ setKw(e.target.value)} + /> +
+ }} + renderItem={(m) => ( + +
+
+
+ {m.name} + + {m.category} + + {m.fields.length} 字段 +
+ + {m.description} + +
+ + 提供方:{m.author} · 已安装 {m.installs.toLocaleString()} 次 + +
+ {/* 表单样式预览:真实控件只读渲染 */} +
+
+ {m.fields.slice(0, 3).map((f) => ( + + {fieldControl(f, true)} + + ))} +
+ {m.fields.length > 3 && ( +
+ … 共 {m.fields.length} 个字段 +
+ )} +
+
+ +
+
+ )} + /> +
+ ); +} diff --git a/client/src/pages/Schedule.tsx b/client/src/pages/Schedule.tsx index 522ffd4..cc0fe71 100644 --- a/client/src/pages/Schedule.tsx +++ b/client/src/pages/Schedule.tsx @@ -359,8 +359,8 @@ export default function SchedulePage() { 日程 - {/* 小型月历 */} -
+ {/* 小型月历:背景融入容器,休息日(周六/周日)与工作日区分 */} +
)} /> + {/* 图例 */}