Commit dd4b6b28 authored by David Yang's avatar David Yang

feat: 应用切换

parent dd0aab32
{ {
"name": "project_manager_new", "name": "risk_case_library",
"version": "1.0.1", "version": "1.0.4",
"build": "abc", "build": "abc",
"description": "merge master", "description": "merge master",
"date": "2025-07-14 19:27:20", "date": "2025-07-14 19:27:20",
......
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>
);
}
import ApplicationSwitcherDialog from '@/pages/project_manager_xy/ApplicationSwitcherDialog';
import xyLogoSmall from '@/assets/xy-logo_small.png'; import xyLogoSmall from '@/assets/xy-logo_small.png';
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
import { SiteContext } from '@/wrapper/Site';
import { import {
ArchiveBoxIcon, ArchiveBoxIcon,
Bars3Icon, Bars3Icon,
...@@ -180,9 +182,11 @@ function MobileSidebar({ open, close, children }) { ...@@ -180,9 +182,11 @@ function MobileSidebar({ open, close, children }) {
export default function ProjectManagerLayout() { export default function ProjectManagerLayout() {
const location = useLocation(); const location = useLocation();
const [isMobile, setIsMobile] = useState(false); const [isMobile, setIsMobile] = useState(false);
const [isApplicationDialogOpen, setIsApplicationDialogOpen] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false); const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const userInfo = useContext(UserContext); const userInfo = useContext(UserContext);
const siteInfo = useContext(SiteContext);
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
...@@ -224,6 +228,21 @@ export default function ProjectManagerLayout() { ...@@ -224,6 +228,21 @@ export default function ProjectManagerLayout() {
); );
}, [location.pathname]); }, [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-[20px] border border-[#f97316]/70 bg-[linear-gradient(180deg,rgba(39,39,42,0.92),rgba(24,24,27,0.96))] text-[#fb923c] shadow-[0_16px_30px_rgba(249,115,22,0.16)] transition duration-200 hover:-translate-y-0.5 hover:border-[#fb923c] hover:text-[#fdba74] hover:shadow-[0_18px_36px_rgba(249,115,22,0.26)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#fb923c]/50',
sidebarCollapsed ? 'size-11' : 'size-14',
)}
>
<Squares2X2Icon className={clsx(sidebarCollapsed ? 'size-4' : 'size-5')} />
</button>
);
return ( return (
<div className="flex h-screen overflow-hidden bg-[radial-gradient(circle_at_top_left,_rgba(251,191,188,0.22),_transparent_26%),radial-gradient(circle_at_bottom_right,_rgba(251,207,232,0.2),_transparent_30%),#f8fafc]"> <div className="flex h-screen overflow-hidden bg-[radial-gradient(circle_at_top_left,_rgba(251,191,188,0.22),_transparent_26%),radial-gradient(circle_at_bottom_right,_rgba(251,207,232,0.2),_transparent_30%),#f8fafc]">
<aside <aside
...@@ -234,22 +253,34 @@ export default function ProjectManagerLayout() { ...@@ -234,22 +253,34 @@ export default function ProjectManagerLayout() {
> >
<div <div
className={clsx( className={clsx(
'mb-7 flex items-center', 'mb-7',
sidebarCollapsed ? 'justify-center' : 'px-1', sidebarCollapsed
? 'flex flex-col items-center gap-3'
: 'flex items-start justify-between gap-4 px-1',
)} )}
> >
{sidebarCollapsed ? ( {sidebarCollapsed ? (
<>
<img <img
src={xyLogoSmall} src={xyLogoSmall}
alt="XY Logo" 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} {applicationSwitcherButton}
{!sidebarCollapsed ? ( </>
) : (
<>
<div className="min-w-0 flex-1">
<h1 className="truncate text-sm font-semibold tracking-[0.02em] text-white/95"> <h1 className="truncate text-sm font-semibold tracking-[0.02em] text-white/95">
{APP_NAME} {APP_NAME}
</h1> </h1>
) : null} <div className="mt-1 text-xs text-zinc-400">
风险案例与廉洁党建台账工作台
</div>
</div>
{applicationSwitcherButton}
</>
)}
</div> </div>
<nav className="flex-1 space-y-5 overflow-y-auto"> <nav className="flex-1 space-y-5 overflow-y-auto">
...@@ -275,6 +306,17 @@ export default function ProjectManagerLayout() { ...@@ -275,6 +306,17 @@ export default function ProjectManagerLayout() {
</div> </div>
))} ))}
</nav> </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> </aside>
<div className="relative z-30 hidden lg:flex"> <div className="relative z-30 hidden lg:flex">
...@@ -302,10 +344,11 @@ export default function ProjectManagerLayout() { ...@@ -302,10 +344,11 @@ export default function ProjectManagerLayout() {
close={() => setIsMobileSidebarOpen(false)} close={() => setIsMobileSidebarOpen(false)}
> >
<div className="px-5 pt-6"> <div className="px-5 pt-6">
<div className="flex items-center"> <div>
<h1 className="text-base font-semibold tracking-[0.02em] text-white"> <h1 className="text-base font-semibold tracking-[0.02em] text-white">
{APP_NAME} {APP_NAME}
</h1> </h1>
<div className="text-xs text-zinc-500">风险案例库工作台</div>
</div> </div>
</div> </div>
...@@ -341,12 +384,26 @@ export default function ProjectManagerLayout() { ...@@ -341,12 +384,26 @@ export default function ProjectManagerLayout() {
<Bars3Icon className="size-5" /> <Bars3Icon className="size-5" />
</NavbarItem> </NavbarItem>
<div className="text-sm text-zinc-500">{currentGroupTitle}</div> <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-4" />
</NavbarItem>
</div> </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"> <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 /> <Outlet />
</div> </div>
</main> </main>
<ApplicationSwitcherDialog
open={isApplicationDialogOpen}
onClose={() => setIsApplicationDialogOpen(false)}
userInfo={userInfo}
siteInfo={siteInfo}
/>
</div> </div>
); );
} }
...@@ -24,9 +24,16 @@ ...@@ -24,9 +24,16 @@
- 左侧菜单:`驾驶舱` - 左侧菜单:`驾驶舱`
- 左侧菜单:`案例管理` - 左侧菜单:`案例管理`
- 左侧菜单顶部:`应用切换`
推荐职责划分: 推荐职责划分:
- `应用切换`
- 桌面端侧栏展开时显示在应用标题右侧,侧栏收起时显示在 Logo 下方
- 移动端显示在顶部菜单栏右侧
- 点击后打开应用切换弹框,优先调用 `/application/info` 读取当前账号可见应用
- 应用列表加载失败时,回退展示当前登录用户信息中的 `applications`
- 点击应用卡片后跳转到对应应用根路径
- `驾驶舱` - `驾驶舱`
- 展示案例总数、分类数量、标签数量、案例类型数量、待整理数量、最近 7 天更新 - 展示案例总数、分类数量、标签数量、案例类型数量、待整理数量、最近 7 天更新
- 通过图表展示分类结构、案例类型排行、案例来源分布 - 通过图表展示分类结构、案例类型排行、案例来源分布
...@@ -242,6 +249,7 @@ ...@@ -242,6 +249,7 @@
- 查询按钮 - 查询按钮
- 筛选项为普通文本输入框,不提供下拉框 - 筛选项为普通文本输入框,不提供下拉框
- `Segment` 视图切换(卡片 / 表格) - `Segment` 视图切换(卡片 / 表格)
- 顶部快捷操作区采用紧凑工具条呈现,保留手动新建、Excel 导入、Excel 导出、重置筛选四个入口
- 手动新建按钮 - 手动新建按钮
- Excel 导入按钮 - Excel 导入按钮
- 导入弹框中的导出模板按钮 - 导入弹框中的导出模板按钮
...@@ -313,6 +321,7 @@ ...@@ -313,6 +321,7 @@
- 批量导入时,单条失败不应影响其它条目继续导入 - 批量导入时,单条失败不应影响其它条目继续导入
- 保存失败时,保留当前表单输入,提示用户重试 - 保存失败时,保留当前表单输入,提示用户重试
- 删除前必须有明确确认,避免误删 - 删除前必须有明确确认,避免误删
- 应用列表加载失败时,应用切换弹框保留打开状态,并提示已回退展示当前账号上的应用信息
--- ---
......
...@@ -16,7 +16,8 @@ ...@@ -16,7 +16,8 @@
- 左侧菜单:`驾驶舱` - 左侧菜单:`驾驶舱`
- 页面内可切换 `案例库统计 / 廉洁党建统计` - 页面内可切换 `案例库统计 / 廉洁党建统计`
- 廉洁党建统计展示台账总数、本年度记录、附件完整率、最近 30 天更新、台账分类分布、年度分布和最近更新台账 - 廉洁党建统计展示台账总数、本年度记录、最近 30 天更新、台账分类分布、年度分布和本年度模块概览
- 本年度模块概览展示培训类 4 个培训类别的开展场次和参加人数、谈话类已谈话人数及投资序列/管理序列人数、调研类/专题会议类/廉洁主题活动/节前廉洁提醒的开展次数
- 左侧菜单:`廉洁党建建设管理` - 左侧菜单:`廉洁党建建设管理`
- 页面内通过 Tab 切换 `培训类 / 谈话类 / 调研类 / 专题会议类 / 廉洁主题活动 / 节前廉洁提醒` - 页面内通过 Tab 切换 `培训类 / 谈话类 / 调研类 / 专题会议类 / 廉洁主题活动 / 节前廉洁提醒`
- 每个 Tab 对应独立台账对象和独立 Excel 模板 - 每个 Tab 对应独立台账对象和独立 Excel 模板
...@@ -42,7 +43,7 @@ ...@@ -42,7 +43,7 @@
4. 筛选区统一包含关键词、年份和当前分类关键字段 4. 筛选区统一包含关键词、年份和当前分类关键字段
5. 用户点击 `查询` 后,前端调用 `POST /record/query` 5. 用户点击 `查询` 后,前端调用 `POST /record/query`
6. 列表按 `updated_at`、`created_at` 倒序展示 6. 列表按 `updated_at`、`created_at` 倒序展示
7. 表格仅展示核心字段、附件状态、更新时间和操作入口 7. 表格仅展示核心字段和操作入口;非培训类保留附件状态、更新时间辅助列,培训类外层展示序号、培训类别、时间、授课主题、授课人、参加人数、培训对象、牵头部门,不展示附件状态和更新时间
8. 完整字段在编辑弹窗中维护 8. 完整字段在编辑弹窗中维护
### 4.2 手动新建台账 ### 4.2 手动新建台账
...@@ -114,6 +115,9 @@ ...@@ -114,6 +115,9 @@
- 保存时日期字段转换为 ISO 8601 字符串 - 保存时日期字段转换为 ISO 8601 字符串
- 布尔字段使用三态 UI:是、否、未填写,对应 `true / false / null` - 布尔字段使用三态 UI:是、否、未填写,对应 `true / false / null`
- 培训类别固定为:廉政党课讲授、廉洁合规培训、专题廉洁合规教育培训、新员工廉洁教育培训 - 培训类别固定为:廉政党课讲授、廉洁合规培训、专题廉洁合规教育培训、新员工廉洁教育培训
- 驾驶舱培训类按 `training_category` 统计本年度场次,并汇总 `participant_count` 为参加人数
- 驾驶舱谈话类仅统计本年度存在 `actual_talk_date` 的已谈话记录;投资序列按 `is_investment_sequence = true` 统计,管理序列按 `is_leader = true` 统计
- 驾驶舱调研类、专题会议类、廉洁主题活动、节前廉洁提醒按本年度记录数统计开展次数
- 重大节假日固定为:元旦、春节、清明、五一、端午、中秋、国庆、其他 - 重大节假日固定为:元旦、春节、清明、五一、端午、中秋、国庆、其他
- 选择重大节假日 `其他` 时,可填写 `holiday_other` - 选择重大节假日 `其他` 时,可填写 `holiday_other`
- 人员、部门、岗位、单位等字段一期全部使用普通文本输入,不做系统引用 - 人员、部门、岗位、单位等字段一期全部使用普通文本输入,不做系统引用
......
...@@ -760,8 +760,12 @@ export default function IntegrityPartyPage() { ...@@ -760,8 +760,12 @@ export default function IntegrityPartyPage() {
{fieldMap[fieldName]?.label || fieldName} {fieldMap[fieldName]?.label || fieldName}
</TableHeader> </TableHeader>
))} ))}
{activeConfig.showAttachmentStatusColumn === false ? null : (
<TableHeader>附件状态</TableHeader> <TableHeader>附件状态</TableHeader>
)}
{activeConfig.showUpdatedAtColumn === false ? null : (
<TableHeader>更新时间</TableHeader> <TableHeader>更新时间</TableHeader>
)}
<TableHeader className="text-right">操作</TableHeader> <TableHeader className="text-right">操作</TableHeader>
</TableRow> </TableRow>
</TableHead> </TableHead>
...@@ -790,6 +794,7 @@ export default function IntegrityPartyPage() { ...@@ -790,6 +794,7 @@ export default function IntegrityPartyPage() {
</span> </span>
</TableCell> </TableCell>
))} ))}
{activeConfig.showAttachmentStatusColumn === false ? null : (
<TableCell> <TableCell>
<span <span
className={clsx( className={clsx(
...@@ -802,7 +807,10 @@ export default function IntegrityPartyPage() { ...@@ -802,7 +807,10 @@ export default function IntegrityPartyPage() {
{record.attachmentStatus} {record.attachmentStatus}
</span> </span>
</TableCell> </TableCell>
)}
{activeConfig.showUpdatedAtColumn === false ? null : (
<TableCell>{formatDateTime(record.updatedAt)}</TableCell> <TableCell>{formatDateTime(record.updatedAt)}</TableCell>
)}
<TableCell className="text-right"> <TableCell className="text-right">
<Button type="button" plain onClick={() => openEditDialog(record)}> <Button type="button" plain onClick={() => openEditDialog(record)}>
<PencilSquareIcon data-slot="icon" /> <PencilSquareIcon data-slot="icon" />
......
...@@ -66,9 +66,13 @@ export const INTEGRITY_LEDGER_CONFIGS = [ ...@@ -66,9 +66,13 @@ export const INTEGRITY_LEDGER_CONFIGS = [
'training_category', 'training_category',
'training_date', 'training_date',
'lecture_topic', 'lecture_topic',
'lecturer',
'participant_count',
'training_target', 'training_target',
'lead_department', 'lead_department',
], ],
showAttachmentStatusColumn: false,
showUpdatedAtColumn: false,
batchFields: ['training_category', 'training_date', 'lead_department'], batchFields: ['training_category', 'training_date', 'lead_department'],
fields: [ fields: [
field('序号', 'sequence_no', FIELD_TYPES.NUMBER), field('序号', 'sequence_no', FIELD_TYPES.NUMBER),
...@@ -1235,25 +1239,9 @@ export async function loadIntegrityPartyDashboard() { ...@@ -1235,25 +1239,9 @@ export async function loadIntegrityPartyDashboard() {
const now = dayjs(); const now = dayjs();
const currentYear = now.year(); const currentYear = now.year();
const recentThreshold = now.subtract(30, 'day'); const recentThreshold = now.subtract(30, 'day');
const totalAttachmentFields = records.reduce((sum, record) => { const currentYearRecords = records.filter(
const config = getConfig(record.ledgerKey); (record) => getRecordYear(record) === String(currentYear),
return (
sum +
config.fields.filter((fieldConfig) => fieldConfig.type === FIELD_TYPES.FILE)
.length
);
}, 0);
const filledAttachmentFields = records.reduce((sum, record) => {
const config = getConfig(record.ledgerKey);
return (
sum +
config.fields
.filter((fieldConfig) => fieldConfig.type === FIELD_TYPES.FILE)
.filter((fieldConfig) => {
return normalizeFileList(record.rawMetadata[fieldConfig.name]).length > 0;
}).length
); );
}, 0);
const ledgerGroups = ledgerResults.map((result) => ({ const ledgerGroups = ledgerResults.map((result) => ({
key: result.config.key, key: result.config.key,
label: result.config.tabLabel, label: result.config.tabLabel,
...@@ -1266,34 +1254,96 @@ export async function loadIntegrityPartyDashboard() { ...@@ -1266,34 +1254,96 @@ export async function loadIntegrityPartyDashboard() {
(item) => item.year || '未填写年份', (item) => item.year || '未填写年份',
(item) => item.year || '未填写年份', (item) => item.year || '未填写年份',
); );
const recentRecords = [...records]
.sort((left, right) =>
String(right.updatedAt || right.createdAt).localeCompare(
String(left.updatedAt || left.createdAt),
),
)
.slice(0, 8);
return { return {
totalCount: ledgerResults.reduce((sum, result) => sum + result.total, 0), totalCount: ledgerResults.reduce((sum, result) => sum + result.total, 0),
currentYearCount: records.filter((record) => getRecordYear(record) === String(currentYear)).length, currentYearCount: currentYearRecords.length,
recentCount: records.filter((record) => { recentCount: records.filter((record) => {
if (!record.updatedAt) { if (!record.updatedAt) {
return false; return false;
} }
return dayjs(record.updatedAt).isAfter(recentThreshold); return dayjs(record.updatedAt).isAfter(recentThreshold);
}).length, }).length,
attachmentCompletionRate:
totalAttachmentFields > 0
? Math.round((filledAttachmentFields / totalAttachmentFields) * 100)
: 0,
ledgerGroups, ledgerGroups,
yearGroups, yearGroups,
recentRecords, moduleOverview: buildIntegrityModuleOverview(currentYearRecords, currentYear),
errors: ledgerResults.map((result) => result.error).filter(Boolean), errors: ledgerResults.map((result) => result.error).filter(Boolean),
}; };
} }
function buildIntegrityModuleOverview(currentYearRecords, currentYear) {
const trainingRecords = currentYearRecords.filter(
(record) => record.ledgerKey === 'training',
);
const talkedRecords = currentYearRecords.filter((record) => {
if (record.ledgerKey !== 'talk') {
return false;
}
const actualTalkDate = formatDate(
record.rawMetadata?.actual_talk_date ?? record.metadata?.actual_talk_date,
);
return actualTalkDate.startsWith(String(currentYear));
});
return {
year: currentYear,
trainingCategories: TRAINING_CATEGORY_OPTIONS.map((category) => {
const categoryRecords = trainingRecords.filter(
(record) =>
String(
record.rawMetadata?.training_category ??
record.metadata?.training_category ??
'',
).trim() === category,
);
return {
key: category,
label: category,
sessionCount: categoryRecords.length,
participantCount: categoryRecords.reduce((sum, record) => {
const participantCount = Number(
record.rawMetadata?.participant_count ??
record.metadata?.participant_count ??
0,
);
return sum + (Number.isFinite(participantCount) ? participantCount : 0);
}, 0),
};
}),
talkSummary: {
totalCount: talkedRecords.length,
investmentCount: talkedRecords.filter(
(record) =>
normalizeBoolean(
record.rawMetadata?.is_investment_sequence ??
record.metadata?.is_investment_sequence,
) === true,
).length,
managementCount: talkedRecords.filter(
(record) =>
normalizeBoolean(record.rawMetadata?.is_leader ?? record.metadata?.is_leader) ===
true,
).length,
},
yearlyModuleCounts: [
buildYearlyModuleCount(currentYearRecords, 'research', '调研类'),
buildYearlyModuleCount(currentYearRecords, 'meeting', '专题会议类'),
buildYearlyModuleCount(currentYearRecords, 'activity', '廉洁主题活动'),
buildYearlyModuleCount(currentYearRecords, 'holiday_reminder', '节前廉洁提醒'),
],
};
}
function buildYearlyModuleCount(records, ledgerKey, label) {
return {
key: ledgerKey,
label,
count: records.filter((record) => record.ledgerKey === ledgerKey).length,
};
}
function getRecordYear(record) { function getRecordYear(record) {
const config = getConfig(record.ledgerKey); const config = getConfig(record.ledgerKey);
const yearField = config.fields.find( const yearField = config.fields.find(
......
...@@ -1049,33 +1049,27 @@ export default function ResourceBookingPage() { ...@@ -1049,33 +1049,27 @@ export default function ResourceBookingPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Panel className="relative overflow-hidden p-6 sm:p-8"> <Panel className="relative overflow-hidden p-4 sm:p-5">
<div className="absolute inset-0 bg-[linear-gradient(135deg,rgba(255,255,255,0.96),rgba(255,241,242,0.94)),radial-gradient(circle_at_top_left,rgba(251,191,188,0.2),transparent_36%),radial-gradient(circle_at_top_right,rgba(251,113,133,0.16),transparent_34%)]" /> <div className="absolute inset-0 bg-[linear-gradient(135deg,rgba(255,255,255,0.98),rgba(255,247,247,0.94))]" />
<div className="relative"> <div className="relative">
<div className="mt-5 flex flex-col gap-6 xl:flex-row xl:items-center xl:justify-between"> <div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div className="max-w-3xl"> <div className="min-w-0">
<h1 className="text-3xl font-semibold tracking-tight text-zinc-950 sm:text-4xl"> <h1 className="text-2xl font-semibold tracking-tight text-zinc-950 sm:text-3xl">
案例管理 案例管理
</h1> </h1>
</div> </div>
<div className="w-full xl:w-[22rem] xl:shrink-0"> <div className="w-full rounded-2xl border border-zinc-200/70 bg-white/80 p-2 shadow-sm backdrop-blur-sm xl:w-auto xl:shrink-0">
<div className="rounded-[26px] border border-white/70 bg-white/65 p-4 shadow-[0_20px_50px_rgba(148,163,184,0.12)] backdrop-blur-sm"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="flex items-start justify-between gap-4"> <div className="flex h-10 shrink-0 items-center gap-2 px-2 text-xs font-semibold tracking-[0.16em] text-zinc-500">
<div> <BookOpenIcon className="size-4 text-[var(--pm-brand)]" />
<div className="text-xs font-semibold tracking-[0.18em] text-zinc-500">
快捷操作 快捷操作
</div> </div>
</div>
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-[linear-gradient(145deg,rgba(180,35,24,0.14),rgba(244,63,94,0.14))] text-[var(--pm-brand)]">
<BookOpenIcon className="size-5" />
</div>
</div>
<div className="mt-4 space-y-3"> <div className="grid flex-1 grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:justify-end">
<Button <Button
color="blue" color="blue"
className="w-full justify-center whitespace-nowrap cursor-pointer" className="w-full justify-center whitespace-nowrap cursor-pointer sm:w-auto"
onClick={() => { onClick={() => {
setError(''); setError('');
setNotice(''); setNotice('');
...@@ -1087,7 +1081,7 @@ export default function ResourceBookingPage() { ...@@ -1087,7 +1081,7 @@ export default function ResourceBookingPage() {
</Button> </Button>
<Button <Button
outline outline
className="w-full justify-center whitespace-nowrap bg-white/80 cursor-pointer" className="w-full justify-center whitespace-nowrap bg-white/80 cursor-pointer sm:w-auto"
onClick={() => { onClick={() => {
setError(''); setError('');
setNotice(''); setNotice('');
...@@ -1099,7 +1093,7 @@ export default function ResourceBookingPage() { ...@@ -1099,7 +1093,7 @@ export default function ResourceBookingPage() {
</Button> </Button>
<Button <Button
outline outline
className="w-full justify-center whitespace-nowrap bg-white/80 cursor-pointer" className="w-full justify-center whitespace-nowrap bg-white/80 cursor-pointer sm:w-auto"
onClick={handleExportCases} onClick={handleExportCases}
disabled={exporting || loading} disabled={exporting || loading}
> >
...@@ -1108,7 +1102,7 @@ export default function ResourceBookingPage() { ...@@ -1108,7 +1102,7 @@ export default function ResourceBookingPage() {
</Button> </Button>
<Button <Button
plain plain
className="w-full justify-center whitespace-nowrap text-zinc-600 cursor-pointer" className="w-full justify-center whitespace-nowrap text-zinc-600 cursor-pointer sm:w-auto"
onClick={handleResetFilters} onClick={handleResetFilters}
> >
<ArrowPathIcon className="size-4" /> <ArrowPathIcon className="size-4" />
...@@ -1122,7 +1116,7 @@ export default function ResourceBookingPage() { ...@@ -1122,7 +1116,7 @@ export default function ResourceBookingPage() {
{error ? ( {error ? (
<div <div
className={clsx( className={clsx(
'mt-6 rounded-2xl border px-4 py-3 text-sm', 'mt-4 rounded-2xl border px-4 py-3 text-sm',
isDemoMode isDemoMode
? 'border-amber-200 bg-amber-50 text-amber-700' ? 'border-amber-200 bg-amber-50 text-amber-700'
: 'border-rose-200 bg-rose-50 text-rose-700', : 'border-rose-200 bg-rose-50 text-rose-700',
...@@ -1133,7 +1127,7 @@ export default function ResourceBookingPage() { ...@@ -1133,7 +1127,7 @@ export default function ResourceBookingPage() {
) : null} ) : null}
{!error && notice ? ( {!error && notice ? (
<div className="mt-6 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700"> <div className="mt-4 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
{notice} {notice}
</div> </div>
) : null} ) : null}
......
...@@ -4,7 +4,6 @@ import { ...@@ -4,7 +4,6 @@ import {
ChartPieIcon, ChartPieIcon,
ClipboardDocumentListIcon, ClipboardDocumentListIcon,
DocumentTextIcon, DocumentTextIcon,
PaperClipIcon,
RectangleStackIcon, RectangleStackIcon,
TagIcon, TagIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
...@@ -404,7 +403,7 @@ export default function ResourceStatPage() { ...@@ -404,7 +403,7 @@ export default function ResourceStatPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Panel className="relative overflow-hidden p-6 sm:p-8"> <Panel className="relative overflow-hidden p-6 sm:p-8">
<div className="absolute inset-x-0 top-0 h-40 bg-[radial-gradient(circle_at_top_left,_rgba(245,158,11,0.16),_transparent_40%),radial-gradient(circle_at_top_right,_rgba(14,165,233,0.18),_transparent_35%)]" /> <div className="absolute inset-0 bg-[linear-gradient(135deg,rgba(255,247,237,0.94)_0%,rgba(255,255,255,0.9)_48%,rgba(224,242,254,0.86)_100%),radial-gradient(circle_at_top_left,rgba(245,158,11,0.14),transparent_36%),radial-gradient(circle_at_top_right,rgba(14,165,233,0.18),transparent_34%)]" />
<div className="relative"> <div className="relative">
<div className="mt-5 flex flex-col gap-6 xl:flex-row xl:items-end xl:justify-between"> <div className="mt-5 flex flex-col gap-6 xl:flex-row xl:items-end xl:justify-between">
<div className="max-w-3xl"> <div className="max-w-3xl">
...@@ -575,15 +574,23 @@ function IntegrityDashboardView({ dashboard, loading }) { ...@@ -575,15 +574,23 @@ function IntegrityDashboardView({ dashboard, loading }) {
totalCount: 0, totalCount: 0,
currentYearCount: 0, currentYearCount: 0,
recentCount: 0, recentCount: 0,
attachmentCompletionRate: 0,
ledgerGroups: [], ledgerGroups: [],
yearGroups: [], yearGroups: [],
recentRecords: [], moduleOverview: {
year: new Date().getFullYear(),
trainingCategories: [],
talkSummary: {
totalCount: 0,
investmentCount: 0,
managementCount: 0,
},
yearlyModuleCounts: [],
},
}; };
return ( return (
<> <>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-4 md:grid-cols-3">
<MetricCard <MetricCard
icon={ClipboardDocumentListIcon} icon={ClipboardDocumentListIcon}
label="台账总数" label="台账总数"
...@@ -596,12 +603,6 @@ function IntegrityDashboardView({ dashboard, loading }) { ...@@ -596,12 +603,6 @@ function IntegrityDashboardView({ dashboard, loading }) {
value={safeDashboard.currentYearCount} value={safeDashboard.currentYearCount}
tone="green" tone="green"
/> />
<MetricCard
icon={PaperClipIcon}
label="附件完整率"
value={`${safeDashboard.attachmentCompletionRate}%`}
tone="amber"
/>
<MetricCard <MetricCard
icon={ArrowTrendingUpIcon} icon={ArrowTrendingUpIcon}
label="最近 30 天更新" label="最近 30 天更新"
...@@ -615,39 +616,97 @@ function IntegrityDashboardView({ dashboard, loading }) { ...@@ -615,39 +616,97 @@ function IntegrityDashboardView({ dashboard, loading }) {
<ChartPanel title="年度分布" groups={safeDashboard.yearGroups} /> <ChartPanel title="年度分布" groups={safeDashboard.yearGroups} />
</div> </div>
<IntegrityModuleOverview overview={safeDashboard.moduleOverview} loading={loading} />
</>
);
}
function IntegrityModuleOverview({ overview, loading }) {
const safeOverview = overview || {
year: new Date().getFullYear(),
trainingCategories: [],
talkSummary: {
totalCount: 0,
investmentCount: 0,
managementCount: 0,
},
yearlyModuleCounts: [],
};
const talkItems = [
['已谈话人数', safeOverview.talkSummary.totalCount],
['投资序列', safeOverview.talkSummary.investmentCount],
['管理序列', safeOverview.talkSummary.managementCount],
];
return (
<Panel className="p-6"> <Panel className="p-6">
<SectionHeader title="最近更新台账" /> <SectionHeader title={`${safeOverview.year} 年模块概览`} />
<div className="space-y-3"> {loading ? (
{safeDashboard.recentRecords.length > 0 ? ( <div className="text-sm text-zinc-400">正在加载廉洁党建统计...</div>
safeDashboard.recentRecords.map((item) => ( ) : (
<div className="grid gap-6 xl:grid-cols-[1.35fr_0.9fr_1fr]">
<div>
<div className="mb-3 text-sm font-semibold text-zinc-900">培训类</div>
<div className="overflow-hidden rounded-2xl border border-zinc-200">
<div className="grid grid-cols-[1fr_88px_88px] bg-zinc-50 px-4 py-3 text-xs font-semibold text-zinc-500">
<div>培训类别</div>
<div className="text-right">场次</div>
<div className="text-right">人数</div>
</div>
{safeOverview.trainingCategories.map((item) => (
<div <div
key={item.id} key={item.key}
className="flex flex-col gap-3 rounded-[24px] border border-zinc-200 bg-zinc-50/70 px-4 py-4 sm:flex-row sm:items-center sm:justify-between" className="grid grid-cols-[1fr_88px_88px] border-t border-zinc-200 px-4 py-3 text-sm"
> >
<div className="min-w-0"> <div className="min-w-0 truncate font-medium text-zinc-800">
<div className="flex flex-wrap items-center gap-2"> {item.label}
<div className="truncate text-sm font-semibold text-zinc-950 sm:text-base">
{item.title}
</div> </div>
<Pill>{item.ledgerLabel}</Pill> <div className="text-right tabular-nums text-zinc-950">
<Pill tone={item.attachmentCount > 0 ? 'green' : 'amber'}> {item.sessionCount}
附件 {item.attachmentCount}
</Pill>
</div> </div>
<div className="mt-2 text-sm text-zinc-500"> <div className="text-right tabular-nums text-zinc-950">
更新于 {item.updatedAtLabel} {item.participantCount}
</div> </div>
</div> </div>
<div className="text-sm text-zinc-500">{item.attachmentStatus}</div> ))}
</div> </div>
))
) : (
<div className="text-sm text-zinc-400">
{loading ? '正在加载廉洁党建统计...' : '暂无最近更新台账'}
</div> </div>
)}
<div>
<div className="mb-3 text-sm font-semibold text-zinc-900">谈话类</div>
<div className="grid gap-3">
{talkItems.map(([label, value]) => (
<div
key={label}
className="flex items-center justify-between rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3"
>
<div className="text-sm text-zinc-600">{label}</div>
<div className="text-2xl font-semibold tabular-nums text-zinc-950">
{value}
</div> </div>
</div>
))}
</div>
</div>
<div>
<div className="mb-3 text-sm font-semibold text-zinc-900">年度开展次数</div>
<div className="space-y-2">
{safeOverview.yearlyModuleCounts.map((item) => (
<div
key={item.key}
className="flex items-center justify-between border-b border-zinc-200 py-3 last:border-b-0"
>
<div className="text-sm font-medium text-zinc-700">{item.label}</div>
<div className="text-xl font-semibold tabular-nums text-zinc-950">
{item.count}
</div>
</div>
))}
</div>
</div>
</div>
)}
</Panel> </Panel>
</>
); );
} }
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