Commit b52ff11f authored by David Yang's avatar David Yang

fix: bugs

parent 40f35588
...@@ -27,6 +27,10 @@ function asSaveValue(value, fieldDef) { ...@@ -27,6 +27,10 @@ function asSaveValue(value, fieldDef) {
return value; return value;
} }
function getClearedValue(fieldDef) {
return fieldDef?.multiple ? [] : null;
}
function Pill({ children, tone = 'zinc' }) { function Pill({ children, tone = 'zinc' }) {
const toneClass = const toneClass =
tone === 'rose' tone === 'rose'
...@@ -241,6 +245,12 @@ export default function TailwindEditableField({ ...@@ -241,6 +245,12 @@ export default function TailwindEditableField({
} }
}; };
const clearValue = async () => {
const clearedValue = getClearedValue(fieldDef);
setDraft(clearedValue);
await save(clearedValue);
};
const cancel = () => { const cancel = () => {
setDraft(value); setDraft(value);
setError(''); setError('');
...@@ -259,7 +269,8 @@ export default function TailwindEditableField({ ...@@ -259,7 +269,8 @@ export default function TailwindEditableField({
try { try {
const uploaded = await uploadFile(file); const uploaded = await uploadFile(file);
const fileValue = uploaded?.file || uploaded?.data || uploaded; const fileValue = uploaded?.file || uploaded?.data || uploaded;
const nextValue = fieldDef?.multiple ? [...toArray(value), fileValue] : fileValue; const nextValue = fieldDef?.multiple ? [...toArray(draft), fileValue] : fileValue;
setDraft(nextValue);
await onSave(nextValue); await onSave(nextValue);
setEditing(false); setEditing(false);
} catch (uploadError) { } catch (uploadError) {
...@@ -269,6 +280,25 @@ export default function TailwindEditableField({ ...@@ -269,6 +280,25 @@ export default function TailwindEditableField({
} }
}; };
const handleRemoveFile = async (indexToRemove) => {
setError('');
const nextValue = fieldDef?.multiple
? toArray(draft).filter((_, index) => index !== indexToRemove)
: null;
try {
setDraft(nextValue);
await onSave(nextValue);
if (!fieldDef?.multiple) {
setEditing(false);
}
} catch (saveError) {
setDraft(draft);
setError(saveError?.message || '删除附件失败,请稍后重试。');
}
};
if (!editing) { if (!editing) {
return ( return (
<button <button
...@@ -300,7 +330,10 @@ export default function TailwindEditableField({ ...@@ -300,7 +330,10 @@ export default function TailwindEditableField({
<div className="flex items-center gap-3 rounded-2xl border border-zinc-200 bg-white px-3 py-2"> <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" /> <Checkbox checked={Boolean(draft)} onChange={(checked) => setDraft(checked)} color="rose" />
<span className="text-sm text-zinc-700">{draft ? '是' : '否'}</span> <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 type="button" onClick={clearValue} className="ml-auto text-sm font-medium text-rose-600">
清空
</button>
<button type="button" onClick={() => save(Boolean(draft))} className="text-sm font-medium text-rose-700">
保存 保存
</button> </button>
<button type="button" onClick={cancel} className="text-sm text-zinc-500"> <button type="button" onClick={cancel} className="text-sm text-zinc-500">
...@@ -334,6 +367,7 @@ export default function TailwindEditableField({ ...@@ -334,6 +367,7 @@ export default function TailwindEditableField({
))} ))}
</Select> </Select>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button type="button" onClick={clearValue} className="text-sm font-medium text-rose-600">清空</button>
<button type="button" onClick={cancel} className="text-sm text-zinc-500">取消</button> <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> <button type="button" onClick={() => save()} className="text-sm font-medium text-rose-700">保存</button>
</div> </div>
...@@ -347,6 +381,7 @@ export default function TailwindEditableField({ ...@@ -347,6 +381,7 @@ export default function TailwindEditableField({
<div className="space-y-3"> <div className="space-y-3">
<UserSelector value={draft} onChange={setDraft} multiple={fieldDef?.multiple} /> <UserSelector value={draft} onChange={setDraft} multiple={fieldDef?.multiple} />
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button type="button" onClick={clearValue} className="text-sm font-medium text-rose-600">清空</button>
<button type="button" onClick={cancel} className="text-sm text-zinc-500">取消</button> <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> <button type="button" onClick={() => save()} className="text-sm font-medium text-rose-700">保存</button>
</div> </div>
...@@ -364,6 +399,7 @@ export default function TailwindEditableField({ ...@@ -364,6 +399,7 @@ export default function TailwindEditableField({
onChange={setDraft} onChange={setDraft}
/> />
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button type="button" onClick={clearValue} className="text-sm font-medium text-rose-600">清空</button>
<button type="button" onClick={cancel} className="text-sm text-zinc-500">取消</button> <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> <button type="button" onClick={() => save()} className="text-sm font-medium text-rose-700">保存</button>
</div> </div>
...@@ -375,10 +411,47 @@ export default function TailwindEditableField({ ...@@ -375,10 +411,47 @@ export default function TailwindEditableField({
if (fieldType === 'file') { if (fieldType === 'file') {
return ( return (
<div className="space-y-3 rounded-2xl border border-zinc-200 bg-white p-3"> <div className="space-y-3 rounded-2xl border border-zinc-200 bg-white p-3">
<FieldValue value={value} fieldDef={fieldDef} /> <div className="space-y-2">
{toArray(draft).length === 0 ? (
<span className="text-zinc-400">未上传</span>
) : (
toArray(draft).map((file, index) => {
const label = file?.name || file?.title || file?.url || `附件 ${index + 1}`;
return (
<div
key={`${label}-${index}`}
className="flex items-center justify-between gap-3 rounded-2xl border border-zinc-100 bg-zinc-50 px-3 py-2"
>
<div className="min-w-0 flex-1">
{file?.url ? (
<a
href={file.url}
target="_blank"
rel="noreferrer"
className="text-sm font-medium text-sky-700 transition hover:text-sky-800"
>
{label}
</a>
) : (
<div className="text-sm font-medium text-zinc-700">{label}</div>
)}
</div>
<button
type="button"
onClick={() => handleRemoveFile(index)}
className="shrink-0 text-sm font-medium text-rose-600"
>
删除
</button>
</div>
);
})
)}
</div>
<input type="file" onChange={handleFileUpload} disabled={uploading} className="block w-full text-sm text-zinc-600" /> <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} {error ? <div className="text-sm text-rose-600">{error}</div> : null}
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button type="button" onClick={clearValue} className="text-sm font-medium text-rose-600">清空</button>
<button type="button" onClick={cancel} className="text-sm text-zinc-500">取消</button> <button type="button" onClick={cancel} className="text-sm text-zinc-500">取消</button>
<span className="text-sm text-zinc-400">{uploading ? '上传中...' : '选择文件后自动保存'}</span> <span className="text-sm text-zinc-400">{uploading ? '上传中...' : '选择文件后自动保存'}</span>
</div> </div>
...@@ -393,6 +466,7 @@ export default function TailwindEditableField({ ...@@ -393,6 +466,7 @@ export default function TailwindEditableField({
<Textarea rows={8} value={jsonValue} onChange={(event) => setDraft(event.target.value)} resizable /> <Textarea rows={8} value={jsonValue} onChange={(event) => setDraft(event.target.value)} resizable />
{error ? <div className="text-sm text-rose-600">{error}</div> : null} {error ? <div className="text-sm text-rose-600">{error}</div> : null}
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button type="button" onClick={clearValue} className="text-sm font-medium text-rose-600">清空</button>
<button type="button" onClick={cancel} className="text-sm text-zinc-500">取消</button> <button type="button" onClick={cancel} className="text-sm text-zinc-500">取消</button>
<button <button
type="button" type="button"
...@@ -411,6 +485,7 @@ export default function TailwindEditableField({ ...@@ -411,6 +485,7 @@ export default function TailwindEditableField({
<div className="space-y-3"> <div className="space-y-3">
<Textarea rows={5} value={draft || ''} onChange={(event) => setDraft(event.target.value)} autoFocus /> <Textarea rows={5} value={draft || ''} onChange={(event) => setDraft(event.target.value)} autoFocus />
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button type="button" onClick={clearValue} className="text-sm font-medium text-rose-600">清空</button>
<button type="button" onClick={cancel} className="text-sm text-zinc-500">取消</button> <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> <button type="button" onClick={() => save()} className="text-sm font-medium text-rose-700">保存</button>
</div> </div>
...@@ -433,6 +508,7 @@ export default function TailwindEditableField({ ...@@ -433,6 +508,7 @@ export default function TailwindEditableField({
autoFocus autoFocus
/> />
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button type="button" onClick={clearValue} className="text-sm font-medium text-rose-600">清空</button>
<button type="button" onClick={cancel} className="text-sm text-zinc-500">取消</button> <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> <button type="button" onClick={() => save()} className="text-sm font-medium text-rose-700">保存</button>
</div> </div>
......
...@@ -63,6 +63,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -63,6 +63,7 @@ const RecordDetailDrawer = (props, ref) => {
onDataChange, onDataChange,
readonly = false, readonly = false,
filterView = [], filterView = [],
preferredViewName,
} = props; } = props;
const [refreshKey, setRefreshKey] = useState(() => Date.now()); const [refreshKey, setRefreshKey] = useState(() => Date.now());
...@@ -112,11 +113,21 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -112,11 +113,21 @@ const RecordDetailDrawer = (props, ref) => {
return; return;
} }
if (preferredViewName) {
const matchedView = detailViews.find(
(view) => view.name === preferredViewName || view.title === preferredViewName,
);
if (matchedView && matchedView.name !== activeTabKey) {
setActiveTabKey(matchedView.name);
return;
}
}
const currentTab = detailViews.find((view) => view.name === activeTabKey); const currentTab = detailViews.find((view) => view.name === activeTabKey);
if (!currentTab) { if (!currentTab) {
setActiveTabKey(detailViews[0].name); setActiveTabKey(detailViews[0].name);
} }
}, [detailViews, activeTabKey]); }, [detailViews, activeTabKey, preferredViewName]);
useImperativeHandle( useImperativeHandle(
ref, ref,
......
import xyLogo from '@/assets/xy-logo.png';
import xyLogoSmall from '@/assets/xy-logo_small.png'; import xyLogoSmall from '@/assets/xy-logo_small.png';
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
import { import {
...@@ -244,16 +243,13 @@ export default function ProblemRectificationLayout() { ...@@ -244,16 +243,13 @@ export default function ProblemRectificationLayout() {
sidebarCollapsed ? 'justify-center' : 'gap-3 px-1', sidebarCollapsed ? 'justify-center' : 'gap-3 px-1',
)} )}
> >
{sidebarCollapsed ? (
<img <img
src={sidebarCollapsed ? xyLogoSmall : xyLogo} src={xyLogoSmall}
alt="XY Logo" alt="XY Logo"
className={clsx( className="h-8 w-auto max-w-8 shrink-0 object-contain"
'shrink-0 object-contain',
sidebarCollapsed
? 'h-8 w-auto max-w-8'
: 'h-8 w-auto max-w-[140px]',
)}
/> />
) : null}
{!sidebarCollapsed ? ( {!sidebarCollapsed ? (
<div className="min-w-0"> <div className="min-w-0">
<h1 className="truncate text-sm font-semibold tracking-[0.02em] text-white/95"> <h1 className="truncate text-sm font-semibold tracking-[0.02em] text-white/95">
...@@ -327,12 +323,7 @@ export default function ProblemRectificationLayout() { ...@@ -327,12 +323,7 @@ export default function ProblemRectificationLayout() {
close={() => setIsMobileSidebarOpen(false)} close={() => setIsMobileSidebarOpen(false)}
> >
<div className="px-5 pt-6"> <div className="px-5 pt-6">
<div className="flex items-center gap-2"> <div>
<img
src={xyLogo}
alt="XY Logo"
className="h-8 w-auto max-w-[140px] shrink-0 object-contain"
/>
<div> <div>
<h1 className="text-base font-semibold tracking-[0.02em] text-white"> <h1 className="text-base font-semibold tracking-[0.02em] text-white">
{APP_NAME} {APP_NAME}
......
import { Button } from '@/catalyst-ui-kit/javascript/button'; import { Button } from '@/catalyst-ui-kit/javascript/button';
import { Checkbox } from '@/catalyst-ui-kit/javascript/checkbox';
import { import {
Dialog, Dialog,
DialogActions, DialogActions,
...@@ -8,14 +9,22 @@ import { ...@@ -8,14 +9,22 @@ import {
} from '@/catalyst-ui-kit/javascript/dialog'; } from '@/catalyst-ui-kit/javascript/dialog';
import { Input } from '@/catalyst-ui-kit/javascript/input'; import { Input } from '@/catalyst-ui-kit/javascript/input';
import { Select } from '@/catalyst-ui-kit/javascript/select'; import { Select } from '@/catalyst-ui-kit/javascript/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/catalyst-ui-kit/javascript/table';
import { Textarea } from '@/catalyst-ui-kit/javascript/textarea'; import { Textarea } from '@/catalyst-ui-kit/javascript/textarea';
import RecordDetailDrawer from '@/pages/RecordDetailDrawer';
import { import {
batchRemoveRecords, batchRemoveRecords,
batchUpdateRecords, batchUpdateRecords,
createNotice, createNotice,
createRecord, createRecord,
executeAction, executeAction,
loadSubject,
loadUser, loadUser,
queryUsers, queryUsers,
sendNotice, sendNotice,
...@@ -24,37 +33,28 @@ import { ...@@ -24,37 +33,28 @@ import {
import * as Headless from '@headlessui/react'; import * as Headless from '@headlessui/react';
import clsx from 'clsx'; import clsx from 'clsx';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { import {
emptyValueForField, formatFieldValue,
getFieldNameFromPath,
getFieldValue,
getSelectOptions, getSelectOptions,
normalizeText, normalizeText,
} from '@/utils/subjectView'; } from '@/utils/subjectView';
import { DEPARTMENT_GROUPS } from '../problemRectificationDashboardService';
import { import {
PROBLEM_RECTIFICATION_SUBJECT, PROBLEM_RECTIFICATION_SUBJECT,
buildGridViewCountMap,
buildGridViews,
buildInitialCreateValues,
buildProblemRectificationConfiguredListView,
buildProblemRectificationCreateFormFields,
buildProblemRectificationCreatePayload, buildProblemRectificationCreatePayload,
buildProblemRectificationListView, getRecordCellText,
loadProblemRectificationListData, loadProblemRectificationListResources,
} from '../problemRectificationListService'; } from '../problemRectificationListService';
const CREATE_FIELD_ORDER = [ const SELECTION_COLUMN_WIDTH = 56;
'project_name', const ACTION_COLUMN_WIDTH = 132;
'inspect_the_implementing_unit',
'check_the_implementation_time',
'inspection_report_date',
'rectification_progress',
'problem_from',
'problem_type',
'feedback_unit',
'related_issue_domain',
'the_time_of_rectification',
'responsible_department',
'problem_brief',
'problem_discovery_situation',
'rectification_situation',
'remark',
];
const FIELD_HINTS = { const FIELD_HINTS = {
inspect_the_implementing_unit: '按对象配置的实施单位选项选择', inspect_the_implementing_unit: '按对象配置的实施单位选项选择',
...@@ -66,20 +66,86 @@ const FIELD_HINTS = { ...@@ -66,20 +66,86 @@ const FIELD_HINTS = {
the_time_of_rectification: '提交时会自动转成 ISO 8601 时间', the_time_of_rectification: '提交时会自动转成 ISO 8601 时间',
}; };
function getPinnedSide(column) {
const pinnedValue = column?.lockPosition || column?.pinned;
if (pinnedValue === 'left' || pinnedValue === 'right') {
return pinnedValue;
}
return null;
}
function buildStickyColumnMeta(columns = []) {
const leftColumns = columns.filter((column) => getPinnedSide(column) === 'left');
const centerColumns = columns.filter((column) => !getPinnedSide(column));
const rightColumns = columns.filter((column) => getPinnedSide(column) === 'right');
const orderedColumns = [...leftColumns, ...centerColumns, ...rightColumns];
let leftOffset = SELECTION_COLUMN_WIDTH;
let rightOffset = ACTION_COLUMN_WIDTH;
const stickyMeta = {};
leftColumns.forEach((column) => {
stickyMeta[column.field] = {
side: 'left',
offset: leftOffset,
isEdge: column.field === leftColumns[leftColumns.length - 1]?.field,
};
leftOffset += column.width || 180;
});
[...rightColumns].reverse().forEach((column) => {
stickyMeta[column.field] = {
side: 'right',
offset: rightOffset,
isEdge: column.field === rightColumns[0]?.field,
};
rightOffset += column.width || 180;
});
return {
orderedColumns,
stickyMeta,
};
}
function getStickyCellClass(meta, kind = 'body') {
if (!meta) {
return '';
}
const baseClass =
kind === 'header'
? 'sticky z-30 bg-zinc-50/95 backdrop-blur'
: 'sticky z-20 bg-white';
const sideClass = meta.side === 'left' ? 'left-0' : 'right-0';
const edgeShadow =
meta.side === 'left' && meta.isEdge
? 'shadow-[10px_0_20px_rgba(15,23,42,0.06)]'
: meta.side === 'right' && meta.isEdge
? 'shadow-[-10px_0_20px_rgba(15,23,42,0.06)]'
: '';
return clsx(baseClass, sideClass, edgeShadow);
}
function SummaryCard({ label, value, hint, tone = 'default' }) { function SummaryCard({ label, value, hint, tone = 'default' }) {
const toneClass = const toneClass =
tone === 'rose' tone === 'rose'
? 'border-rose-200 bg-rose-50/80' ? 'border-rose-200/80 bg-rose-50/85'
: tone === 'amber' : tone === 'amber'
? 'border-amber-200 bg-amber-50/80' ? 'border-amber-200/80 bg-amber-50/85'
: tone === 'emerald' : tone === 'emerald'
? 'border-emerald-200 bg-emerald-50/80' ? 'border-emerald-200/80 bg-emerald-50/85'
: 'border-zinc-200 bg-zinc-50/80'; : 'border-zinc-200/80 bg-white/88';
return ( return (
<div className={clsx('rounded-[24px] border p-4 shadow-sm', toneClass)}> <div className={clsx('rounded-[26px] border p-5 shadow-sm ring-1 ring-zinc-950/5', toneClass)}>
<div className="text-sm font-medium text-slate-500">{label}</div> <div className="text-xs font-semibold uppercase tracking-[0.22em] text-slate-400">
<div className="mt-3 text-3xl font-semibold tracking-tight text-slate-900"> {label}
</div>
<div className="mt-3 text-3xl font-semibold tracking-tight text-slate-950">
{value} {value}
</div> </div>
<div className="mt-2 text-sm text-slate-500">{hint}</div> <div className="mt-2 text-sm text-slate-500">{hint}</div>
...@@ -89,7 +155,7 @@ function SummaryCard({ label, value, hint, tone = 'default' }) { ...@@ -89,7 +155,7 @@ function SummaryCard({ label, value, hint, tone = 'default' }) {
function EmptyState({ title, description }) { function EmptyState({ title, description }) {
return ( return (
<div className="rounded-[28px] border border-dashed border-zinc-300 bg-white/70 px-6 py-12 text-center"> <div className="rounded-[28px] border border-dashed border-zinc-300 bg-white/80 px-6 py-14 text-center">
<div className="text-xl font-semibold text-slate-900">{title}</div> <div className="text-xl font-semibold text-slate-900">{title}</div>
<div className="mt-3 text-sm leading-6 text-slate-500">{description}</div> <div className="mt-3 text-sm leading-6 text-slate-500">{description}</div>
</div> </div>
...@@ -99,20 +165,15 @@ function EmptyState({ title, description }) { ...@@ -99,20 +165,15 @@ function EmptyState({ title, description }) {
function StatusPill({ text, tone = 'default' }) { function StatusPill({ text, tone = 'default' }) {
const toneClass = const toneClass =
tone === 'rose' tone === 'rose'
? 'bg-rose-50 text-rose-700' ? 'bg-rose-50 text-rose-700 ring-rose-100'
: tone === 'emerald' : tone === 'emerald'
? 'bg-emerald-50 text-emerald-700' ? 'bg-emerald-50 text-emerald-700 ring-emerald-100'
: tone === 'amber' : tone === 'amber'
? 'bg-amber-50 text-amber-700' ? 'bg-amber-50 text-amber-700 ring-amber-100'
: 'bg-slate-100 text-slate-600'; : 'bg-slate-100 text-slate-600 ring-slate-200';
return ( return (
<span <span className={clsx('inline-flex rounded-full px-3 py-1 text-xs font-medium ring-1', toneClass)}>
className={clsx(
'inline-flex rounded-full px-3 py-1 text-xs font-medium',
toneClass,
)}
>
{text || '-'} {text || '-'}
</span> </span>
); );
...@@ -141,10 +202,6 @@ function resolveAuditTone(status = '') { ...@@ -141,10 +202,6 @@ function resolveAuditTone(status = '') {
return 'default'; return 'default';
} }
function formatUsers(users) {
return users && users.length > 0 ? users.join('、') : '-';
}
function SelectedUsers({ users, onRemove }) { function SelectedUsers({ users, onRemove }) {
if (users.length === 0) { if (users.length === 0) {
return ( return (
...@@ -176,37 +233,28 @@ function SelectedUsers({ users, onRemove }) { ...@@ -176,37 +233,28 @@ function SelectedUsers({ users, onRemove }) {
); );
} }
function RemoteUserSelect({ function RemoteUserSelect({ label, value, onChange, placeholder, onSearch }) {
label,
value,
onChange,
placeholder,
onSearch,
}) {
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [options, setOptions] = useState([]); const [options, setOptions] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const requestIdRef = useRef(0);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
const requestId = requestIdRef.current + 1;
requestIdRef.current = requestId;
const loadOptions = async () => { const loadOptions = async () => {
setLoading(true); setLoading(true);
try { try {
const users = await onSearch(keyword); const users = await onSearch(keyword);
if (!cancelled && requestIdRef.current === requestId) { if (!cancelled) {
setOptions(users); setOptions(users);
} }
} catch (error) { } catch (error) {
if (!cancelled && requestIdRef.current === requestId) { if (!cancelled) {
setOptions([]); setOptions([]);
} }
} finally { } finally {
if (!cancelled && requestIdRef.current === requestId) { if (!cancelled) {
setLoading(false); setLoading(false);
} }
} }
...@@ -219,18 +267,12 @@ function RemoteUserSelect({ ...@@ -219,18 +267,12 @@ function RemoteUserSelect({
}; };
}, [keyword, onSearch]); }, [keyword, onSearch]);
const selectedUsers = useMemo(() => value.filter(Boolean), [value]); const selectedIds = useMemo(() => new Set(value.map((item) => item._id)), [value]);
const selectedIds = useMemo(
() => new Set(selectedUsers.map((user) => user._id)),
[selectedUsers],
);
const availableOptions = options.filter((user) => !selectedIds.has(user._id)); const availableOptions = options.filter((user) => !selectedIds.has(user._id));
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<label className="mb-2 block text-sm font-medium text-slate-700"> <label className="block text-sm font-medium text-slate-700">{label}</label>
{label}
</label>
<Headless.Combobox <Headless.Combobox
value={null} value={null}
nullable nullable
...@@ -238,7 +280,7 @@ function RemoteUserSelect({ ...@@ -238,7 +280,7 @@ function RemoteUserSelect({
if (!user || selectedIds.has(user._id)) { if (!user || selectedIds.has(user._id)) {
return; return;
} }
onChange([...selectedUsers, user]); onChange([...value, user]);
setKeyword(''); setKeyword('');
}} }}
> >
...@@ -271,9 +313,7 @@ function RemoteUserSelect({ ...@@ -271,9 +313,7 @@ function RemoteUserSelect({
value={user} value={user}
className="cursor-pointer rounded-xl px-3 py-2 text-sm text-slate-700 data-focus:bg-rose-50 data-focus:text-slate-900" className="cursor-pointer rounded-xl px-3 py-2 text-sm text-slate-700 data-focus:bg-rose-50 data-focus:text-slate-900"
> >
<div className="font-medium text-slate-900"> <div className="font-medium text-slate-900">{user.display_name || user.username}</div>
{user.display_name || user.username}
</div>
<div className="text-xs text-slate-500">{user.username}</div> <div className="text-xs text-slate-500">{user.username}</div>
</Headless.ComboboxOption> </Headless.ComboboxOption>
)) ))
...@@ -286,30 +326,13 @@ function RemoteUserSelect({ ...@@ -286,30 +326,13 @@ function RemoteUserSelect({
</div> </div>
</Headless.Combobox> </Headless.Combobox>
<SelectedUsers <SelectedUsers
users={selectedUsers} users={value}
onRemove={(userId) => onRemove={(userId) => onChange(value.filter((user) => user._id !== userId))}
onChange(selectedUsers.filter((user) => user._id !== userId))
}
/> />
</div> </div>
); );
} }
function buildInitialCreateValues(subject) {
return (subject?.fields || [])
.filter((field) => CREATE_FIELD_ORDER.includes(field.name))
.reduce((result, field) => {
result[field.name] = emptyValueForField(field);
return result;
}, {});
}
function normalizeFieldList(subject) {
const fieldMap = new Map((subject?.fields || []).map((field) => [field.name, field]));
return CREATE_FIELD_ORDER.map((fieldName) => fieldMap.get(fieldName)).filter(Boolean);
}
function CreateField({ fieldDef, value, onChange }) { function CreateField({ fieldDef, value, onChange }) {
const options = getSelectOptions(fieldDef); const options = getSelectOptions(fieldDef);
const isMultiple = Boolean(fieldDef?.multiple); const isMultiple = Boolean(fieldDef?.multiple);
...@@ -328,13 +351,7 @@ function CreateField({ fieldDef, value, onChange }) { ...@@ -328,13 +351,7 @@ function CreateField({ fieldDef, value, onChange }) {
} }
if (fieldType === 'date') { if (fieldType === 'date') {
return ( return <Input type="date" value={value || ''} onChange={(event) => onChange(event.target.value)} />;
<Input
type="date"
value={value || ''}
onChange={(event) => onChange(event.target.value)}
/>
);
} }
if (fieldType === 'select') { if (fieldType === 'select') {
...@@ -344,9 +361,7 @@ function CreateField({ fieldDef, value, onChange }) { ...@@ -344,9 +361,7 @@ function CreateField({ fieldDef, value, onChange }) {
value={isMultiple ? value || [] : value || ''} value={isMultiple ? value || [] : value || ''}
onChange={(event) => { onChange={(event) => {
if (isMultiple) { if (isMultiple) {
onChange( onChange(Array.from(event.target.selectedOptions).map((option) => option.value));
Array.from(event.target.selectedOptions).map((option) => option.value),
);
return; return;
} }
onChange(event.target.value); onChange(event.target.value);
...@@ -364,6 +379,7 @@ function CreateField({ fieldDef, value, onChange }) { ...@@ -364,6 +379,7 @@ function CreateField({ fieldDef, value, onChange }) {
return ( return (
<Input <Input
type={fieldType === 'number' ? 'number' : 'text'}
value={value || ''} value={value || ''}
onChange={(event) => onChange(event.target.value)} onChange={(event) => onChange(event.target.value)}
placeholder={placeholder} placeholder={placeholder}
...@@ -371,89 +387,178 @@ function CreateField({ fieldDef, value, onChange }) { ...@@ -371,89 +387,178 @@ function CreateField({ fieldDef, value, onChange }) {
); );
} }
function FieldValuePreview({ column, record }) {
const fieldName = getFieldNameFromPath(column.field);
const rawRecord = record.raw || record;
const value = getFieldValue(rawRecord, column.field);
if (fieldName === 'rectification_progress') {
return <StatusPill text={formatFieldValue(value, column.fieldDef) || '未填写'} tone={resolveProgressTone(formatFieldValue(value, column.fieldDef) || '')} />;
}
if (fieldName === 'audit_status' || fieldName === 'final_audit_status') {
return <StatusPill text={formatFieldValue(value, column.fieldDef) || '未填写'} tone={resolveAuditTone(formatFieldValue(value, column.fieldDef) || '')} />;
}
if (fieldName === 'fill_in_the_people' || fieldName === 'review_people') {
const text = getRecordCellText(record, column);
return <div className="whitespace-normal break-words text-sm text-slate-700">{text}</div>;
}
if (column.fieldDef?.type === 'file') {
const fileValue = Array.isArray(value) ? value.filter(Boolean) : value ? [value] : [];
if (fileValue.length === 0) {
return <span className="text-slate-400">未上传</span>;
}
return (
<div className="flex flex-wrap gap-2">
{fileValue.map((file, index) => {
const label = file?.name || file?.title || `附件 ${index + 1}`;
if (!file?.url) {
return (
<span key={`${label}-${index}`} className="inline-flex rounded-full bg-zinc-100 px-2.5 py-1 text-xs font-medium text-zinc-700">
{label}
</span>
);
}
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 transition hover:bg-sky-100"
>
{label}
</a>
);
})}
</div>
);
}
const text = getRecordCellText(record, column);
const isLongText = ['long_text', 'rich_text'].includes(column.fieldDef?.type);
return (
<div className={clsx('whitespace-normal break-words text-sm text-slate-700', isLongText && 'line-clamp-3')}>
{text}
</div>
);
}
export default function ProblemRectificationListPage() { export default function ProblemRectificationListPage() {
const [records, setRecords] = useState([]); const [records, setRecords] = useState([]);
const [createSubject, setCreateSubject] = useState(null); const [subject, setSubject] = useState(null);
const [views, setViews] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [actionLoading, setActionLoading] = useState(false); const [actionLoading, setActionLoading] = useState(false);
const [filters, setFilters] = useState({ const [filters, setFilters] = useState({
groupKey: 'all',
keyword: '', keyword: '',
progress: '', progress: '',
auditStatus: '', auditStatus: '',
}); });
const [activeViewName, setActiveViewName] = useState('');
const [selectedIds, setSelectedIds] = useState([]); const [selectedIds, setSelectedIds] = useState([]);
const [showCreateModal, setShowCreateModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false);
const [showPushModal, setShowPushModal] = useState(false); const [showPushModal, setShowPushModal] = useState(false);
const [formValues, setFormValues] = useState({}); const [formValues, setFormValues] = useState({});
const [fillerUsers, setFillerUsers] = useState([]); const [fillerUsers, setFillerUsers] = useState([]);
const [auditorUsers, setAuditorUsers] = useState([]); const [auditorUsers, setAuditorUsers] = useState([]);
const [drawerRecordName, setDrawerRecordName] = useState('');
const [drawerDirty, setDrawerDirty] = useState(false);
const reloadList = async () => { const reloadList = useCallback(async () => {
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
const nextRecords = await loadProblemRectificationListData(); const resources = await loadProblemRectificationListResources();
setRecords(nextRecords); setRecords(resources.records || []);
setSubject(resources.subject || null);
setViews(resources.views || []);
} catch (requestError) { } catch (requestError) {
setRecords([]); setRecords([]);
setSubject(null);
setViews([]);
setError(requestError?.message || '问题整改列表加载失败,请稍后重试。'); setError(requestError?.message || '问题整改列表加载失败,请稍后重试。');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, []);
useEffect(() => { useEffect(() => {
reloadList(); reloadList();
}, []); }, [reloadList]);
useEffect(() => { const gridViews = useMemo(() => buildGridViews(views), [views]);
let cancelled = false;
(async () => { useEffect(() => {
try { if (gridViews.length === 0) {
const subject = await loadSubject(PROBLEM_RECTIFICATION_SUBJECT); setActiveViewName('');
if (!cancelled) { return;
setCreateSubject(subject);
setFormValues(buildInitialCreateValues(subject));
}
} catch (requestError) {
if (!cancelled) {
setCreateSubject(null);
} }
const currentView = gridViews.find((view) => view.name === activeViewName);
if (!currentView) {
setActiveViewName(gridViews[0].name);
} }
})(); }, [gridViews, activeViewName]);
return () => { const activeGridView = useMemo(
cancelled = true; () => gridViews.find((view) => view.name === activeViewName) || gridViews[0] || null,
}; [gridViews, activeViewName],
}, []); );
const configuredView = useMemo(
() => buildProblemRectificationConfiguredListView(records, filters, activeGridView, subject),
[records, filters, activeGridView, subject],
);
const view = useMemo( const stickyColumns = useMemo(
() => buildProblemRectificationListView(records, filters), () => buildStickyColumnMeta(configuredView.columns || []),
[records, filters], [configuredView.columns],
); );
const gridViewCounts = useMemo(
() => buildGridViewCountMap(records, filters, gridViews, subject),
[records, filters, gridViews, subject],
);
const createFields = useMemo( const createFields = useMemo(
() => normalizeFieldList(createSubject), () => buildProblemRectificationCreateFormFields(activeGridView, subject),
[createSubject], [activeGridView, subject],
); );
const initialCreateValues = useMemo( const initialCreateValues = useMemo(
() => buildInitialCreateValues(createSubject), () => buildInitialCreateValues(createFields),
[createSubject], [createFields],
); );
const filteredRecords = view.filteredRecords || []; useEffect(() => {
const groupCounts = view.groupCounts || {}; if (!showCreateModal) {
setFormValues(initialCreateValues);
}
}, [initialCreateValues, showCreateModal]);
const filteredRecords = configuredView.filteredRecords || [];
const selectedRecords = useMemo( const selectedRecords = useMemo(
() => filteredRecords.filter((item) => selectedIds.includes(item.id)), () => filteredRecords.filter((item) => selectedIds.includes(item.id)),
[filteredRecords, selectedIds], [filteredRecords, selectedIds],
); );
useEffect(() => {
const visibleIds = new Set(filteredRecords.map((item) => item.id));
setSelectedIds((current) => current.filter((id) => visibleIds.has(id)));
}, [filteredRecords]);
const fixedCount = filteredRecords.filter( const fixedCount = filteredRecords.filter(
(item) => item.progress === '已整改' || item.progress === '已优化', (item) => item.progress === '已整改' || item.progress === '已优化',
).length; ).length;
const pendingAuditCount = filteredRecords.filter( const pendingAuditCount = filteredRecords.filter(
(item) => item.auditStatus && item.auditStatus !== '已审批', (item) => item.auditStatus && item.auditStatus !== '已审批',
).length; ).length;
...@@ -474,6 +579,20 @@ export default function ProblemRectificationListPage() { ...@@ -474,6 +579,20 @@ export default function ProblemRectificationListPage() {
setSelectedIds((current) => current.filter((item) => item !== recordId)); setSelectedIds((current) => current.filter((item) => item !== recordId));
}; };
const handleOpenDrawer = (record) => {
setDrawerDirty(false);
setDrawerRecordName(record.name || record.id);
};
const handleCloseDrawer = async () => {
setDrawerRecordName('');
if (drawerDirty) {
setDrawerDirty(false);
await reloadList();
}
};
const handleCreate = async () => { const handleCreate = async () => {
if ( if (
!normalizeText(formValues.project_name) && !normalizeText(formValues.project_name) &&
...@@ -510,9 +629,7 @@ export default function ProblemRectificationListPage() { ...@@ -510,9 +629,7 @@ export default function ProblemRectificationListPage() {
return; return;
} }
const confirmed = window.confirm( const confirmed = window.confirm(`确认删除 ${targetRecords.length} 条问题整改记录吗?此操作不可撤销。`);
`确认删除 ${targetRecords.length} 条问题整改记录吗?此操作不可撤销。`,
);
if (!confirmed) { if (!confirmed) {
return; return;
} }
...@@ -527,14 +644,13 @@ export default function ProblemRectificationListPage() { ...@@ -527,14 +644,13 @@ export default function ProblemRectificationListPage() {
} }
await batchRemoveRecords({ await batchRemoveRecords({
subject: 'problem_rectification', subject: PROBLEM_RECTIFICATION_SUBJECT,
filter: { filter: {
name: { $in: names }, name: { $in: names },
}, },
}); });
setSelectedIds((current) =>
current.filter((id) => !targetRecords.some((item) => item.id === id)), setSelectedIds((current) => current.filter((id) => !targetRecords.some((item) => item.id === id)));
);
await reloadList(); await reloadList();
} catch (requestError) { } catch (requestError) {
setError(requestError?.message || '删除问题整改记录失败,请稍后重试。'); setError(requestError?.message || '删除问题整改记录失败,请稍后重试。');
...@@ -565,6 +681,7 @@ export default function ProblemRectificationListPage() { ...@@ -565,6 +681,7 @@ export default function ProblemRectificationListPage() {
setError('请先勾选需要推送的问题整改记录。'); setError('请先勾选需要推送的问题整改记录。');
return; return;
} }
if (fillerIds.length === 0 || auditorIds.length === 0) { if (fillerIds.length === 0 || auditorIds.length === 0) {
setError('请先选择填报人和复核人。'); setError('请先选择填报人和复核人。');
return; return;
...@@ -574,10 +691,8 @@ export default function ProblemRectificationListPage() { ...@@ -574,10 +691,8 @@ export default function ProblemRectificationListPage() {
setError(''); setError('');
try { try {
const fillerUsers = await Promise.all(fillerIds.map((id) => loadUser(id))); const fillerProfiles = await Promise.all(fillerIds.map((id) => loadUser(id)));
const auditorUsers = await Promise.all(auditorIds.map((id) => loadUser(id))); const currentUserInfo = JSON.parse(window.localStorage.getItem('current_user') || '{}') || {};
const currentUserInfo =
JSON.parse(window.localStorage.getItem('current_user') || '{}') || {};
const messageRecord = await createRecord({ const messageRecord = await createRecord({
subject: 'message_reference_id', subject: 'message_reference_id',
...@@ -589,8 +704,8 @@ export default function ProblemRectificationListPage() { ...@@ -589,8 +704,8 @@ export default function ProblemRectificationListPage() {
restriction_key: '_id', restriction_key: '_id',
restriction_value: selectedRecords.map((item) => item.id), restriction_value: selectedRecords.map((item) => item.id),
reference_record_id: selectedRecords.map((item) => item.id), reference_record_id: selectedRecords.map((item) => item.id),
view_name: filters.groupKey, view_name: activeGridView?.name || '',
subject_name: 'problem_rectification', subject_name: PROBLEM_RECTIFICATION_SUBJECT,
editor_status: '未完成', editor_status: '未完成',
auditor_status: '未完成', auditor_status: '未完成',
sender_status: '未完成', sender_status: '未完成',
...@@ -598,14 +713,14 @@ export default function ProblemRectificationListPage() { ...@@ -598,14 +713,14 @@ export default function ProblemRectificationListPage() {
}); });
const progressIdEditor = []; const progressIdEditor = [];
for (const fillerUser of fillerUsers) { for (const fillerProfile of fillerProfiles) {
const oaProcessResult = await executeAction('oa_notice:createProgress', { const oaProcessResult = await executeAction('oa_notice:createProgress', {
title: `${dayjs().format('YYYY-MM-DD')} 填写问题整改表`, title: `${dayjs().format('YYYY-MM-DD')} 填写问题整改表`,
classify: '流程待办', classify: '流程待办',
contentType: '流程待办', contentType: '流程待办',
receiveUserId: fillerUser._id, receiveUserId: fillerProfile._id,
receiveUserName: fillerUser.username, receiveUserName: fillerProfile.username,
receiveDisplayName: fillerUser.display_name, receiveDisplayName: fillerProfile.display_name,
sendUserId: currentUserInfo?._id || currentUserInfo?.id || '', sendUserId: currentUserInfo?._id || currentUserInfo?.id || '',
sendUserName: currentUserInfo?.username || '', sendUserName: currentUserInfo?.username || '',
sendDisplayName: currentUserInfo?.display_name || '', sendDisplayName: currentUserInfo?.display_name || '',
...@@ -621,7 +736,7 @@ export default function ProblemRectificationListPage() { ...@@ -621,7 +736,7 @@ export default function ProblemRectificationListPage() {
}); });
await batchUpdateRecords({ await batchUpdateRecords({
subject: 'problem_rectification', subject: PROBLEM_RECTIFICATION_SUBJECT,
filter: { filter: {
_id: { $in: selectedRecords.map((item) => item.id) }, _id: { $in: selectedRecords.map((item) => item.id) },
}, },
...@@ -654,337 +769,266 @@ export default function ProblemRectificationListPage() { ...@@ -654,337 +769,266 @@ export default function ProblemRectificationListPage() {
}; };
return ( return (
<div className="space-y-6"> <div className="space-y-6 pb-4">
<section className="grid gap-4 md:grid-cols-3"> <section className="grid gap-4 md:grid-cols-3">
<SummaryCard <SummaryCard
label="当前分组问题数" label="当前视图记录数"
value={filteredRecords.length} value={filteredRecords.length}
hint="筛选结果" hint={activeGridView?.title ? `${activeGridView.title} 视图` : '筛选结果'}
tone="rose" tone="rose"
/> />
<SummaryCard <SummaryCard
label="已整改 / 已优化" label="已整改 / 已优化"
value={fixedCount} value={fixedCount}
hint="已完成数量" hint="按当前视图与筛选统计"
tone="emerald" tone="emerald"
/> />
<SummaryCard <SummaryCard
label="待复核项" label="待复核项"
value={pendingAuditCount} value={pendingAuditCount}
hint="待处理记录" hint="审批状态未通过的记录"
tone="amber" tone="amber"
/> />
</section> </section>
<section className="overflow-x-auto"> <section className="rounded-[30px] border border-white/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(250,250,250,0.92))] p-3 shadow-sm ring-1 ring-zinc-950/5">
<div className="inline-flex min-w-full gap-3 rounded-[28px] border border-zinc-200 bg-white/90 p-2 shadow-sm"> <div className="flex flex-wrap gap-2">
{DEPARTMENT_GROUPS.map((group) => ( {gridViews.map((view) => {
const active = view.name === activeGridView?.name;
return (
<button <button
key={group.key} key={view.name}
type="button" type="button"
onClick={() => onClick={() => {
setFilters((current) => ({ ...current, groupKey: group.key })) setActiveViewName(view.name);
} setSelectedIds([]);
}}
className={clsx( className={clsx(
'rounded-[18px] px-4 py-3 text-sm font-medium transition-all duration-200', 'inline-flex min-h-11 items-center gap-2 rounded-full px-4 py-2.5 text-sm font-medium transition',
filters.groupKey === group.key active
? 'bg-[linear-gradient(135deg,#7f1d1d,#be123c)] text-white shadow-lg' ? 'bg-[linear-gradient(135deg,#7f1d1d,#be123c)] text-white shadow-lg shadow-rose-200/60'
: 'text-slate-600 hover:bg-rose-50 hover:text-rose-700', : 'bg-white text-slate-600 ring-1 ring-zinc-200 hover:bg-rose-50 hover:text-rose-700',
)} )}
> >
{group.label} <span>{view.title || view.name}</span>
<span <span
className={clsx( className={clsx(
'ml-2 rounded-full px-2 py-0.5 text-xs', 'rounded-full px-2 py-0.5 text-xs',
filters.groupKey === group.key active ? 'bg-white/20 text-white' : 'bg-zinc-100 text-slate-500',
? 'bg-white/18 text-white'
: 'bg-zinc-100 text-slate-500',
)} )}
> >
{groupCounts[group.key] || 0} {gridViewCounts[view.name] || 0}
</span> </span>
</button> </button>
))} );
})}
</div> </div>
</section> </section>
<section className="rounded-[30px] border border-zinc-200 bg-white/95 p-5 shadow-sm"> <section className="rounded-[30px] border border-zinc-200 bg-white/95 p-5 shadow-sm ring-1 ring-zinc-950/5">
<div className="grid gap-4 xl:grid-cols-[1.4fr_0.8fr_0.8fr_auto]"> <div className="grid gap-4 xl:grid-cols-[1.4fr_0.8fr_0.8fr_auto]">
<div> <div>
<label className="mb-2 block text-sm font-medium text-slate-700"> <label className="mb-2 block text-sm font-medium text-slate-700">关键词搜索</label>
关键词搜索 <Input
</label>
<input
value={filters.keyword} value={filters.keyword}
onChange={(event) => onChange={(event) => setFilters((current) => ({ ...current, keyword: event.target.value }))}
setFilters((current) => ({ placeholder="搜索当前视图中的项目名称、状态、问题简述、责任人..."
...current,
keyword: event.target.value,
}))
}
placeholder="搜索项目名称、问题简述、问题类型、反馈单位..."
className="w-full rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-rose-300 focus:bg-white"
/> />
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-slate-700"> <label className="mb-2 block text-sm font-medium text-slate-700">整改进度</label>
整改进度 <Select
</label>
<select
value={filters.progress} value={filters.progress}
onChange={(event) => onChange={(event) => setFilters((current) => ({ ...current, progress: event.target.value }))}
setFilters((current) => ({
...current,
progress: event.target.value,
}))
}
className="w-full rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-rose-300 focus:bg-white"
> >
<option value="">全部进度</option> <option value="">全部进度</option>
{view.progressOptions.map((option) => ( {configuredView.progressOptions.map((option) => (
<option key={option} value={option}> <option key={option} value={option}>
{option} {option}
</option> </option>
))} ))}
</select> </Select>
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-slate-700"> <label className="mb-2 block text-sm font-medium text-slate-700">审批状态</label>
审批状态 <Select
</label>
<select
value={filters.auditStatus} value={filters.auditStatus}
onChange={(event) => onChange={(event) => setFilters((current) => ({ ...current, auditStatus: event.target.value }))}
setFilters((current) => ({
...current,
auditStatus: event.target.value,
}))
}
className="w-full rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-rose-300 focus:bg-white"
> >
<option value="">全部状态</option> <option value="">全部状态</option>
{view.auditStatusOptions.map((option) => ( {configuredView.auditStatusOptions.map((option) => (
<option key={option} value={option}> <option key={option} value={option}>
{option} {option}
</option> </option>
))} ))}
</select> </Select>
</div> </div>
<div className="flex items-end"> <div className="flex items-end">
<button <Button
type="button" outline
className="w-full justify-center"
onClick={() => { onClick={() => {
setFilters({ setFilters({
groupKey: 'all',
keyword: '', keyword: '',
progress: '', progress: '',
auditStatus: '', auditStatus: '',
}); });
setSelectedIds([]); setSelectedIds([]);
}} }}
className="w-full rounded-2xl border border-zinc-200 bg-white px-4 py-3 text-sm font-medium text-slate-600 transition hover:border-rose-200 hover:text-rose-700"
> >
重置筛选 清空筛选
</button> </Button>
</div> </div>
</div> </div>
</section> </section>
<section className="rounded-[30px] border border-zinc-200 bg-white/95 shadow-sm"> <section className="overflow-hidden rounded-[32px] border border-white/80 bg-white/96 shadow-[0_18px_48px_rgba(15,23,42,0.08)] ring-1 ring-zinc-950/5">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-zinc-100 px-6 py-5"> <div className="flex flex-wrap items-start justify-between gap-4 border-b border-zinc-100 px-6 py-5">
<div> <div>
<div className="text-lg font-semibold text-slate-900">问题清单</div> <div className="text-lg font-semibold text-slate-900">问题清单</div>
<div className="mt-1 text-sm text-slate-500"> <div className="mt-1 text-sm text-slate-500">
当前选中 {selectedIds.length} 条记录。 当前视图显示 {filteredRecords.length} 条记录,已选中 {selectedIds.length} 条。
</div> </div>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<button <Button color="rose" onClick={() => setShowCreateModal(true)}>
type="button"
onClick={() => setShowCreateModal(true)}
className="rounded-full bg-[linear-gradient(135deg,#7f1d1d,#be123c)] px-4 py-2 text-sm font-medium text-white shadow-lg transition hover:brightness-105"
>
新增记录 新增记录
</button> </Button>
<button <Button outline disabled={selectedIds.length === 0 || actionLoading} onClick={() => setShowPushModal(true)}>
type="button"
disabled={selectedIds.length === 0 || actionLoading}
onClick={() => setShowPushModal(true)}
className="rounded-full border border-zinc-200 px-4 py-2 text-sm font-medium text-slate-600 transition hover:border-rose-200 hover:text-rose-700 disabled:cursor-not-allowed disabled:opacity-50"
>
推送整改表 推送整改表
</button> </Button>
<button <Button outline disabled>
type="button"
className="rounded-full border border-zinc-200 px-4 py-2 text-sm font-medium text-slate-600 transition hover:border-rose-200 hover:text-rose-700"
>
导出 导出
</button> </Button>
<button <Button outline disabled>
type="button"
className="rounded-full border border-zinc-200 px-4 py-2 text-sm font-medium text-slate-600 transition hover:border-rose-200 hover:text-rose-700"
>
一键归档 一键归档
</button> </Button>
<button <Button color="red" disabled={selectedIds.length === 0 || actionLoading} onClick={() => handleDelete(selectedRecords)}>
type="button"
disabled={selectedIds.length === 0 || actionLoading}
onClick={() => handleDelete(selectedRecords)}
className="rounded-full border border-rose-200 bg-rose-50 px-4 py-2 text-sm font-medium text-rose-700 transition hover:bg-rose-100 disabled:cursor-not-allowed disabled:opacity-50"
>
删除选中 删除选中
</button> </Button>
</div> </div>
</div> </div>
{error ? ( {error ? (
<div className="border-b border-zinc-100 px-6 py-4 text-sm text-rose-600"> <div className="border-b border-zinc-100 bg-rose-50/70 px-6 py-4 text-sm text-rose-700">
{error} {error}
</div> </div>
) : null} ) : null}
{loading ? ( {loading ? (
<div className="px-6 py-5"> <div className="px-6 py-6">
<EmptyState <EmptyState title="正在加载问题整改列表" description="正在读取对象结构、视图配置和记录数据,请稍候。" />
title="正在加载问题整改列表"
description="请稍候。"
/>
</div> </div>
) : filteredRecords.length === 0 ? ( ) : filteredRecords.length === 0 ? (
<div className="px-6 py-5"> <div className="px-6 py-6">
<EmptyState <EmptyState
title="暂无数据" title="当前视图暂无数据"
description="未查询到记录。" description="可以切换其他视图,或调整关键词、审批状态和整改进度筛选。"
/> />
</div> </div>
) : ( ) : (
<div className="overflow-x-auto"> <div className="px-3 pb-3 pt-2">
<table className="min-w-full border-separate border-spacing-0"> <Table bleed dense className="[--gutter:--spacing(3)]">
<thead> <TableHead>
<tr className="text-left text-sm text-slate-500"> <TableRow className="bg-zinc-50/80">
<th className="border-b border-zinc-100 bg-zinc-50/80 px-4 py-4 font-medium"> <TableHeader
<input className="w-14 sticky left-0 z-40 bg-zinc-50/95 backdrop-blur shadow-[10px_0_20px_rgba(15,23,42,0.06)]"
type="checkbox" style={{ width: SELECTION_COLUMN_WIDTH, minWidth: SELECTION_COLUMN_WIDTH }}
checked={
filteredRecords.length > 0 &&
selectedIds.length === filteredRecords.length
}
onChange={(event) => toggleAll(event.target.checked)}
/>
</th>
{[
'检查项目名称',
'反馈单位',
'整改进度',
'审批状态',
'最终审批',
'预计整改完成时间',
'问题类型',
'填写人 / 复核人',
'问题简述',
'操作',
].map((header) => (
<th
key={header}
className="border-b border-zinc-100 bg-zinc-50/80 px-6 py-4 font-medium"
> >
{header} <div className="flex justify-center">
</th> <Checkbox
))} checked={filteredRecords.length > 0 && selectedIds.length === filteredRecords.length}
</tr> onChange={toggleAll}
</thead> color="rose"
<tbody> aria-label="全选当前视图记录"
{filteredRecords.map((record, index) => ( />
<tr </div>
key={record.id} </TableHeader>
{stickyColumns.orderedColumns.map((column) => {
const stickyMeta = stickyColumns.stickyMeta[column.field];
return (
<TableHeader
key={column.field}
style={{
width: column.width || 180,
minWidth: Math.min(Math.max(column.width || 180, 150), 360),
left: stickyMeta?.side === 'left' ? `${stickyMeta.offset}px` : undefined,
right: stickyMeta?.side === 'right' ? `${stickyMeta.offset}px` : undefined,
}}
className={clsx( className={clsx(
'align-top text-sm text-slate-700', 'text-xs font-semibold uppercase tracking-[0.18em] text-slate-400',
index % 2 === 0 ? 'bg-white' : 'bg-zinc-50/45', getStickyCellClass(stickyMeta, 'header'),
)} )}
> >
<td className="border-b border-zinc-100 px-4 py-5"> {column.label}
<input </TableHeader>
type="checkbox" );
})}
<TableHeader
className="sticky right-0 z-40 bg-zinc-50/95 text-xs font-semibold uppercase tracking-[0.18em] text-slate-400 shadow-[-10px_0_20px_rgba(15,23,42,0.06)] backdrop-blur"
style={{ width: ACTION_COLUMN_WIDTH, minWidth: ACTION_COLUMN_WIDTH }}
>
操作
</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{filteredRecords.map((record) => (
<TableRow key={record.id} className="align-top">
<TableCell
className="align-top sticky left-0 z-30 bg-white shadow-[10px_0_20px_rgba(15,23,42,0.05)]"
style={{ width: SELECTION_COLUMN_WIDTH, minWidth: SELECTION_COLUMN_WIDTH }}
>
<div className="flex justify-center pt-1">
<Checkbox
checked={selectedIds.includes(record.id)} checked={selectedIds.includes(record.id)}
onChange={(event) => onChange={(checked) => toggleRow(record.id, checked)}
toggleRow(record.id, event.target.checked) color="rose"
} aria-label={`选择 ${record.title || record.projectName || '当前记录'}`}
/>
</td>
<td className="max-w-[220px] border-b border-zinc-100 px-6 py-5">
<div className="font-semibold text-slate-900">
{record.projectName}
</div>
<div className="mt-2 text-xs leading-5 text-slate-500">
{record.inspectUnit || '未填写实施单位'}
</div>
</td>
<td className="max-w-[180px] border-b border-zinc-100 px-6 py-5">
{record.feedbackUnit || '-'}
</td>
<td className="border-b border-zinc-100 px-6 py-5">
<StatusPill
text={record.progress || '未填写'}
tone={resolveProgressTone(record.progress)}
/>
</td>
<td className="border-b border-zinc-100 px-6 py-5">
<StatusPill
text={record.auditStatus || '未填写'}
tone={resolveAuditTone(record.auditStatus)}
/>
</td>
<td className="border-b border-zinc-100 px-6 py-5">
<StatusPill
text={record.finalAuditStatus || '未填写'}
tone={resolveAuditTone(record.finalAuditStatus)}
/> />
</td>
<td className="border-b border-zinc-100 px-6 py-5">
{record.dueDateLabel}
</td>
<td className="max-w-[180px] border-b border-zinc-100 px-6 py-5">
<div>{record.problemType || '-'}</div>
<div className="mt-2 text-xs text-slate-500">
{record.problemFrom || '未填写来源'}
</div> </div>
</td> </TableCell>
<td className="max-w-[220px] border-b border-zinc-100 px-6 py-5">
<div className="text-xs uppercase tracking-[0.18em] text-slate-400"> {stickyColumns.orderedColumns.map((column) => {
填写人 const stickyMeta = stickyColumns.stickyMeta[column.field];
</div> return (
<div className="mt-1">{formatUsers(record.fillUsers)}</div> <TableCell
<div className="mt-3 text-xs uppercase tracking-[0.18em] text-slate-400"> key={column.field}
复核人 style={{
</div> width: column.width || 180,
<div className="mt-1">{formatUsers(record.reviewUsers)}</div> minWidth: Math.min(Math.max(column.width || 180, 150), 360),
</td> left: stickyMeta?.side === 'left' ? `${stickyMeta.offset}px` : undefined,
<td className="max-w-[340px] border-b border-zinc-100 px-6 py-5"> right: stickyMeta?.side === 'right' ? `${stickyMeta.offset}px` : undefined,
<div className="line-clamp-2 text-slate-700"> }}
{record.brief || '暂无问题简述'} className={clsx('align-top', getStickyCellClass(stickyMeta))}
</div>
<div className="mt-2 text-xs leading-5 text-slate-500">
{record.relatedIssueDomain || '未填写业务领域'}
</div>
</td>
<td className="border-b border-zinc-100 px-6 py-5">
<div className="flex flex-col gap-2">
<button
type="button"
onClick={() => handleDelete([record])}
className="rounded-full border border-rose-200 px-3 py-1 text-xs font-medium text-rose-700 transition hover:bg-rose-50"
> >
<FieldValuePreview column={column} record={record} />
</TableCell>
);
})}
<TableCell
className="align-top sticky right-0 z-30 bg-white shadow-[-10px_0_20px_rgba(15,23,42,0.05)]"
style={{ width: ACTION_COLUMN_WIDTH, minWidth: ACTION_COLUMN_WIDTH }}
>
<div className="flex min-w-28 flex-col gap-2">
<Button plain onClick={() => handleOpenDrawer(record)}>
查看详情
</Button>
<Button outline color="red" onClick={() => handleDelete([record])}>
删除 删除
</button> </Button>
</div> </div>
</td> </TableCell>
</tr> </TableRow>
))} ))}
</tbody> </TableBody>
</table> </Table>
</div> </div>
)} )}
</section> </section>
...@@ -992,29 +1036,30 @@ export default function ProblemRectificationListPage() { ...@@ -992,29 +1036,30 @@ export default function ProblemRectificationListPage() {
<Dialog open={showCreateModal} onClose={handleCloseCreateModal} size="5xl"> <Dialog open={showCreateModal} onClose={handleCloseCreateModal} size="5xl">
<DialogTitle>新增问题整改记录</DialogTitle> <DialogTitle>新增问题整改记录</DialogTitle>
<DialogDescription> <DialogDescription>
表单字段已按 <code className="rounded bg-zinc-100 px-1.5 py-0.5 text-xs">problem_rectification</code>{' '} 当前新增表单按视图 <code className="rounded bg-zinc-100 px-1.5 py-0.5 text-xs">{activeGridView?.title || activeGridView?.name || '默认视图'}</code>{' '}
对象定义渲染,`select` 选项直接读取对象配置,日期字段会在提交时自动转成 ISO 8601。 的字段顺序渲染,`select` 选项来自 Subject 配置,日期提交时会自动转为 ISO 8601。
</DialogDescription> </DialogDescription>
<DialogBody> <DialogBody>
<div className="rounded-[24px] border border-zinc-100 bg-zinc-50/50 px-4 py-3 text-sm text-slate-500"> <div className="rounded-[24px] border border-zinc-100 bg-zinc-50/60 px-4 py-3 text-sm text-slate-500">
至少填写“检查项目名称”或“问题简述”之一。长文本字段已展开为更适合录入的输入区域。 至少填写“检查项目名称”或“问题简述”之一。当前只展示本视图所关心的字段,减少一次性录入负担。
</div> </div>
<div className="mt-6 grid gap-5 md:grid-cols-2"> <div className="mt-6 grid gap-5 md:grid-cols-2">
{createFields.map((fieldDef) => { {createFields.map((field) => {
const isLongText = fieldDef.type === 'long_text'; const fieldDef = field.fieldDef;
const isSingleColumn = !isLongText && fieldDef.name !== 'responsible_department'; const isLongText = fieldDef?.type === 'long_text';
const fullWidth = isLongText || fieldDef?.multiple;
return ( return (
<div <div
key={fieldDef.name} key={field.field}
className={clsx( className={clsx(
'rounded-[24px] border border-zinc-200 bg-white/90 p-4 shadow-sm', 'rounded-[24px] border border-zinc-200 bg-white/92 p-4 shadow-sm',
isSingleColumn ? '' : 'md:col-span-2', fullWidth && 'md:col-span-2',
)} )}
> >
<label className="mb-2 block text-sm font-medium text-slate-700"> <label className="mb-2 block text-sm font-medium text-slate-700">
{fieldDef.label || fieldDef.name} {field.label}
</label> </label>
<CreateField <CreateField
fieldDef={fieldDef} fieldDef={fieldDef}
...@@ -1027,9 +1072,7 @@ export default function ProblemRectificationListPage() { ...@@ -1027,9 +1072,7 @@ export default function ProblemRectificationListPage() {
} }
/> />
{FIELD_HINTS[fieldDef.name] ? ( {FIELD_HINTS[fieldDef.name] ? (
<div className="mt-2 text-xs leading-5 text-slate-500"> <div className="mt-2 text-xs leading-5 text-slate-500">{FIELD_HINTS[fieldDef.name]}</div>
{FIELD_HINTS[fieldDef.name]}
</div>
) : null} ) : null}
</div> </div>
); );
...@@ -1040,7 +1083,7 @@ export default function ProblemRectificationListPage() { ...@@ -1040,7 +1083,7 @@ export default function ProblemRectificationListPage() {
<Button outline onClick={handleCloseCreateModal}> <Button outline onClick={handleCloseCreateModal}>
取消 取消
</Button> </Button>
<Button color="blue" disabled={actionLoading || !createSubject} onClick={handleCreate}> <Button color="blue" disabled={actionLoading || !subject} onClick={handleCreate}>
{actionLoading ? '提交中...' : '确认新增'} {actionLoading ? '提交中...' : '确认新增'}
</Button> </Button>
</DialogActions> </DialogActions>
...@@ -1058,7 +1101,8 @@ export default function ProblemRectificationListPage() { ...@@ -1058,7 +1101,8 @@ export default function ProblemRectificationListPage() {
> >
<DialogTitle>推送问题整改表</DialogTitle> <DialogTitle>推送问题整改表</DialogTitle>
<DialogDescription> <DialogDescription>
已选择 {selectedRecords.length} 条记录。请通过远程搜索选择填报人和复核人。 已从视图 <code className="rounded bg-zinc-100 px-1.5 py-0.5 text-xs">{activeGridView?.title || activeGridView?.name || '-'}</code>{' '}
中选择 {selectedRecords.length} 条记录,请为本次流程配置填报人和复核人。
</DialogDescription> </DialogDescription>
<DialogBody> <DialogBody>
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
...@@ -1077,6 +1121,7 @@ export default function ProblemRectificationListPage() { ...@@ -1077,6 +1121,7 @@ export default function ProblemRectificationListPage() {
placeholder="搜索复核人用户名或显示名" placeholder="搜索复核人用户名或显示名"
/> />
</div> </div>
<div className="mt-6 rounded-[24px] border border-zinc-200 bg-zinc-50/80 p-4"> <div className="mt-6 rounded-[24px] border border-zinc-200 bg-zinc-50/80 p-4">
<div className="text-sm font-medium text-slate-900">本次推送记录</div> <div className="text-sm font-medium text-slate-900">本次推送记录</div>
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
...@@ -1085,7 +1130,7 @@ export default function ProblemRectificationListPage() { ...@@ -1085,7 +1130,7 @@ export default function ProblemRectificationListPage() {
key={record.id} key={record.id}
className="rounded-full bg-white px-3 py-1 text-xs font-medium text-slate-700 shadow-sm ring-1 ring-zinc-200" className="rounded-full bg-white px-3 py-1 text-xs font-medium text-slate-700 shadow-sm ring-1 ring-zinc-200"
> >
{record.projectName} {record.title || record.projectName || record.name}
</span> </span>
))} ))}
</div> </div>
...@@ -1108,6 +1153,16 @@ export default function ProblemRectificationListPage() { ...@@ -1108,6 +1153,16 @@ export default function ProblemRectificationListPage() {
</DialogActions> </DialogActions>
</Dialog> </Dialog>
) : null} ) : null}
{drawerRecordName ? (
<RecordDetailDrawer
open={Boolean(drawerRecordName)}
recordName={drawerRecordName}
setCustomComponentClose={handleCloseDrawer}
onDataChange={() => setDrawerDirty(true)}
preferredViewName={activeGridView?.title || activeGridView?.name}
/>
) : null}
</div> </div>
); );
} }
...@@ -51,6 +51,19 @@ ...@@ -51,6 +51,19 @@
- 新增、编辑、查看详情优先采用抽屉或弹窗,不走重型嵌套表格交互 - 新增、编辑、查看详情优先采用抽屉或弹窗,不走重型嵌套表格交互
- 批量操作放在顶部工具栏,不再依赖右键菜单作为唯一入口 - 批量操作放在顶部工具栏,不再依赖右键菜单作为唯一入口
### 3.3 视图配置驱动约定
问题整改主列表与详情抽屉统一以 `getSubjectView(subjectName)` 返回的视图配置驱动,不再在页面内写死部门标签和字段顺序:
- `type = "grid"` 的视图用于主列表顶部视图切换,替代原先写死的 tab
- `settings.columns` 决定列表列顺序、宽度和排序优先级
- `settings.restrictions` 与 `settings.filters.conditions` 共同决定该视图下可见的数据范围
- `settings.viewAuth` 决定当前用户是否能看到该视图
- `type = "detail"` 的视图用于详情抽屉字段编排,按 `columns` 顺序渲染字段
- `settings.auth` 与 `disableColumns` 用于控制详情抽屉内字段是否允许编辑
因此前端新增字段展示、部门视图调整、列顺序变化时,应优先改 Subject View 配置,而不是直接修改页面常量。
### 3.1 目标页面拆分 ### 3.1 目标页面拆分
为便于当前项目直接重构,建议把模块拆成以下 4 个页面入口: 为便于当前项目直接重构,建议把模块拆成以下 4 个页面入口:
...@@ -179,6 +192,13 @@ ...@@ -179,6 +192,13 @@
- 表格内分组拖拽 - 表格内分组拖拽
- 全屏容器能力 - 全屏容器能力
替代实现约定:
- 列表页保留表格浏览、搜索、筛选、批量选择
- 字段编辑统一进入右侧详情抽屉
- 长文本、附件、状态类字段在详情抽屉中直接维护
- 详情抽屉内字段顺序与显示权限跟随 `detail view` 配置
--- ---
## 6. 流程角色 ## 6. 流程角色
...@@ -624,6 +644,7 @@ const result = await queryUsers(0, 20, { ...@@ -624,6 +644,7 @@ const result = await queryUsers(0, 20, {
布局层负责: 布局层负责:
- 左侧菜单 - 左侧菜单
- 桌面展开态仅展示模块名称,去掉大 Logo;仅在桌面折叠态展示小 Logo
- 顶部用户区 - 顶部用户区
- 内容容器 - 内容容器
- 移动端侧栏开关 - 移动端侧栏开关
......
...@@ -7,6 +7,7 @@ import { ...@@ -7,6 +7,7 @@ import {
import { DEPARTMENT_GROUPS } from './problemRectificationDashboardService'; import { DEPARTMENT_GROUPS } from './problemRectificationDashboardService';
import { import {
buildViewColumns, buildViewColumns,
checkViewCondition,
emptyValueForField, emptyValueForField,
formatFieldValue, formatFieldValue,
getFieldNameFromPath, getFieldNameFromPath,
...@@ -162,6 +163,18 @@ export function buildProblemRectificationConfiguredListView(records, filters, gr ...@@ -162,6 +163,18 @@ export function buildProblemRectificationConfiguredListView(records, filters, gr
return false; return false;
} }
if (
!checkViewCondition(
{
relationship: gridView?.settings?.filters?.relationship || 'all',
rules: gridView?.settings?.filters?.conditions || [],
},
rawRecord,
)
) {
return false;
}
if (nextProgress && record.progress !== nextProgress) { if (nextProgress && record.progress !== nextProgress) {
return false; return false;
} }
...@@ -200,6 +213,18 @@ export function buildProblemRectificationConfiguredListView(records, filters, gr ...@@ -200,6 +213,18 @@ export function buildProblemRectificationConfiguredListView(records, filters, gr
}; };
} }
export function buildGridViewCountMap(records, filters, views, subject) {
return views.reduce((counts, view) => {
counts[view.name] = buildProblemRectificationConfiguredListView(
records,
filters,
view,
subject,
).filteredRecords.length;
return counts;
}, {});
}
function matchesDepartmentGroup(record, groupKey) { function matchesDepartmentGroup(record, groupKey) {
if (!groupKey || groupKey === 'all') { if (!groupKey || groupKey === 'all') {
return true; return true;
......
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