Commit 5a3fb30d authored by David Yang's avatar David Yang

feat: 应用切换

parent 48eaa28c
......@@ -20,6 +20,8 @@ export default defineConfig({
react: resolvePackageRoot('react'),
'react-dom': resolvePackageRoot('react-dom'),
},
base: '/model_analysis/',
publicPath: '/model_analysis/',
routes: [
{
path: ROUTE_PATHS.login,
......
{
"name": "model_analysis",
"version": "1.0.1",
"build": "abc",
"description": "merge master",
"date": "2025-07-14 19:27:20",
"author": "yang"
}
......@@ -100,7 +100,7 @@ export function Combobox({
// Anchor positioning
'[--anchor-gap:--spacing(2)] [--anchor-padding:--spacing(4)] sm:data-[anchor~=start]:[--anchor-offset:-4px]',
// Base styles,
'isolate min-w-[calc(var(--input-width)+8px)] scroll-py-1 rounded-xl p-1 select-none empty:invisible',
'isolate z-[160] min-w-[calc(var(--input-width)+8px)] scroll-py-1 rounded-xl p-1 select-none empty:invisible',
// Invisible border that is only visible in `forced-colors` mode for accessibility purposes
'outline outline-transparent focus:outline-hidden',
// Handle scrolling when menu won't fit in viewport
......
import { UserContext } from '@/wrapper/Auth';
import {
ChartCard,
HeroBanner,
MetricCard,
NoticeBanner,
Panel,
SectionHeader,
Tag,
} from './AnalysisShared';
import {
ClockIcon,
ShareIcon,
} from '@heroicons/react/24/outline';
import {
buildOverviewCharts,
buildMetricCards,
......@@ -19,6 +19,58 @@ import {
} from './analysisService';
import { useContext, useEffect, useMemo, useState } from 'react';
function OverviewStatusItem({ icon: Icon, label, value, hint }) {
return (
<div className="flex min-w-0 items-start gap-3 rounded-lg border border-zinc-200 bg-white px-4 py-3 shadow-sm">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-rose-50 text-rose-600">
<Icon className="size-[18px]" />
</div>
<div className="min-w-0">
<div className="text-xs font-medium text-zinc-500">{label}</div>
<div className="mt-1 truncate text-lg font-semibold leading-6 text-zinc-950">
{value}
</div>
<div className="mt-0.5 truncate text-xs text-zinc-500">{hint}</div>
</div>
</div>
);
}
function OverviewHeader({ welcomeName, lastUpdatedAt, sharedViewCount }) {
return (
<section className="overflow-hidden rounded-lg border border-zinc-200/80 bg-white shadow-sm">
<div className="grid gap-5 bg-[linear-gradient(135deg,rgba(255,241,242,0.78),rgba(255,255,255,0.96)_42%,rgba(248,250,252,0.98))] p-5 lg:grid-cols-[minmax(0,1fr)_minmax(360px,480px)] lg:items-center lg:p-6">
<div className="min-w-0">
<div className="inline-flex rounded-full border border-rose-200 bg-white/80 px-3 py-1 text-[11px] font-semibold tracking-[0.16em] text-rose-700">
ANALYSIS OVERVIEW
</div>
<h1 className="mt-4 max-w-4xl text-2xl font-semibold leading-tight text-zinc-950 sm:text-3xl">
{welcomeName},这里是新分析工具的统一驾驶舱
</h1>
<p className="mt-3 max-w-4xl text-sm leading-6 text-zinc-600">
聚合视图配置、转发审批和图表看板三条主链路,先判断配置成熟度,再进入具体分析任务。
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2">
<OverviewStatusItem
icon={ClockIcon}
label="最近更新时间"
value={lastUpdatedAt}
hint="配置或审批变更"
/>
<OverviewStatusItem
icon={ShareIcon}
label="共享视图数"
value={sharedViewCount}
hint="团队开放配置"
/>
</div>
</div>
</section>
);
}
export default function AnalysisOverview() {
const userInfo = useContext(UserContext);
const [subjects, setSubjects] = useState([]);
......@@ -76,6 +128,20 @@ export default function AnalysisOverview() {
);
const welcomeName =
userInfo?.display_name || userInfo?.displayName || userInfo?.username || '业务同事';
const lastUpdatedAt = useMemo(
() =>
formatDateTime(
[...views, ...forwards, ...boards]
.map((item) => item.updatedAt || item.createdAt)
.sort()
.pop(),
),
[boards, forwards, views],
);
const sharedViewCount = useMemo(
() => views.filter((item) => item.scope === 'shared').length,
[views],
);
const viewChartOption = useMemo(
() => ({
......@@ -159,41 +225,10 @@ export default function AnalysisOverview() {
return (
<div className="space-y-6">
<HeroBanner
eyebrow="ANALYSIS OVERVIEW"
title={`${welcomeName},这里是新分析工具的统一驾驶舱`}
description="总览页聚合对象覆盖、视图配置、转发审批和图表看板四条主链路,方便业务先看配置成熟度,再进入视图或审批页处理具体分析任务。"
aside={
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-[24px] border border-white/70 bg-white/80 p-4 shadow-sm">
<div className="text-xs font-semibold tracking-[0.18em] text-zinc-500">
最近更新时间
</div>
<div className="mt-2 text-2xl font-semibold text-zinc-950">
{formatDateTime(
[...views, ...forwards, ...boards]
.map((item) => item.updatedAt || item.createdAt)
.sort()
.pop(),
)}
</div>
<div className="mt-1 text-sm text-zinc-500">
最新配置或审批变更时间
</div>
</div>
<div className="rounded-[24px] border border-white/70 bg-white/80 p-4 shadow-sm">
<div className="text-xs font-semibold tracking-[0.18em] text-zinc-500">
共享视图数
</div>
<div className="mt-2 text-2xl font-semibold text-zinc-950">
{views.filter((item) => item.scope === 'shared').length}
</div>
<div className="mt-1 text-sm text-zinc-500">
已对团队开放的分析配置
</div>
</div>
</div>
}
<OverviewHeader
welcomeName={welcomeName}
lastUpdatedAt={lastUpdatedAt}
sharedViewCount={sharedViewCount}
/>
{loading ? <NoticeBanner>正在加载分析总览...</NoticeBanner> : null}
......@@ -230,83 +265,6 @@ export default function AnalysisOverview() {
emptyText="还没有图表看板"
/>
</div>
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<Panel className="p-6">
<SectionHeader
title="对象覆盖"
description="后端对象定义是整个分析工具的底座,新增对象后可以直接在视图页配置。"
/>
<div className="grid gap-3">
{subjects.length > 0 ? (
subjects.map((subject) => (
<div
key={subject.name}
className="rounded-[22px] border border-zinc-200 bg-zinc-50/80 px-4 py-4"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="text-base font-semibold text-zinc-950">
{subject.title || subject.name}
</div>
<div className="mt-1 text-sm text-zinc-500">
{subject.description || '当前对象已可用于配置分析视图。'}
</div>
</div>
<Tag tone="sky">{subject.fields.length} 个字段</Tag>
</div>
</div>
))
) : (
<div className="text-sm text-zinc-400">暂无可用对象定义</div>
)}
</div>
</Panel>
<Panel className="p-6">
<SectionHeader
title="最近活动"
description="把最近新增或更新的视图、审批和看板放在一起,方便判断业务关注点。"
/>
<div className="space-y-3">
{[...views, ...forwards, ...boards]
.sort((left, right) =>
String(right.updatedAt || right.createdAt).localeCompare(
String(left.updatedAt || left.createdAt),
),
)
.slice(0, 6)
.map((item) => (
<div
key={`${item.id}_${item.updatedAt || item.createdAt}`}
className="rounded-[22px] border border-zinc-200 bg-white px-4 py-4"
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-950">
{item.title}
</div>
<div className="mt-1 text-xs text-zinc-500">
更新于 {formatDateTime(item.updatedAt || item.createdAt)}
</div>
</div>
{'approvalStatus' in item ? (
<Tag tone="amber">审批记录</Tag>
) : 'widgets' in item ? (
<Tag tone="emerald">图表看板</Tag>
) : (
<Tag tone="sky">分析视图</Tag>
)}
</div>
</div>
))}
{views.length + forwards.length + boards.length === 0 ? (
<div className="text-sm text-zinc-400">当前还没有配置活动</div>
) : null}
</div>
</Panel>
</div>
</div>
);
}
......@@ -38,6 +38,8 @@ import {
formatDateTime,
formatFieldValue,
getApprovalStatusLabel,
getForwardRowStatusLabel,
getForwardRowStatusTone,
getFieldInputKind,
getViewScopeLabel,
loadAnalysisSubjectDefinition,
......@@ -153,6 +155,10 @@ export function ApprovalStatusTag({ status }) {
return <Tag tone={tone}>{getApprovalStatusLabel(status)}</Tag>;
}
export function WorkflowStatusTag({ status }) {
return <Tag tone={getForwardRowStatusTone(status)}>{getForwardRowStatusLabel(status)}</Tag>;
}
export function EmptyCollection({ title, description }) {
return (
<div className="rounded-[24px] border border-dashed border-zinc-200 bg-zinc-50 px-5 py-10 text-center">
......@@ -258,6 +264,7 @@ export function UserSelectField({
required = false,
placeholder = '搜索用户',
}) {
const [query, setQuery] = useState('');
const [options, setOptions] = useState([]);
const [loading, setLoading] = useState(false);
......@@ -266,17 +273,22 @@ export function UserSelectField({
(async () => {
setLoading(true);
const result = await searchUsers('');
try {
const result = await searchUsers(query);
if (!cancelled) {
setOptions(result);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, []);
}, [query]);
return (
<div>
......@@ -291,6 +303,7 @@ export function UserSelectField({
.toLowerCase()
.includes(String(query || '').toLowerCase())
}
onQueryChange={setQuery}
placeholder={placeholder}
aria-label={label}
>
......@@ -645,6 +658,9 @@ export function RecordsResultTable({
records,
columns,
selectedIds,
rowStatusByRecordId = {},
forwardItemByRecordId = new Map(),
onOpenForwardItem,
onToggleRow,
onToggleAll,
}) {
......@@ -663,6 +679,7 @@ export function RecordsResultTable({
/>
</TableHeader>
<TableHeader>标题</TableHeader>
<TableHeader>当前状态</TableHeader>
{columns.map((column) => (
<TableHeader key={column.alias}>{column.label}</TableHeader>
))}
......@@ -670,7 +687,11 @@ export function RecordsResultTable({
</TableRow>
</TableHead>
<TableBody>
{records.map((record) => (
{records.map((record) => {
const recordKey = record.recordName || record.id;
const forwardItem = forwardItemByRecordId.get(String(recordKey));
return (
<TableRow key={record.id}>
<TableCell>
<Checkbox
......@@ -681,7 +702,17 @@ export function RecordsResultTable({
/>
</TableCell>
<TableCell className="font-medium text-zinc-950">
<button
type="button"
onClick={() => onOpenForwardItem?.(record, forwardItem || null)}
className="max-w-72 truncate text-left font-medium text-sky-700 underline-offset-4 hover:text-sky-900 hover:underline"
aria-label={`查看 ${record.title || record.id} 的回填详情`}
>
{record.title || '-'}
</button>
</TableCell>
<TableCell>
<WorkflowStatusTag status={rowStatusByRecordId[recordKey] || 'pending_send'} />
</TableCell>
{columns.map((column) => (
<TableCell key={column.alias} className="text-zinc-600">
......@@ -692,7 +723,8 @@ export function RecordsResultTable({
{formatDateTime(record.updatedAt)}
</TableCell>
</TableRow>
))}
);
})}
</TableBody>
</Table>
);
......@@ -705,4 +737,3 @@ export function ForwardStatusOptionsHint() {
</div>
);
}
......@@ -24,9 +24,23 @@ import {
ArrowsPointingInIcon,
ArrowsPointingOutIcon,
ChartBarIcon,
ChevronDownIcon,
ChevronRightIcon,
FolderIcon,
FolderPlusIcon,
PencilSquareIcon,
PlusIcon,
TagIcon,
TrashIcon,
} from '@heroicons/react/24/outline';
import {
startTransition,
useContext,
useDeferredValue,
useEffect,
useMemo,
useState,
} from 'react';
import {
ChartCard,
DynamicFieldInput,
......@@ -36,6 +50,7 @@ import {
NoticeBanner,
Panel,
RecordsResultTable,
SearchInput,
SectionHeader,
SelectionToolbar,
Tag,
......@@ -45,18 +60,34 @@ import {
import {
buildChartOption,
buildDefaultViewDraft,
buildForwardItemIndex,
buildForwardStatusIndex,
buildGroupTree,
CHART_TYPE_OPTIONS,
createEmptyForwardDraft,
deleteAnalysisGroup,
deleteAnalysisView,
ensureForwardRecordItems,
formatDateTime,
getFieldInputKind,
getForwardRowStatusLabel,
getTruthStatusLabel,
loadAnalysisGroups,
loadAnalysisRecordsByIds,
loadAnalysisSubjectDefinition,
loadAnalysisViews,
loadForwardItems,
loadForwardRecords,
loadViewRecordCounts,
queryAnalysisRecords,
resolveForwardRowStatus,
saveAnalysisGroup,
saveAnalysisView,
saveForwardRecord,
deleteAnalysisView,
searchAnalysisSubjects,
} from './analysisService';
import { useContext, useEffect, useMemo, useState } from 'react';
const ROOT_GROUP_ID = '__all__';
function buildEmptyViewForm() {
return {
......@@ -69,6 +100,18 @@ function buildEmptyViewForm() {
defaultFilters: {},
columns: [],
charts: [],
groupId: '',
groupName: '',
tags: [],
hiddenRecordIds: [],
};
}
function buildEmptyGroupForm() {
return {
recordName: '',
groupName: '',
parentGroupId: '',
};
}
......@@ -104,6 +147,37 @@ function mergeSubjectsByName(subjects = []) {
return Array.from(subjectMap.values());
}
function flattenGroupOptions(groups = [], depth = 0) {
return groups.flatMap((group) => [
{
id: group.id,
label: `${' '.repeat(depth)}${group.groupName || group.title}`,
},
...flattenGroupOptions(group.children || [], depth + 1),
]);
}
function collectDescendantIds(groups = [], targetId) {
if (!targetId) {
return new Set();
}
const target = groups.find((group) => group.id === targetId);
if (!target) {
return new Set();
}
const descendants = new Set([target.id]);
groups.forEach((group) => {
if (group.parentGroupId === target.id) {
collectDescendantIds(groups, group.id).forEach((id) => descendants.add(id));
}
});
return descendants;
}
function SubjectSearchCombobox({
subjects,
value,
......@@ -138,11 +212,143 @@ function SubjectSearchCombobox({
);
}
function GroupTreeItem({
group,
depth = 0,
activeGroupId,
expandedGroupIds,
onToggle,
onSelect,
}) {
const hasChildren = (group.children || []).length > 0;
const expanded = expandedGroupIds.includes(group.id);
return (
<div>
<div
className={`flex items-center gap-1 rounded-2xl px-2 py-1.5 transition ${
activeGroupId === group.id ? 'bg-rose-50' : 'hover:bg-zinc-50'
}`}
style={{ paddingLeft: `${depth * 14 + 8}px` }}
>
<button
type="button"
onClick={() => (hasChildren ? onToggle(group.id) : onSelect(group.id))}
aria-label={hasChildren ? `${expanded ? '收起' : '展开'} ${group.groupName}` : group.groupName}
className="inline-flex h-8 w-8 items-center justify-center rounded-xl text-zinc-400 transition hover:bg-white hover:text-zinc-700"
>
{hasChildren ? (
expanded ? (
<ChevronDownIcon className="size-4" />
) : (
<ChevronRightIcon className="size-4" />
)
) : (
<FolderIcon className="size-4" />
)}
</button>
<button
type="button"
onClick={() => onSelect(group.id)}
className={`min-w-0 flex-1 rounded-xl px-2 py-1.5 text-left text-sm font-medium transition ${
activeGroupId === group.id
? 'bg-white text-rose-700 shadow-sm'
: 'text-zinc-700 hover:text-zinc-950'
}`}
>
<span className="truncate">{group.groupName}</span>
</button>
</div>
{hasChildren && expanded ? (
<div className="mt-1 space-y-1">
{group.children.map((child) => (
<GroupTreeItem
key={child.id}
group={child}
depth={depth + 1}
activeGroupId={activeGroupId}
expandedGroupIds={expandedGroupIds}
onToggle={onToggle}
onSelect={onSelect}
/>
))}
</div>
) : null}
</div>
);
}
function TagEditor({ values, onChange, placeholder = '输入标签后回车' }) {
const [draft, setDraft] = useState('');
function addTag(nextTag) {
const normalized = String(nextTag || '').trim();
if (!normalized) {
return;
}
if (values.includes(normalized)) {
setDraft('');
return;
}
onChange([...values, normalized]);
setDraft('');
}
return (
<div>
<div className="flex gap-3">
<Input
value={draft}
placeholder={placeholder}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ',' || event.key === ',') {
event.preventDefault();
addTag(draft);
}
}}
/>
<Button outline onClick={() => addTag(draft)}>
<TagIcon className="size-4" />
添加
</Button>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{values.length > 0 ? (
values.map((tag) => (
<Tag key={tag} tone="rose">
<span>{tag}</span>
<button
type="button"
className="ml-2"
onClick={() => onChange(values.filter((item) => item !== tag))}
aria-label={`移除 ${tag}`}
>
x
</button>
</Tag>
))
) : (
<span className="text-sm text-zinc-400">暂未添加标签</span>
)}
</div>
</div>
);
}
export default function AnalysisViews() {
const userInfo = useContext(UserContext);
const [subjects, setSubjects] = useState([]);
const [groups, setGroups] = useState([]);
const [views, setViews] = useState([]);
const [viewCounts, setViewCounts] = useState({});
const [viewKeyword, setViewKeyword] = useState('');
const deferredViewKeyword = useDeferredValue(viewKeyword);
const [activeTagFilters, setActiveTagFilters] = useState([]);
const [activeGroupId, setActiveGroupId] = useState(ROOT_GROUP_ID);
const [expandedGroupIds, setExpandedGroupIds] = useState([]);
const [selectedSubjectName, setSelectedSubjectName] = useState('');
const [subjectKeyword, setSubjectKeyword] = useState('');
const [subjectSearching, setSubjectSearching] = useState(false);
......@@ -153,17 +359,29 @@ export default function AnalysisViews() {
const [filterValues, setFilterValues] = useState({});
const [records, setRecords] = useState([]);
const [total, setTotal] = useState(0);
const [currentViewForwards, setCurrentViewForwards] = useState([]);
const [currentViewForwardItems, setCurrentViewForwardItems] = useState([]);
const [selectedRecordIds, setSelectedRecordIds] = useState([]);
const [loading, setLoading] = useState(true);
const [querying, setQuerying] = useState(false);
const [countingViews, setCountingViews] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [isViewDialogOpen, setIsViewDialogOpen] = useState(false);
const [isGroupDialogOpen, setIsGroupDialogOpen] = useState(false);
const [isDeleteGroupDialogOpen, setIsDeleteGroupDialogOpen] = useState(false);
const [isForwardDialogOpen, setIsForwardDialogOpen] = useState(false);
const [isHiddenRecordsDialogOpen, setIsHiddenRecordsDialogOpen] = useState(false);
const [isResultFullscreen, setIsResultFullscreen] = useState(false);
const [viewForm, setViewForm] = useState(buildEmptyViewForm);
const [groupForm, setGroupForm] = useState(buildEmptyGroupForm);
const [groupPendingDelete, setGroupPendingDelete] = useState(null);
const [forwardForm, setForwardForm] = useState(createEmptyForwardDraft);
const [hiddenRecordsLoading, setHiddenRecordsLoading] = useState(false);
const [hiddenRecords, setHiddenRecords] = useState([]);
const [selectedHiddenRecordIds, setSelectedHiddenRecordIds] = useState([]);
const [forwardItemDetail, setForwardItemDetail] = useState(null);
useEffect(() => {
let cancelled = false;
......@@ -171,8 +389,9 @@ export default function AnalysisViews() {
(async () => {
setLoading(true);
try {
const [subjectList, viewList] = await Promise.all([
const [subjectList, groupList, viewList] = await Promise.all([
searchAnalysisSubjects('', 10),
loadAnalysisGroups(),
loadAnalysisViews(),
]);
......@@ -201,9 +420,11 @@ export default function AnalysisViews() {
}
setSubjects(nextSubjects);
setGroups(groupList);
setViews(viewList);
setExpandedGroupIds(groupList.map((group) => group.id));
setSelectedSubjectName(preferredSubject);
setActiveViewId(viewList.find((item) => item.subjectName === preferredSubject)?.id || viewList[0]?.id || '');
setActiveViewId(viewList[0]?.id || '');
setError('');
} catch (loadError) {
if (!cancelled) {
......@@ -306,38 +527,102 @@ export default function AnalysisViews() {
};
}, [isViewDialogOpen, viewForm.subjectName]);
const filteredViews = useMemo(() => {
const keyword = String(viewKeyword || '').trim().toLowerCase();
useEffect(() => {
let cancelled = false;
if (views.length === 0) {
setViewCounts({});
return undefined;
}
(async () => {
setCountingViews(true);
try {
const result = await loadViewRecordCounts(views);
if (!cancelled) {
setViewCounts(result);
}
} finally {
if (!cancelled) {
setCountingViews(false);
}
}
})();
return () => {
cancelled = true;
};
}, [views]);
if (!keyword) {
const groupTree = useMemo(() => buildGroupTree(groups), [groups]);
const flatGroupOptions = useMemo(() => flattenGroupOptions(groupTree), [groupTree]);
const selectedGroup = useMemo(
() => groups.find((group) => group.id === activeGroupId) || null,
[activeGroupId, groups],
);
const visibleGroupIds = useMemo(() => {
if (activeGroupId === ROOT_GROUP_ID) {
return new Set(views.map((view) => view.groupId).filter(Boolean));
}
return collectDescendantIds(groups, activeGroupId);
}, [activeGroupId, groups, views]);
const viewsInCurrentGroup = useMemo(() => {
if (activeGroupId === ROOT_GROUP_ID) {
return views;
}
return views.filter((view) =>
`${view.title || ''} ${view.description || ''} ${view.subjectName || ''}`
return views.filter((view) => visibleGroupIds.has(view.groupId));
}, [activeGroupId, views, visibleGroupIds]);
const availableTags = useMemo(() => {
return Array.from(
new Set(viewsInCurrentGroup.flatMap((view) => view.tags || []).filter(Boolean)),
).sort((left, right) => left.localeCompare(right, 'zh-CN'));
}, [viewsInCurrentGroup]);
const filteredViews = useMemo(() => {
const keyword = String(deferredViewKeyword || '').trim().toLowerCase();
return viewsInCurrentGroup.filter((view) => {
const matchesKeyword =
!keyword ||
`${view.title || ''} ${view.description || ''} ${view.subjectName || ''} ${(view.tags || []).join(' ')}`
.toLowerCase()
.includes(keyword),
);
}, [viewKeyword, views]);
.includes(keyword);
const matchesTags =
activeTagFilters.length === 0 ||
activeTagFilters.every((tag) => (view.tags || []).includes(tag));
return matchesKeyword && matchesTags;
});
}, [activeTagFilters, deferredViewKeyword, viewsInCurrentGroup]);
useEffect(() => {
if (!filteredViews.some((view) => view.id === activeViewId)) {
startTransition(() => {
setActiveViewId(filteredViews[0]?.id || '');
});
}
}, [activeViewId, filteredViews]);
const activeView = useMemo(
() => views.find((item) => item.id === activeViewId) || views[0] || null,
() => views.find((item) => item.id === activeViewId) || null,
[views, activeViewId],
);
const selectedSubject = useMemo(
() =>
subjects.find((subject) => subject.name === selectedSubjectName) ||
subjectDefinition ||
null,
[subjectDefinition, selectedSubjectName, subjects],
);
const selectedRecords = useMemo(
() => records.filter((record) => selectedRecordIds.includes(record.id)),
[records, selectedRecordIds],
);
useEffect(() => {
setActiveTagFilters((prev) => prev.filter((tag) => availableTags.includes(tag)));
}, [availableTags]);
useEffect(() => {
if (!activeView) {
setSelectedSubjectName('');
setSelectedRecordIds([]);
setCurrentViewForwards([]);
setCurrentViewForwardItems([]);
return;
}
......@@ -348,7 +633,38 @@ export default function AnalysisViews() {
setFilterValues(activeView.defaultFilters || {});
setSelectedRecordIds([]);
setActiveResultTab('table');
}, [activeView, selectedSubjectName, subjectDefinition]);
}, [activeView, selectedSubjectName]);
useEffect(() => {
let cancelled = false;
if (!activeView?.id) {
setCurrentViewForwards([]);
return undefined;
}
(async () => {
try {
const [forwardRecords, forwardItems] = await Promise.all([
loadForwardRecords({ viewId: activeView.id }),
loadForwardItems({ viewId: activeView.id }),
]);
if (!cancelled) {
setCurrentViewForwards(forwardRecords);
setCurrentViewForwardItems(forwardItems);
}
} catch {
if (!cancelled) {
setCurrentViewForwards([]);
setCurrentViewForwardItems([]);
}
}
})();
return () => {
cancelled = true;
};
}, [activeView?.id]);
async function runQuery(nextFilters = filterValues) {
if (!selectedSubjectName || !subjectDefinition) {
......@@ -374,7 +690,76 @@ export default function AnalysisViews() {
useEffect(() => {
runQuery(activeView?.defaultFilters || {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeViewId, selectedSubjectName, subjectDefinition?.name]);
}, [activeView?.id, selectedSubjectName, subjectDefinition?.name]);
const forwardStatusIndex = useMemo(
() => buildForwardStatusIndex(currentViewForwards),
[currentViewForwards],
);
const forwardItemByRecordId = useMemo(
() => buildForwardItemIndex(currentViewForwardItems),
[currentViewForwardItems],
);
const manuallyHiddenRecordIdSet = useMemo(
() =>
new Set(
(activeView?.hiddenRecordIds || [])
.filter(Boolean)
.map((recordId) => String(recordId)),
),
[activeView?.hiddenRecordIds],
);
const visibleRecords = useMemo(() => {
return records.filter((record) => {
const recordKey = String(record.recordName || record.id || '');
return (
!manuallyHiddenRecordIdSet.has(recordKey) &&
!forwardStatusIndex.hiddenRecordIdSet.has(recordKey)
);
});
}, [forwardStatusIndex.hiddenRecordIdSet, manuallyHiddenRecordIdSet, records]);
const manuallyHiddenVisibleCount = useMemo(
() =>
records.filter((record) =>
manuallyHiddenRecordIdSet.has(String(record.recordName || record.id || '')),
).length,
[manuallyHiddenRecordIdSet, records],
);
const flowHiddenVisibleCount = useMemo(
() =>
records.filter((record) => {
const recordKey = String(record.recordName || record.id || '');
return (
!manuallyHiddenRecordIdSet.has(recordKey) &&
forwardStatusIndex.hiddenRecordIdSet.has(recordKey)
);
}).length,
[forwardStatusIndex.hiddenRecordIdSet, manuallyHiddenRecordIdSet, records],
);
const rowStatusByRecordId = useMemo(() => {
return records.reduce((accumulator, record) => {
const recordKey = String(record.recordName || record.id || '');
const forwardRecord =
forwardStatusIndex.latestForwardByRecordId.get(recordKey) || null;
accumulator[recordKey] = resolveForwardRowStatus(forwardRecord);
return accumulator;
}, {});
}, [forwardStatusIndex.latestForwardByRecordId, records]);
const selectedRecords = useMemo(
() => visibleRecords.filter((record) => selectedRecordIds.includes(record.id)),
[selectedRecordIds, visibleRecords],
);
useEffect(() => {
const visibleIds = new Set(visibleRecords.map((record) => record.id));
setSelectedRecordIds((prev) => prev.filter((id) => visibleIds.has(id)));
}, [visibleRecords]);
const visibleFilterFields = useMemo(
() =>
......@@ -385,6 +770,7 @@ export default function AnalysisViews() {
.filter(Boolean),
[activeView, subjectDefinition],
);
const visibleColumns = useMemo(
() =>
(activeView?.columns || [])
......@@ -395,10 +781,19 @@ export default function AnalysisViews() {
[activeView, subjectDefinition],
);
const chartConfigs = activeView?.charts || [];
const chartOptions = useMemo(
() =>
(activeView?.charts || []).map((chart) => ({
config: chart,
option: buildChartOption(records, chart, subjectDefinition?.fields || []),
})),
[activeView?.charts, records, subjectDefinition],
);
function handleSelectView(view) {
startTransition(() => {
setActiveViewId(view.id);
});
setSelectedSubjectName(view.subjectName);
setSelectedRecordIds([]);
setActiveResultTab('table');
......@@ -406,6 +801,22 @@ export default function AnalysisViews() {
setError('');
}
function handleSelectGroup(groupId) {
startTransition(() => {
setActiveGroupId(groupId);
});
setNotice('');
setError('');
}
function toggleGroupExpanded(groupId) {
setExpandedGroupIds((prev) =>
prev.includes(groupId)
? prev.filter((id) => id !== groupId)
: [...prev, groupId],
);
}
async function handleSelectViewSubject(subject) {
const nextSubjectName = subject?.name || '';
const nextDefinition = nextSubjectName
......@@ -436,54 +847,75 @@ export default function AnalysisViews() {
...buildEmptyViewForm(),
...draft,
subjectName: selectedSubjectName,
groupId: activeGroupId === ROOT_GROUP_ID ? '' : activeGroupId,
groupName:
activeGroupId === ROOT_GROUP_ID
? ''
: groups.find((group) => group.id === activeGroupId)?.groupName || '',
});
setIsViewDialogOpen(true);
}
function openEditDialog() {
if (!activeView) {
function openEditDialog(targetView = activeView) {
if (!targetView) {
return;
}
setViewForm({
recordName: activeView.recordName,
title: activeView.title,
subjectName: activeView.subjectName,
description: activeView.description,
scope: activeView.scope,
filterFields: activeView.filterFields,
defaultFilters: activeView.defaultFilters,
columns: activeView.columns,
recordName: targetView.recordName,
title: targetView.title,
subjectName: targetView.subjectName,
description: targetView.description,
scope: targetView.scope,
filterFields: targetView.filterFields,
defaultFilters: targetView.defaultFilters,
columns: targetView.columns,
charts:
activeView.charts.length > 0
? activeView.charts
: [buildChartDraft(subjectDefinition?.fields || [])],
targetView.charts.length > 0
? targetView.charts
: [
buildChartDraft(
targetView.subjectName === selectedSubjectName
? subjectDefinition?.fields || []
: [],
),
],
groupId: targetView.groupId || '',
groupName:
groups.find((group) => group.id === targetView.groupId)?.groupName ||
targetView.groupName ||
'',
tags: targetView.tags || [],
hiddenRecordIds: targetView.hiddenRecordIds || [],
});
setIsViewDialogOpen(true);
}
async function handleSaveView() {
if (!String(viewForm.title || '').trim()) {
setError('请填写视图名称。');
return;
function openCreateGroupDialog(parentGroupId = '') {
setGroupForm({
...buildEmptyGroupForm(),
parentGroupId,
});
setIsGroupDialogOpen(true);
}
if (!viewForm.subjectName) {
setError('请先选择视图绑定的对象。');
function openEditGroupDialog() {
if (!selectedGroup) {
return;
}
if ((viewForm.columns || []).length === 0) {
setError('请至少选择一个结果列。');
return;
setGroupForm({
recordName: selectedGroup.recordName,
groupName: selectedGroup.groupName,
parentGroupId: selectedGroup.parentGroupId || '',
});
setIsGroupDialogOpen(true);
}
setSaving(true);
try {
const saved = await saveAnalysisView(viewForm, userInfo);
function mergeSavedViewIntoState(savedView) {
const nextViews = [
saved,
...views.filter((item) => item.id !== saved.id),
savedView,
...views.filter((item) => item.id !== savedView.id),
].sort((left, right) =>
String(right.updatedAt || right.createdAt).localeCompare(
String(left.updatedAt || left.createdAt),
......@@ -494,14 +926,129 @@ export default function AnalysisViews() {
setSubjects((prev) =>
mergeSubjectsByName([
{
name: saved.subjectName,
title: selectedSubject?.title || saved.subjectName,
name: savedView.subjectName,
title: savedView.subjectName,
},
...prev,
]),
);
setSelectedSubjectName(saved.subjectName);
setActiveViewId(saved.id);
setSelectedSubjectName(savedView.subjectName);
setActiveGroupId(savedView.groupId || ROOT_GROUP_ID);
setActiveViewId(savedView.id);
return nextViews;
}
async function handleSaveGroup() {
if (!String(groupForm.groupName || '').trim()) {
setError('请填写分组名称。');
return;
}
if (groupForm.recordName) {
const currentGroup = groups.find((group) => group.recordName === groupForm.recordName);
const descendantIds = collectDescendantIds(groups, currentGroup?.id);
if (
currentGroup?.id &&
(groupForm.parentGroupId === currentGroup.id ||
descendantIds.has(groupForm.parentGroupId))
) {
setError('父分组不能选择当前分组或其子分组。');
return;
}
}
setSaving(true);
try {
const saved = await saveAnalysisGroup(groupForm, userInfo);
const nextGroups = [
saved,
...groups.filter((item) => item.id !== saved.id),
];
setGroups(nextGroups);
setExpandedGroupIds((prev) =>
Array.from(new Set([...prev, saved.id, saved.parentGroupId].filter(Boolean))),
);
setActiveGroupId(saved.id);
setIsGroupDialogOpen(false);
setNotice(`分组“${saved.groupName}”已保存。`);
setError('');
} catch (saveError) {
setError(saveError?.message || '分组保存失败');
} finally {
setSaving(false);
}
}
function openDeleteGroupDialog() {
if (!selectedGroup) {
return;
}
const hasChildren = groups.some(
(group) => group.parentGroupId === selectedGroup.id,
);
const hasViews = views.some((view) => view.groupId === selectedGroup.id);
if (hasChildren || hasViews) {
setError('请先迁移子分组或视图,再删除当前分组。');
return;
}
setGroupPendingDelete(selectedGroup);
setIsDeleteGroupDialogOpen(true);
}
async function handleDeleteGroup() {
if (!groupPendingDelete) {
return;
}
setSaving(true);
try {
await deleteAnalysisGroup(groupPendingDelete);
setGroups((prev) => prev.filter((item) => item.id !== groupPendingDelete.id));
setExpandedGroupIds((prev) => prev.filter((id) => id !== groupPendingDelete.id));
setActiveGroupId(ROOT_GROUP_ID);
setIsDeleteGroupDialogOpen(false);
setGroupPendingDelete(null);
setNotice(`已删除分组“${groupPendingDelete.groupName}”。`);
setError('');
} catch (deleteError) {
setError(deleteError?.message || '分组删除失败');
} finally {
setSaving(false);
}
}
async function handleSaveView() {
if (!String(viewForm.title || '').trim()) {
setError('请填写视图名称。');
return;
}
if (!viewForm.subjectName) {
setError('请先选择视图绑定的对象。');
return;
}
if ((viewForm.columns || []).length === 0) {
setError('请至少选择一个结果列。');
return;
}
const selectedViewGroup = groups.find((group) => group.id === viewForm.groupId);
setSaving(true);
try {
const saved = await saveAnalysisView(
{
...viewForm,
groupName: selectedViewGroup?.groupName || '',
},
userInfo,
);
mergeSavedViewIntoState(saved);
setActiveResultTab('table');
setNotice('分析视图已保存。');
setError('');
......@@ -513,39 +1060,141 @@ export default function AnalysisViews() {
}
}
async function handleDeleteView() {
if (!activeView) {
async function handleDeleteView(targetView = activeView) {
if (!targetView) {
return;
}
const confirmed = window.confirm(
`确认删除视图“${activeView.title}”吗?`,
);
const confirmed = window.confirm(`确认删除视图“${targetView.title}”吗?`);
if (!confirmed) {
return;
}
await deleteAnalysisView(activeView);
const nextViews = views.filter((item) => item.id !== activeView.id);
await deleteAnalysisView(targetView);
const nextViews = views.filter((item) => item.id !== targetView.id);
setViews(nextViews);
if (activeView?.id === targetView.id) {
setActiveViewId(nextViews[0]?.id || '');
setNotice(`已删除视图“${activeView.title}”。`);
}
setNotice(`已删除视图“${targetView.title}”。`);
}
function handleToggleRow(record, checked) {
setSelectedRecordIds((prev) =>
checked ? [...new Set([...prev, record.id])] : prev.filter((id) => id !== record.id),
checked
? [...new Set([...prev, record.id])]
: prev.filter((id) => id !== record.id),
);
}
function handleToggleAll(checked) {
if (checked) {
setSelectedRecordIds(records.map((record) => record.id));
setSelectedRecordIds(visibleRecords.map((record) => record.id));
return;
}
setSelectedRecordIds([]);
}
async function handleHideSelectedRecords() {
if (!activeView) {
return;
}
if (selectedRecords.length === 0) {
setError('请先勾选要隐藏的记录。');
return;
}
const nextHiddenRecordIds = Array.from(
new Set([
...(activeView.hiddenRecordIds || []),
...selectedRecords.map((record) => String(record.recordName || record.id || '')),
]),
);
setSaving(true);
try {
const saved = await saveAnalysisView(
{
...activeView,
hiddenRecordIds: nextHiddenRecordIds,
},
userInfo,
);
mergeSavedViewIntoState(saved);
setSelectedRecordIds([]);
setNotice(`已在当前视图中隐藏 ${selectedRecords.length} 条记录。`);
setError('');
} catch (saveError) {
setError(saveError?.message || '隐藏记录失败');
} finally {
setSaving(false);
}
}
async function openHiddenRecordsDialog() {
if (!activeView?.hiddenRecordIds?.length) {
return;
}
setIsHiddenRecordsDialogOpen(true);
setHiddenRecordsLoading(true);
setSelectedHiddenRecordIds([]);
try {
const result = await loadAnalysisRecordsByIds(
activeView.subjectName,
activeView.hiddenRecordIds,
);
setHiddenRecords(result.records);
setError('');
} catch (loadError) {
setHiddenRecords([]);
setError(loadError?.message || '已隐藏记录加载失败');
} finally {
setHiddenRecordsLoading(false);
}
}
async function handleRestoreHiddenRecords() {
if (!activeView) {
return;
}
if (selectedHiddenRecordIds.length === 0) {
setError('请先勾选要恢复显示的记录。');
return;
}
const restoreIdSet = new Set(selectedHiddenRecordIds.map((id) => String(id)));
const nextHiddenRecordIds = (activeView.hiddenRecordIds || []).filter(
(recordId) => !restoreIdSet.has(String(recordId)),
);
setSaving(true);
try {
const saved = await saveAnalysisView(
{
...activeView,
hiddenRecordIds: nextHiddenRecordIds,
},
userInfo,
);
mergeSavedViewIntoState(saved);
setHiddenRecords((prev) =>
prev.filter((record) => !restoreIdSet.has(String(record.recordName || record.id || ''))),
);
setSelectedHiddenRecordIds([]);
setIsHiddenRecordsDialogOpen(false);
setNotice(`已恢复 ${restoreIdSet.size} 条记录在当前视图中的显示。`);
setError('');
} catch (saveError) {
setError(saveError?.message || '恢复显示失败');
} finally {
setSaving(false);
}
}
async function handleSubmitForward() {
......@@ -559,6 +1208,16 @@ export default function AnalysisViews() {
return;
}
const excludedIdSet = new Set(forwardForm.excludedRecordIds || []);
const deliverRecords = selectedRecords.filter(
(record) => !excludedIdSet.has(record.recordName || record.id),
);
if (deliverRecords.length === 0) {
setError('请至少保留一条需要下发的记录。');
return;
}
if (forwardForm.recipientIds.length === 0) {
setError('请至少添加一位接收人。');
return;
......@@ -576,12 +1235,15 @@ export default function AnalysisViews() {
setSaving(true);
try {
await saveForwardRecord(
const savedForwardRecord = await saveForwardRecord(
{
viewId: activeView.id,
viewName: activeView.title,
subjectName: activeView.subjectName,
selectedRecordIds: selectedRecords.map((record) => record.recordName || record.id),
selectedRecordIds: deliverRecords.map((record) => record.recordName || record.id),
excludedRecordIds: selectedRecords
.filter((record) => excludedIdSet.has(record.recordName || record.id))
.map((record) => record.recordName || record.id),
recipientIds: forwardForm.recipientIds.map((item) => item.id),
recipientNames: forwardForm.recipientIds.map((item) => item.label),
note: forwardForm.note,
......@@ -594,7 +1256,15 @@ export default function AnalysisViews() {
},
userInfo,
);
setNotice(`已提交 ${selectedRecords.length} 条记录的转发审批。`);
await ensureForwardRecordItems(savedForwardRecord, deliverRecords, userInfo);
const [refreshedForwards, refreshedForwardItems] = await Promise.all([
loadForwardRecords({ viewId: activeView.id }),
loadForwardItems({ viewId: activeView.id }),
]);
setCurrentViewForwards(refreshedForwards);
setCurrentViewForwardItems(refreshedForwardItems);
setNotice(`已提交 ${deliverRecords.length} 条记录的转发审批。`);
setIsForwardDialogOpen(false);
setForwardForm(createEmptyForwardDraft());
setSelectedRecordIds([]);
......@@ -606,14 +1276,11 @@ export default function AnalysisViews() {
}
}
const chartOptions = useMemo(
() =>
chartConfigs.map((chart) => ({
config: chart,
option: buildChartOption(records, chart, subjectDefinition?.fields || []),
})),
[chartConfigs, records, subjectDefinition],
);
const activeGroupLabel =
activeGroupId === ROOT_GROUP_ID
? '全部视图'
: selectedGroup?.groupName || '未命名分组';
const hiddenCount = manuallyHiddenVisibleCount + flowHiddenVisibleCount;
return (
<div className="space-y-6">
......@@ -621,102 +1288,262 @@ export default function AnalysisViews() {
{!loading && error ? <NoticeBanner tone="error">{error}</NoticeBanner> : null}
{!error && notice ? <NoticeBanner tone="success">{notice}</NoticeBanner> : null}
<div className="grid gap-6 xl:grid-cols-[22rem_minmax(0,1fr)]">
<Panel className="p-4 sm:p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<div className="grid gap-6 xl:grid-cols-[19rem_minmax(0,1fr)] xl:items-start">
<div className="xl:sticky xl:top-0">
<Panel className="overflow-hidden xl:flex xl:max-h-[calc(100dvh-9rem)] xl:flex-col">
<div className="border-b border-zinc-200/70 bg-[linear-gradient(180deg,rgba(255,241,242,0.78),rgba(255,255,255,0.94))] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-base font-semibold text-zinc-950">
全部视图
</div>
<div className="text-base font-semibold text-zinc-950">视图分组</div>
<div className="mt-1 text-xs text-zinc-500">
直接选择视图查看右侧数据。
像网盘文件夹一样维护无限级分组。
</div>
</div>
<Button outline onClick={() => openCreateGroupDialog(activeGroupId === ROOT_GROUP_ID ? '' : activeGroupId)}>
<FolderPlusIcon className="size-4" />
新建
</Button>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button
outline
onClick={openEditGroupDialog}
disabled={activeGroupId === ROOT_GROUP_ID}
>
<PencilSquareIcon className="size-4" />
重命名
</Button>
<Button
outline
onClick={openDeleteGroupDialog}
disabled={activeGroupId === ROOT_GROUP_ID}
>
<TrashIcon className="size-4" />
删除
</Button>
</div>
</div>
<div className="min-h-[20rem] overflow-y-auto p-4 xl:min-h-0 xl:flex-1">
<button
type="button"
onClick={() => handleSelectGroup(ROOT_GROUP_ID)}
className={`mb-3 flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-left text-sm font-medium transition ${
activeGroupId === ROOT_GROUP_ID
? 'bg-zinc-950 text-white shadow-sm'
: 'bg-zinc-50 text-zinc-700 hover:bg-zinc-100'
}`}
>
<FolderIcon className="size-4" />
<span>全部视图</span>
</button>
<div className="space-y-1">
{groupTree.length > 0 ? (
groupTree.map((group) => (
<GroupTreeItem
key={group.id}
group={group}
activeGroupId={activeGroupId}
expandedGroupIds={expandedGroupIds}
onToggle={toggleGroupExpanded}
onSelect={handleSelectGroup}
/>
))
) : (
<EmptyCollection
title="还没有分组"
description="先创建一个分组,再把视图归档进去。"
/>
)}
</div>
</div>
</Panel>
</div>
<div className="min-w-0 space-y-6">
<Panel className="overflow-hidden">
<div className="border-b border-zinc-200/70 bg-[linear-gradient(180deg,rgba(255,250,240,0.9),rgba(255,255,255,0.96))] p-5 sm:p-6">
<SectionHeader
title={activeGroupLabel}
description={
activeGroupId === ROOT_GROUP_ID
? '这里汇总全部视图,你可以按名称、对象或标签快速筛选。'
: '右侧只展示当前分组及其子分组下的视图,方便在同一业务主题里连续切换。'
}
action={
<div className="flex flex-wrap gap-2">
<Tag>{viewsInCurrentGroup.length} 个视图</Tag>
{countingViews ? <Tag tone="amber">正在统计数据量</Tag> : null}
<Button color="blue" onClick={openCreateDialog}>
<PlusIcon className="size-4" />
新建
新建视图
</Button>
</div>
<div className="mb-3 flex justify-end">
<Tag>{views.length} 个视图</Tag>
}
/>
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-start">
<SearchInput
value={viewKeyword}
onChange={setViewKeyword}
placeholder="搜索视图名称、说明、对象或标签"
/>
<div className="flex flex-wrap gap-2">
{availableTags.map((tag) => {
const active = activeTagFilters.includes(tag);
return (
<button
key={tag}
type="button"
aria-pressed={active}
onClick={() =>
startTransition(() => {
setActiveTagFilters((prev) =>
prev.includes(tag)
? prev.filter((item) => item !== tag)
: [...prev, tag],
);
})
}
className={`rounded-full border px-3 py-1 text-xs font-medium transition ${
active
? 'border-rose-300 bg-rose-50 text-rose-700'
: 'border-zinc-200 bg-white text-zinc-600 hover:border-zinc-300'
}`}
>
{tag}
</button>
);
})}
</div>
</div>
<Input
value={viewKeyword}
onChange={(event) => setViewKeyword(event.target.value)}
placeholder="搜索视图名称、说明或对象"
/>
<div className="mt-4 max-h-[calc(100vh-10rem)] min-h-[32rem] space-y-3 overflow-y-auto pr-1">
</div>
<div className="max-h-[26rem] overflow-y-auto p-5 sm:p-6">
{filteredViews.length > 0 ? (
filteredViews.map((view) => (
<button
<div className="relative overflow-x-auto rounded-[22px] border border-zinc-200 bg-white">
<div className="grid min-w-[72rem] grid-cols-[minmax(18rem,1.8fr)_8rem_8rem_minmax(9rem,0.9fr)_5rem_5rem_5rem_5rem_6.5rem] gap-4 border-b border-zinc-200 bg-zinc-50 px-4 py-3 text-sm font-medium text-zinc-500">
<div>视图</div>
<div>对象</div>
<div>分组</div>
<div>标签</div>
<div className="text-right">数据量</div>
<div className="text-right">结果列</div>
<div className="text-right">筛选</div>
<div className="text-right">图表</div>
<div className="sticky right-0 -my-3 flex items-center justify-end bg-zinc-50 py-3 pl-4 shadow-[-16px_0_24px_rgba(255,255,255,0.92)]">
操作
</div>
</div>
<div className="divide-y divide-zinc-100">
{filteredViews.map((view) => {
const groupName =
groups.find((group) => group.id === view.groupId)?.groupName ||
view.groupName ||
'未分组';
const active = activeView?.id === view.id;
return (
<div
key={view.id}
type="button"
role="button"
tabIndex={0}
onClick={() => handleSelectView(view)}
className={`w-full rounded-[24px] border px-4 py-4 text-left transition ${
activeView?.id === view.id
? 'border-sky-300 bg-sky-50 shadow-sm ring-2 ring-sky-100'
: 'border-zinc-200 bg-white hover:border-zinc-300 hover:bg-zinc-50'
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleSelectView(view);
}
}}
className={`group grid min-w-[72rem] grid-cols-[minmax(18rem,1.8fr)_8rem_8rem_minmax(9rem,0.9fr)_5rem_5rem_5rem_5rem_6.5rem] items-center gap-4 border-l-4 px-4 py-3 text-left transition ${
active
? 'border-l-rose-500 bg-rose-50/80'
: 'border-l-transparent hover:bg-zinc-50'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-950">
<div
className={`truncate text-sm font-semibold ${
active ? 'text-rose-700' : 'text-zinc-950'
}`}
>
{view.title}
</div>
<div className="mt-1 line-clamp-2 text-xs leading-5 text-zinc-500">
<div className="mt-1 line-clamp-2 text-sm leading-6 text-zinc-500">
{view.description || '未填写视图说明'}
</div>
</div>
<Tag>{view.subjectName}</Tag>
<div>
<Tag tone="sky">{view.subjectName}</Tag>
</div>
<div className="truncate text-sm text-zinc-600">{groupName}</div>
<div className="flex min-w-0 flex-wrap gap-1.5">
{(view.tags || []).length > 0 ? (
(view.tags || []).map((tag) => (
<Tag key={tag} tone="rose">
{tag}
</Tag>
))
) : (
<span className="text-sm text-zinc-400">-</span>
)}
</div>
<div className="text-right text-sm font-semibold text-zinc-950">
{viewCounts[view.id] ?? '-'}
</div>
<div className="text-right text-sm text-zinc-600">
{view.columns.length}
</div>
<div className="text-right text-sm text-zinc-600">
{view.filterFields.length}
</div>
<div className="text-right text-sm text-zinc-600">
{view.charts.length}
</div>
<div
className={`sticky right-0 -my-3 flex justify-end gap-2 py-3 pl-4 shadow-[-16px_0_24px_rgba(255,255,255,0.86)] ${
active ? 'bg-rose-50' : 'bg-white group-hover:bg-zinc-50'
}`}
>
<Button
outline
aria-label={`编辑视图 ${view.title}`}
title="编辑"
className="h-11 w-11 px-0"
onClick={(event) => {
event.stopPropagation();
openEditDialog(view);
}}
>
<ChartBarIcon className="size-4" />
</Button>
<Button
outline
aria-label={`删除视图 ${view.title}`}
title="删除"
className="h-11 w-11 px-0"
onClick={(event) => {
event.stopPropagation();
handleDeleteView(view);
}}
>
<TrashIcon className="size-4" />
</Button>
</div>
</div>
);
})}
</div>
<div className="mt-3 flex flex-wrap gap-2 text-xs text-zinc-500">
<span className="rounded-full bg-zinc-100 px-2.5 py-1">
{view.columns.length} 列
</span>
<span className="rounded-full bg-zinc-100 px-2.5 py-1">
{view.filterFields.length} 筛选
</span>
<span className="rounded-full bg-zinc-100 px-2.5 py-1">
{view.charts.length} 图表
</span>
</div>
</button>
))
) : (
<EmptyCollection
title="还没有匹配的分析视图"
description="可以调整关键词,或新建一个分析视图后再查看。"
title="当前分组下还没有匹配的视图"
description="可以调整关键词或标签,也可以先新建一个分析视图。"
/>
)}
</div>
</Panel>
<div className="min-w-0 space-y-5">
{activeView && subjectDefinition ? (
<>
<Panel className="p-5 sm:p-6">
<SectionHeader
title={activeView.title}
description={
activeView.description ||
'当前视图未填写说明,可在编辑视图中补充。'
}
action={
<div className="flex flex-wrap items-center gap-2">
<Tag tone="sky">{activeView.subjectName}</Tag>
<Tag>{subjectDefinition?.fields?.length || 0} 个字段</Tag>
{querying ? <Tag tone="sky">正在刷新...</Tag> : null}
<Button outline onClick={openEditDialog}>
<ChartBarIcon className="size-4" />
编辑
</Button>
<Button outline onClick={handleDeleteView}>
<TrashIcon className="size-4" />
删除
</Button>
</div>
}
/>
<DynamicFilterGrid
fields={visibleFilterFields}
values={filterValues}
......@@ -727,7 +1554,6 @@ export default function AnalysisViews() {
}}
onSubmit={() => runQuery(filterValues)}
/>
</Panel>
<Panel
className={`${
......@@ -742,13 +1568,19 @@ export default function AnalysisViews() {
结果区
</div>
<div className="mt-1 text-sm leading-6 text-zinc-500">
当前命中 {total} 条记录,表格和图表通过 Tab 切换。
当前命中 {total} 条记录,当前展示 {visibleRecords.length} 条,
隐藏 {hiddenCount} 条。
{hiddenCount > 0 ? (
<span>
其中手动隐藏 {manuallyHiddenVisibleCount} 条,流程临时隐藏 {flowHiddenVisibleCount} 条。
</span>
) : null}
</div>
</div>
<div className="flex flex-wrap gap-2">
<div className="inline-flex rounded-full border border-zinc-200 bg-zinc-50 p-1">
{[
{ key: 'table', label: '表格', hint: `${records.length} 条` },
{ key: 'table', label: '表格', hint: `${visibleRecords.length} 条` },
{ key: 'charts', label: '图表', hint: `${chartOptions.length} 个` },
].map((tab) => (
<button
......@@ -763,17 +1595,13 @@ export default function AnalysisViews() {
}`}
>
{tab.label}
<span className="ml-2 text-xs text-zinc-400">
{tab.hint}
</span>
<span className="ml-2 text-xs text-zinc-400">{tab.hint}</span>
</button>
))}
</div>
<Button
outline
onClick={() =>
setIsResultFullscreen((current) => !current)
}
onClick={() => setIsResultFullscreen((current) => !current)}
>
{isResultFullscreen ? (
<ArrowsPointingInIcon className="size-4" />
......@@ -782,39 +1610,68 @@ export default function AnalysisViews() {
)}
{isResultFullscreen ? '缩小' : '全屏'}
</Button>
<Button
outline
onClick={openHiddenRecordsDialog}
disabled={!activeView.hiddenRecordIds?.length}
>
管理已隐藏 {activeView.hiddenRecordIds?.length || 0} 条
</Button>
</div>
</div>
{activeResultTab === 'table' ? (
<>
<div className="mb-4 flex flex-col gap-3 rounded-[22px] border border-zinc-200 bg-zinc-50 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm leading-6 text-zinc-600">
可以把当前视图里不想再看到的记录直接隐藏。隐藏后再次进入这个视图也不会显示;后续可在“管理已隐藏”里恢复。转发出去的记录不会因为转发动作自动消失。
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
outline
onClick={handleHideSelectedRecords}
disabled={saving || selectedRecordIds.length === 0}
>
隐藏所选
</Button>
</div>
</div>
<SelectionToolbar
count={selectedRecordIds.length}
onForward={() => {
setForwardForm(createEmptyForwardDraft());
setIsForwardDialogOpen(true);
setError('');
}}
onClear={() => setSelectedRecordIds([])}
/>
{records.length > 0 ? (
{visibleRecords.length > 0 ? (
<div
className={`overflow-auto rounded-[22px] border border-zinc-200 bg-white ${
isResultFullscreen
? 'h-[calc(100vh-16rem)]'
: 'h-[32rem]'
isResultFullscreen ? 'h-[calc(100vh-16rem)]' : 'h-[32rem]'
}`}
>
<RecordsResultTable
records={records}
records={visibleRecords}
columns={visibleColumns}
selectedIds={selectedRecordIds}
rowStatusByRecordId={rowStatusByRecordId}
forwardItemByRecordId={forwardItemByRecordId}
onOpenForwardItem={(record, forwardItem) =>
setForwardItemDetail({ record, forwardItem })
}
onToggleRow={handleToggleRow}
onToggleAll={handleToggleAll}
/>
</div>
) : (
<EmptyCollection
title="当前筛选条件下没有命中记录"
description="可以调整筛选条件,或者先确认该对象是否已经同步到 Record 数据中。"
title={hiddenCount > 0 ? '当前记录已被隐藏' : '当前筛选条件下没有命中记录'}
description={
hiddenCount > 0
? `当前筛选结果里有 ${manuallyHiddenVisibleCount} 条被手动隐藏,${flowHiddenVisibleCount} 条因流程中的“不下发”暂时隐藏。可以通过“管理已隐藏”恢复手动隐藏数据。`
: '可以调整筛选条件,或者先确认该对象是否已经同步到 Record 数据中。'
}
/>
)}
</>
......@@ -845,23 +1702,260 @@ export default function AnalysisViews() {
</>
) : (
<EmptyCollection
title="先创建或选择一个分析视图"
description="左侧会直接展示全部视图,选择后右侧自动加载该视图绑定对象的数据。"
title="先在当前分组里选择一个分析视图"
description="左侧只负责切换分组,右侧卡片区负责选择具体视图。"
/>
)}
</div>
</div>
<Dialog
open={Boolean(forwardItemDetail)}
onClose={(nextOpen) => {
if (!nextOpen) {
setForwardItemDetail(null);
}
}}
size="2xl"
>
<DialogTitle>回填详情</DialogTitle>
<DialogDescription>
这里展示当前记录最近一次转发回填的属实情况、整改方案和预计完成时间。
</DialogDescription>
<DialogBody>
{forwardItemDetail ? (
<div className="space-y-4">
<div className="rounded-[22px] border border-zinc-200 bg-zinc-50 px-4 py-4">
<div className="text-sm font-medium text-zinc-500">源记录</div>
<div className="mt-2 text-base font-semibold text-zinc-950">
{forwardItemDetail.record?.title ||
forwardItemDetail.forwardItem?.sourceRecordTitle ||
'-'}
</div>
<div className="mt-1 text-xs text-zinc-500">
记录 ID:
{forwardItemDetail.record?.recordName ||
forwardItemDetail.record?.id ||
forwardItemDetail.forwardItem?.sourceRecordId ||
'-'}
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-[22px] border border-zinc-200 bg-white px-4 py-4">
<div className="text-sm font-medium text-zinc-500">是否属实</div>
<div className="mt-2 text-base font-semibold text-zinc-950">
{getTruthStatusLabel(forwardItemDetail.forwardItem?.truthStatus)}
</div>
</div>
<div className="rounded-[22px] border border-zinc-200 bg-white px-4 py-4">
<div className="text-sm font-medium text-zinc-500">预计整改完成时间</div>
<div className="mt-2 text-base font-semibold text-zinc-950">
{formatDateTime(
forwardItemDetail.forwardItem?.expectedRectificationCompletedAt,
)}
</div>
</div>
</div>
<div className="rounded-[22px] border border-zinc-200 bg-white px-4 py-4">
<div className="text-sm font-medium text-zinc-500">整改方案</div>
<div className="mt-3 whitespace-pre-wrap text-sm leading-6 text-zinc-700">
{forwardItemDetail.forwardItem?.rectificationPlan || '-'}
</div>
</div>
<div className="rounded-[22px] border border-zinc-200 bg-zinc-50 px-4 py-4 text-sm leading-6 text-zinc-600">
填写人:{forwardItemDetail.forwardItem?.filledByName || '-'}
<br />
填写时间:{formatDateTime(forwardItemDetail.forwardItem?.filledAt)}
<br />
来源批次:{forwardItemDetail.forwardItem?.forwardRecordTitle || '-'}
</div>
</div>
) : null}
</DialogBody>
<DialogActions>
<Button outline onClick={() => setForwardItemDetail(null)}>
关闭
</Button>
</DialogActions>
</Dialog>
<Dialog
open={isHiddenRecordsDialogOpen}
onClose={(nextOpen) => {
setIsHiddenRecordsDialogOpen(nextOpen);
if (!nextOpen) {
setSelectedHiddenRecordIds([]);
}
}}
size="4xl"
>
<DialogTitle>管理已隐藏记录</DialogTitle>
<DialogDescription>
这里管理当前视图里被手动隐藏的记录。恢复后,这些记录会重新出现在本视图的结果表格中。
</DialogDescription>
<DialogBody>
{hiddenRecordsLoading ? (
<NoticeBanner>正在加载已隐藏记录...</NoticeBanner>
) : hiddenRecords.length > 0 ? (
<div className="space-y-3">
{hiddenRecords.map((record) => {
const recordKey = String(record.recordName || record.id || '');
const checked = selectedHiddenRecordIds.includes(recordKey);
return (
<CheckboxField
key={recordKey}
className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3"
>
<Checkbox
color="sky"
checked={checked}
onChange={(nextChecked) =>
setSelectedHiddenRecordIds((prev) =>
nextChecked
? [...new Set([...prev, recordKey])]
: prev.filter((id) => id !== recordKey),
)
}
/>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-zinc-800">
{record.title || record.recordName || record.id}
</div>
<div className="mt-1 text-xs text-zinc-500">
{record.missing
? `该记录当前无法读取,将按 ID ${recordKey} 恢复显示。`
: `记录 ID:${recordKey}`}
</div>
</div>
</CheckboxField>
);
})}
</div>
) : (
<EmptyCollection
title="当前视图还没有手动隐藏记录"
description="后续在结果区勾选记录并点击“隐藏所选”后,会出现在这里。"
/>
)}
</DialogBody>
<DialogActions>
<Button
outline
onClick={() => {
setIsHiddenRecordsDialogOpen(false);
setSelectedHiddenRecordIds([]);
}}
>
关闭
</Button>
<Button
color="blue"
onClick={handleRestoreHiddenRecords}
disabled={saving || hiddenRecordsLoading || selectedHiddenRecordIds.length === 0}
>
恢复勾选记录
</Button>
</DialogActions>
</Dialog>
<Dialog open={isGroupDialogOpen} onClose={setIsGroupDialogOpen} size="2xl">
<DialogTitle>
{groupForm.recordName ? '编辑视图分组' : '新建视图分组'}
</DialogTitle>
<DialogDescription>
分组会以树形菜单展示在左侧,用于承载当前业务主题下的多个分析视图。
</DialogDescription>
<DialogBody>
<div className="grid gap-4">
<div>
<FieldLabel required>分组名称</FieldLabel>
<Input
value={groupForm.groupName}
onChange={(event) =>
setGroupForm((prev) => ({
...prev,
groupName: event.target.value,
}))
}
/>
</div>
<div>
<FieldLabel>父分组</FieldLabel>
<Select
value={groupForm.parentGroupId}
onChange={(event) =>
setGroupForm((prev) => ({
...prev,
parentGroupId: event.target.value,
}))
}
>
<option value="">作为一级分组</option>
{flatGroupOptions
.filter((group) => group.id !== groups.find((item) => item.recordName === groupForm.recordName)?.id)
.map((group) => (
<option key={group.id} value={group.id}>
{group.label}
</option>
))}
</Select>
</div>
</div>
</DialogBody>
<DialogActions>
<Button outline onClick={() => setIsGroupDialogOpen(false)}>
取消
</Button>
<Button color="blue" onClick={handleSaveGroup} disabled={saving}>
保存分组
</Button>
</DialogActions>
</Dialog>
<Dialog
open={isViewDialogOpen}
onClose={setIsViewDialogOpen}
size="5xl"
open={isDeleteGroupDialogOpen}
onClose={(nextOpen) => {
setIsDeleteGroupDialogOpen(nextOpen);
if (!nextOpen) {
setGroupPendingDelete(null);
}
}}
size="lg"
>
<DialogTitle>确认删除分组</DialogTitle>
<DialogDescription>
删除后当前分组会立即移除,且无法撤回。请确认该分组下已经没有子分组和视图。
</DialogDescription>
<DialogBody>
<div className="rounded-[22px] border border-red-200 bg-red-50 px-4 py-4 text-sm leading-6 text-red-700">
你将删除分组“{groupPendingDelete?.groupName || '未命名分组'}”。
</div>
</DialogBody>
<DialogActions>
<Button
outline
onClick={() => {
setIsDeleteGroupDialogOpen(false);
setGroupPendingDelete(null);
}}
>
取消
</Button>
<Button color="red" onClick={handleDeleteGroup} disabled={saving}>
确认删除
</Button>
</DialogActions>
</Dialog>
<Dialog open={isViewDialogOpen} onClose={setIsViewDialogOpen} size="5xl">
<DialogTitle>
{viewForm.recordName ? '编辑分析视图' : '新建分析视图'}
</DialogTitle>
<DialogDescription>
视图会保存对象、筛选字段、默认筛选、结果列和图表配置,后续业务同事直接打开即可复用。
视图会保存对象、筛选字段、默认筛选、结果列、图表配置、所属分组和标签,后续业务同事直接打开即可复用。
</DialogDescription>
<DialogBody>
<div className="grid gap-5">
......@@ -883,9 +1977,7 @@ export default function AnalysisViews() {
<SubjectSearchCombobox
subjects={subjects}
value={
subjects.find(
(subject) => subject.name === viewForm.subjectName,
) ||
subjects.find((subject) => subject.name === viewForm.subjectName) ||
(viewForm.subjectName
? {
name: viewForm.subjectName,
......@@ -897,6 +1989,45 @@ export default function AnalysisViews() {
onQueryChange={setSubjectKeyword}
placeholder="搜索并选择绑定对象"
/>
{subjectSearching ? (
<div className="mt-2 text-xs text-zinc-400">正在搜索对象...</div>
) : null}
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<FieldLabel>所属分组</FieldLabel>
<Select
value={viewForm.groupId}
onChange={(event) =>
setViewForm((prev) => ({
...prev,
groupId: event.target.value,
groupName:
groups.find((group) => group.id === event.target.value)?.groupName || '',
}))
}
>
<option value="">暂不归档到分组</option>
{flatGroupOptions.map((group) => (
<option key={group.id} value={group.id}>
{group.label}
</option>
))}
</Select>
</div>
<div>
<FieldLabel>视图标签</FieldLabel>
<TagEditor
values={viewForm.tags || []}
onChange={(nextTags) =>
setViewForm((prev) => ({
...prev,
tags: nextTags,
}))
}
/>
</div>
</div>
......@@ -998,9 +2129,7 @@ export default function AnalysisViews() {
charts: [
...(prev.charts || []),
buildChartDraft(
viewSubjectDefinition?.fields ||
subjectDefinition?.fields ||
[],
viewSubjectDefinition?.fields || subjectDefinition?.fields || [],
),
],
}))
......@@ -1191,9 +2320,7 @@ export default function AnalysisViews() {
</div>
<div className="rounded-[24px] border border-zinc-200 bg-zinc-50/80 p-4">
<div className="text-sm font-semibold text-zinc-950">
默认筛选值
</div>
<div className="text-sm font-semibold text-zinc-950">默认筛选值</div>
<div className="mt-3 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{(viewSubjectDefinition?.fields || subjectDefinition?.fields || [])
.filter((field) => viewForm.filterFields.includes(field.alias))
......@@ -1228,19 +2355,15 @@ export default function AnalysisViews() {
</DialogActions>
</Dialog>
<Dialog
open={isForwardDialogOpen}
onClose={setIsForwardDialogOpen}
size="3xl"
>
<Dialog open={isForwardDialogOpen} onClose={setIsForwardDialogOpen} size="4xl">
<DialogTitle>提交转发审批</DialogTitle>
<DialogDescription>
当前会把勾选记录绑定到来源视图,并同步记录一级审批状态与最终审批状态。
当前批次会记录“本次下发”和“本次不下发”的记录,未结束前不下发记录会从原表格中临时隐藏。
</DialogDescription>
<DialogBody>
<div className="grid gap-4">
<div className="rounded-[22px] border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-zinc-600">
已勾选 {selectedRecords.length} 条记录,来源视图为
当前已勾选 {selectedRecords.length} 条记录,来源视图为
<span className="mx-1 font-medium text-zinc-950">
{activeView?.title || '-'}
</span>
......@@ -1251,6 +2374,47 @@ export default function AnalysisViews() {
。
</div>
<div className="rounded-[24px] border border-zinc-200 bg-white p-4">
<SectionHeader
title="本次记录去向"
description="默认全部作为“本次下发”处理,取消勾选后会进入“不下发”列表,并在当前批次未结束前从表格中隐藏。"
/>
<div className="grid gap-3">
{selectedRecords.map((record) => {
const recordKey = record.recordName || record.id;
const excluded = (forwardForm.excludedRecordIds || []).includes(recordKey);
return (
<CheckboxField
key={record.id}
className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3"
>
<Checkbox
color="sky"
checked={!excluded}
onChange={(checked) =>
setForwardForm((prev) => ({
...prev,
excludedRecordIds: checked
? (prev.excludedRecordIds || []).filter((id) => id !== recordKey)
: [...new Set([...(prev.excludedRecordIds || []), recordKey])],
}))
}
/>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-zinc-800">
{record.title || record.recordName || record.id}
</div>
<div className="mt-1 text-xs text-zinc-500">
当前将归入{excluded ? '“本次不下发”' : '“本次下发”'},
状态为 {getForwardRowStatusLabel(rowStatusByRecordId[recordKey] || 'pending_send')}
</div>
</div>
</CheckboxField>
);
})}
</div>
</div>
<UserMultiSelectField
label="转发接收人"
required
......
import { getApplications } from '@/services/DataService';
import * as Headless from '@headlessui/react';
import {
ArrowTopRightOnSquareIcon,
ExclamationCircleIcon,
SparklesIcon,
} from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { useEffect, useMemo, useState } from 'react';
function normalizeLabelList(items) {
if (!Array.isArray(items)) {
return [];
}
return items
.map((item) => {
if (typeof item === 'string') {
return item;
}
return item?.display_name || item?.title || item?.name || '';
})
.filter(Boolean);
}
function resolveApplicationUrl(application) {
if (application?.name) {
return `/${application.name}`;
}
return '#';
}
function normalizeApplication(application, fallback = {}) {
const name = application?.name || fallback?.name || '';
return {
name,
title:
application?.title ||
fallback?.title ||
application?.name ||
fallback?.name ||
'未命名应用',
description:
application?.description ||
fallback?.description ||
fallback?.title ||
application?.title ||
'进入对应应用继续处理业务',
logo: application?.logo || fallback?.logo || null,
url: resolveApplicationUrl({
name,
}),
access: application?.access || fallback?.access || '',
status: application?.status || fallback?.status || '',
type: application?.type || fallback?.type || '',
order: application?.order ?? fallback?.order ?? 0,
roles: normalizeLabelList(
application?.roles?.length ? application.roles : fallback?.roles,
),
groups: normalizeLabelList(
application?.groups?.length ? application.groups : fallback?.groups,
),
};
}
function formatAccessLabel(access) {
if (access === 'public') {
return '公开';
}
if (access === 'protected') {
return '受限';
}
if (access === 'private') {
return '私有';
}
if (access === 'internal') {
return '内部';
}
return '';
}
function mergeApplications(fetchedApplications, fallbackApplications) {
const fallbackMap = new Map(
fallbackApplications.map((application) => [application.name, application]),
);
const normalizedFetched = fetchedApplications
.map((application) =>
normalizeApplication(application, fallbackMap.get(application?.name)),
)
.filter((application) => application.name);
const mergedMap = new Map(
normalizedFetched.map((application) => [application.name, application]),
);
fallbackApplications.forEach((application) => {
if (!application?.name || mergedMap.has(application.name)) {
return;
}
mergedMap.set(
application.name,
normalizeApplication(application, application),
);
});
return Array.from(mergedMap.values()).sort((left, right) => {
const orderDifference = Number(left.order || 0) - Number(right.order || 0);
if (orderDifference !== 0) {
return orderDifference;
}
return left.title.localeCompare(right.title, 'zh-CN');
});
}
function buildApplicationSections(applications, applicationGroups) {
if (!Array.isArray(applicationGroups) || applicationGroups.length === 0) {
return [
{
key: 'all',
label: '全部应用',
applications,
},
];
}
const applicationMap = new Map(
applications.map((application) => [application.name, application]),
);
const groupedApplicationNames = new Set();
const sections = applicationGroups
.map((group, index) => {
const groupApplications = Array.isArray(group?.applications)
? group.applications
.map((name) => {
const application = applicationMap.get(name);
if (application) {
groupedApplicationNames.add(name);
}
return application;
})
.filter(Boolean)
: [];
return {
key: group?.label || `group-${index}`,
label: group?.label || '应用分组',
applications: groupApplications,
};
})
.filter((section) => section.applications.length > 0);
const ungroupedApplications = applications.filter(
(application) => !groupedApplicationNames.has(application.name),
);
if (ungroupedApplications.length > 0) {
sections.push({
key: 'ungrouped',
label: '其他应用',
applications: ungroupedApplications,
});
}
return sections;
}
function isCurrentApplication(application) {
if (typeof window === 'undefined') {
return false;
}
const currentPath = window.location.pathname;
const applicationRoot = application?.name ? `/${application.name}` : '';
if (application?.url?.startsWith('http')) {
try {
const url = new URL(application.url);
return url.pathname === currentPath;
} catch (error) {
return false;
}
}
return (
application?.url === currentPath ||
(!!applicationRoot &&
(currentPath === applicationRoot ||
currentPath.startsWith(`${applicationRoot}/`)))
);
}
function ApplicationCard({ application }) {
const current = isCurrentApplication(application);
const target = application?.url?.startsWith('http') ? '_blank' : undefined;
const accessLabel = formatAccessLabel(application.access);
const firstRole = application.roles?.[0];
const firstGroup = application.groups?.[0];
return (
<a
href={application.url}
target={target}
rel={target ? 'noreferrer' : undefined}
className={clsx(
'group relative min-h-[84px] overflow-hidden rounded-2xl border border-zinc-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(248,250,252,0.94))] p-2.5 shadow-[0_10px_24px_rgba(15,23,42,0.06)] transition duration-200 ease-out hover:-translate-y-0.5 hover:border-rose-200 hover:shadow-[0_16px_32px_rgba(244,63,94,0.12)] focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/60',
current &&
'border-rose-300 bg-[linear-gradient(180deg,rgba(255,241,242,0.95),rgba(255,255,255,0.96))]',
)}
>
<div className="absolute inset-x-0 top-0 h-14 bg-[radial-gradient(circle_at_top_right,rgba(251,113,133,0.14),transparent_50%)] opacity-0 transition duration-200 group-hover:opacity-100" />
<div className="relative flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-2.5">
{application.logo ? (
<img
src={application.logo}
alt={application.title}
className="size-9 rounded-xl border border-white/80 bg-white object-cover shadow-sm"
/>
) : (
<div className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-[linear-gradient(135deg,#b42318,#f43f5e)] text-sm font-semibold text-white shadow-[0_8px_18px_rgba(180,35,24,0.22)]">
{application.title.slice(0, 1)}
</div>
)}
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-slate-950">
{application.title}
</div>
<div className="mt-0.5 truncate text-[11px] leading-4 text-slate-500">
{application.description}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{current ? (
<span className="rounded-full bg-rose-100 px-2 py-0.5 text-[11px] font-medium text-rose-700">
当前
</span>
) : null}
<span className="rounded-full bg-slate-100 p-1.5 text-slate-500 transition group-hover:bg-rose-50 group-hover:text-rose-600">
<ArrowTopRightOnSquareIcon className="size-3.5" />
</span>
</div>
</div>
{accessLabel || firstRole || firstGroup ? (
<div className="relative mt-2 flex flex-wrap gap-1.5">
{accessLabel ? (
<span className="rounded-full bg-zinc-100 px-2 py-0.5 text-[11px] font-medium text-zinc-700">
{accessLabel}
</span>
) : null}
{firstRole ? (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-700">
{firstRole}
</span>
) : null}
{firstGroup ? (
<span className="rounded-full bg-rose-50 px-2 py-0.5 text-[11px] font-medium text-rose-700">
{firstGroup}
</span>
) : null}
</div>
) : null}
</a>
);
}
function LoadingSkeleton() {
return (
<div className="grid gap-2.5 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-6">
{Array.from({ length: 6 }).map((_, index) => (
<div
key={index}
className="min-h-[84px] rounded-2xl border border-zinc-200 bg-white/90 p-2.5 shadow-[0_10px_24px_rgba(15,23,42,0.05)]"
>
<div className="animate-pulse">
<div className="flex items-start gap-3">
<div className="size-9 rounded-xl bg-zinc-200" />
<div className="min-w-0 flex-1 space-y-2">
<div className="h-4 w-2/5 rounded-full bg-zinc-200" />
<div className="h-3 w-full rounded-full bg-zinc-100" />
</div>
</div>
<div className="mt-2 flex gap-1.5">
<div className="h-5 w-12 rounded-full bg-zinc-100" />
<div className="h-5 w-14 rounded-full bg-zinc-100" />
</div>
</div>
</div>
))}
</div>
);
}
export default function ApplicationSwitcherDialog({
open,
onClose,
userInfo,
siteInfo,
}) {
const fallbackApplications = useMemo(() => {
if (!Array.isArray(userInfo?.applications)) {
return [];
}
return userInfo.applications
.map((application) =>
normalizeApplication(application, {
...application,
description: application?.title,
}),
)
.filter((application) => application.name);
}, [userInfo]);
const [applications, setApplications] = useState(fallbackApplications);
const [status, setStatus] = useState(
fallbackApplications.length > 0 ? 'success' : 'idle',
);
const [errorMessage, setErrorMessage] = useState('');
const applicationSections = useMemo(() => {
return buildApplicationSections(applications, siteInfo?.group);
}, [applications, siteInfo?.group]);
useEffect(() => {
if (fallbackApplications.length === 0) {
return;
}
setApplications((previous) => {
if (previous.length > 0) {
return previous;
}
return fallbackApplications;
});
}, [fallbackApplications]);
useEffect(() => {
if (!open) {
return;
}
let cancelled = false;
const loadApplications = async () => {
setStatus((previous) => (previous === 'success' ? previous : 'loading'));
setErrorMessage('');
try {
const response = await getApplications();
const fetchedApplications = Array.isArray(response) ? response : [];
if (cancelled) {
return;
}
const mergedApplications = mergeApplications(
fetchedApplications,
fallbackApplications,
);
setApplications(mergedApplications);
setStatus('success');
} catch (error) {
if (cancelled) {
return;
}
setApplications(fallbackApplications);
setStatus('error');
setErrorMessage('应用列表加载失败,已回退展示当前账号上的应用信息。');
}
};
loadApplications();
return () => {
cancelled = true;
};
}, [fallbackApplications, open]);
return (
<Headless.Dialog
open={open}
onClose={onClose}
className="relative z-[180]"
>
<Headless.DialogBackdrop
transition
className="fixed inset-0 bg-[rgba(15,23,42,0.48)] backdrop-blur-sm transition duration-300 ease-out data-closed:opacity-0"
/>
<div className="fixed inset-0 overflow-y-auto p-3 sm:p-4 lg:p-5">
<div className="flex min-h-full items-center justify-center">
<Headless.DialogPanel
transition
className="flex max-h-[calc(100dvh-2rem)] w-full max-w-[92rem] flex-col overflow-hidden rounded-[28px] border border-white/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(248,250,252,0.94))] p-4 shadow-[0_30px_80px_rgba(15,23,42,0.22)] ring-1 ring-black/5 transition duration-300 ease-out data-closed:translate-y-4 data-closed:scale-[0.97] data-closed:opacity-0 sm:p-5"
>
<div className="flex shrink-0 flex-col gap-3 border-b border-zinc-200/80 pb-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="inline-flex items-center gap-2 rounded-full bg-rose-50 px-2.5 py-0.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-rose-600">
<SparklesIcon className="size-3.5" />
Applications
</div>
<Headless.DialogTitle className="mt-2 text-xl font-semibold tracking-tight text-slate-950 sm:text-2xl">
应用切换
</Headless.DialogTitle>
<p className="mt-1 max-w-2xl text-xs leading-5 text-slate-500">
这里展示当前账号可访问的全部应用,点击卡片即可进入对应工作区。
</p>
</div>
<div className="flex items-center gap-3">
<div className="rounded-2xl border border-rose-100 bg-rose-50/70 px-3 py-2 text-right">
<div className="text-[11px] font-medium tracking-wide text-rose-500">
可见应用
</div>
<div className="mt-0.5 text-xl font-semibold tracking-tight text-slate-950">
{applications.length}
</div>
</div>
<button
type="button"
onClick={onClose}
className="inline-flex h-10 items-center justify-center rounded-xl border border-zinc-200 bg-white px-3.5 text-sm font-medium text-slate-600 transition hover:border-zinc-300 hover:text-slate-900"
>
关闭
</button>
</div>
</div>
<div className="mt-4 min-h-0 flex-1 overflow-y-auto pr-1 [scrollbar-gutter:stable]">
{status === 'error' && errorMessage ? (
<div className="mb-4 flex items-start gap-3 rounded-2xl border border-amber-200 bg-amber-50/80 px-4 py-3 text-sm text-amber-900">
<ExclamationCircleIcon className="mt-0.5 size-5 shrink-0 text-amber-500" />
<div>{errorMessage}</div>
</div>
) : null}
{status === 'loading' && applications.length === 0 ? (
<LoadingSkeleton />
) : applications.length > 0 ? (
<div className="space-y-4">
{applicationSections.map((section) => (
<section key={section.key}>
<div className="mb-2 flex items-center gap-3">
<h3 className="text-sm font-semibold text-slate-900">
{section.label}
</h3>
<span className="h-px flex-1 bg-zinc-200/80" />
<span className="text-xs font-medium text-slate-400">
{section.applications.length}
</span>
</div>
<div className="grid gap-2.5 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-6">
{section.applications.map((application) => (
<ApplicationCard
key={application.name}
application={application}
/>
))}
</div>
</section>
))}
</div>
) : (
<div className="flex min-h-72 flex-col items-center justify-center rounded-[28px] border border-dashed border-zinc-200 bg-zinc-50/80 px-6 text-center">
<div className="text-lg font-semibold text-slate-900">
暂无可切换应用
</div>
<p className="mt-2 max-w-md text-sm leading-6 text-slate-500">
当前账号还没有分配其它应用权限,后续分配后这里会自动展示。
</p>
</div>
)}
</div>
</Headless.DialogPanel>
</div>
</div>
</Headless.Dialog>
);
}
......@@ -6,24 +6,29 @@ import {
DialogDescription,
DialogTitle,
} from '@/catalyst-ui-kit/javascript/dialog';
import { Textarea } from '@/catalyst-ui-kit/javascript/textarea';
import { UserContext } from '@/wrapper/Auth';
import {
ApprovalStatusTag,
DynamicFieldInput,
EmptyCollection,
FieldLabel,
HeroBanner,
NoticeBanner,
Panel,
SectionHeader,
Tag,
UserSelectField,
} from './AnalysisShared';
import {
createEmptyForwardDraft,
findIncompleteForwardItemRecords,
FORWARD_ITEM_FILL_FIELDS,
formatFieldValue,
formatDateTime,
getApprovalStatusLabel,
getEditorStatusLabel,
getFieldInputKind,
loadForwardEditableRecords,
loadForwardRecords,
saveForwardRecord,
saveForwardEditableRecords,
updateForwardRecordStatus,
} from './analysisService';
import { useContext, useEffect, useMemo, useState } from 'react';
......@@ -41,11 +46,16 @@ export default function ForwardApprovals() {
const [activeFilter, setActiveFilter] = useState('initiated');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [editLoading, setEditLoading] = useState(false);
const [editSaving, setEditSaving] = useState(false);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [activeRecordId, setActiveRecordId] = useState('');
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [manualForwardDraft, setManualForwardDraft] = useState(createEmptyForwardDraft);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [editingRecord, setEditingRecord] = useState(null);
const [editableSubjectDefinition, setEditableSubjectDefinition] = useState(null);
const [editableRecords, setEditableRecords] = useState([]);
const [activeEditableRecordId, setActiveEditableRecordId] = useState('');
useEffect(() => {
let cancelled = false;
......@@ -56,6 +66,14 @@ export default function ForwardApprovals() {
const result = await loadForwardRecords();
if (!cancelled) {
setRecords(result);
const hasEditableInvolvedRecord = result.some(
(record) =>
record.recipientIds.includes(currentUserId) &&
record.editorStatus !== 'completed',
);
if (hasEditableInvolvedRecord) {
setActiveFilter('involved');
}
setError('');
}
} catch (loadError) {
......@@ -72,7 +90,17 @@ export default function ForwardApprovals() {
return () => {
cancelled = true;
};
}, []);
}, [currentUserId]);
const editableInvolvedCount = useMemo(
() =>
records.filter(
(record) =>
record.recipientIds.includes(currentUserId) &&
record.editorStatus !== 'completed',
).length,
[currentUserId, records],
);
const filteredRecords = useMemo(() => {
return records.filter((record) => {
......@@ -83,8 +111,10 @@ export default function ForwardApprovals() {
if (activeFilter === 'pending') {
return (
(record.firstApprover === currentUserId &&
record.editorStatus === 'completed' &&
record.approvalStatus === 'pending') ||
(record.finalApprover === currentUserId &&
record.editorStatus === 'completed' &&
record.approvalStatus === 'approved' &&
record.finalApprovalStatus === 'pending')
);
......@@ -125,8 +155,130 @@ export default function ForwardApprovals() {
}
}
async function openEditDialog(record) {
if (
!record ||
!(
record.recipientIds.includes(currentUserId) ||
record.firstApprover === currentUserId ||
record.finalApprover === currentUserId
)
) {
return;
}
setEditingRecord(record);
setIsEditDialogOpen(true);
setEditLoading(true);
setError('');
try {
const result = await loadForwardEditableRecords(record);
setEditableSubjectDefinition(result.subjectDefinition);
setEditableRecords(result.records);
setActiveEditableRecordId(result.records[0]?.id || '');
} catch (loadError) {
setError(loadError?.message || '填写数据加载失败');
setEditingRecord(null);
setEditableSubjectDefinition(null);
setEditableRecords([]);
setActiveEditableRecordId('');
} finally {
setEditLoading(false);
}
}
function handleChangeEditableRecord(recordId, fieldAlias, nextValue) {
const aliasMap = {
truth_status: 'truthStatus',
rectification_plan: 'rectificationPlan',
expected_rectification_completed_at: 'expectedRectificationCompletedAt',
};
const displayAlias = aliasMap[fieldAlias];
setEditableRecords((prev) =>
prev.map((record) =>
record.id === recordId
? {
...record,
[fieldAlias]: nextValue,
...(displayAlias ? { [displayAlias]: nextValue } : {}),
}
: record,
),
);
}
async function handleSaveEditableRecords() {
if (!editingRecord) {
return;
}
setEditSaving(true);
try {
const saved = await saveForwardEditableRecords(
editingRecord,
editableRecords,
userInfo,
);
setEditableRecords(saved);
setNotice('填写数据已保存。');
setError('');
} catch (saveError) {
setError(saveError?.message || '填写数据保存失败');
} finally {
setEditSaving(false);
}
}
async function handleCompleteEditing() {
if (!editingRecord) {
return;
}
const incompleteRecords = findIncompleteForwardItemRecords(editableRecords);
if (incompleteRecords.length > 0) {
setError(`还有 ${incompleteRecords.length} 条记录未完整填写是否属实、整改方案或预计整改完成时间。`);
return;
}
setEditSaving(true);
try {
if (editableRecords.length > 0) {
const saved = await saveForwardEditableRecords(
editingRecord,
editableRecords,
userInfo,
);
setEditableRecords(saved);
}
const updatedForwardRecord = await updateForwardRecordStatus(editingRecord.recordName, {
editor_status: 'completed',
completed_by_id:
userInfo?._id || userInfo?.id || userInfo?.username || '',
completed_by_name:
userInfo?.display_name || userInfo?.displayName || userInfo?.username || '',
completed_at: new Date().toISOString(),
});
setEditingRecord(updatedForwardRecord);
setIsEditDialogOpen(false);
await refreshRecords('填写已完成,当前批次已进入待审批。');
setError('');
} catch (saveError) {
setError(saveError?.message || '完成填写失败');
} finally {
setEditSaving(false);
}
}
async function handleApprove(record) {
if (record.firstApprover === currentUserId && record.approvalStatus === 'pending') {
if (
record.firstApprover === currentUserId &&
record.editorStatus === 'completed' &&
record.approvalStatus === 'pending'
) {
await handleUpdateRecord(record, { approval_status: 'approved' });
return;
}
......@@ -141,7 +293,11 @@ export default function ForwardApprovals() {
}
async function handleReject(record) {
if (record.firstApprover === currentUserId && record.approvalStatus === 'pending') {
if (
record.firstApprover === currentUserId &&
record.editorStatus === 'completed' &&
record.approvalStatus === 'pending'
) {
await handleUpdateRecord(record, {
approval_status: 'rejected',
final_approval_status: 'pending',
......@@ -158,46 +314,72 @@ export default function ForwardApprovals() {
}
}
async function handleCreateManualForward() {
if (!String(manualForwardDraft.note || '').trim()) {
setError('请先填写转发说明。');
return;
}
const canCurrentUserEdit = Boolean(
activeRecord &&
activeRecord.recipientIds.includes(currentUserId) &&
activeRecord.editorStatus !== 'completed',
);
if (!manualForwardDraft.firstApprover || !manualForwardDraft.finalApprover) {
setError('请指定一级审批人和终审人。');
return;
}
const canCurrentUserViewData = Boolean(
activeRecord &&
(
activeRecord.recipientIds.includes(currentUserId) ||
activeRecord.firstApprover === currentUserId ||
activeRecord.finalApprover === currentUserId
),
);
setSaving(true);
try {
await saveForwardRecord(
{
viewId: 'manual_forward_view',
viewName: '手工补录转发',
subjectName: 'manual_forward',
selectedRecordIds: [],
recipientIds: [],
recipientNames: [],
note: manualForwardDraft.note,
firstApprover: manualForwardDraft.firstApprover.id,
firstApproverName: manualForwardDraft.firstApprover.label,
finalApprover: manualForwardDraft.finalApprover.id,
finalApproverName: manualForwardDraft.finalApprover.label,
approvalStatus: 'pending',
finalApprovalStatus: 'pending',
},
userInfo,
const canEditingRecordEdit = Boolean(
editingRecord &&
editingRecord.recipientIds.includes(currentUserId) &&
editingRecord.editorStatus !== 'completed',
);
const activeEditableRecord = useMemo(
() =>
editableRecords.find((record) => record.id === activeEditableRecordId) ||
editableRecords[0] ||
null,
[activeEditableRecordId, editableRecords],
);
const simpleSummaryFields = useMemo(() => {
const fields = editableSubjectDefinition?.fields || [];
return fields
.filter((field) => {
const kind = getFieldInputKind(field);
return ['text', 'number', 'date', 'boolean', 'select', 'reference'].includes(kind);
})
.slice(0, 3);
}, [editableSubjectDefinition?.fields]);
const dirtyRecordIdSet = useMemo(() => {
return new Set(
editableRecords
.filter(
(record) =>
(record.truth_status || '') !== (record.forwardItem?.truthStatus || '') ||
(record.rectification_plan || '') !==
(record.forwardItem?.rectificationPlan || '') ||
(record.expected_rectification_completed_at || '') !==
(record.forwardItem?.expectedRectificationCompletedAt || ''),
)
.map((record) => record.id),
);
}, [editableRecords]);
function renderReadonlyField(field, value) {
const kind = getFieldInputKind(field);
const singleColumn = ['textarea', 'json', 'subject', 'file'].includes(kind);
return (
<div key={field.alias} className={singleColumn ? 'md:col-span-2' : ''}>
<FieldLabel required={field.isRequired}>{field.label}</FieldLabel>
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm leading-6 text-zinc-700">
{formatFieldValue(field, value)}
</div>
</div>
);
setIsDialogOpen(false);
setManualForwardDraft(createEmptyForwardDraft());
await refreshRecords('已补录一条手工转发审批记录。');
setError('');
} catch (saveError) {
setError(saveError?.message || '手工补录失败');
} finally {
setSaving(false);
}
}
return (
......@@ -206,18 +388,6 @@ export default function ForwardApprovals() {
eyebrow="FORWARD APPROVALS"
title="转发审批页负责把视图分析结果继续推到协作链路里"
description="这里集中展示我的发起、待我审批和我参与的转发记录。两级顺序审批会明确区分“审批状态”和“最终审批状态”,终审只能在一级审批通过后再推进。"
actions={
<Button
color="blue"
onClick={() => {
setIsDialogOpen(true);
setNotice('');
setError('');
}}
>
手工补录转发
</Button>
}
/>
{loading ? <NoticeBanner>正在加载转发审批记录...</NoticeBanner> : null}
......@@ -245,6 +415,11 @@ export default function ForwardApprovals() {
</button>
))}
</div>
{editableInvolvedCount > 0 ? (
<div className="mt-4 rounded-[20px] border border-sky-200 bg-sky-50 px-4 py-3 text-sm leading-6 text-sky-700">
你当前有 {editableInvolvedCount} 条待填写记录。编辑入口在“我参与的”视角,选中批次后点击右侧“进入填写”即可补充行级回填。
</div>
) : null}
</Panel>
<div className="grid gap-6 xl:grid-cols-[0.92fr_1.08fr]">
......@@ -279,6 +454,9 @@ export default function ForwardApprovals() {
</div>
</div>
<div className="flex flex-wrap gap-2">
<Tag tone={record.editorStatus === 'completed' ? 'emerald' : 'amber'}>
{getEditorStatusLabel(record.editorStatus)}
</Tag>
<ApprovalStatusTag status={record.approvalStatus} />
<ApprovalStatusTag status={record.finalApprovalStatus} />
</div>
......@@ -288,7 +466,7 @@ export default function ForwardApprovals() {
) : (
<EmptyCollection
title="当前视角下没有审批记录"
description="可以从分析视图页勾选记录发起转发,也可以在本页手工补录一条审批记录。"
description="可以回到分析视图页勾选记录并发起新的转发审批。"
/>
)}
</div>
......@@ -312,6 +490,9 @@ export default function ForwardApprovals() {
</div>
</div>
<div className="flex flex-wrap gap-2">
<Tag tone={activeRecord.editorStatus === 'completed' ? 'emerald' : 'amber'}>
{getEditorStatusLabel(activeRecord.editorStatus)}
</Tag>
<ApprovalStatusTag status={activeRecord.approvalStatus} />
<ApprovalStatusTag status={activeRecord.finalApprovalStatus} />
</div>
......@@ -340,6 +521,17 @@ export default function ForwardApprovals() {
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-[22px] border border-zinc-200 bg-white p-4">
<div className="text-sm font-medium text-zinc-500">填写状态</div>
<div className="mt-2 text-base font-semibold text-zinc-950">
{getEditorStatusLabel(activeRecord.editorStatus)}
</div>
<div className="mt-2 text-sm text-zinc-500">
{activeRecord.completedByName
? `已由 ${activeRecord.completedByName} 于 ${formatDateTime(activeRecord.completedAt)} 完成填写`
: '接收人仍可进入填写并保存修改'}
</div>
</div>
<div className="rounded-[22px] border border-zinc-200 bg-white p-4">
<div className="text-sm font-medium text-zinc-500">一级审批人</div>
<div className="mt-2 text-base font-semibold text-zinc-950">
......@@ -360,6 +552,22 @@ export default function ForwardApprovals() {
</div>
</div>
<div className="rounded-[22px] border border-zinc-200 bg-white p-4">
<div className="text-sm font-medium text-zinc-500">数据查看与填写</div>
<div className="mt-3 flex flex-col gap-3 sm:flex-row">
<Button
color="blue"
disabled={saving || !canCurrentUserViewData}
onClick={() => openEditDialog(activeRecord)}
>
{canCurrentUserEdit ? '进入填写' : '查看本批次数据'}
</Button>
</div>
<div className="mt-3 text-xs leading-5 text-zinc-500">
规则:填写人、一级审批人和终审人都可以查看这批次数据;只有接收人且批次仍处于填写中时可以修改。任意一位接收人点击“完成填写”后,这批数据即锁定为不可修改,并进入待审批状态。
</div>
</div>
<div className="rounded-[22px] border border-zinc-200 bg-zinc-50 p-4">
<div className="text-sm font-medium text-zinc-500">审批动作</div>
<div className="mt-3 flex flex-col gap-3 sm:flex-row">
......@@ -369,8 +577,10 @@ export default function ForwardApprovals() {
saving ||
!(
(activeRecord.firstApprover === currentUserId &&
activeRecord.editorStatus === 'completed' &&
activeRecord.approvalStatus === 'pending') ||
(activeRecord.finalApprover === currentUserId &&
activeRecord.editorStatus === 'completed' &&
activeRecord.approvalStatus === 'approved' &&
activeRecord.finalApprovalStatus === 'pending')
)
......@@ -385,8 +595,10 @@ export default function ForwardApprovals() {
saving ||
!(
(activeRecord.firstApprover === currentUserId &&
activeRecord.editorStatus === 'completed' &&
activeRecord.approvalStatus === 'pending') ||
(activeRecord.finalApprover === currentUserId &&
activeRecord.editorStatus === 'completed' &&
activeRecord.approvalStatus === 'approved' &&
activeRecord.finalApprovalStatus === 'pending')
)
......@@ -425,62 +637,204 @@ export default function ForwardApprovals() {
</Panel>
</div>
<Dialog open={isDialogOpen} onClose={setIsDialogOpen} size="3xl">
<DialogTitle>手工补录转发审批</DialogTitle>
<Dialog
open={isEditDialogOpen}
onClose={(nextOpen) => {
setIsEditDialogOpen(nextOpen);
if (!nextOpen) {
setEditingRecord(null);
}
}}
size="5xl"
>
<DialogTitle>填写下发数据</DialogTitle>
<DialogDescription>
当外部线下动作已经发生,但仍需要在系统里补齐审批轨迹时,可以在这里补录。
{canEditingRecordEdit
? '接收人需要逐条填写是否属实、整改方案和预计整改完成时间。点击“完成填写”后,本批次回填数据会锁定并进入待审批状态。'
: '当前角色可查看本批次下发记录及行级回填数据;如该批次已完成填写或当前角色不是接收人,则仅可查看不可修改。'}
</DialogDescription>
<DialogBody>
<div className="grid gap-4">
{editLoading ? (
<NoticeBanner>正在加载可填写数据...</NoticeBanner>
) : editableRecords.length > 0 && editableSubjectDefinition ? (
<div className="grid gap-5 xl:grid-cols-[22rem_minmax(0,1fr)]">
<div className="rounded-[24px] border border-zinc-200 bg-zinc-50/70 p-4">
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">补录说明</div>
<Textarea
rows={4}
value={manualForwardDraft.note}
onChange={(event) =>
setManualForwardDraft((prev) => ({
...prev,
note: event.target.value,
}))
}
/>
<div className="text-base font-semibold text-zinc-950">
本批次共 {editableRecords.length} 条记录
</div>
<div className="mt-1 text-sm text-zinc-500">
{editingRecord?.title || editingRecord?.viewName || '当前批次'} · {editingRecord?.subjectName || '-'}
</div>
</div>
<Tag tone={canEditingRecordEdit ? 'amber' : 'sky'}>
{canEditingRecordEdit ? '可编辑' : '只读查看'}
</Tag>
</div>
<div className="max-h-[32rem] space-y-3 overflow-y-auto pr-1">
{editableRecords.map((record, index) => {
const active = activeEditableRecord?.id === record.id;
const dirty = dirtyRecordIdSet.has(record.id);
return (
<button
key={record.id}
type="button"
onClick={() => setActiveEditableRecordId(record.id)}
className={`w-full rounded-[20px] border px-4 py-4 text-left transition ${
active
? 'border-sky-300 bg-white shadow-sm'
: 'border-zinc-200 bg-white/70 hover:border-zinc-300'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-zinc-400">
记录 {index + 1}
</div>
<div className="mt-2 truncate text-sm font-semibold text-zinc-950">
{record.title || record.recordName || record.id}
</div>
</div>
<div className="flex flex-col items-end gap-2">
{active ? <Tag tone="sky">当前编辑中</Tag> : null}
{dirty ? <Tag tone="amber">已修改</Tag> : null}
{!canEditingRecordEdit ? <Tag>只读</Tag> : null}
</div>
</div>
<div className="mt-3 space-y-1">
{simpleSummaryFields.map((field) => (
<div key={field.alias} className="flex items-start justify-between gap-3 text-xs">
<span className="shrink-0 text-zinc-400">{field.label}</span>
<span className="truncate text-right text-zinc-600">
{formatFieldValue(field, record[field.alias])}
</span>
</div>
))}
</div>
</button>
);
})}
</div>
</div>
<div className="rounded-[24px] border border-zinc-200 bg-white p-5">
{activeEditableRecord ? (
<div className="space-y-5">
<div className="flex flex-col gap-3 border-b border-zinc-200 pb-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="text-lg font-semibold text-zinc-950">
{activeEditableRecord.title || activeEditableRecord.recordName || activeEditableRecord.id}
</div>
<div className="mt-1 text-sm text-zinc-500">
当前第 {editableRecords.findIndex((record) => record.id === activeEditableRecord.id) + 1} 条,共 {editableRecords.length} 条
</div>
</div>
<div className="flex flex-wrap gap-2">
<Tag>{editingRecord?.subjectName || '-'}</Tag>
{dirtyRecordIdSet.has(activeEditableRecord.id) ? (
<Tag tone="amber">当前记录未保存</Tag>
) : (
<Tag tone="emerald">当前记录已同步</Tag>
)}
</div>
</div>
{simpleSummaryFields.length > 0 ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{simpleSummaryFields.map((field) => (
<div key={field.alias} className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3">
<div className="text-xs font-medium uppercase tracking-[0.14em] text-zinc-400">
{field.label}
</div>
<div className="mt-2 truncate text-sm font-medium text-zinc-800">
{formatFieldValue(field, activeEditableRecord[field.alias])}
</div>
</div>
))}
</div>
) : null}
<div className="max-h-[32rem] overflow-y-auto pr-1">
<div className="grid gap-4 md:grid-cols-2">
<UserSelectField
label="一级审批人"
required
value={manualForwardDraft.firstApprover}
onChange={(nextValue) =>
setManualForwardDraft((prev) => ({
...prev,
firstApprover: nextValue,
}))
}
/>
<UserSelectField
label="终审人"
required
value={manualForwardDraft.finalApprover}
{FORWARD_ITEM_FILL_FIELDS.map((field) => {
const singleColumn = ['textarea', 'json', 'subject', 'file'].includes(
getFieldInputKind(field),
);
return (
<div
key={`${activeEditableRecord.id}_${field.alias}`}
className={singleColumn ? 'md:col-span-2' : ''}
>
{canEditingRecordEdit ? (
<DynamicFieldInput
field={field}
value={activeEditableRecord[field.alias]}
disabled={!canEditingRecordEdit}
onChange={(nextValue) =>
setManualForwardDraft((prev) => ({
...prev,
finalApprover: nextValue,
}))
handleChangeEditableRecord(
activeEditableRecord.id,
field.alias,
nextValue,
)
}
/>
) : (
renderReadonlyField(field, activeEditableRecord[field.alias])
)}
</div>
);
})}
</div>
</div>
</div>
) : null}
</div>
</div>
) : (
<EmptyCollection
title="当前批次没有可填写记录"
description="请先确认本批次是否带有下发记录,或这些记录是否已经存在于 Record 数据中。"
/>
)}
</DialogBody>
<DialogActions>
<Button outline onClick={() => setIsDialogOpen(false)}>
取消
<Button
outline
onClick={() => {
setIsEditDialogOpen(false);
setEditingRecord(null);
}}
>
关闭
</Button>
<Button color="blue" onClick={handleCreateManualForward} disabled={saving}>
确认补录
<Button
outline
disabled={
editLoading ||
editSaving ||
editableRecords.length === 0 ||
!canEditingRecordEdit
}
onClick={handleSaveEditableRecords}
>
保存修改
</Button>
<Button
color="blue"
disabled={
editLoading ||
editSaving ||
editableRecords.length === 0 ||
!canEditingRecordEdit
}
onClick={handleCompleteEditing}
>
完成填写
</Button>
</DialogActions>
</Dialog>
</div>
);
}
import ApplicationSwitcherDialog from '@/pages/project_manager_xy/ApplicationSwitcherDialog';
import xyLogo from '@/assets/xy-logo.png';
import xyLogoSmall from '@/assets/xy-logo_small.png';
import { UserContext } from '@/wrapper/Auth';
import { SiteContext } from '@/wrapper/Site';
import {
ArrowsRightLeftIcon,
Bars3Icon,
ChartBarSquareIcon,
ChartPieIcon,
ClipboardDocumentListIcon,
Squares2X2Icon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { useEffect, useMemo, useState } from 'react';
import { useContext, useEffect, useMemo, useState } from 'react';
import { Link, Outlet, useLocation } from 'umi';
const APP_NAME = '数据分析工具';
......@@ -185,7 +189,10 @@ function MobileSidebar({ open, close, children }) {
export default function ProjectManagerLayout() {
const location = useLocation();
const userInfo = useContext(UserContext);
const siteInfo = useContext(SiteContext);
const [isMobile, setIsMobile] = useState(false);
const [isApplicationDialogOpen, setIsApplicationDialogOpen] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
......@@ -229,6 +236,23 @@ export default function ProjectManagerLayout() {
);
}, [location.pathname]);
const applicationSwitcherButton = (
<button
type="button"
onClick={() => setIsApplicationDialogOpen(true)}
aria-label="Open application switcher"
title="应用切换"
className={clsx(
'inline-flex items-center justify-center rounded-xl border border-zinc-700 bg-zinc-800 text-zinc-300 shadow-sm transition duration-200 hover:border-[#fb923c]/70 hover:bg-zinc-800/90 hover:text-[#fdba74] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#fb923c]/40',
sidebarCollapsed ? 'size-9' : 'size-10',
)}
>
<Squares2X2Icon
className={clsx(sidebarCollapsed ? 'size-[18px]' : 'size-5')}
/>
</button>
);
return (
<div className="flex h-screen overflow-hidden bg-zinc-50">
<aside
......@@ -239,22 +263,34 @@ export default function ProjectManagerLayout() {
>
<div
className={clsx(
'mb-7 flex items-center',
sidebarCollapsed ? 'justify-center' : 'gap-3 px-1',
'mb-7',
sidebarCollapsed
? 'flex flex-col items-center gap-3'
: 'flex items-start justify-between gap-4 px-1',
)}
>
{sidebarCollapsed ? (
<>
<img
src={xyLogoSmall}
alt="XY Logo"
className="h-9 w-auto max-w-[52px] shrink-0 object-contain"
className="h-8 w-auto max-w-8 shrink-0 object-contain"
/>
) : null}
{!sidebarCollapsed ? (
{applicationSwitcherButton}
</>
) : (
<>
<div className="min-w-0 flex-1">
<h1 className="truncate text-sm font-semibold tracking-[0.02em] text-white/95">
{APP_NAME}
</h1>
) : null}
<div className="mt-1 text-xs text-zinc-400">
配置驱动的数据分析工具
</div>
</div>
{applicationSwitcherButton}
</>
)}
</div>
<nav className="flex-1 space-y-5 overflow-y-auto">
......@@ -280,6 +316,17 @@ export default function ProjectManagerLayout() {
</div>
))}
</nav>
{!sidebarCollapsed ? (
<div className="mt-6 rounded-2xl border border-zinc-800 bg-zinc-950/70 p-4 text-sm text-zinc-300">
<div className="font-medium text-white/90">
{userInfo?.display_name || userInfo?.username || '当前用户'}
</div>
<div className="mt-1 text-xs text-zinc-500">
当前正在查看数据分析工具
</div>
</div>
) : null}
</aside>
<div className="relative z-30 hidden lg:flex">
......@@ -307,6 +354,7 @@ export default function ProjectManagerLayout() {
close={() => setIsMobileSidebarOpen(false)}
>
<div className="px-5 pt-6">
<div>
<div className="flex items-center gap-2">
<img
src={xyLogo}
......@@ -317,6 +365,10 @@ export default function ProjectManagerLayout() {
{APP_NAME}
</h1>
</div>
<div className="mt-1 text-xs text-zinc-500">
配置驱动分析工作区
</div>
</div>
</div>
<nav className="space-y-5 overflow-y-auto px-5 pb-6 pt-6">
......@@ -351,12 +403,26 @@ export default function ProjectManagerLayout() {
<Bars3Icon className="size-5" />
</NavbarItem>
<div className="text-sm text-zinc-500">{currentGroupTitle}</div>
<NavbarItem
onClick={() => setIsApplicationDialogOpen(true)}
aria-label="Open application switcher"
className="border border-rose-200 bg-white text-rose-500 shadow-sm hover:bg-rose-50 hover:text-rose-600"
>
<Squares2X2Icon className="size-5" />
</NavbarItem>
</div>
<div className="flex-1 overflow-y-auto rounded-[32px] border border-white/70 bg-white/75 p-4 shadow-[0_24px_80px_rgba(15,23,42,0.06)] backdrop-blur sm:p-6">
<Outlet />
</div>
</main>
<ApplicationSwitcherDialog
open={isApplicationDialogOpen}
onClose={() => setIsApplicationDialogOpen(false)}
userInfo={userInfo}
siteInfo={siteInfo}
/>
</div>
);
}
......@@ -14,7 +14,9 @@ import {
import dayjs from 'dayjs';
export const ANALYSIS_VIEW_SUBJECT = 'analysis_view_config';
export const ANALYSIS_GROUP_SUBJECT = 'analysis_view_group';
export const ANALYSIS_FORWARD_SUBJECT = 'analysis_forward_record';
export const ANALYSIS_FORWARD_ITEM_SUBJECT = 'analysis_forward_record_item';
export const ANALYSIS_BOARD_SUBJECT = 'analysis_dashboard_board';
export const VIEW_SCOPE_OPTIONS = [
......@@ -42,18 +44,72 @@ export const APPROVAL_STATUS_OPTIONS = [
{ value: 'rejected', label: '已驳回' },
];
export const EDITOR_STATUS_OPTIONS = [
{ value: 'editing', label: '填写中' },
{ value: 'completed', label: '已完成填写' },
];
export const TRUTH_STATUS_OPTIONS = [
{ value: 'truthful', label: '属实' },
{ value: 'untruthful', label: '不属实' },
];
export const FORWARD_ITEM_FILL_FIELDS = [
{
alias: 'truth_status',
label: '是否属实',
type: 'select',
isRequired: true,
settings: {
options: TRUTH_STATUS_OPTIONS,
},
},
{
alias: 'rectification_plan',
label: '整改方案',
type: 'long_text',
isRequired: true,
},
{
alias: 'expected_rectification_completed_at',
label: '预计整改完成时间',
type: 'date',
isRequired: true,
},
];
const ANALYSIS_SYSTEM_SUBJECTS = new Set([
ANALYSIS_VIEW_SUBJECT,
ANALYSIS_GROUP_SUBJECT,
ANALYSIS_FORWARD_SUBJECT,
ANALYSIS_FORWARD_ITEM_SUBJECT,
ANALYSIS_BOARD_SUBJECT,
]);
const LOCAL_STORAGE_KEYS = {
groups: 'analysis_tool_groups',
views: 'analysis_tool_views',
forwards: 'analysis_tool_forwards',
forwardItems: 'analysis_tool_forward_items',
boards: 'analysis_tool_boards',
};
const DEMO_USER_IDS = {
current: '64b6575b80bdbd9b3d65d09b',
firstApprover: '68f59ff3722d1977138f672f',
finalApprover: '661de69af0e41b429e6a712e',
recipientA: '649e5a55ad1c1038911baac0',
recipientB: '69b12a47e92b1de77c94a9f5',
};
const DEMO_USER_NAMES = {
current: '杨康',
firstApprover: '杨长青',
finalApprover: 'Alice',
recipientA: '系统',
recipientB: '杨康',
};
const FALLBACK_SUBJECT_DEFINITIONS = {
am_material: {
name: 'am_material',
......@@ -130,7 +186,7 @@ const FALLBACK_SUBJECT_DEFINITIONS = {
description: '分析视图配置对象',
fields: [
{ alias: 'view_name', label: '视图名称', type: 'text', isRequired: true },
{ alias: 'subject_name', label: '数据源对象', type: 'subject', isRequired: true },
{ alias: 'subject_name', label: '数据源对象', type: 'text', isRequired: true },
{ alias: 'description', label: '视图说明', type: 'long_text' },
{
alias: 'scope',
......@@ -140,10 +196,25 @@ const FALLBACK_SUBJECT_DEFINITIONS = {
options: VIEW_SCOPE_OPTIONS,
},
},
{ alias: 'filter_fields', label: '筛选字段', type: 'json' },
{ alias: 'default_filters', label: '默认筛选值', type: 'json' },
{ alias: 'columns', label: '结果列', type: 'json' },
{ alias: 'charts', label: '图表配置', type: 'json' },
{ alias: 'filter_fields', label: '筛选字段', type: 'text', isMultiple: true },
{ alias: 'default_filters', label: '默认筛选值', type: 'object' },
{ alias: 'columns', label: '结果列', type: 'text', isMultiple: true, isRequired: true },
{ alias: 'charts', label: '图表配置', type: 'object', isMultiple: true },
{ alias: 'group_id', label: '所属分组 ID', type: 'text' },
{ alias: 'group_name', label: '分组名', type: 'text' },
{ alias: 'tags', label: '视图标签', type: 'text', isMultiple: true },
{ alias: 'hidden_record_ids', label: '手动隐藏记录 ID', type: 'text', isMultiple: true },
],
},
[ANALYSIS_GROUP_SUBJECT]: {
name: ANALYSIS_GROUP_SUBJECT,
title: '分析视图分组',
description: '分析视图分组树对象',
fields: [
{ alias: 'group_name', label: '分组名称', type: 'text', isRequired: true },
{ alias: 'parent_group_id', label: '父分组 ID', type: 'text' },
{ alias: 'created_by_user_id', label: '创建人 ID', type: 'user' },
{ alias: 'created_by_user_name', label: '创建人名称', type: 'text' },
],
},
[ANALYSIS_FORWARD_SUBJECT]: {
......@@ -151,15 +222,45 @@ const FALLBACK_SUBJECT_DEFINITIONS = {
title: '转发审批记录',
description: '分析视图转发与审批记录',
fields: [
{ alias: 'view_id', label: '来源视图', type: 'reference', isRequired: true },
{ alias: 'subject_name', label: '数据源对象', type: 'subject', isRequired: true },
{ alias: 'selected_record_ids', label: '勾选记录', type: 'json', isRequired: true },
{ alias: 'recipient_ids', label: '转发接收人', type: 'user', isMultiple: true },
{ alias: 'view_id', label: '来源视图', type: 'text', isRequired: true },
{ alias: 'view_name', label: '来源视图名称', type: 'text' },
{ alias: 'subject_name', label: '数据源对象', type: 'text', isRequired: true },
{ alias: 'selected_record_ids', label: '勾选记录', type: 'text', isMultiple: true, isRequired: true },
{ alias: 'recipient_ids', label: '转发接收人', type: 'text', isMultiple: true },
{ alias: 'recipient_names', label: '接收人名称', type: 'text', isMultiple: true },
{ alias: 'excluded_record_ids', label: '不下发记录', type: 'text', isMultiple: true },
{ alias: 'note', label: '转发说明', type: 'long_text', isRequired: true },
{ alias: 'editor_status', label: '填写状态', type: 'select' },
{ alias: 'completed_by_id', label: '完成人 ID', type: 'text' },
{ alias: 'completed_by_name', label: '完成人名称', type: 'text' },
{ alias: 'completed_at', label: '完成时间', type: 'date' },
{ alias: 'approval_status', label: '审批状态', type: 'select' },
{ alias: 'final_approval_status', label: '最终审批状态', type: 'select' },
{ alias: 'first_approver', label: '一级审批人', type: 'user', isRequired: true },
{ alias: 'final_approver', label: '终审人', type: 'user', isRequired: true },
{ alias: 'first_approver', label: '一级审批人', type: 'text', isRequired: true },
{ alias: 'first_approver_name', label: '一级审批人名称', type: 'text' },
{ alias: 'final_approver', label: '终审人', type: 'text', isRequired: true },
{ alias: 'final_approver_name', label: '终审人名称', type: 'text' },
{ alias: 'initiator_id', label: '发起人 ID', type: 'text' },
{ alias: 'initiator_name', label: '发起人名称', type: 'text' },
],
},
[ANALYSIS_FORWARD_ITEM_SUBJECT]: {
name: ANALYSIS_FORWARD_ITEM_SUBJECT,
title: '转发记录行级回填明细',
description: '每条下发记录的属实与整改回填数据',
fields: [
{ alias: 'forward_record_id', label: '来源转发批次 ID', type: 'text', isRequired: true },
{ alias: 'forward_record_title', label: '来源转发批次名称', type: 'text' },
{ alias: 'view_id', label: '来源视图 ID', type: 'text', isRequired: true },
{ alias: 'view_name', label: '来源视图名称', type: 'text' },
{ alias: 'subject_name', label: '数据源对象', type: 'text', isRequired: true },
{ alias: 'source_record_id', label: '源记录 ID', type: 'text', isRequired: true },
{ alias: 'source_record_title', label: '源记录标题', type: 'text' },
...FORWARD_ITEM_FILL_FIELDS,
{ alias: 'filled_by_id', label: '填写人 ID', type: 'user' },
{ alias: 'filled_by_name', label: '填写人名称', type: 'text' },
{ alias: 'filled_at', label: '填写时间', type: 'date' },
{ alias: 'remark', label: '备注', type: 'long_text' },
],
},
[ANALYSIS_BOARD_SUBJECT]: {
......@@ -230,12 +331,371 @@ function createLocalRecord(subject, values = {}) {
};
}
function buildDemoViewRecords() {
const now = dayjs();
return [
{
_id: 'demo-analysis-view-1',
name: 'demo_analysis_view_1',
title: '材料分类概览',
subject: ANALYSIS_VIEW_SUBJECT,
metadata: {
view_name: '材料分类概览',
subject_name: 'am_material',
description: '按材料分类和上传时间快速查看审计材料分布。',
scope: 'personal',
filter_fields: ['archive_category', 'tag', 'uploaded_at', 'uploader'],
default_filters: {},
columns: ['material_title', 'archive_category', 'tag', 'uploader', 'uploaded_at', 'remark'],
group_id: 'demo-group-material-overview',
group_name: '材料概览',
tags: ['分类', '概览'],
hidden_record_ids: [],
charts: [
normalizeChartConfig({
id: 'demo-chart-view-1',
title: '材料分类分布',
type: 'bar',
dimensionField: 'archive_category',
metricField: '',
aggregation: 'count',
topN: 8,
}),
],
created_by_user_id: DEMO_USER_IDS.current,
created_by_user_name: DEMO_USER_NAMES.current,
},
created_at: now.subtract(5, 'day').toISOString(),
updated_at: now.subtract(1, 'day').toISOString(),
},
{
_id: 'demo-analysis-view-2',
name: 'demo_analysis_view_2',
title: '上传人跟进视图',
subject: ANALYSIS_VIEW_SUBJECT,
metadata: {
view_name: '上传人跟进视图',
subject_name: 'am_material',
description: '按上传人与标签查看当前材料归集进度。',
scope: 'personal',
filter_fields: ['uploader', 'tag', 'source_app'],
default_filters: {},
columns: ['material_title', 'uploader', 'tag', 'source_app', 'uploaded_at'],
group_id: 'demo-group-material-progress',
group_name: '归集进度',
tags: ['进度', '上传'],
hidden_record_ids: [],
charts: [
normalizeChartConfig({
id: 'demo-chart-view-2',
title: '上传人分布',
type: 'pie',
dimensionField: 'uploader',
metricField: '',
aggregation: 'count',
topN: 6,
}),
],
created_by_user_id: DEMO_USER_IDS.current,
created_by_user_name: DEMO_USER_NAMES.current,
},
created_at: now.subtract(4, 'day').toISOString(),
updated_at: now.subtract(12, 'hour').toISOString(),
},
{
_id: 'demo-analysis-view-3',
name: 'demo_analysis_view_3',
title: '材料来源筛查',
subject: ANALYSIS_VIEW_SUBJECT,
metadata: {
view_name: '材料来源筛查',
subject_name: 'am_material',
description: '聚焦不同来源应用与提供部门的材料结构。',
scope: 'personal',
filter_fields: ['source_app', 'provide_department', 'is_confidential'],
default_filters: {},
columns: ['material_title', 'source_app', 'provide_department', 'is_confidential', 'provide_time'],
group_id: 'demo-group-material-audit',
group_name: '重点巡检',
tags: ['巡检', '来源'],
hidden_record_ids: [],
charts: [
normalizeChartConfig({
id: 'demo-chart-view-3',
title: '来源应用统计',
type: 'bar',
dimensionField: 'source_app',
metricField: '',
aggregation: 'count',
topN: 8,
}),
],
created_by_user_id: DEMO_USER_IDS.current,
created_by_user_name: DEMO_USER_NAMES.current,
},
created_at: now.subtract(3, 'day').toISOString(),
updated_at: now.subtract(8, 'hour').toISOString(),
},
{
_id: 'demo-analysis-view-4',
name: 'demo_analysis_view_4',
title: '归档状态巡检',
subject: ANALYSIS_VIEW_SUBJECT,
metadata: {
view_name: '归档状态巡检',
subject_name: 'am_material',
description: '适合临时筛出待补充说明、待归档标签的材料记录。',
scope: 'personal',
filter_fields: ['archive_category', 'remark', 'is_audit_dept_material'],
default_filters: {},
columns: ['material_title', 'archive_category', 'remark', 'is_audit_dept_material', 'uploaded_at'],
group_id: 'demo-group-material-audit',
group_name: '重点巡检',
tags: ['巡检', '归档'],
hidden_record_ids: [],
charts: [
normalizeChartConfig({
id: 'demo-chart-view-4',
title: '审计部材料占比',
type: 'pie',
dimensionField: 'is_audit_dept_material',
metricField: '',
aggregation: 'count',
topN: 2,
}),
],
created_by_user_id: DEMO_USER_IDS.current,
created_by_user_name: DEMO_USER_NAMES.current,
},
created_at: now.subtract(2, 'day').toISOString(),
updated_at: now.subtract(4, 'hour').toISOString(),
},
];
}
function buildDemoGroupRecords() {
const now = dayjs();
return [
createLocalRecord(ANALYSIS_GROUP_SUBJECT, {
_id: 'demo-group-material-overview',
name: 'demo_group_material_overview',
title: '材料概览',
metadata: {
group_name: '材料概览',
parent_group_id: '',
created_by_user_id: DEMO_USER_IDS.current,
created_by_user_name: DEMO_USER_NAMES.current,
},
created_at: now.subtract(5, 'day').toISOString(),
updated_at: now.subtract(5, 'day').toISOString(),
}),
createLocalRecord(ANALYSIS_GROUP_SUBJECT, {
_id: 'demo-group-material-progress',
name: 'demo_group_material_progress',
title: '归集进度',
metadata: {
group_name: '归集进度',
parent_group_id: 'demo-group-material-overview',
created_by_user_id: DEMO_USER_IDS.current,
created_by_user_name: DEMO_USER_NAMES.current,
},
created_at: now.subtract(4, 'day').toISOString(),
updated_at: now.subtract(4, 'day').toISOString(),
}),
createLocalRecord(ANALYSIS_GROUP_SUBJECT, {
_id: 'demo-group-material-audit',
name: 'demo_group_material_audit',
title: '重点巡检',
metadata: {
group_name: '重点巡检',
parent_group_id: 'demo-group-material-overview',
created_by_user_id: DEMO_USER_IDS.current,
created_by_user_name: DEMO_USER_NAMES.current,
},
created_at: now.subtract(3, 'day').toISOString(),
updated_at: now.subtract(3, 'day').toISOString(),
}),
];
}
function buildDemoForwardRecords() {
const now = dayjs();
return [
{
_id: 'demo-forward-1',
name: 'demo_forward_1',
title: '材料分类概览',
subject: ANALYSIS_FORWARD_SUBJECT,
metadata: {
view_id: 'demo-analysis-view-1',
view_name: '材料分类概览',
subject_name: 'am_material',
selected_record_ids: ['am_record_001', 'am_record_002', 'am_record_003'],
recipient_ids: [DEMO_USER_IDS.recipientA],
recipient_names: [DEMO_USER_NAMES.recipientA],
excluded_record_ids: ['am_record_004'],
note: '请先确认分类口径,再决定是否继续补充标签。',
editor_status: 'editing',
approval_status: 'pending',
final_approval_status: 'pending',
first_approver: DEMO_USER_IDS.firstApprover,
first_approver_name: DEMO_USER_NAMES.firstApprover,
final_approver: DEMO_USER_IDS.finalApprover,
final_approver_name: DEMO_USER_NAMES.finalApprover,
initiator_id: DEMO_USER_IDS.current,
initiator_name: DEMO_USER_NAMES.current,
},
created_at: now.subtract(20, 'hour').toISOString(),
updated_at: now.subtract(20, 'hour').toISOString(),
},
{
_id: 'demo-forward-2',
name: 'demo_forward_2',
title: '上传人跟进视图',
subject: ANALYSIS_FORWARD_SUBJECT,
metadata: {
view_id: 'demo-analysis-view-2',
view_name: '上传人跟进视图',
subject_name: 'am_material',
selected_record_ids: ['am_record_011', 'am_record_012'],
recipient_ids: [DEMO_USER_IDS.recipientB],
recipient_names: [DEMO_USER_NAMES.recipientB],
excluded_record_ids: [],
note: '这批材料已经补齐,请帮忙做一级审批。',
editor_status: 'completed',
completed_by_id: DEMO_USER_IDS.recipientB,
completed_by_name: DEMO_USER_NAMES.recipientB,
completed_at: now.subtract(12, 'hour').toISOString(),
approval_status: 'approved',
final_approval_status: 'pending',
first_approver: DEMO_USER_IDS.firstApprover,
first_approver_name: DEMO_USER_NAMES.firstApprover,
final_approver: DEMO_USER_IDS.finalApprover,
final_approver_name: DEMO_USER_NAMES.finalApprover,
initiator_id: DEMO_USER_IDS.current,
initiator_name: DEMO_USER_NAMES.current,
},
created_at: now.subtract(18, 'hour').toISOString(),
updated_at: now.subtract(10, 'hour').toISOString(),
},
{
_id: 'demo-forward-3',
name: 'demo_forward_3',
title: '材料来源筛查',
subject: ANALYSIS_FORWARD_SUBJECT,
metadata: {
view_id: 'demo-analysis-view-3',
view_name: '材料来源筛查',
subject_name: 'am_material',
selected_record_ids: ['am_record_021', 'am_record_022', 'am_record_023', 'am_record_024'],
recipient_ids: [DEMO_USER_IDS.recipientA, DEMO_USER_IDS.recipientB],
recipient_names: [DEMO_USER_NAMES.recipientA, DEMO_USER_NAMES.recipientB],
excluded_record_ids: [],
note: '来源应用字段需要复核,先驳回补资料。',
editor_status: 'completed',
completed_by_id: DEMO_USER_IDS.recipientA,
completed_by_name: DEMO_USER_NAMES.recipientA,
completed_at: now.subtract(14, 'hour').toISOString(),
approval_status: 'rejected',
final_approval_status: 'pending',
first_approver: DEMO_USER_IDS.firstApprover,
first_approver_name: DEMO_USER_NAMES.firstApprover,
final_approver: DEMO_USER_IDS.finalApprover,
final_approver_name: DEMO_USER_NAMES.finalApprover,
initiator_id: DEMO_USER_IDS.current,
initiator_name: DEMO_USER_NAMES.current,
},
created_at: now.subtract(15, 'hour').toISOString(),
updated_at: now.subtract(13, 'hour').toISOString(),
},
{
_id: 'demo-forward-4',
name: 'demo_forward_4',
title: '归档状态巡检',
subject: ANALYSIS_FORWARD_SUBJECT,
metadata: {
view_id: 'demo-analysis-view-4',
view_name: '归档状态巡检',
subject_name: 'am_material',
selected_record_ids: ['am_record_031'],
recipient_ids: [DEMO_USER_IDS.recipientA],
recipient_names: [DEMO_USER_NAMES.recipientA],
excluded_record_ids: [],
note: '终审前请再确认是否涉密字段展示无误。',
editor_status: 'completed',
completed_by_id: DEMO_USER_IDS.recipientA,
completed_by_name: DEMO_USER_NAMES.recipientA,
completed_at: now.subtract(10, 'hour').toISOString(),
approval_status: 'approved',
final_approval_status: 'approved',
first_approver: DEMO_USER_IDS.firstApprover,
first_approver_name: DEMO_USER_NAMES.firstApprover,
final_approver: DEMO_USER_IDS.finalApprover,
final_approver_name: DEMO_USER_NAMES.finalApprover,
initiator_id: DEMO_USER_IDS.current,
initiator_name: DEMO_USER_NAMES.current,
},
created_at: now.subtract(12, 'hour').toISOString(),
updated_at: now.subtract(6, 'hour').toISOString(),
},
{
_id: 'demo-forward-5',
name: 'demo_forward_5',
title: '材料分类概览',
subject: ANALYSIS_FORWARD_SUBJECT,
metadata: {
view_id: 'demo-analysis-view-1',
view_name: '材料分类概览',
subject_name: 'am_material',
selected_record_ids: ['am_record_041', 'am_record_042'],
recipient_ids: [DEMO_USER_IDS.recipientB],
recipient_names: [DEMO_USER_NAMES.recipientB],
excluded_record_ids: [],
note: '这条留给终审人模拟待办场景。',
editor_status: 'completed',
completed_by_id: DEMO_USER_IDS.recipientB,
completed_by_name: DEMO_USER_NAMES.recipientB,
completed_at: now.subtract(4, 'hour').toISOString(),
approval_status: 'approved',
final_approval_status: 'pending',
first_approver: DEMO_USER_IDS.firstApprover,
first_approver_name: DEMO_USER_NAMES.firstApprover,
final_approver: DEMO_USER_IDS.current,
final_approver_name: DEMO_USER_NAMES.current,
initiator_id: DEMO_USER_IDS.firstApprover,
initiator_name: DEMO_USER_NAMES.firstApprover,
},
created_at: now.subtract(9, 'hour').toISOString(),
updated_at: now.subtract(3, 'hour').toISOString(),
},
];
}
function getDemoAnalysisGroups() {
return buildDemoGroupRecords().map(normalizeGroupRecord);
}
function getDemoAnalysisViews() {
return buildDemoViewRecords().map(normalizeViewRecord);
}
function getDemoForwardRecords() {
return buildDemoForwardRecords().map(normalizeForwardRecord);
}
function extractArray(response) {
const candidates = [
response,
response?.data,
response?.subjects,
response?.data?.subjects,
response?.records,
response?.data?.records,
response?.users,
response?.data?.users,
response?.list,
response?.data?.list,
response?.items,
......@@ -256,8 +716,12 @@ function extractCount(response) {
const candidates = [
response?.count,
response?.total,
response?.total_subjects,
response?.total_users,
response?.data?.count,
response?.data?.total,
response?.data?.total_subjects,
response?.data?.total_users,
response?.result?.count,
response?.result?.total,
];
......@@ -482,6 +946,14 @@ export function formatFieldValue(field, value) {
return formatDateTime(value);
}
if (kind === 'select') {
return (
normalizeOptions(field.settings?.options || []).find(
(option) => option.value === value,
)?.label || String(value)
);
}
if (kind === 'json' || kind === 'subject') {
return typeof value === 'string' ? value : JSON.stringify(value);
}
......@@ -578,6 +1050,16 @@ function normalizeChartConfig(chart = {}, index = 0) {
function normalizeViewRecord(record = {}) {
const metadata = record.metadata || {};
const tags = Array.isArray(metadata.tags)
? metadata.tags.filter(Boolean)
: metadata.tags
? [metadata.tags].filter(Boolean)
: [];
const hiddenRecordIds = Array.isArray(metadata.hidden_record_ids)
? metadata.hidden_record_ids.filter(Boolean)
: metadata.hidden_record_ids
? [metadata.hidden_record_ids].filter(Boolean)
: [];
return {
id: record._id || record.id || record.name || '',
......@@ -597,6 +1079,35 @@ function normalizeViewRecord(record = {}) {
charts: Array.isArray(metadata.charts)
? metadata.charts.map(normalizeChartConfig)
: [],
groupId: metadata.group_id || '',
groupName: metadata.group_name || '',
tags,
hiddenRecordIds,
createdById:
metadata.created_by_user_id ||
record.created_by?._id ||
record.created_by?.id ||
'',
createdByName:
metadata.created_by_user_name ||
record.created_by?.display_name ||
record.created_by?.username ||
'',
createdAt: record.created_at || '',
updatedAt: record.updated_at || '',
rawRecord: record,
};
}
function normalizeGroupRecord(record = {}) {
const metadata = record.metadata || {};
return {
id: record._id || record.id || record.name || '',
recordName: record.name || record._id || record.id || '',
title: metadata.group_name || record.title || '未命名分组',
groupName: metadata.group_name || record.title || '未命名分组',
parentGroupId: metadata.parent_group_id || '',
createdById:
metadata.created_by_user_id ||
record.created_by?._id ||
......@@ -635,6 +1146,11 @@ function normalizeForwardRecord(record = {}) {
: metadata.recipient_ids
? [metadata.recipient_ids]
: [];
const excludedRecordIds = Array.isArray(metadata.excluded_record_ids)
? metadata.excluded_record_ids
: metadata.excluded_record_ids
? [metadata.excluded_record_ids]
: [];
return {
id: record._id || record.id || record.name || '',
......@@ -650,7 +1166,12 @@ function normalizeForwardRecord(record = {}) {
recipientNames: Array.isArray(metadata.recipient_names)
? metadata.recipient_names
: [],
excludedRecordIds,
note: metadata.note || '',
editorStatus: metadata.editor_status || 'editing',
completedById: metadata.completed_by_id || '',
completedByName: metadata.completed_by_name || '',
completedAt: metadata.completed_at || '',
approvalStatus: metadata.approval_status || 'pending',
finalApprovalStatus: metadata.final_approval_status || 'pending',
firstApprover: metadata.first_approver || '',
......@@ -665,6 +1186,38 @@ function normalizeForwardRecord(record = {}) {
};
}
function normalizeForwardItemRecord(record = {}) {
const metadata = record.metadata || {};
return {
id: record._id || record.id || record.name || '',
recordName: record.name || record._id || record.id || '',
title:
record.title ||
metadata.source_record_title ||
metadata.forward_record_title ||
'转发回填明细',
forwardRecordId: metadata.forward_record_id || '',
forwardRecordTitle: metadata.forward_record_title || '',
viewId: metadata.view_id || '',
viewName: metadata.view_name || '',
subjectName: metadata.subject_name || '',
sourceRecordId: metadata.source_record_id || '',
sourceRecordTitle: metadata.source_record_title || '',
truthStatus: metadata.truth_status || '',
rectificationPlan: metadata.rectification_plan || '',
expectedRectificationCompletedAt:
metadata.expected_rectification_completed_at || '',
filledById: metadata.filled_by_id || '',
filledByName: metadata.filled_by_name || '',
filledAt: metadata.filled_at || '',
remark: metadata.remark || '',
createdAt: record.created_at || '',
updatedAt: record.updated_at || '',
rawRecord: record,
};
}
function normalizeBoardRecord(record = {}) {
const metadata = record.metadata || {};
......@@ -703,6 +1256,14 @@ function createViewPayload(values = {}, currentUser = null) {
charts: Array.isArray(values.charts)
? values.charts.map(normalizeChartConfig)
: [],
group_id: String(values.groupId || '').trim(),
group_name: String(values.groupName || '').trim(),
tags: Array.isArray(values.tags)
? values.tags.map((tag) => String(tag || '').trim()).filter(Boolean)
: [],
hidden_record_ids: Array.isArray(values.hiddenRecordIds)
? values.hiddenRecordIds.map((id) => String(id || '').trim()).filter(Boolean)
: [],
created_by_user_id:
currentUser?._id || currentUser?.id || currentUser?.username || '',
created_by_user_name:
......@@ -732,7 +1293,14 @@ function createForwardPayload(values = {}, currentUser = null) {
recipient_names: Array.isArray(values.recipientNames)
? values.recipientNames
: [],
excluded_record_ids: Array.isArray(values.excludedRecordIds)
? values.excludedRecordIds
: [],
note: String(values.note || '').trim(),
editor_status: values.editorStatus || 'editing',
completed_by_id: values.completedById || '',
completed_by_name: values.completedByName || '',
completed_at: values.completedAt || '',
approval_status: values.approvalStatus || 'pending',
final_approval_status: values.finalApprovalStatus || 'pending',
first_approver: values.firstApprover || '',
......@@ -750,6 +1318,65 @@ function createForwardPayload(values = {}, currentUser = null) {
};
}
function toIsoDateString(value) {
if (!value) {
return '';
}
const parsed = dayjs(value);
return parsed.isValid() ? parsed.toISOString() : String(value);
}
function createForwardItemPayload(values = {}, currentUser = null) {
const sourceRecordTitle =
String(values.sourceRecordTitle || values.title || '').trim() || '转发回填明细';
const hasFillContent = Boolean(
values.truthStatus ||
String(values.rectificationPlan || '').trim() ||
values.expectedRectificationCompletedAt,
);
const filledById =
values.filledById ||
(hasFillContent
? currentUser?._id || currentUser?.id || currentUser?.username
: '') ||
'';
const filledByName =
values.filledByName ||
(hasFillContent
? currentUser?.display_name || currentUser?.displayName || currentUser?.username
: '') ||
'';
return {
title: sourceRecordTitle,
subject: ANALYSIS_FORWARD_ITEM_SUBJECT,
content_type: 'text',
metadata: {
forward_record_id: values.forwardRecordId || '',
forward_record_title: values.forwardRecordTitle || '',
view_id: values.viewId || '',
view_name: values.viewName || '',
subject_name: values.subjectName || '',
source_record_id: values.sourceRecordId || '',
source_record_title: sourceRecordTitle,
truth_status: values.truthStatus || '',
rectification_plan: String(values.rectificationPlan || '').trim(),
expected_rectification_completed_at: toIsoDateString(
values.expectedRectificationCompletedAt,
),
filled_by_id: filledById,
filled_by_name: filledByName,
filled_at: values.filledAt
? toIsoDateString(values.filledAt)
: hasFillContent
? dayjs().toISOString()
: '',
remark: String(values.remark || '').trim(),
},
};
}
function createBoardPayload(values = {}, currentUser = null) {
return {
title: String(values.title || '').trim() || '未命名看板',
......@@ -907,23 +1534,39 @@ export function buildDefaultViewDraft(subjectName, fields = []) {
};
}
export async function loadAnalysisViews() {
export async function loadAnalysisGroups() {
try {
const response = await queryRecords({
subject: ANALYSIS_VIEW_SUBJECT,
subject: ANALYSIS_GROUP_SUBJECT,
page: 0,
page_size: 200,
sort: '-updated_at',
sort: 'created_at',
});
return extractArray(response).map(normalizeViewRecord);
return extractArray(response).map(normalizeGroupRecord);
} catch (_error) {
return getStoredList(LOCAL_STORAGE_KEYS.views).map(normalizeViewRecord);
const storedGroups = getStoredList(LOCAL_STORAGE_KEYS.groups).map(normalizeGroupRecord);
return storedGroups.length > 0 ? storedGroups : getDemoAnalysisGroups();
}
}
export async function saveAnalysisView(values, currentUser = null) {
const payload = createViewPayload(values, currentUser);
export async function saveAnalysisGroup(values, currentUser = null) {
const payload = {
title: String(values.groupName || values.title || '').trim() || '未命名分组',
subject: ANALYSIS_GROUP_SUBJECT,
content_type: 'text',
metadata: {
group_name: String(values.groupName || values.title || '').trim() || '未命名分组',
parent_group_id: String(values.parentGroupId || '').trim(),
created_by_user_id:
currentUser?._id || currentUser?.id || currentUser?.username || '',
created_by_user_name:
currentUser?.display_name ||
currentUser?.displayName ||
currentUser?.username ||
'',
},
};
try {
if (values.recordName) {
......@@ -932,34 +1575,34 @@ export async function saveAnalysisView(values, currentUser = null) {
response?.data ||
response?.result ||
(await loadRecord(values.recordName));
return normalizeViewRecord(record);
return normalizeGroupRecord(record);
}
const response = await createRecord(payload);
const record = response?.data || response?.result || response;
return normalizeViewRecord(record);
return normalizeGroupRecord(record);
} catch (_error) {
if (values.recordName) {
return updateLocalRecord(
ANALYSIS_VIEW_SUBJECT,
ANALYSIS_GROUP_SUBJECT,
values.recordName,
payload,
LOCAL_STORAGE_KEYS.views,
normalizeViewRecord,
LOCAL_STORAGE_KEYS.groups,
normalizeGroupRecord,
);
}
return saveLocalRecord(
ANALYSIS_VIEW_SUBJECT,
ANALYSIS_GROUP_SUBJECT,
payload,
LOCAL_STORAGE_KEYS.views,
normalizeViewRecord,
LOCAL_STORAGE_KEYS.groups,
normalizeGroupRecord,
);
}
}
export async function deleteAnalysisView(view) {
const recordName = view?.recordName || view?.id;
export async function deleteAnalysisGroup(group) {
const recordName = group?.recordName || group?.id;
if (!recordName) {
return;
}
......@@ -967,23 +1610,88 @@ export async function deleteAnalysisView(view) {
try {
await removeRecord(recordName);
} catch (_error) {
removeLocalRecord(recordName, LOCAL_STORAGE_KEYS.views);
removeLocalRecord(recordName, LOCAL_STORAGE_KEYS.groups);
}
}
export function buildRecordQueryFilter(fields = [], filters = {}) {
const fieldMap = new Map(fields.map((field) => [field.alias, field]));
const filter = {};
Object.entries(filters || {}).forEach(([alias, rawValue]) => {
const field = fieldMap.get(alias);
export async function loadAnalysisViews() {
try {
const response = await queryRecords({
subject: ANALYSIS_VIEW_SUBJECT,
page: 0,
page_size: 200,
sort: '-updated_at',
});
if (!field) {
return;
return extractArray(response).map(normalizeViewRecord);
} catch (_error) {
const storedViews = getStoredList(LOCAL_STORAGE_KEYS.views).map(normalizeViewRecord);
return storedViews.length > 0 ? storedViews : getDemoAnalysisViews();
}
}
const kind = getFieldInputKind(field);
const key = `metadata.${alias}`;
export async function saveAnalysisView(values, currentUser = null) {
const payload = createViewPayload(values, currentUser);
try {
if (values.recordName) {
const response = await updateRecord(values.recordName, payload);
const record =
response?.data ||
response?.result ||
(await loadRecord(values.recordName));
return normalizeViewRecord(record);
}
const response = await createRecord(payload);
const record = response?.data || response?.result || response;
return normalizeViewRecord(record);
} catch (_error) {
if (values.recordName) {
return updateLocalRecord(
ANALYSIS_VIEW_SUBJECT,
values.recordName,
payload,
LOCAL_STORAGE_KEYS.views,
normalizeViewRecord,
);
}
return saveLocalRecord(
ANALYSIS_VIEW_SUBJECT,
payload,
LOCAL_STORAGE_KEYS.views,
normalizeViewRecord,
);
}
}
export async function deleteAnalysisView(view) {
const recordName = view?.recordName || view?.id;
if (!recordName) {
return;
}
try {
await removeRecord(recordName);
} catch (_error) {
removeLocalRecord(recordName, LOCAL_STORAGE_KEYS.views);
}
}
export function buildRecordQueryFilter(fields = [], filters = {}) {
const fieldMap = new Map(fields.map((field) => [field.alias, field]));
const filter = {};
Object.entries(filters || {}).forEach(([alias, rawValue]) => {
const field = fieldMap.get(alias);
if (!field) {
return;
}
const kind = getFieldInputKind(field);
const key = `metadata.${alias}`;
const value = rawValue;
if (
......@@ -1086,6 +1794,560 @@ export async function queryAnalysisRecords({
}
}
export async function loadViewRecordCounts(views = []) {
const uniqueViews = Array.isArray(views) ? views.filter((view) => view?.id) : [];
if (uniqueViews.length === 0) {
return {};
}
const uniqueSubjectNames = Array.from(
new Set(uniqueViews.map((view) => view.subjectName).filter(Boolean)),
);
const definitions = await Promise.all(
uniqueSubjectNames.map(async (subjectName) => [
subjectName,
await loadAnalysisSubjectDefinition(subjectName),
]),
);
const definitionMap = new Map(definitions);
const settled = await Promise.allSettled(
uniqueViews.map(async (view) => {
const definition = definitionMap.get(view.subjectName);
const filter = buildRecordQueryFilter(
definition?.fields || [],
view.defaultFilters || {},
);
const response = await countRecords({
subject: view.subjectName,
filter,
});
return [view.id, extractCount(response)];
}),
);
return settled.reduce((accumulator, item) => {
if (item.status === 'fulfilled') {
const [viewId, count] = item.value;
accumulator[viewId] = Number.isFinite(Number(count)) ? Number(count) : 0;
}
return accumulator;
}, {});
}
export async function loadForwardEditableRecords(forwardRecord) {
if (!forwardRecord?.subjectName) {
return {
subjectDefinition: null,
records: [],
};
}
const subjectDefinition = await loadAnalysisSubjectDefinition(forwardRecord.subjectName);
const recordIds = Array.isArray(forwardRecord.selectedRecordIds)
? forwardRecord.selectedRecordIds.filter(Boolean)
: [];
if (recordIds.length === 0) {
return {
subjectDefinition,
records: [],
};
}
const [recordResults, forwardItems] = await Promise.all([
Promise.allSettled(recordIds.map((recordId) => loadRecord(recordId))),
loadForwardItems({ forwardRecordId: forwardRecord.id }),
]);
const forwardItemBySourceId = new Map(
forwardItems
.filter((item) => item.sourceRecordId)
.map((item) => [String(item.sourceRecordId), item]),
);
const records = recordResults.map((item, index) => {
const recordId = String(recordIds[index] || '');
const sourceRecord =
item.status === 'fulfilled'
? normalizeRecord(item.value, subjectDefinition?.fields || [])
: {
id: recordId,
recordName: recordId,
title: recordId,
subject: forwardRecord.subjectName,
metadata: {},
updatedAt: '',
missing: true,
rawRecord: null,
};
const sourceRecordId = String(sourceRecord.recordName || sourceRecord.id || recordId);
const forwardItem = forwardItemBySourceId.get(sourceRecordId) || null;
return {
...sourceRecord,
forwardItem,
forwardItemRecordName: forwardItem?.recordName || '',
truth_status: forwardItem?.truthStatus || '',
rectification_plan: forwardItem?.rectificationPlan || '',
expected_rectification_completed_at:
forwardItem?.expectedRectificationCompletedAt || '',
truthStatus: forwardItem?.truthStatus || '',
rectificationPlan: forwardItem?.rectificationPlan || '',
expectedRectificationCompletedAt:
forwardItem?.expectedRectificationCompletedAt || '',
filledById: forwardItem?.filledById || '',
filledByName: forwardItem?.filledByName || '',
filledAt: forwardItem?.filledAt || '',
remark: forwardItem?.remark || '',
};
});
return {
subjectDefinition,
records,
};
}
export async function loadAnalysisRecordsByIds(subjectName, recordIds = []) {
if (!subjectName) {
return {
subjectDefinition: null,
records: [],
};
}
const subjectDefinition = await loadAnalysisSubjectDefinition(subjectName);
const uniqueRecordIds = Array.from(
new Set((Array.isArray(recordIds) ? recordIds : []).filter(Boolean).map((id) => String(id))),
);
if (uniqueRecordIds.length === 0) {
return {
subjectDefinition,
records: [],
};
}
const settled = await Promise.allSettled(
uniqueRecordIds.map((recordId) => loadRecord(recordId)),
);
const records = settled.map((item, index) => {
const recordId = uniqueRecordIds[index];
if (item.status === 'fulfilled') {
return normalizeRecord(item.value, subjectDefinition?.fields || []);
}
return {
id: recordId,
recordName: recordId,
title: recordId,
subject: subjectName,
metadata: {},
updatedAt: '',
missing: true,
rawRecord: null,
};
});
return {
subjectDefinition,
records,
};
}
export async function loadForwardItems(options = {}) {
const filter = {};
if (options.forwardRecordId) {
filter['metadata.forward_record_id'] = options.forwardRecordId;
}
if (options.viewId) {
filter['metadata.view_id'] = options.viewId;
}
if (Array.isArray(options.sourceRecordIds) && options.sourceRecordIds.length > 0) {
filter['metadata.source_record_id'] = {
$in: options.sourceRecordIds.map((id) => String(id)),
};
}
try {
const response = await queryRecords({
subject: ANALYSIS_FORWARD_ITEM_SUBJECT,
page: 0,
page_size: 500,
sort: '-updated_at',
filter,
});
return extractArray(response).map(normalizeForwardItemRecord);
} catch (_error) {
const items = getStoredList(LOCAL_STORAGE_KEYS.forwardItems).map(
normalizeForwardItemRecord,
);
return items.filter((item) => {
if (options.forwardRecordId && item.forwardRecordId !== options.forwardRecordId) {
return false;
}
if (options.viewId && item.viewId !== options.viewId) {
return false;
}
if (
Array.isArray(options.sourceRecordIds) &&
options.sourceRecordIds.length > 0 &&
!options.sourceRecordIds.map((id) => String(id)).includes(item.sourceRecordId)
) {
return false;
}
return true;
});
}
}
export async function saveForwardItemRecord(values = {}, currentUser = null) {
const payload = createForwardItemPayload(values, currentUser);
try {
if (values.recordName) {
const response = await updateRecord(values.recordName, payload);
const record =
response?.data || response?.result || (await loadRecord(values.recordName));
return normalizeForwardItemRecord(record);
}
const response = await createRecord(payload);
const record = response?.data || response?.result || response;
return normalizeForwardItemRecord(record);
} catch (_error) {
if (values.recordName) {
return updateLocalRecord(
ANALYSIS_FORWARD_ITEM_SUBJECT,
values.recordName,
payload,
LOCAL_STORAGE_KEYS.forwardItems,
normalizeForwardItemRecord,
);
}
return saveLocalRecord(
ANALYSIS_FORWARD_ITEM_SUBJECT,
payload,
LOCAL_STORAGE_KEYS.forwardItems,
normalizeForwardItemRecord,
);
}
}
export async function ensureForwardRecordItems(
forwardRecord,
sourceRecords = [],
currentUser = null,
) {
if (!forwardRecord?.id) {
return [];
}
const selectedRecordIds = Array.isArray(forwardRecord.selectedRecordIds)
? forwardRecord.selectedRecordIds.map((id) => String(id)).filter(Boolean)
: [];
if (selectedRecordIds.length === 0) {
return [];
}
const existingItems = await loadForwardItems({ forwardRecordId: forwardRecord.id });
const existingSourceIdSet = new Set(
existingItems.map((item) => String(item.sourceRecordId)).filter(Boolean),
);
const sourceRecordById = new Map(
(Array.isArray(sourceRecords) ? sourceRecords : []).map((record) => [
String(record.recordName || record.id || ''),
record,
]),
);
const missingRecordIds = selectedRecordIds.filter(
(recordId) => !existingSourceIdSet.has(recordId),
);
if (missingRecordIds.length === 0) {
return existingItems;
}
const createdItems = await Promise.all(
missingRecordIds.map((sourceRecordId) => {
const sourceRecord = sourceRecordById.get(sourceRecordId);
return saveForwardItemRecord(
{
forwardRecordId: forwardRecord.id,
forwardRecordTitle: forwardRecord.title || forwardRecord.viewName || '',
viewId: forwardRecord.viewId || '',
viewName: forwardRecord.viewName || '',
subjectName: forwardRecord.subjectName || '',
sourceRecordId,
sourceRecordTitle:
sourceRecord?.title || sourceRecord?.sourceRecordTitle || sourceRecordId,
},
currentUser,
);
}),
);
return [...existingItems, ...createdItems];
}
export async function saveForwardEditableRecords(
forwardRecord,
records = [],
currentUser = null,
) {
if (!forwardRecord?.id) {
throw new Error('缺少转发批次标识');
}
const results = await Promise.all(
records.map((record) =>
saveForwardItemRecord(
{
recordName: record.forwardItemRecordName,
forwardRecordId: forwardRecord.id,
forwardRecordTitle: forwardRecord.title || forwardRecord.viewName || '',
viewId: forwardRecord.viewId || '',
viewName: forwardRecord.viewName || '',
subjectName: forwardRecord.subjectName || '',
sourceRecordId: record.recordName || record.id || '',
sourceRecordTitle: record.title || record.recordName || record.id || '',
truthStatus: record.truthStatus || record.truth_status || '',
rectificationPlan:
record.rectificationPlan || record.rectification_plan || '',
expectedRectificationCompletedAt:
record.expectedRectificationCompletedAt ||
record.expected_rectification_completed_at ||
'',
remark: record.remark || '',
},
currentUser,
),
),
);
const resultBySourceId = new Map(
results.map((item) => [String(item.sourceRecordId), item]),
);
return records.map((record) => {
const sourceRecordId = String(record.recordName || record.id || '');
const forwardItem = resultBySourceId.get(sourceRecordId) || record.forwardItem;
return {
...record,
forwardItem,
forwardItemRecordName: forwardItem?.recordName || '',
truth_status: forwardItem?.truthStatus || '',
rectification_plan: forwardItem?.rectificationPlan || '',
expected_rectification_completed_at:
forwardItem?.expectedRectificationCompletedAt || '',
truthStatus: forwardItem?.truthStatus || '',
rectificationPlan: forwardItem?.rectificationPlan || '',
expectedRectificationCompletedAt:
forwardItem?.expectedRectificationCompletedAt || '',
filledById: forwardItem?.filledById || '',
filledByName: forwardItem?.filledByName || '',
filledAt: forwardItem?.filledAt || '',
remark: forwardItem?.remark || '',
};
});
}
export function getTruthStatusLabel(status) {
return (
TRUTH_STATUS_OPTIONS.find((option) => option.value === status)?.label ||
status ||
'-'
);
}
export function buildForwardItemIndex(forwardItems = []) {
const sorted = [...forwardItems].sort((left, right) =>
String(right.updatedAt || right.createdAt).localeCompare(
String(left.updatedAt || left.createdAt),
),
);
const latestItemByRecordId = new Map();
sorted.forEach((item) => {
const sourceRecordId = String(item.sourceRecordId || '');
if (sourceRecordId && !latestItemByRecordId.has(sourceRecordId)) {
latestItemByRecordId.set(sourceRecordId, item);
}
});
return latestItemByRecordId;
}
export function findIncompleteForwardItemRecords(records = []) {
return records.filter(
(record) =>
!(record.truthStatus || record.truth_status) ||
!String(record.rectificationPlan || record.rectification_plan || '').trim() ||
!(record.expectedRectificationCompletedAt || record.expected_rectification_completed_at),
);
}
export function isForwardRecordClosed(forwardRecord = {}) {
return (
forwardRecord.approvalStatus === 'rejected' ||
forwardRecord.finalApprovalStatus === 'approved' ||
forwardRecord.finalApprovalStatus === 'rejected'
);
}
export function resolveForwardRowStatus(forwardRecord = null) {
if (!forwardRecord) {
return 'pending_send';
}
if (forwardRecord.editorStatus !== 'completed') {
return 'in_progress';
}
if (
forwardRecord.approvalStatus === 'rejected' ||
forwardRecord.finalApprovalStatus === 'rejected'
) {
return 'rejected';
}
if (forwardRecord.approvalStatus === 'pending') {
return 'pending_approval';
}
if (forwardRecord.finalApprovalStatus === 'approved') {
return 'final_approved';
}
if (forwardRecord.approvalStatus === 'approved') {
return 'approved';
}
return 'in_progress';
}
export function getForwardRowStatusLabel(status) {
const labels = {
pending_send: '待发送',
in_progress: '进行中',
pending_approval: '待审批',
approved: '已审批',
final_approved: '已最终审批',
rejected: '已驳回',
};
return labels[status] || status;
}
export function getForwardRowStatusTone(status) {
const tones = {
pending_send: 'default',
in_progress: 'amber',
pending_approval: 'sky',
approved: 'sky',
final_approved: 'emerald',
rejected: 'rose',
};
return tones[status] || 'default';
}
export function getEditorStatusLabel(status) {
return (
EDITOR_STATUS_OPTIONS.find((option) => option.value === status)?.label ||
status
);
}
export function buildForwardStatusIndex(forwardRecords = []) {
const sorted = [...forwardRecords].sort((left, right) =>
String(right.updatedAt || right.createdAt).localeCompare(
String(left.updatedAt || left.createdAt),
),
);
const latestForwardByRecordId = new Map();
const hiddenRecordIdSet = new Set();
sorted.forEach((forwardRecord) => {
const isClosed = isForwardRecordClosed(forwardRecord);
if (!isClosed) {
(forwardRecord.excludedRecordIds || []).forEach((recordId) => {
if (recordId) {
hiddenRecordIdSet.add(String(recordId));
}
});
}
(forwardRecord.selectedRecordIds || []).forEach((recordId) => {
const key = String(recordId || '');
if (key && !latestForwardByRecordId.has(key)) {
latestForwardByRecordId.set(key, forwardRecord);
}
});
});
return {
hiddenRecordIdSet,
latestForwardByRecordId,
};
}
export function buildGroupTree(groups = []) {
const map = new Map();
groups.forEach((group) => {
if (!group?.id) {
return;
}
map.set(group.id, {
...group,
children: [],
});
});
const roots = [];
map.forEach((group) => {
if (group.parentGroupId && map.has(group.parentGroupId)) {
map.get(group.parentGroupId).children.push(group);
return;
}
roots.push(group);
});
const sortGroups = (items) => {
items.sort((left, right) =>
String(left.groupName || left.title).localeCompare(
String(right.groupName || right.title),
'zh-CN',
),
);
items.forEach((item) => sortGroups(item.children));
return items;
};
return sortGroups(roots);
}
export function buildMetricCards({ subjects = [], views = [], forwards = [], boards = [] }) {
const pendingApprovals = forwards.filter(
(item) =>
......@@ -1206,18 +2468,32 @@ export async function loadUsersByIds(ids = []) {
.filter((user) => user.id);
}
export async function loadForwardRecords() {
export async function loadForwardRecords(options = {}) {
const filter = {};
if (options.viewId) {
filter['metadata.view_id'] = options.viewId;
}
try {
const response = await queryRecords({
subject: ANALYSIS_FORWARD_SUBJECT,
page: 0,
page_size: 200,
sort: '-updated_at',
filter,
});
return extractArray(response).map(normalizeForwardRecord);
} catch (_error) {
return getStoredList(LOCAL_STORAGE_KEYS.forwards).map(normalizeForwardRecord);
const storedForwards = getStoredList(LOCAL_STORAGE_KEYS.forwards).map(normalizeForwardRecord);
const fallback = storedForwards.length > 0 ? storedForwards : getDemoForwardRecords();
if (options.viewId) {
return fallback.filter((item) => item.viewId === options.viewId);
}
return fallback;
}
}
......@@ -1245,16 +2521,46 @@ export async function updateForwardRecordStatus(recordName, nextMetadata = {}) {
try {
const currentRecord = await loadRecord(recordName);
const currentForward = normalizeForwardRecord(currentRecord);
const payload = {
title: currentRecord?.title || '分析转发记录',
title: currentRecord?.title || currentForward.title || '分析转发记录',
subject: ANALYSIS_FORWARD_SUBJECT,
content_type: 'text',
metadata: {
...(currentRecord?.metadata || {}),
...nextMetadata,
view_id: currentForward.viewId || '',
view_name: currentForward.viewName || '',
subject_name: currentForward.subjectName || '',
selected_record_ids: currentForward.selectedRecordIds || [],
recipient_ids: currentForward.recipientIds || [],
recipient_names: currentForward.recipientNames || [],
excluded_record_ids: currentForward.excludedRecordIds || [],
note: currentForward.note || '',
editor_status:
nextMetadata.editor_status ?? currentForward.editorStatus ?? 'editing',
completed_by_id:
nextMetadata.completed_by_id ?? currentForward.completedById ?? '',
completed_by_name:
nextMetadata.completed_by_name ?? currentForward.completedByName ?? '',
completed_at:
nextMetadata.completed_at ?? currentForward.completedAt ?? '',
approval_status:
nextMetadata.approval_status ?? currentForward.approvalStatus ?? 'pending',
final_approval_status:
nextMetadata.final_approval_status ??
currentForward.finalApprovalStatus ??
'pending',
first_approver: currentForward.firstApprover || '',
first_approver_name: currentForward.firstApproverName || '',
final_approver: currentForward.finalApprover || '',
final_approver_name: currentForward.finalApproverName || '',
initiator_id: currentForward.initiatorId || '',
initiator_name: currentForward.initiatorName || '',
},
};
const response = await updateRecord(recordName, payload);
return normalizeForwardRecord(response?.data || response?.result || payload);
return normalizeForwardRecord(
response?.data || response?.result || (await loadRecord(recordName)) || payload,
);
} catch (_error) {
const existing = getStoredList(LOCAL_STORAGE_KEYS.forwards);
const target = existing.find((item) => (item.name || item._id) === recordName);
......@@ -1638,6 +2944,7 @@ export function createEmptyForwardDraft() {
return {
recipientIds: [],
recipientNames: [],
excludedRecordIds: [],
note: '',
firstApprover: null,
firstApproverName: '',
......
......@@ -5,6 +5,7 @@
本模块已从“审计材料中心”重构为“配置驱动的数据分析工具”,核心目标如下:
- 让业务人员可以针对任意业务对象快速搭建单对象分析视图
- 支持以无限级分组树管理分析视图,并通过标签快速检索
- 在分析视图中同时完成筛选、表格查看、图表分析与记录勾选
- 支持把勾选结果转发给其他人,并补充转发记录数据后进入两级顺序审批
- 支持将多个图表组合成独立看板,对不同角色输出隔离的分析展示页
......@@ -15,6 +16,8 @@
- 所有配置与审批记录都走独立 Record 对象,统一写入 `metadata`
- 图表看板和分析视图配置隔离,但允许复用同一数据源与同一图表定义
- 分析视图前端不再区分个人视图 / 共享视图,历史 `scope` 字段仅作为兼容字段保留
- 表格逐行状态根据当前视图下的转发审批批次记录映射得出
- 每条下发记录的“是否属实、整改方案、预计整改完成时间”通过 `analysis_forward_record_item` 独立保存,不写入源对象字段
---
......@@ -27,21 +30,22 @@
- `转发审批`
- `图表看板`
菜单栏左上角提供“应用切换”入口,点击后以弹框展示当前账号可访问的全部应用;桌面端在侧栏展开和收起状态均可进入,移动端在顶部导航栏进入,点击应用卡片后跳转到对应应用工作区。
推荐职责划分:
- `分析总览`
- 展示对象覆盖数、视图数、转发审批数、图表看板数
- 展示可分析对象数、视图数、转发审批数、图表看板数
- 展示对象视图分布、审批状态分布、看板共享情况
- 展示最近配置活动
- `分析视图`
- 选择对象
- 维护视图分组树
- 新建、编辑、删除分析视图
- 查看当前分组及其子分组下的全部视图
- 运行动态筛选、查看结果表格、查看图表
- 在结果区勾选记录后发起转发审批
- `转发审批`
- 展示我的发起、待我审批、我参与的转发记录
- 推进一级审批状态与最终审批状态
- 支持手工补录转发审批
- `图表看板`
- 新建、编辑、删除看板
- 将多个视图图表组合到同一看板
......@@ -52,7 +56,9 @@
## 3. 核心业务对象
- Subject:`analysis_view_config`
- Subject:`analysis_view_group`
- Subject:`analysis_forward_record`
- Subject:`analysis_forward_record_item`
- Subject:`analysis_dashboard_board`
- 数据源对象:当前至少支持 `am_material`
......@@ -65,15 +71,36 @@
- `default_filters`
- `columns`
- `charts`
- `group_id`
- `group_name`
- `tags`
- `hidden_record_ids`
- `analysis_view_group`
- `group_name`
- `parent_group_id`
- `analysis_forward_record`
- `view_id`
- `selected_record_ids`
- `excluded_record_ids`
- `recipient_ids`
- `note`
- `editor_status`
- `completed_by_id`
- `completed_at`
- `approval_status`
- `final_approval_status`
- `first_approver`
- `final_approver`
- `analysis_forward_record_item`
- `forward_record_id`
- `view_id`
- `subject_name`
- `source_record_id`
- `truth_status`
- `rectification_plan`
- `expected_rectification_completed_at`
- `filled_by_id`
- `filled_at`
- `analysis_dashboard_board`
- `board_name`
- `scope`
......@@ -86,68 +113,129 @@
### 4.1 创建分析视图
1. 用户进入 `分析视图`
2. 点击“新建视图”
2. 在左侧分组树中选择一个已有分组,或先创建新分组
3. 点击“新建视图”
3. 在弹窗中通过对象下拉搜索框选择一个业务对象
- 对象列表默认只加载前 10 条
- 用户输入关键词后通过接口搜索对象名称或对象标识
4. 填写视图名称、视图说明
5. 选择要暴露给业务使用的筛选字段
6. 选择结果表格列
7. 配置 1-N 个图表卡片:
5. 选择所属分组,录入 0-N 个视图标签
6. 选择要暴露给业务使用的筛选字段
7. 选择结果表格列
8. 配置 1-N 个图表卡片:
- 图表类型
- 维度字段
- 指标字段
- Top N
- 说明文本
8. 可删除任意一张图表配置卡片,不影响其它图表配置
9. 如有需要,补充默认筛选值
10. 点击保存后创建 `analysis_view_config`
9. 可删除任意一张图表配置卡片,不影响其它图表配置
10. 如有需要,补充默认筛选值
11. 点击保存后创建 `analysis_view_config`
### 4.2 使用分析视图查看数据
### 4.2 管理视图分组
1. 用户进入 `分析视图`
2. 页面左侧直接展示全部分析视图列表,不要求先选择对象
3. 用户可以通过视图搜索框按视图名称、说明或绑定对象快速定位
4. 在左侧纵向滚动视图列表中快速浏览和切换视图
- 视图卡片展示名称、说明、绑定对象、列数、筛选字段数和图表数
- 列表固定高度并支持上下滚动,不随视图数量拉长页面
5. 选择视图后,右侧自动加载该视图绑定对象并按视图配置动态渲染筛选区
6. 用户修改筛选条件并刷新结果
7. 系统查询对象数据并刷新结果区
8. 结果区使用 Tab 分离:
2. 左侧展示 `全部视图` 根节点和无限级分组树
3. 用户可对分组执行:
- 新建同级分组
- 新建子分组
- 重命名分组
- 删除空分组
4. 菜单中只展示分组名,不直接展示分组下的视图
5. 删除分组时,若仍存在子分组或视图,则需先迁移后再删除
### 4.3 使用分析视图查看数据
1. 用户进入 `分析视图`
2. 页面左侧先选择某个分组节点
3. 右侧先展示当前分组及其所有子分组下的全部分析视图
4. 桌面端左侧分组树在页面内固定显示,不随右侧内容区滚动而离开视口
5. 当左侧分组树节点过多时,分组树区域自身出现滚动条,避免拉长整页
6. 用户可以通过搜索和标签快速定位视图
7. 视图列表展示名称、说明、绑定对象、所属分组、标签、当前命中数据量、结果列数、筛选数和图表数
8. 选择视图后,右侧自动加载该视图绑定对象并按视图配置动态渲染筛选区
9. 用户修改筛选条件并刷新结果
10. 系统查询对象数据并刷新结果区
11. 结果区使用 Tab 分离:
- `表格`:展示查询结果,支持勾选记录并发起转发审批
- `图表`:展示当前视图配置的 1-N 个 ECharts 图表
9. 表格区域固定高度并在内部滚动,避免列表较长时拉长整个页面
10. 结果区支持全屏 / 缩小,用于临时放大表格或图表查看空间
11. 用户可以继续切换视图,而不同视图配置互不影响
### 4.3 在视图中转发记录并发起审批
12. 表格区域固定高度并在内部滚动,避免列表较长时拉长整个页面
13. 表格中每条记录都展示当前状态:
- `待发送`
- `进行中`
- `待审批`
- `已审批`
- `已最终审批`
- `已驳回`
14. 表格标题列可点击,点击后打开当前记录的回填详情弹窗
15. 回填详情弹窗展示是否属实、整改方案、预计整改完成时间、填写人、填写时间和来源批次
16. 用户可在结果区勾选 1-N 条记录后执行“隐藏所选”
17. 被手动隐藏的记录需要写入当前视图的 `hidden_record_ids`
18. 用户再次进入同一个视图时,这些手动隐藏记录默认不展示
19. 页面需提供“管理已隐藏”入口,支持查看已隐藏记录并勾选恢复
20. 转发出去的记录不会因为转发动作自动从视图中消失
21. 结果区支持全屏 / 缩小,用于临时放大表格或图表查看空间
22. 用户可以继续切换视图,而不同视图配置互不影响
### 4.4 在视图中转发记录并发起审批
1. 用户在分析视图结果区勾选 1-N 条记录
2. 点击“转发勾选记录”
3. 在弹窗中填写:
3. 在弹窗中区分:
- 本次需要下发的记录
- 本次明确不下发的记录
4. 在弹窗中填写:
- 接收人
- 转发说明 / 补充记录
- 一级审批人
- 终审人
4. 提交后创建 `analysis_forward_record`
5. 记录初始化状态:
5. 提交后创建 `analysis_forward_record`
6. 为本次实际下发的每条源记录创建一条 `analysis_forward_record_item`
7. 批次记录初始化状态:
- `editor_status = editing`
- `approval_status = pending`
- `final_approval_status = pending`
### 4.4 推进两级审批
8. `selected_record_ids` 保存本次实际下发的记录
9. `excluded_record_ids` 保存本次选中但明确不下发的记录
10. 当前批次未结束前,命中 `excluded_record_ids` 的记录在原视图表格中不展示
11. 本次实际下发的记录不会因为转发动作自动从视图中消失
### 4.5 接收人填写并提交
1. 接收人进入 `转发审批`
2. 在“我参与的”视角看到自己参与的批次
3. 选中某个仍处于 `editor_status = editing` 的批次
4. 点击右侧“进入填写”
5. 页面加载 `selected_record_ids` 对应的原始业务记录作为只读摘要,并加载对应 `analysis_forward_record_item`
6. 接收人逐条填写:
- 是否属实 `truth_status`
- 整改方案 `rectification_plan`
- 预计整改完成时间 `expected_rectification_completed_at`
7. 接收人可多次保存行级回填,只要尚未点击“完成填写”,该批次始终视为“进行中”
8. 点击“完成填写”前,系统校验每条下发记录的 3 个回填字段均已填写
9. 任意一位接收人点击“完成填写”后,系统同步写入:
- `editor_status = completed`
- `completed_by_id`
- `completed_by_name`
- `completed_at`
10. 一旦完成填写,该批次对应行级回填数据立即锁定,不再允许其他接收人继续修改
11. 填写人、一级审批人、终审人都可以继续查看这批次源记录摘要和行级回填,但此时仅保留查看权限
12. 批次状态从“进行中”切换为真正的“待审批”
### 4.6 推进两级审批
1. 一级审批人进入 `转发审批`
2. 在“待我审批”视角看到待办记录
3. 一级审批人执行通过或驳回:
3. 只有 `editor_status = completed` 的批次才能出现在待审批列表中
4. 一级审批人执行通过或驳回:
- 通过:`approval_status = approved`
- 驳回:`approval_status = rejected`
4. 只有当一级审批通过后,终审人才能推进最终审批状态
5. 终审人执行通过或驳回:
5. 只有当一级审批通过后,终审人才能推进最终审批状态
6. 终审人执行通过或驳回:
- 通过:`final_approval_status = approved`
- 驳回:`final_approval_status = rejected`
### 4.5 组合图表看板
### 4.7 组合图表看板
1. 用户进入 `图表看板`
2. 点击“新建看板”
......@@ -180,9 +268,22 @@
### 5.3 转发审批与视图的关系
- 转发必须从某个分析视图结果区发起,或通过审批页手工补录
- 转发必须从某个分析视图结果区发起
- 转发记录必须能追溯到来源视图
- 审批页更新状态时,不反向修改原始业务对象数据
- 审批页更新状态和行级回填时,不反向修改原始业务对象数据
- 接收人编辑入口位于 `转发审批 -> 我参与的 -> 进入填写`
- 接收人编辑的是 `analysis_forward_record_item` 行级回填明细,`selected_record_ids` 对应的源记录仅用于只读摘要展示
- 视图里的“手动隐藏”能力独立于转发审批,持久化在 `analysis_view_config.hidden_record_ids`
- 被手动隐藏的记录再次进入同一个视图时默认不展示,但可在“管理已隐藏”中恢复
- 行级回填字段按当前视图下最新命中的 `analysis_forward_record_item` 展示
- 表格逐行状态按当前视图下最新命中的 `analysis_forward_record` 批次计算
- 对某条记录的状态判断规则:
- 未命中任何批次:`待发送`
- 命中批次且 `editor_status = editing`:`进行中`
- 命中批次且 `editor_status = completed`、`approval_status = pending`:`待审批`
- 命中批次且 `approval_status = approved`、`final_approval_status = pending`:`已审批`
- 命中批次且 `final_approval_status = approved`:`已最终审批`
- 命中批次且任一审批状态为 `rejected`:`已驳回`
---
......@@ -199,7 +300,6 @@
- 对象视图分布
- 审批状态分布
- 看板共享情况
- 最近活动
### 6.2 分析视图
......@@ -207,13 +307,17 @@
- 紧凑操作入口
- 不使用大面积 Hero / 截图说明区域
- 新建入口融入左侧视图列表
- 编辑、删除入口融入右侧当前视图详情
- 左侧全部视图列表
- 不要求先选择对象
- 支持按视图名称、说明或绑定对象搜索
- 纵向滚动浏览、快速切换
- 每个视图展示列数、筛选字段数、图表数等摘要
- 新建入口融入左侧分组树和右侧视图列表区
- 编辑、删除入口融入右侧视图列表的行操作区
- 左侧分组树
- 支持无限级分组
- 菜单只显示分组名
- 支持新建、重命名、删除空分组
- 右侧视图列表区
- 展示当前分组及其所有子分组下的全部视图
- 支持按视图名称、说明、绑定对象和标签搜索
- 每行视图展示说明、对象、分组、标签、数据量、列数、筛选字段数、图表数和行操作
- 点击视图行切换到该视图,当前视图行需要有清晰选中态
- 右侧当前视图工作区
- 根据当前视图自动加载绑定对象
- 展示动态筛选区
......@@ -221,13 +325,19 @@
- 结果区 Tab
- `表格` Tab:结果表格与勾选工具条
- 表格固定高度,超出后在表格区域内部滚动
- 表格每条记录展示当前下发/审批状态
- 表格标题列作为行级回填详情入口
- 行级回填详情弹窗展示是否属实、整改方案和预计整改完成时间
- `图表` Tab:当前视图配置的图表卡片
- 支持结果区全屏 / 缩小
- 当前页勾选工具条
- 转发审批弹窗
- 支持区分“本次下发”与“本次不下发”的记录
- 提交后为本次下发记录初始化行级回填明细
- 视图配置弹窗
- 绑定对象同样使用下拉搜索框
- 不展示个人视图 / 共享视图区分
- 支持选择所属分组和维护标签
- 图表配置卡片支持逐项删除
### 6.3 转发审批
......@@ -239,7 +349,6 @@
- 审批详情区
- 当前状态展示
- 通过 / 驳回按钮
- 手工补录弹窗
### 6.4 图表看板
......@@ -274,7 +383,17 @@
- 更新视图:
- `PUT /record/:name`
### 7.3 转发审批持久化
### 7.3 视图分组持久化
- 创建分组:
- `POST /record`
- `subject = analysis_view_group`
- 更新分组:
- `PUT /record/:name`
- 删除分组:
- `DELETE /record/:name`
### 7.4 转发审批持久化
- 创建转发记录:
- `POST /record`
......@@ -282,7 +401,7 @@
- 更新审批状态:
- `PUT /record/:name`
### 7.4 图表看板持久化
### 7.5 图表看板持久化
- 创建看板:
- `POST /record`
......@@ -290,24 +409,49 @@
- 更新看板:
- `PUT /record/:name`
### 7.5 用户选择
### 7.6 用户选择
- 查询系统用户:
- `POST /user/query`
- 转发审批弹窗中的接收人、一级审批人、终审人都必须在输入关键字时远程调用该接口,不允许只做本地静态筛选
- 推荐请求体:
- `page = 0`
- `page_size = 20`
- `filter.$or = [{ username: { $regex: keyword } }, { display_name: { $regex: keyword } }]`
- `status = null`
- `groups = false`
- 接收人、一级审批人、终审人优先复用该接口
- 查询绑定对象:
- `POST /subject/query`
- 视图配置弹窗中的绑定对象必须在输入关键字时远程调用该接口,不允许只做本地静态筛选
- 推荐请求体:
- `page = 0`
- `page_size = 10`
- `keyword = 搜索词`
- `sort = "title name"`
- 推荐返回结构兼容:
- `subjects = []`
- `total_subjects = number`
---
## 8. 异常处理与限制条件
- 若对象定义不存在,页面必须优雅降级,不得白屏
- 若 `analysis_view_config` 或 `analysis_forward_record` 这两个分析专用 subject 在当前环境不存在,前端需回退展示内置演示数据:
- 若 `analysis_view_config`、`analysis_view_group` 或 `analysis_forward_record` 这些分析专用 subject 在当前环境不存在,前端需回退展示内置演示数据:
- 默认展示 4 条分析视图
- 默认展示若干分组节点
- 默认展示 5 条转发审批记录
- 仅当真实接口失败且本地缓存为空时启用,避免覆盖用户本地已创建的数据
- 对象选择区域不得一次性加载全部对象,默认只展示 10 条并通过搜索补充
- 视图数量较多时必须保留纵向滚动列表,避免页面被视图卡片无限拉长
- 视图数量较多时必须保留分组树和视图列表区的独立滚动,避免页面被无限拉长
- 若分析视图保存失败,需要明确提示失败原因
- 若图表配置不完整,视图仍可保存,但未完成的图表卡片应显示为空状态
- 若转发提交失败,不得清空当前勾选记录
- 若一级审批未通过,终审按钮必须不可用
- 审批状态与最终审批状态必须始终分别维护,不能共用一个字段
- 当前批次未结束前,`excluded_record_ids` 命中的记录在原视图表格中需隐藏
- 图表看板至少需要 1 张卡片才能保存
- `select` 类型选项必须来自 `settings.options`,严禁前端私自扩展业务枚举
- `isPrimaryKey: true` 的字段在编辑态必须禁用
......
......@@ -317,7 +317,7 @@ export async function searchSystemUsers(keyword = '') {
: '',
null,
);
return mergeUserRecords(response?.records || []);
return mergeUserRecords(response?.users || response?.records || []);
}
export async function fetchSystemUsersByIds(ids = []) {
......
......@@ -603,7 +603,7 @@ function parseDocument(formData) {
}
// user
function queryUsers(page, page_size, filter = {}, status = null) {
function queryUsers(page, page_size, filter = {}, status = null, groups = false) {
return request('/user/query', {
method: 'post',
data: {
......@@ -611,6 +611,7 @@ function queryUsers(page, page_size, filter = {}, status = null) {
page_size,
filter,
status,
groups,
},
});
}
......
......@@ -8,7 +8,9 @@
- `am_material`:审计材料主档
- `analysis_view_config`:分析视图配置
- `analysis_view_group`:分析视图分组
- `analysis_forward_record`:转发审批记录
- `analysis_forward_record_item`:转发记录行级回填明细
- `analysis_dashboard_board`:图表看板配置
---
......@@ -140,20 +142,24 @@ type Record = {
- **subject**: `analysis_view_config`
一条记录代表一个“单对象分析视图”,保存对象、筛选、结果列与图表配置。
一条记录代表一个“单对象分析视图”,保存对象、筛选、结果列、图表配置、所属分组与标签。
| 字段标签 | 字段别名 | 字段类型 | 是否主键 | 是否多值 | 是否必填 | 说明 |
| --- | --- | --- | --- | --- | --- | --- |
| 视图名称 | `view_name` | text | 否 | 否 | 是 | 业务展示名称 |
| 数据源对象 | `subject_name` | subject | 否 | 否 | 是 | 绑定的单个对象名 |
| 数据源对象 | `subject_name` | text | 否 | 否 | 是 | 绑定的单个对象名 |
| 视图说明 | `description` | text | 否 | 否 | 否 | 给业务看的说明 |
| 兼容范围 | `scope` | select | 否 | 否 | 否 | 仅作为历史兼容字段,前端不再区分个人视图/共享视图 |
| 筛选字段 | `filter_fields` | json | 否 | 是 | 否 | 存字段别名数组 |
| 默认筛选值 | `default_filters` | json | 否 | 否 | 否 | 视图默认筛选配置 |
| 结果列 | `columns` | json | 否 | 是 | 是 | 存字段别名数组 |
| 图表配置 | `charts` | json | 否 | 是 | 否 | 1-N 个图表卡片配置 |
| 创建人 ID | `created_by_user_id` | user | 否 | 否 | 否 | 保存视图创建人 |
| 兼容范围 | `scope` | text | 否 | 否 | 否 | 仅作为历史兼容字段,前端不再区分个人视图/共享视图 |
| 筛选字段 | `filter_fields` | text | 否 | 是 | 否 | 存字段别名数组 |
| 默认筛选值 | `default_filters` | object | 否 | 否 | 否 | 视图默认筛选配置 |
| 结果列 | `columns` | text | 否 | 是 | 是 | 存字段别名数组 |
| 图表配置 | `charts` | object | 否 | 是 | 否 | 1-N 个图表卡片配置 |
| 创建人 ID | `created_by_user_id` | text | 否 | 否 | 否 | 保存视图创建人 |
| 创建人名称 | `created_by_user_name` | text | 否 | 否 | 否 | 便于展示 |
| 所属分组 ID | `group_id` | text | 否 | 否 | 否 | 关联 `analysis_view_group` 的记录 ID |
| 分组名 | `group_name` | text | 否 | 否 | 否 | 冗余展示用 |
| 视图标签 | `tags` | text | 否 | 是 | 否 | 支持多标签检索和展示 |
| 手动隐藏记录 ID 列表 | `hidden_record_ids` | text | 否 | 是 | 否 | 当前视图中被业务人员手动隐藏的记录 ID |
### 3.2.1 `scope` 兼容约束
......@@ -188,37 +194,102 @@ type Record = {
]
```
## 3.3 转发审批记录
## 3.3 分析视图分组
- **subject**: `analysis_view_group`
一条记录代表一个分析视图分组节点,用于左侧无限级树形菜单。
| 字段标签 | 字段别名 | 字段类型 | 是否主键 | 是否多值 | 是否必填 | 说明 |
| --- | --- | --- | --- | --- | --- | --- |
| 分组名称 | `group_name` | text | 否 | 否 | 是 | 文件夹名称 |
| 父分组 ID | `parent_group_id` | text | 否 | 否 | 否 | 上级分组记录 ID,根分组可为空 |
| 创建人 ID | `created_by_user_id` | user | 否 | 否 | 否 | 创建人 |
| 创建人名称 | `created_by_user_name` | text | 否 | 否 | 否 | 展示用途 |
## 3.4 转发审批记录
- **subject**: `analysis_forward_record`
一条记录代表一次“从分析视图结果区勾选记录后发起的转发审批”。
一条记录代表一次“从分析视图结果区勾选记录后发起的转发审批批次”。
| 字段标签 | 字段别名 | 字段类型 | 是否主键 | 是否多值 | 是否必填 | 说明 |
| --- | --- | --- | --- | --- | --- | --- |
| 来源视图 ID | `view_id` | reference | 否 | 否 | 是 | 仅存视图记录 ID |
| 来源视图 ID | `view_id` | text | 否 | 否 | 是 | 仅存视图记录 ID |
| 来源视图名称 | `view_name` | text | 否 | 否 | 否 | 展示用途 |
| 数据源对象 | `subject_name` | subject | 否 | 否 | 是 | 该次转发对应哪个对象 |
| 勾选记录 ID 列表 | `selected_record_ids` | json | 否 | 是 | 是 | 仅保存 record `name/_id` 数组 |
| 接收人 ID 列表 | `recipient_ids` | user | 否 | 是 | 否 | 仅保存用户 ID 数组 |
| 接收人名称列表 | `recipient_names` | json | 否 | 是 | 否 | 展示用途 |
| 数据源对象 | `subject_name` | text | 否 | 否 | 是 | 该次转发对应哪个对象 |
| 勾选记录 ID 列表 | `selected_record_ids` | text | 否 | 是 | 是 | 仅保存 record `name/_id` 数组 |
| 接收人 ID 列表 | `recipient_ids` | text | 否 | 是 | 否 | 仅保存用户 ID 数组 |
| 接收人名称列表 | `recipient_names` | text | 否 | 是 | 否 | 展示用途 |
| 不下发记录 ID 列表 | `excluded_record_ids` | text | 否 | 是 | 否 | 本批次中选中但明确不下发的记录 ID |
| 转发说明 | `note` | text | 否 | 否 | 是 | 补充转发记录数据 |
| 审批状态 | `approval_status` | select | 否 | 否 | 是 | 一级审批状态 |
| 最终审批状态 | `final_approval_status` | select | 否 | 否 | 是 | 终审状态 |
| 一级审批人 | `first_approver` | user | 否 | 否 | 是 | 一级审批人 ID |
| 填写状态 | `editor_status` | select | 否 | 否 | 是 | 接收人当前是否仍可填写 |
| 完成人 ID | `completed_by_id` | text | 否 | 否 | 否 | 任一接收人点击完成后的用户 ID |
| 完成人名称 | `completed_by_name` | text | 否 | 否 | 否 | 展示用途 |
| 完成时间 | `completed_at` | date | 否 | 否 | 否 | 点击完成填写的时间 |
| 审批状态 | `approval_status` | text | 否 | 否 | 是 | 一级审批状态 |
| 最终审批状态 | `final_approval_status` | text | 否 | 否 | 是 | 终审状态 |
| 一级审批人 | `first_approver` | text | 否 | 否 | 是 | 一级审批人 ID |
| 一级审批人名称 | `first_approver_name` | text | 否 | 否 | 否 | 展示用途 |
| 终审人 | `final_approver` | user | 否 | 否 | 是 | 终审人 ID |
| 终审人 | `final_approver` | text | 否 | 否 | 是 | 终审人 ID |
| 终审人名称 | `final_approver_name` | text | 否 | 否 | 否 | 展示用途 |
| 发起人 ID | `initiator_id` | user | 否 | 否 | 否 | 当前登录用户 |
| 发起人 ID | `initiator_id` | text | 否 | 否 | 否 | 当前登录用户 |
| 发起人名称 | `initiator_name` | text | 否 | 否 | 否 | 展示用途 |
### 3.3.1 审批状态约束
### 3.4.1 审批状态约束
- `editor_status` 推荐值:`editing` / `completed`
- `approval_status` 与 `final_approval_status` 为两个独立字段
- 推荐值:`pending` / `approved` / `rejected`
- 接收人收到批次后,只要 `editor_status = editing`,即视为“填写中”,仍可填写或保存行级回填明细
- 任一接收人点击完成后,需同步写入:
- `editor_status = completed`
- `completed_by_id`
- `completed_by_name`
- `completed_at`
- 当 `editor_status = completed` 后,该批次对应行级回填明细需锁定为不可再编辑,并进入真正的待审批阶段
- 终审只能在 `approval_status = approved` 后推进
- 表格逐行流程状态由前端根据当前视图下最新命中的 `analysis_forward_record` 批次映射:
- 未命中任何批次:`待发送`
- 命中批次且 `editor_status = editing`:`进行中`
- 命中批次且 `editor_status = completed`、`approval_status = pending`:`待审批`
- 命中批次且 `approval_status = approved`、`final_approval_status = pending`:`已审批`
- 命中批次且 `final_approval_status = approved`:`已最终审批`
- 任一审批字段为 `rejected`:`已驳回`
- `excluded_record_ids` 仅用于当前未结束批次内的表格隐藏,不回写视图配置对象
## 3.5 转发记录行级回填明细
- **subject**: `analysis_forward_record_item`
一条记录代表某个转发批次里某一条源记录的回填结果,用于保存“是否属实、整改方案、预计整改完成时间”,不回写源对象字段。
## 3.4 图表看板配置
| 字段标签 | 字段别名 | 字段类型 | 是否主键 | 是否多值 | 是否必填 | 说明 |
| --- | --- | --- | --- | --- | --- | --- |
| 来源转发批次 ID | `forward_record_id` | text | 否 | 否 | 是 | 关联 `analysis_forward_record` 记录 ID |
| 来源转发批次名称 | `forward_record_title` | text | 否 | 否 | 否 | 展示用途 |
| 来源视图 ID | `view_id` | text | 否 | 否 | 是 | 关联分析视图 |
| 来源视图名称 | `view_name` | text | 否 | 否 | 否 | 展示用途 |
| 数据源对象 | `subject_name` | text | 否 | 否 | 是 | 如 `am_material` |
| 源记录 ID | `source_record_id` | text | 否 | 否 | 是 | 被下发的原始记录 ID |
| 源记录标题 | `source_record_title` | text | 否 | 否 | 否 | 展示用途 |
| 是否属实 | `truth_status` | select | 否 | 否 | 是 | 选项来自 `settings.options`,推荐 `truthful/untruthful` |
| 整改方案 | `rectification_plan` | long_text | 否 | 否 | 是 | 接收人填写 |
| 预计整改完成时间 | `expected_rectification_completed_at` | date | 否 | 否 | 是 | ISO 8601 字符串 |
| 填写人 ID | `filled_by_id` | user | 否 | 否 | 否 | 最近保存人 |
| 填写人名称 | `filled_by_name` | text | 否 | 否 | 否 | 展示用途 |
| 填写时间 | `filled_at` | date | 否 | 否 | 否 | 最近保存时间 |
| 备注 | `remark` | long_text | 否 | 否 | 否 | 后续扩展 |
### 3.2.3 视图隐藏记录约束
- `hidden_record_ids` 归属于 `analysis_view_config`
- 业务人员在某个视图中手动隐藏记录后,需要把对应记录 ID 持久化到该字段
- 用户再次进入同一个视图时,命中 `hidden_record_ids` 的记录默认不展示
- 需要提供单独的“已隐藏记录管理 / 恢复显示”界面
- 该字段与转发审批无关,转发出去的记录不会因为转发动作自动从视图中消失
## 3.6 图表看板配置
- **subject**: `analysis_dashboard_board`
......@@ -233,7 +304,7 @@ type Record = {
| 创建人 ID | `created_by_user_id` | user | 否 | 否 | 否 | 创建人 |
| 创建人名称 | `created_by_user_name` | text | 否 | 否 | 否 | 展示用途 |
### 3.4.1 `widgets` 配置示例
### 3.6.1 `widgets` 配置示例
```json
[
......@@ -267,5 +338,6 @@ type Record = {
- 第一期开启“任意对象可分析”,但单个保存视图只绑定一个对象
- 转发基于“视图下勾选记录”发起,不做“整视图直接分发”
- 每条下发记录的属实情况与整改信息写入 `analysis_forward_record_item`,不写回源对象字段
- 配置持久化全部采用独立 Record 对象,继续走统一 `metadata` 协议
- 若后端未正式创建 `analysis_view_config`、`analysis_forward_record`、`analysis_dashboard_board`,前端需优雅降级,不得因为对象未建而白屏
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