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",
......
This diff is collapsed.
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 <>
src={xyLogoSmall} <img
alt="XY Logo" src={xyLogoSmall}
className="h-9 w-auto max-w-[52px] shrink-0 object-contain" alt="XY Logo"
/> className="h-8 w-auto max-w-8 shrink-0 object-contain"
) : null} />
{!sidebarCollapsed ? ( {applicationSwitcherButton}
<h1 className="truncate text-sm font-semibold tracking-[0.02em] text-white/95"> </>
{APP_NAME} ) : (
</h1> <>
) : null} <div className="min-w-0 flex-1">
<h1 className="truncate text-sm font-semibold tracking-[0.02em] text-white/95">
{APP_NAME}
</h1>
<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>
))} ))}
<TableHeader>附件状态</TableHeader> {activeConfig.showAttachmentStatusColumn === false ? null : (
<TableHeader>更新时间</TableHeader> <TableHeader>附件状态</TableHeader>
)}
{activeConfig.showUpdatedAtColumn === false ? null : (
<TableHeader>更新时间</TableHeader>
)}
<TableHeader className="text-right">操作</TableHeader> <TableHeader className="text-right">操作</TableHeader>
</TableRow> </TableRow>
</TableHead> </TableHead>
...@@ -790,19 +794,23 @@ export default function IntegrityPartyPage() { ...@@ -790,19 +794,23 @@ export default function IntegrityPartyPage() {
</span> </span>
</TableCell> </TableCell>
))} ))}
<TableCell> {activeConfig.showAttachmentStatusColumn === false ? null : (
<span <TableCell>
className={clsx( <span
'inline-flex rounded-full px-2.5 py-1 text-xs font-medium', className={clsx(
record.attachmentCount > 0 'inline-flex rounded-full px-2.5 py-1 text-xs font-medium',
? 'bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200' record.attachmentCount > 0
: 'bg-zinc-100 text-zinc-600 ring-1 ring-zinc-200', ? 'bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200'
)} : 'bg-zinc-100 text-zinc-600 ring-1 ring-zinc-200',
> )}
{record.attachmentStatus} >
</span> {record.attachmentStatus}
</TableCell> </span>
<TableCell>{formatDateTime(record.updatedAt)}</TableCell> </TableCell>
)}
{activeConfig.showUpdatedAtColumn === false ? null : (
<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 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>
<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>
<Panel className="p-6"> <IntegrityModuleOverview overview={safeDashboard.moduleOverview} loading={loading} />
<SectionHeader title="最近更新台账" /> </>
<div className="space-y-3"> );
{safeDashboard.recentRecords.length > 0 ? ( }
safeDashboard.recentRecords.map((item) => (
<div function IntegrityModuleOverview({ overview, loading }) {
key={item.id} const safeOverview = overview || {
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" year: new Date().getFullYear(),
> trainingCategories: [],
<div className="min-w-0"> talkSummary: {
<div className="flex flex-wrap items-center gap-2"> totalCount: 0,
<div className="truncate text-sm font-semibold text-zinc-950 sm:text-base"> investmentCount: 0,
{item.title} managementCount: 0,
</div> },
<Pill>{item.ledgerLabel}</Pill> yearlyModuleCounts: [],
<Pill tone={item.attachmentCount > 0 ? 'green' : 'amber'}> };
附件 {item.attachmentCount} const talkItems = [
</Pill> ['已谈话人数', safeOverview.talkSummary.totalCount],
['投资序列', safeOverview.talkSummary.investmentCount],
['管理序列', safeOverview.talkSummary.managementCount],
];
return (
<Panel className="p-6">
<SectionHeader title={`${safeOverview.year} 年模块概览`} />
{loading ? (
<div className="text-sm text-zinc-400">正在加载廉洁党建统计...</div>
) : (
<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
key={item.key}
className="grid grid-cols-[1fr_88px_88px] border-t border-zinc-200 px-4 py-3 text-sm"
>
<div className="min-w-0 truncate font-medium text-zinc-800">
{item.label}
</div> </div>
<div className="mt-2 text-sm text-zinc-500"> <div className="text-right tabular-nums text-zinc-950">
更新于 {item.updatedAtLabel} {item.sessionCount}
</div>
<div className="text-right tabular-nums text-zinc-950">
{item.participantCount}
</div> </div>
</div> </div>
<div className="text-sm text-zinc-500">{item.attachmentStatus}</div> ))}
</div>
))
) : (
<div className="text-sm text-zinc-400">
{loading ? '正在加载廉洁党建统计...' : '暂无最近更新台账'}
</div> </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 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> </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