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

feat: 完善审计材料中心

parent 58f53090
......@@ -13,6 +13,9 @@
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.20",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.6",
"jszip": "^3.10.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwindcss": "^4.2.0",
......@@ -10230,6 +10233,36 @@
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT"
},
"node_modules/echarts": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.1.0"
}
},
"node_modules/echarts-for-react": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.6.tgz",
"integrity": "sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"size-sensor": "^1.0.1"
},
"peerDependencies": {
"echarts": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0",
"react": "^15.0.0 || >=16.0.0"
}
},
"node_modules/echarts/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
......@@ -12462,6 +12495,12 @@
"node": ">=0.10.0"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/immer": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-8.0.4.tgz",
......@@ -13608,6 +13647,54 @@
"node": ">=4.0"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jszip/node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jszip/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/jszip/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/jszip/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
......@@ -13725,6 +13812,15 @@
"node": ">= 0.8.0"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
......@@ -18491,6 +18587,12 @@
"integrity": "sha512-9SQdmsyz4HSP+3gs6PJzhkaMEg+6zTlu9oxIghnwUX3eq+ajq4ft5egl0iyR55LAmO/UwvU8NgIWs/ZyQMa6dw==",
"license": "MIT"
},
"node_modules/size-sensor": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.3.tgz",
"integrity": "sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==",
"license": "ISC"
},
"node_modules/slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
......@@ -21161,6 +21263,21 @@
"peerDependencies": {
"zod": "^3.18.0"
}
},
"node_modules/zrender": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
"integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zrender/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
}
}
}
......@@ -15,6 +15,9 @@
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.20",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.6",
"jszip": "^3.10.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwindcss": "^4.2.0",
......
......@@ -43,7 +43,7 @@ export const AvatarButton = forwardRef(function AvatarButton(
let classes = clsx(
className,
square ? 'rounded-[20%]' : 'rounded-full',
'relative inline-grid focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500'
'relative inline-grid focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-[var(--pm-brand)]'
)
return typeof props.href === 'string' ? (
......
......@@ -54,7 +54,7 @@ export const BadgeButton = forwardRef(function BadgeButton(
) {
let classes = clsx(
className,
'group relative inline-flex rounded-md focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500'
'group relative inline-flex rounded-md focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-[var(--pm-brand)]'
)
return typeof props.href === 'string' ? (
......
......@@ -57,7 +57,7 @@ const base = [
'after:absolute after:inset-0 after:rounded-[calc(0.3125rem-1px)] after:shadow-[inset_0_1px_--theme(--color-white/15%)]',
,
// Focus ring
'group-data-focus:outline-2 group-data-focus:outline-offset-2 group-data-focus:outline-blue-500',
'group-data-focus:outline-2 group-data-focus:outline-offset-2 group-data-focus:outline-[var(--pm-brand)]',
// Disabled state
'group-data-disabled:opacity-50',
'group-data-disabled:border-zinc-950/25 group-data-disabled:bg-zinc-950/5 group-data-disabled:[--checkbox-check:var(--color-zinc-950)]/50 group-data-disabled:before:bg-transparent',
......
......@@ -38,7 +38,7 @@ export function Combobox({
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
,
// Focus ring
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-blue-500',
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-[var(--pm-brand)]',
// Disabled state
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
// Invalid state
......@@ -133,7 +133,7 @@ export function ComboboxOption({ children, className, ...props }) {
// Typography
'text-base/6 text-zinc-950 sm:text-sm/6 forced-colors:text-[CanvasText]',
// Focus
'outline-hidden data-focus:bg-blue-500 data-focus:text-white',
'outline-hidden data-focus:bg-[var(--pm-brand)] data-focus:text-white',
// Forced colors mode
'forced-color-adjust-none forced-colors:data-focus:bg-[Highlight] forced-colors:data-focus:text-[HighlightText]',
// Disabled
......
......@@ -51,7 +51,7 @@ export function DropdownItem({ className, ...props }) {
// Text styles
'text-left text-base/6 text-zinc-950 sm:text-sm/6 forced-colors:text-[CanvasText]',
// Focus
'data-focus:bg-blue-500 data-focus:text-white',
'data-focus:bg-[var(--pm-brand)] data-focus:text-white',
// Disabled state
'data-disabled:opacity-50',
// Forced colors mode
......
......@@ -38,7 +38,7 @@ export const Input = forwardRef(function Input(
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
,
// Focus ring
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-blue-500',
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-[var(--pm-brand)]',
// Disabled state
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
])}
......
......@@ -22,7 +22,7 @@ export function Listbox({ className, placeholder, autoFocus, 'aria-label': ariaL
// Hide default focus styles
'focus:outline-hidden',
// Focus ring
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset data-focus:after:ring-2 data-focus:after:ring-blue-500',
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset data-focus:after:ring-2 data-focus:after:ring-[var(--pm-brand)]',
// Disabled state
'data-disabled:opacity-50 data-disabled:before:bg-zinc-950/5 data-disabled:before:shadow-none',
])}
......@@ -115,7 +115,7 @@ export function ListboxOption({ children, className, ...props }) {
// Typography
'text-base/6 text-zinc-950 sm:text-sm/6 forced-colors:text-[CanvasText]',
// Focus
'outline-hidden data-focus:bg-blue-500 data-focus:text-white',
'outline-hidden data-focus:bg-[var(--pm-brand)] data-focus:text-white',
// Forced colors mode
'forced-color-adjust-none forced-colors:data-focus:bg-[Highlight] forced-colors:data-focus:text-[HighlightText]',
// Disabled
......
......@@ -61,7 +61,7 @@ const base = [
// Indicator color (dark mode)
,
// Focus ring
'group-data-focus:outline group-data-focus:outline-2 group-data-focus:outline-offset-2 group-data-focus:outline-blue-500',
'group-data-focus:outline group-data-focus:outline-2 group-data-focus:outline-offset-2 group-data-focus:outline-[var(--pm-brand)]',
// Disabled state
'group-data-disabled:opacity-50',
'group-data-disabled:border-zinc-950/25 group-data-disabled:bg-zinc-950/5 group-data-disabled:[--radio-checked-indicator:var(--color-zinc-950)]/50 group-data-disabled:before:bg-transparent',
......
......@@ -15,7 +15,7 @@ export const Select = forwardRef(function Select({ className, multiple, ...props
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
,
// Focus ring
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset has-data-focus:after:ring-2 has-data-focus:after:ring-blue-500',
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset has-data-focus:after:ring-2 has-data-focus:after:ring-[var(--pm-brand)]',
// Disabled state
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
])}
......
......@@ -148,7 +148,7 @@ export function Switch({ color = 'dark/zinc', className, ...props }) {
// Checked
'data-checked:bg-(--switch-bg) data-checked:ring-(--switch-bg-ring)',
// Focus
'focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500',
'focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-[var(--pm-brand)]',
// Hover
'data-hover:ring-black/15 data-hover:data-checked:ring-(--switch-bg-ring)',
,
......
......@@ -70,7 +70,7 @@ export function TableRow({ href, target, title, className, ...props }) {
className={clsx(
className,
href &&
'has-[[data-row-link][data-focus]]:outline-2 has-[[data-row-link][data-focus]]:-outline-offset-2 has-[[data-row-link][data-focus]]:outline-blue-500',
'has-[[data-row-link][data-focus]]:outline-2 has-[[data-row-link][data-focus]]:-outline-offset-2 has-[[data-row-link][data-focus]]:outline-[var(--pm-brand)]',
striped && 'even:bg-zinc-950/2.5',
href && striped && 'hover:bg-zinc-950/5',
href && !striped && 'hover:bg-zinc-950/2.5',
......
......@@ -15,7 +15,7 @@ export const Textarea = forwardRef(function Textarea({ className, resizable = tr
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
,
// Focus ring
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-blue-500',
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-[var(--pm-brand)]',
// Disabled state
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
])}
......
:root {
--pm-brand: #009cd9;
--pm-brand-strong: #007bb8;
--pm-brand: #c81e3a;
--pm-brand-strong: #9f1239;
--pm-surface: #ffffff;
--pm-bg: #eef5f9;
--pm-bg: #fff1f2;
--pm-sidebar: #0f172a;
--pm-sidebar-soft: #1e293b;
--pm-text: #0f172a;
......@@ -17,7 +17,7 @@ body,
body {
margin: 0;
background: linear-gradient(180deg, #f8fbfd 0%, #eef5f9 100%);
background: linear-gradient(180deg, #fff7f7 0%, #fff1f2 100%);
color: var(--pm-text);
font-family:
"PingFang SC",
......
......@@ -32,11 +32,11 @@ export default function LoginPage() {
const backgroundStyle = useMemo(() => {
const overlay =
'linear-gradient(135deg, rgba(5, 23, 41, 0.92) 0%, rgba(14, 62, 90, 0.9) 50%, rgba(0, 122, 181, 0.82) 100%)';
'linear-gradient(135deg, rgba(28, 10, 16, 0.94) 0%, rgba(98, 20, 38, 0.9) 52%, rgba(190, 24, 93, 0.82) 100%)';
if (!site?.background) {
return {
backgroundImage: `${overlay}, radial-gradient(circle at top, #12385d 0%, #081a2c 55%, #040b15 100%)`,
backgroundImage: `${overlay}, radial-gradient(circle at top, #57111f 0%, #21070f 55%, #120308 100%)`,
};
}
......@@ -96,12 +96,12 @@ export default function LoginPage() {
return (
<div
className="min-h-screen bg-[#061321] px-4 py-6 text-white md:px-8"
className="min-h-screen bg-[#18060d] px-4 py-6 text-white md:px-8"
style={backgroundStyle}
>
<div className="mx-auto flex min-h-[calc(100vh-3rem)] w-full max-w-7xl overflow-hidden rounded-[32px] border border-white/10 bg-white/6 shadow-[0_28px_120px_rgba(0,0,0,0.38)] backdrop-blur-sm">
<section className="relative hidden w-[52%] flex-col justify-between overflow-hidden px-10 py-12 lg:flex">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.2),transparent_36%),radial-gradient(circle_at_bottom_right,rgba(49,196,255,0.3),transparent_32%)]" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.2),transparent_36%),radial-gradient(circle_at_bottom_right,rgba(244,63,94,0.28),transparent_32%)]" />
<div className="relative z-10 max-w-xl">
<p className="text-xs font-medium uppercase tracking-[0.45em] text-white/60">
Streams Style Login
......@@ -137,7 +137,7 @@ export default function LoginPage() {
<section className="flex flex-1 items-center justify-center bg-white/92 px-6 py-10 text-slate-900 sm:px-10">
<div className="w-full max-w-md">
<div className="mb-8">
<p className="text-sm font-medium uppercase tracking-[0.35em] text-sky-600/80">
<p className="text-sm font-medium uppercase tracking-[0.35em] text-rose-600/80">
Sign In
</p>
<h2 className="mt-3 text-3xl font-semibold text-slate-900">
......@@ -154,7 +154,7 @@ export default function LoginPage() {
账号
</label>
<input
className="w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-sky-500 focus:bg-white"
className="w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-rose-500 focus:bg-white"
value={identity}
onChange={(event) => setIdentity(event.target.value)}
disabled={loading}
......@@ -169,7 +169,7 @@ export default function LoginPage() {
</label>
<input
type="password"
className="w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-sky-500 focus:bg-white"
className="w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-rose-500 focus:bg-white"
value={password}
onChange={(event) => setPassword(event.target.value)}
disabled={loading}
......@@ -184,7 +184,7 @@ export default function LoginPage() {
</label>
<div className="flex gap-3">
<input
className="min-w-0 flex-1 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-sky-500 focus:bg-white"
className="min-w-0 flex-1 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-rose-500 focus:bg-white"
value={captchaCode}
onChange={(event) => setCaptchaCode(event.target.value)}
disabled={loading}
......@@ -198,7 +198,7 @@ export default function LoginPage() {
refreshCaptcha();
}}
disabled={loading || captchaLoading}
className="flex h-[56px] w-[148px] shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-slate-200 bg-white px-2 text-xs text-slate-500 transition hover:border-sky-300 hover:text-sky-600 disabled:cursor-not-allowed disabled:opacity-70"
className="flex h-[56px] w-[148px] shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-slate-200 bg-white px-2 text-xs text-slate-500 transition hover:border-rose-300 hover:text-rose-600 disabled:cursor-not-allowed disabled:opacity-70"
>
{captchaData?.image ? (
<span
......@@ -221,7 +221,7 @@ export default function LoginPage() {
<button
type="submit"
disabled={loading || captchaLoading}
className="flex w-full items-center justify-center rounded-2xl bg-[linear-gradient(135deg,#0b75b6_0%,#1095d3_55%,#18b6f6_100%)] px-4 py-3 text-sm font-medium text-white shadow-[0_16px_40px_rgba(17,128,196,0.28)] transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-70"
className="flex w-full items-center justify-center rounded-2xl bg-[linear-gradient(135deg,#9f1239_0%,#be123c_52%,#e11d48_100%)] px-4 py-3 text-sm font-medium text-white shadow-[0_16px_40px_rgba(190,24,93,0.28)] transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-70"
>
{loading ? '登录中...' : '登录'}
</button>
......
export default function EmptyStatePage({ title, description }) {
return (
<section className="relative overflow-hidden rounded-[32px] border border-white/70 bg-white/90 p-8 shadow-[0_30px_80px_rgba(15,23,42,0.08)]">
<div className="absolute -right-20 -top-20 h-56 w-56 rounded-full bg-sky-100 blur-3xl" />
<div className="absolute bottom-0 left-0 h-40 w-40 rounded-full bg-cyan-50 blur-3xl" />
<div className="absolute -right-20 -top-20 h-56 w-56 rounded-full bg-rose-100 blur-3xl" />
<div className="absolute bottom-0 left-0 h-40 w-40 rounded-full bg-rose-50 blur-3xl" />
<div className="relative">
<div className="inline-flex rounded-full border border-sky-200 bg-sky-50 px-3 py-1 text-xs font-semibold tracking-[0.2em] text-sky-700">
<div className="inline-flex rounded-full border border-rose-200 bg-rose-50 px-3 py-1 text-xs font-semibold tracking-[0.2em] text-rose-700">
COMING SOON
</div>
<h2 className="mt-6 text-3xl font-semibold text-slate-900">{title}</h2>
......
import rsmLogo from '@/assets/rsm_logo.svg';
import rsmLogoSmall from '@/assets/rsm_logo_small.svg';
import xyLogo from '@/assets/xy-logo.png';
import xyLogoSmall from '@/assets/xy-logo_small.png';
import { UserContext } from '@/wrapper/Auth';
import {
ArchiveBoxIcon,
......@@ -234,8 +234,8 @@ export default function ProjectManagerLayout() {
)}
>
<img
src={sidebarCollapsed ? rsmLogoSmall : rsmLogo}
alt="RSM Logo"
src={sidebarCollapsed ? xyLogoSmall : xyLogo}
alt="XY Logo"
className={clsx(
'shrink-0 object-contain',
sidebarCollapsed
......@@ -302,8 +302,8 @@ export default function ProjectManagerLayout() {
<div className="px-5 pt-6">
<div className="flex items-center gap-2">
<img
src={rsmLogo}
alt="RSM Logo"
src={xyLogo}
alt="XY Logo"
className="h-5 w-auto max-w-[128px] shrink-0 object-contain"
/>
<h1 className="text-base font-semibold tracking-[0.02em] text-white">
......
......@@ -31,9 +31,9 @@
- 提供全局检索入口
- `材料归档`
- 上传材料
- 编辑标题、标签、分类、备注
- 编辑标题、标签、分类
- 查看材料列表与详情
- 支持“全部文件”汇总视图与按分类切换浏览
- 支持“全部文件”列表视图与按分类切换浏览
- 材料详情使用弹框查看与编辑
---
......@@ -51,7 +51,11 @@
- `source_app`:来源应用,可选
- `archive_category`:分类,可选
- `uploader` / `uploaded_at`:上传人、上传时间
- `remark`:备注,可选
- `tag`:用户自定义分组标签
- `provide_department` / `provide_time`:材料提供部门、提供时间
- `is_confidential`:是否涉密
- `is_audit_dept_material`:是否审计部材料
---
......@@ -60,12 +64,17 @@
### 4.1 在材料中心上传本地材料
1. 用户进入 `材料归档`
2. 选择本地文件
3. 先调用上传接口上传二进制文件
4. 上传成功后,得到 `{name, type, size, url}` 文件对象
5. 再创建一条 `am_material` 记录
6. 用户补充标题、标签、分类、备注等信息,其中来源应用和分类允许留空
7. 材料进入统一材料池,可被其他审计应用统一查看
2. 选择本地文件;若选择的是 `ZIP` 压缩包,可进一步选择上传模式:
- `整包上传`:将压缩包本身作为 1 份材料处理
- `解压后逐文件上传`:按压缩包内文件逐个建档
3. 用户补充标题、标签、分类、提供部门、提供时间、是否涉密、是否审计部材料等信息
4. 先调用上传接口上传二进制文件
5. 上传成功后,得到 `{name, type, size, url}` 文件对象
6. 再创建 `am_material` 记录:
- 普通文件或 `整包上传`:创建 1 条记录
- `解压后逐文件上传`:压缩包内有多少个文件,就创建多少条记录
7. 当使用 `解压后逐文件上传` 时,上传表单中的来源应用、分类、标签、提供信息、合规属性等字段作为每个文件的通用属性一并写入
8. 材料进入统一材料池,可被其他审计应用统一查看
### 4.2 在其他业务应用上传材料
......@@ -79,14 +88,26 @@
1. 用户进入 `材料归档`
2. 按关键词、标签、来源应用筛选材料
3. 页面提供“全部文件”与“分类切换”两种浏览方式
4. 在“全部文件”视图下,直接平铺展示当前筛选条件下的全部材料
5. 切换到某个分类标签后,只查看该分类下的材料
5. 点击某份材料后,以弹框方式打开详情
6. 弹框内查看:
4. 列表默认每页 20 条,支持切换为 50 或 100 条
5. 用户通过上一页 / 下一页进行分页浏览
6. 在“全部文件”视图下,直接以列表展示当前筛选条件下的全部材料
7. 切换到某个分类标签后,只查看该分类下的材料
8. `未分类` 仅表示 `metadata.archive_category` 为空、空字符串或缺失,不作为真实分类值写回查询条件
9. 列表支持当前页批量勾选,并提供“全选当前页”能力
10. 勾选后可执行以下批量操作:
- 批量修改:按字段选择是否覆盖来源应用、分类、提供部门、提供时间、是否涉密、是否审计部材料
- 批量打标签:统一覆盖 `metadata.tag`
- 批量删除:对已勾选材料执行软删除
11. 点击某份材料后,以弹框方式打开详情
12. 弹框内查看:
- 文件预览或下载入口
- 标签与分类
- 来源信息
7. 管理员或有权限用户可在弹框中修改分类、标签、备注
- 提供部门、提供时间
- 涉密标记、审计部材料标记
13. 管理员或有权限用户可在弹框中修改分类、标签
14. 详情弹框提供单个删除入口,删除前需二次确认,确认后对当前材料执行软删除并关闭弹框
15. 详情弹框中的编辑区域按“基础信息 / 提供信息 / 合规属性”三段式维护,减少字段混排
### 4.4 删除或归档材料
......@@ -129,7 +150,17 @@
- 本周新增材料数
- 来源应用分布
- 分类分布
- 提供信息完整度
- 涉密材料 / 审计部材料概览
- 最近上传材料
- 待完善材料提醒
当前工作台推荐采用“图表 + 风险清单”布局:
- 来源应用分布使用柱状图,便于快速判断材料是否已从各业务应用顺利汇入
- 分类分布使用环形图,便于观察常用分类与未分类占比
- 提供与合规信息使用双层环图或类似图形,突出补录完成度、涉密材料、审计部材料情况
- 最近上传与待完善材料以列表方式承接,方便后续直接进入归档页处理
### 6.2 材料归档
......@@ -140,7 +171,14 @@
- 来源应用筛选
- 分类切换标签页
- 材料列表表格
- 当前页批量勾选工具条
- 材料详情弹框
- 批量修改弹框
- 批量打标签弹框
- 创建与编辑弹框中的分区式表单:
- 基础信息区
- 提供信息区
- 合规属性区
### 6.3 材料详情
......@@ -151,6 +189,8 @@
- 标签
- 分类
- 来源应用
- 提供部门 / 提供时间
- 是否涉密 / 是否审计部材料
- 上传人 / 上传时间
- 下载或预览入口
......@@ -165,6 +205,10 @@
2. 创建材料主档:
- `POST /record`
- `subject = am_material`
3. ZIP 上传约定:
- 选择 `整包上传` 时,按普通文件流程调用 1 次 `POST /upload` 和 1 次 `POST /record`
- 选择 `解压后逐文件上传` 时,前端先在本地解析 ZIP,再对压缩包内每个文件分别调用 `POST /upload` 和 `POST /record`
- 拆分上传时,当前表单填写的公共属性需复制到每一条新建材料记录的 `metadata`
### 7.2 查询材料池
......@@ -175,13 +219,29 @@
- `metadata.archive_category`
- `metadata.tag`
### 7.3 大数据量分页策略
### 7.3 批量管理材料
- 批量修改:
- 前端先基于当前页勾选结果拿到材料记录
- 再按勾选字段逐条调用 `PUT /record/:record`
- 所有业务字段仍写入 `metadata`
- 批量删除:
- 使用批量删除接口对当前勾选材料执行软删除
- 请求体按 `subject = am_material` 与 `filter.name.$in = [recordName...]` 组织
- 单个删除:
- 在材料详情弹框内调用 `DELETE /record/:name`
- 默认软删除,`:name` 使用材料记录 `name` 或 `_id`
- 批量打标签:
- 本质上属于批量修改的一种
- 统一覆盖 `metadata.tag`
### 7.4 大数据量分页策略
- 当材料数量超过 1000 份时,不应一次性全量拉取
- 归档页首屏只加载一页数据,建议每页 36 条左右
- 用户滚动到列表底部时,自动请求下一页
- 当用户切换分类时,按当前分类重新从第一页加载
- “全部文件”视图按页平铺追加结果
- 归档页首屏只加载一页数据,默认每页 20 条,可切换为 50 或 100 条
- 用户通过分页按钮切换页码,不再使用滚动加载
- 当用户切换分类、关键词、来源或页大小时,按当前条件重新从第一页加载
- “全部文件”视图按页展示结果
- 总数统计通过 `count` 接口单独获取,不依赖前端已加载条数推断
---
......@@ -190,8 +250,14 @@
- 上传文件失败时,不创建 `am_material`
- 创建 `am_material` 失败时,需要提示用户重试,并避免出现“文件已传但列表无记录”的静默失败
- ZIP 拆分上传若中途失败,需要明确提示已成功创建的记录条数以及失败文件名,便于用户补传
- 标签允许自由输入,但需控制重复标签与空标签
- 分类和标签用于支持用户自定义分组
- 批量修改仅允许覆盖用户显式勾选的字段,未勾选字段必须保留原值,避免误覆盖
- 批量删除前必须二次确认,默认仍按软删除处理
- 当前版本的批量勾选范围限定在“当前筛选结果的当前页”,切换分页后需要重新确认勾选结果
- `provide_time` 提交时统一转为 `ISO 8601`
- 布尔字段 `is_confidential`、`is_audit_dept_material` 需要以 true/false 写入 `metadata`
---
......
import {
batchRemoveRecords,
countRecords,
createRecord,
queryRecords,
removeRecord,
updateRecord,
uploadFile,
} from '@/services/DataService';
import dayjs from 'dayjs';
import JSZip from 'jszip';
export const MATERIAL_SUBJECT = 'am_material';
export const ARCHIVE_UPLOAD_MODE = {
EXTRACT: 'extract',
ARCHIVE: 'archive',
};
export const UNCATEGORIZED_CATEGORY_LABEL = '未分类';
const SOURCE_APP_LABEL_MAP = {
audit_material_center: '材料中心',
......@@ -24,6 +32,8 @@ export const SOURCE_APP_SUGGESTIONS = [
{ value: '中台', label: '中台' },
];
const ARCHIVE_IGNORE_PATTERNS = [/^__MACOSX\//, /\/\.DS_Store$/i, /^\.DS_Store$/i];
const DEMO_MATERIALS = [
{
_id: 'demo-material-1',
......@@ -34,14 +44,12 @@ const DEMO_MATERIALS = [
updated_at: '2026-05-08T10:30:00.000Z',
metadata: {
material_title: '函证回函扫描件',
material_code: 'AM-2026-001',
file_asset: {
name: '函证回函扫描件.pdf',
type: 'application/pdf',
size: 2438210,
url: 'https://example.com/materials/demo-1.pdf',
},
file_hash: 'sha256:demo001',
source_app: 'project_manager_xy',
archive_category: '审计证据',
uploader: {
......@@ -49,7 +57,6 @@ const DEMO_MATERIALS = [
display_name: '杨康',
},
uploaded_at: '2026-05-08T09:12:00.000Z',
remark: '用于审计项目立项资料归档',
tag: '项目一期',
},
},
......@@ -62,14 +69,12 @@ const DEMO_MATERIALS = [
updated_at: '2026-05-07T05:15:00.000Z',
metadata: {
material_title: '项目启动会纪要',
material_code: 'AM-2026-002',
file_asset: {
name: '项目启动会纪要.docx',
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
size: 481204,
url: 'https://example.com/materials/demo-2.docx',
},
file_hash: 'sha256:demo002',
source_app: 'audit_material_center',
archive_category: '项目资料',
uploader: {
......@@ -77,7 +82,6 @@ const DEMO_MATERIALS = [
display_name: '杨康',
},
uploaded_at: '2026-05-07T04:30:00.000Z',
remark: '便于项目组成员统一查看',
tag: '启动会',
},
},
......@@ -90,14 +94,12 @@ const DEMO_MATERIALS = [
updated_at: '2026-05-06T01:20:00.000Z',
metadata: {
material_title: '整改证明附件',
material_code: 'AM-2026-003',
file_asset: {
name: '整改证明附件.jpg',
type: 'image/jpeg',
size: 984122,
url: 'https://images.unsplash.com/photo-1455390582262-044cdead277a?auto=format&fit=crop&w=900&q=80',
},
file_hash: 'sha256:demo003',
source_app: 'issue_rectification',
archive_category: '整改资料',
uploader: {
......@@ -105,7 +107,6 @@ const DEMO_MATERIALS = [
display_name: '整改专员',
},
uploaded_at: '2026-05-05T07:48:00.000Z',
remark: '整改闭环资料,方便统一追踪',
tag: '闭环',
},
},
......@@ -118,14 +119,12 @@ const DEMO_MATERIALS = [
updated_at: '2026-05-03T02:18:00.000Z',
metadata: {
material_title: '访谈提纲',
material_code: '',
file_asset: {
name: '访谈提纲.pdf',
type: 'application/pdf',
size: 372110,
url: 'https://example.com/materials/demo-4.pdf',
},
file_hash: '',
source_app: 'audit_material_center',
archive_category: '项目资料',
uploader: {
......@@ -133,7 +132,6 @@ const DEMO_MATERIALS = [
display_name: '审计经理',
},
uploaded_at: '2026-05-03T02:18:00.000Z',
remark: '供项目启动前统一沟通使用',
tag: '',
},
},
......@@ -146,14 +144,12 @@ const DEMO_MATERIALS = [
updated_at: '2026-05-02T06:20:00.000Z',
metadata: {
material_title: '盘点照片',
material_code: 'AM-2026-005',
file_asset: {
name: '盘点照片.png',
type: 'image/png',
size: 1540190,
url: 'https://images.unsplash.com/photo-1516321318423-f06f85e504b3?auto=format&fit=crop&w=900&q=80',
},
file_hash: 'sha256:demo005',
source_app: 'project_manager_xy',
archive_category: '审计证据',
uploader: {
......@@ -161,7 +157,6 @@ const DEMO_MATERIALS = [
display_name: '现场组',
},
uploaded_at: '2026-05-01T11:11:00.000Z',
remark: '现场盘点图像资料',
tag: '现场',
},
},
......@@ -279,25 +274,29 @@ export function normalizeMaterialRecord(record = {}) {
const sourceApp = metadata.source_app || '';
const category = metadata.archive_category || '';
const tag = metadata.tag || '';
const provideTime = metadata.provide_time || '';
return {
id: record._id || record.id || record.name || '',
recordName: record.name || record._id || record.id || '',
title: record.title || materialTitle,
materialTitle,
materialCode: metadata.material_code || '',
fileAsset,
fileName: fileAsset.name || `${materialTitle}.${inferExtension(fileAsset.type || '').toLowerCase()}`,
fileType: fileAsset.type || 'application/octet-stream',
fileSize: Number(fileAsset.size || 0),
fileSizeLabel: formatFileSize(fileAsset.size || 0),
fileUrl: fileAsset.url || '',
fileHash: metadata.file_hash || '',
sourceApp,
sourceAppLabel: getSourceAppLabel(sourceApp),
archiveCategory: category || '未分类',
archiveCategory: category || UNCATEGORIZED_CATEGORY_LABEL,
tag,
remark: metadata.remark || '',
provideDepartment: metadata.provide_department || '',
provideTime,
provideTimeLabel: formatDateTime(provideTime),
isConfidential: Boolean(metadata.is_confidential),
isAuditDeptMaterial: Boolean(metadata.is_audit_dept_material),
uploaderValue: uploader.value,
uploaderLabel: uploader.label,
uploadedAt,
......@@ -311,6 +310,10 @@ export function normalizeMaterialRecord(record = {}) {
};
}
function isUncategorizedCategory(category = '') {
return String(category || '').trim() === UNCATEGORIZED_CATEGORY_LABEL;
}
function applyClientFilters(materials, filters = {}) {
const keyword = String(filters.keyword || '').trim().toLowerCase();
const sourceApp = String(filters.sourceApp || '').trim();
......@@ -322,8 +325,6 @@ function applyClientFilters(materials, filters = {}) {
!keyword ||
[
item.materialTitle,
item.materialCode,
item.remark,
item.fileName,
item.archiveCategory,
item.tag,
......@@ -334,7 +335,10 @@ function applyClientFilters(materials, filters = {}) {
const matchesSource = !sourceApp || item.sourceApp === sourceApp;
const matchesCategory =
!archiveCategory || item.archiveCategory === archiveCategory;
!archiveCategory ||
(isUncategorizedCategory(archiveCategory)
? !String(item.rawMetadata?.archive_category || '').trim()
: item.archiveCategory === archiveCategory);
const matchesTag =
!tag || String(item.tag || '').toLowerCase().includes(tag);
......@@ -397,7 +401,13 @@ function buildRecordFilter(filters = {}) {
}
if (filters.archiveCategory) {
filter['metadata.archive_category'] = filters.archiveCategory;
filter['$or'] = isUncategorizedCategory(filters.archiveCategory)
? [
{ 'metadata.archive_category': '' },
{ 'metadata.archive_category': null },
{ 'metadata.archive_category': { $exists: false } },
]
: [{ 'metadata.archive_category': filters.archiveCategory }];
}
if (filters.tag) {
......@@ -440,14 +450,19 @@ export function buildMaterialDashboard(materials = [], options = {}) {
);
const categoryGroups = buildGroup(
materials,
(item) => item.archiveCategory || '未分类',
(item) => item.archiveCategory || '未分类',
(item) => item.archiveCategory || UNCATEGORIZED_CATEGORY_LABEL,
(item) => item.archiveCategory || UNCATEGORIZED_CATEGORY_LABEL,
);
const tagGroups = buildGroup(
materials.filter((item) => item.tag),
(item) => item.tag,
(item) => item.tag,
);
const provideDepartmentGroups = buildGroup(
materials.filter((item) => item.provideDepartment),
(item) => item.provideDepartment,
(item) => item.provideDepartment,
);
const recentMaterials = [...materials]
.sort((left, right) =>
......@@ -457,8 +472,42 @@ export function buildMaterialDashboard(materials = [], options = {}) {
)
.slice(0, 6);
const confidentialCount = materials.filter((item) => item.isConfidential).length;
const auditDeptCount = materials.filter((item) => item.isAuditDeptMaterial).length;
const providedInfoCompleteCount = materials.filter(
(item) => item.provideDepartment && item.provideTime,
).length;
const missingProvideDepartmentCount = materials.filter(
(item) => !item.provideDepartment,
).length;
const missingProvideTimeCount = materials.filter((item) => !item.provideTime).length;
const attentionMaterials = materials
.filter((item) => !item.tag || !item.materialCode || !item.archiveCategory)
.map((item) => {
const attentionReasons = [];
if (!item.sourceApp) {
attentionReasons.push('缺少来源应用');
}
if (!item.rawMetadata?.archive_category) {
attentionReasons.push('缺少材料分类');
}
if (!item.tag) {
attentionReasons.push('缺少标签分组');
}
if (!item.provideDepartment) {
attentionReasons.push('缺少提供部门');
}
if (!item.provideTime) {
attentionReasons.push('缺少提供时间');
}
return {
...item,
attentionReasons,
};
})
.filter((item) => item.attentionReasons.length > 0)
.slice(0, 5);
const uniqueSourceCount = new Set(
......@@ -475,9 +524,15 @@ export function buildMaterialDashboard(materials = [], options = {}) {
recentCount,
uniqueSourceCount,
uniqueCategoryCount,
confidentialCount,
auditDeptCount,
providedInfoCompleteCount,
missingProvideDepartmentCount,
missingProvideTimeCount,
sourceGroups,
categoryGroups,
tagGroups,
provideDepartmentGroups,
recentMaterials,
attentionMaterials,
sourceSuggestions: [
......@@ -636,52 +691,187 @@ function getUploadAsset(uploadResult, file) {
};
}
export function inferTitleFromFile(file) {
const filename = file?.name || '';
return filename.replace(/\.[^.]+$/, '') || '未命名材料';
export function isZipFile(file) {
const filename = String(file?.name || '').toLowerCase();
const type = String(file?.type || '').toLowerCase();
return filename.endsWith('.zip') || type === 'application/zip' || type === 'application/x-zip-compressed';
}
export async function createMaterialRecord({
file,
values,
currentUser,
}) {
if (!file) {
throw new Error('请先选择要上传的材料文件');
function shouldIgnoreArchiveEntry(entryName = '') {
return ARCHIVE_IGNORE_PATTERNS.some((pattern) => pattern.test(entryName));
}
function inferMimeTypeFromName(filename = '') {
const normalized = String(filename).toLowerCase();
if (normalized.endsWith('.pdf')) return 'application/pdf';
if (normalized.endsWith('.doc')) return 'application/msword';
if (normalized.endsWith('.docx')) {
return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
}
if (normalized.endsWith('.xls')) return 'application/vnd.ms-excel';
if (normalized.endsWith('.xlsx')) {
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
}
if (normalized.endsWith('.ppt')) return 'application/vnd.ms-powerpoint';
if (normalized.endsWith('.pptx')) {
return 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
}
if (normalized.endsWith('.txt')) return 'text/plain';
if (normalized.endsWith('.csv')) return 'text/csv';
if (normalized.endsWith('.json')) return 'application/json';
if (normalized.endsWith('.jpg') || normalized.endsWith('.jpeg')) return 'image/jpeg';
if (normalized.endsWith('.png')) return 'image/png';
if (normalized.endsWith('.gif')) return 'image/gif';
if (normalized.endsWith('.zip')) return 'application/zip';
return 'application/octet-stream';
}
const uploadResult = await uploadFile(file);
const fileAsset = getUploadAsset(uploadResult, file);
async function extractFilesFromArchive(file) {
const zip = await JSZip.loadAsync(file);
const entries = [];
if (!fileAsset.url) {
throw new Error('文件上传成功,但未获取到可用的文件地址');
await Promise.all(
Object.values(zip.files).map(async (entry) => {
if (entry.dir || shouldIgnoreArchiveEntry(entry.name)) {
return;
}
const blob = await entry.async('blob');
const leafName = entry.name.split('/').filter(Boolean).pop() || entry.name;
const nestedPath = entry.name;
const extractedFile = new File([blob], leafName, {
type: blob.type || inferMimeTypeFromName(leafName),
lastModified: file.lastModified || Date.now(),
});
entries.push({
file: extractedFile,
path: nestedPath,
});
}),
);
return entries.sort((left, right) => left.path.localeCompare(right.path, 'zh-Hans-CN'));
}
function buildMaterialPayload({
file,
values,
currentUser,
materialTitleOverride,
}) {
const materialTitle =
String(values.materialTitle || '').trim() || inferTitleFromFile(file);
const payload = {
String(materialTitleOverride || values.materialTitle || '').trim() ||
inferTitleFromFile(file);
return {
subject: MATERIAL_SUBJECT,
title: materialTitle,
content_type: 'text',
metadata: {
material_title: materialTitle,
material_code: String(values.materialCode || '').trim(),
file_asset: fileAsset,
file_hash: String(values.fileHash || '').trim(),
source_app: String(values.sourceApp || '').trim(),
archive_category: String(values.archiveCategory || '').trim(),
uploader:
currentUser?._id || currentUser?.username || currentUser?.id || '',
uploaded_at: dayjs().toISOString(),
remark: String(values.remark || '').trim(),
tag: String(values.tag || '').trim(),
provide_department: String(values.provideDepartment || '').trim(),
provide_time: values.provideTime
? dayjs(values.provideTime).toISOString()
: '',
is_confidential: Boolean(values.isConfidential),
is_audit_dept_material: Boolean(values.isAuditDeptMaterial),
},
};
}
async function uploadAndCreateSingleMaterial({
file,
values,
currentUser,
materialTitleOverride,
}) {
const uploadResult = await uploadFile(file);
const fileAsset = getUploadAsset(uploadResult, file);
if (!fileAsset.url) {
throw new Error('文件上传成功,但未获取到可用的文件地址');
}
const payload = buildMaterialPayload({
file,
values,
currentUser,
materialTitleOverride,
});
payload.metadata.file_asset = fileAsset;
const created = await createRecord(payload);
return normalizeMaterialRecord(created?.data || created?.result || payload);
}
export function inferTitleFromFile(file) {
const filename = file?.name || '';
return filename.replace(/\.[^.]+$/, '') || '未命名材料';
}
export async function createMaterialRecord({
file,
values,
currentUser,
uploadMode = ARCHIVE_UPLOAD_MODE.ARCHIVE,
}) {
if (!file) {
throw new Error('请先选择要上传的材料文件');
}
const isArchive = isZipFile(file);
if (!isArchive || uploadMode === ARCHIVE_UPLOAD_MODE.ARCHIVE) {
return uploadAndCreateSingleMaterial({
file,
values,
currentUser,
});
}
const extractedFiles = await extractFilesFromArchive(file);
if (!extractedFiles.length) {
throw new Error('压缩包内没有可上传的文件,请检查后重试。');
}
const createdRecords = [];
for (const entry of extractedFiles) {
try {
const created = await uploadAndCreateSingleMaterial({
file: entry.file,
values,
currentUser,
materialTitleOverride: inferTitleFromFile(entry.file),
});
createdRecords.push(created);
} catch (error) {
const succeeded = createdRecords.length;
const baseMessage = error?.message || '未知错误';
throw new Error(
`压缩包拆分上传在文件“${entry.path}”处失败,已成功创建 ${succeeded} 条记录。${baseMessage}`,
);
}
}
return {
mode: ARCHIVE_UPLOAD_MODE.EXTRACT,
total: createdRecords.length,
records: createdRecords,
};
}
export async function updateMaterialRecord(material, values) {
const recordName = material?.recordName || material?.id;
......@@ -691,15 +881,16 @@ export async function updateMaterialRecord(material, values) {
const nextTitle =
String(values.materialTitle || '').trim() || material.materialTitle;
const restMetadata = { ...(material.rawMetadata || {}) };
delete restMetadata.material_code;
delete restMetadata.file_hash;
const payload = {
title: nextTitle,
content_type: 'text',
metadata: {
...material.rawMetadata,
...restMetadata,
material_title: nextTitle,
material_code: String(values.materialCode || '').trim(),
file_asset: material.fileAsset,
file_hash: material.fileHash || '',
source_app: String(values.sourceApp || '').trim(),
archive_category: String(values.archiveCategory || '').trim(),
uploader:
......@@ -710,8 +901,13 @@ export async function updateMaterialRecord(material, values) {
material.rawMetadata?.uploaded_at ||
material.uploadedAt ||
dayjs().toISOString(),
remark: String(values.remark || '').trim(),
tag: String(values.tag || '').trim(),
provide_department: String(values.provideDepartment || '').trim(),
provide_time: values.provideTime
? dayjs(values.provideTime).toISOString()
: '',
is_confidential: Boolean(values.isConfidential),
is_audit_dept_material: Boolean(values.isAuditDeptMaterial),
},
};
......@@ -725,3 +921,72 @@ export async function updateMaterialRecord(material, values) {
},
);
}
export async function batchUpdateMaterialRecords(materials = [], getNextValues) {
if (!Array.isArray(materials) || materials.length === 0) {
throw new Error('请先选择需要批量修改的材料');
}
const settled = await Promise.allSettled(
materials.map(async (material) => {
const nextValues =
typeof getNextValues === 'function'
? getNextValues(material)
: getNextValues;
return updateMaterialRecord(material, nextValues || {});
}),
);
const succeeded = settled
.filter((item) => item.status === 'fulfilled')
.map((item) => item.value);
const failed = settled.filter((item) => item.status === 'rejected');
if (failed.length > 0) {
const successCount = succeeded.length;
throw new Error(
successCount > 0
? `批量修改部分完成,已成功更新 ${successCount} 份,失败 ${failed.length} 份。`
: failed[0]?.reason?.message || '批量修改失败,请稍后重试。',
);
}
return succeeded;
}
export async function batchRemoveMaterialRecords(materials = []) {
if (!Array.isArray(materials) || materials.length === 0) {
throw new Error('请先选择需要删除的材料');
}
const recordNames = materials
.map((item) => item?.recordName || item?.id)
.filter(Boolean);
if (recordNames.length === 0) {
throw new Error('未找到可删除的材料记录标识');
}
await batchRemoveRecords({
subject: MATERIAL_SUBJECT,
filter: {
name: {
$in: recordNames,
},
},
});
return recordNames.length;
}
export async function removeMaterialRecord(material) {
const recordName = material?.recordName || material?.id;
if (!recordName) {
throw new Error('未找到可删除的材料记录标识');
}
await removeRecord(recordName);
return recordName;
}
import { Button } from '@/catalyst-ui-kit/javascript/button';
import {
Checkbox,
CheckboxField,
} from '@/catalyst-ui-kit/javascript/checkbox';
import {
Dialog,
DialogActions,
......@@ -7,17 +11,25 @@ import {
DialogTitle,
} from '@/catalyst-ui-kit/javascript/dialog';
import { Input, InputGroup } from '@/catalyst-ui-kit/javascript/input';
import { Select } from '@/catalyst-ui-kit/javascript/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/catalyst-ui-kit/javascript/table';
import { Textarea } from '@/catalyst-ui-kit/javascript/textarea';
import { UserContext } from '@/wrapper/Auth';
import {
ArrowTopRightOnSquareIcon,
ArrowUpTrayIcon,
ArrowTopRightOnSquareIcon,
ClipboardDocumentIcon,
FolderIcon,
MagnifyingGlassIcon,
SparklesIcon,
Squares2X2Icon,
TagIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import clsx from 'clsx';
import {
......@@ -31,18 +43,25 @@ import {
} from 'react';
import EmptyStatePage from '../EmptyStatePage';
import {
ARCHIVE_UPLOAD_MODE,
batchRemoveMaterialRecords,
batchUpdateMaterialRecords,
buildMaterialDashboard,
createMaterialRecord,
formatDateTime,
formatFileSize,
inferTitleFromFile,
isZipFile,
loadMaterialPage,
removeMaterialRecord,
UNCATEGORIZED_CATEGORY_LABEL,
SOURCE_APP_SUGGESTIONS,
updateMaterialRecord,
} from '../materialService';
const ALL_CATEGORY_KEY = '__all__';
const PAGE_SIZE = 36;
const DEFAULT_PAGE_SIZE = 20;
const PAGE_SIZE_OPTIONS = [20, 50, 100];
function Panel({ className, children }) {
return (
......@@ -126,7 +145,7 @@ function FileVisual({ material, compact = false }) {
return (
<div
className={clsx(
'flex items-center justify-center rounded-[22px] border border-zinc-200 bg-[linear-gradient(135deg,rgba(14,165,233,0.12),rgba(59,130,246,0.02))] text-zinc-950',
'flex items-center justify-center rounded-[22px] border border-zinc-200 bg-[linear-gradient(135deg,rgba(225,29,72,0.12),rgba(190,24,93,0.03))] text-zinc-950',
compact ? 'h-18 w-18' : 'h-60 w-full',
)}
>
......@@ -149,54 +168,106 @@ function FileVisual({ material, compact = false }) {
);
}
function MaterialCard({ material, onOpen }) {
function MaterialListRow({ material, onOpen, checked, onToggle }) {
return (
<button
type="button"
<TableRow
onClick={() => onOpen(material)}
className="group flex h-full w-full flex-col rounded-[28px] border border-zinc-200 bg-white p-4 text-left shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:border-sky-300 hover:shadow-[0_18px_45px_rgba(14,165,233,0.12)]"
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onOpen(material);
}
}}
className="cursor-pointer hover:bg-zinc-950/2.5"
>
<FileVisual material={material} />
<div className="mt-4 flex min-h-[152px] flex-1 flex-col">
<div className="flex flex-wrap items-center gap-2">
<div className="max-w-full truncate text-base font-semibold tracking-tight text-zinc-950">
{material.materialTitle}
</div>
<TableCell onClick={(event) => event.stopPropagation()}>
<Checkbox
color="sky"
checked={checked}
onChange={(nextChecked) => onToggle(material, Boolean(nextChecked))}
aria-label={`选择材料 ${material.materialTitle}`}
/>
</TableCell>
<TableCell className="font-medium text-zinc-950">
<div className="max-w-[340px] truncate">{material.materialTitle}</div>
</TableCell>
<TableCell className="text-zinc-600">
<div className="max-w-[240px] truncate">{material.fileName}</div>
<div className="mt-1 text-xs text-zinc-500">
{material.fileType} · {material.fileSizeLabel}
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Badge tone="sky">{material.archiveCategory}</Badge>
{material.tag ? <Badge tone="emerald">{material.tag}</Badge> : null}
</div>
<div className="mt-2 text-sm text-zinc-500">
{material.sourceAppLabel} · {material.uploaderLabel}
</div>
<div className="mt-3 flex flex-wrap gap-3 text-xs text-zinc-500">
<span>{material.fileName}</span>
<span>{material.fileSizeLabel}</span>
<span>{formatDateTime(material.uploadedAt)}</span>
</div>
<div className="mt-3 min-h-[48px] text-sm leading-6 text-zinc-600">
{material.remark ? (
<div className="line-clamp-2">{material.remark}</div>
) : (
<div className="invisible line-clamp-2">占位备注,保持卡片内部节奏统一。</div>
)}
</div>
{material.tag ? <Badge tone="emerald">{material.tag}</Badge> : <Badge>未设置标签</Badge>}
</div>
</button>
</TableCell>
<TableCell className="text-zinc-600">{material.sourceAppLabel}</TableCell>
<TableCell className="text-zinc-600">{material.uploaderLabel}</TableCell>
<TableCell className="text-zinc-600">{formatDateTime(material.uploadedAt)}</TableCell>
</TableRow>
);
}
function buildEmptyUploadForm() {
return {
materialTitle: '',
materialCode: '',
fileHash: '',
sourceApp: '',
archiveCategory: '',
remark: '',
tag: '',
provideDepartment: '',
provideTime: '',
isConfidential: false,
isAuditDeptMaterial: false,
uploadMode: ARCHIVE_UPLOAD_MODE.ARCHIVE,
};
}
function buildEmptyBatchEditForm() {
return {
sourceApp: '',
archiveCategory: '',
provideDepartment: '',
provideTime: '',
isConfidential: false,
isAuditDeptMaterial: false,
};
}
function buildEmptyBatchFieldSelection() {
return {
sourceApp: false,
archiveCategory: false,
provideDepartment: false,
provideTime: false,
isConfidential: false,
isAuditDeptMaterial: false,
};
}
function buildEmptyBatchTagForm() {
return {
tag: '',
};
}
function formatDateTimeLocalInput(value) {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
const timezoneOffset = date.getTimezoneOffset() * 60000;
return new Date(date.getTime() - timezoneOffset).toISOString().slice(0, 16);
}
function buildDetailForm(material) {
if (!material) {
return buildEmptyUploadForm();
......@@ -204,35 +275,44 @@ function buildDetailForm(material) {
return {
materialTitle: material.materialTitle || '',
materialCode: material.materialCode || '',
fileHash: material.fileHash || '',
sourceApp: material.sourceApp || '',
archiveCategory: material.archiveCategory || '',
remark: material.remark || '',
tag: material.tag || '',
sourceApp: material.rawMetadata?.source_app || material.sourceApp || '',
archiveCategory:
material.rawMetadata?.archive_category || material.archiveCategory || '',
tag: material.rawMetadata?.tag || material.tag || '',
provideDepartment:
material.rawMetadata?.provide_department || material.provideDepartment || '',
provideTime: formatDateTimeLocalInput(
material.rawMetadata?.provide_time || material.provideTime,
),
isConfidential: Boolean(material.isConfidential),
isAuditDeptMaterial: Boolean(material.isAuditDeptMaterial),
};
}
function buildCategorySections(materials) {
const groups = materials.reduce((accumulator, item) => {
const key = item.archiveCategory || '未分类';
if (!accumulator[key]) {
accumulator[key] = [];
}
accumulator[key].push(item);
return accumulator;
}, {});
function FormSection({ eyebrow, title, description, children }) {
return (
<section className="rounded-[24px] border border-zinc-200 bg-zinc-50/70 p-4 sm:p-5">
<div className="max-w-2xl">
<div className="text-[11px] font-semibold tracking-[0.24em] text-zinc-500">
{eyebrow}
</div>
<div className="mt-2 text-lg font-semibold tracking-tight text-zinc-950">
{title}
</div>
<p className="mt-1 text-sm leading-6 text-zinc-500">{description}</p>
</div>
<div className="mt-5">{children}</div>
</section>
);
}
return Object.entries(groups)
.map(([category, items]) => ({
category,
items: items.sort((left, right) =>
String(right.uploadedAt || right.updatedAt).localeCompare(
String(left.uploadedAt || left.updatedAt),
),
),
}))
.sort((left, right) => right.items.length - left.items.length);
function FieldLabel({ children, required = false }) {
return (
<div className="mb-2 flex items-center gap-1 text-sm font-medium text-zinc-600">
<span>{children}</span>
{required ? <span className="text-rose-500">*</span> : null}
</div>
);
}
function sortMaterialsByTime(materials) {
......@@ -249,7 +329,7 @@ function mergeCategoryOptions(previousOptions, nextMaterials) {
);
(nextMaterials || []).forEach((item) => {
const key = item.archiveCategory || '未分类';
const key = item.archiveCategory || UNCATEGORIZED_CATEGORY_LABEL;
const current = optionMap.get(key);
optionMap.set(key, {
key,
......@@ -265,21 +345,19 @@ function mergeCategoryOptions(previousOptions, nextMaterials) {
export default function ResourceBookingPage() {
const userInfo = useContext(UserContext);
const fileInputRef = useRef(null);
const loadMoreRef = useRef(null);
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const [sourceAppFilter, setSourceAppFilter] = useState('');
const [tagFilter, setTagFilter] = useState('');
const [activeCategory, setActiveCategory] = useState(ALL_CATEGORY_KEY);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [page, setPage] = useState(0);
const [categoryOptions, setCategoryOptions] = useState([]);
const [materials, setMaterials] = useState([]);
const [dashboard, setDashboard] = useState(() => buildMaterialDashboard([]));
const [activeMaterialId, setActiveMaterialId] = useState('');
const [page, setPage] = useState(0);
const [total, setTotal] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState('');
......@@ -287,9 +365,18 @@ export default function ResourceBookingPage() {
const [isDemoMode, setIsDemoMode] = useState(false);
const [isUploadOpen, setIsUploadOpen] = useState(false);
const [isDetailOpen, setIsDetailOpen] = useState(false);
const [isBatchEditOpen, setIsBatchEditOpen] = useState(false);
const [isBatchTagOpen, setIsBatchTagOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState(null);
const [uploadForm, setUploadForm] = useState(buildEmptyUploadForm);
const [detailForm, setDetailForm] = useState(buildEmptyUploadForm);
const [selectedMaterialIds, setSelectedMaterialIds] = useState([]);
const [batchEditForm, setBatchEditForm] = useState(buildEmptyBatchEditForm);
const [batchEditFields, setBatchEditFields] = useState(
buildEmptyBatchFieldSelection,
);
const [batchTagForm, setBatchTagForm] = useState(buildEmptyBatchTagForm);
const selectedFileIsZip = useMemo(() => isZipFile(selectedFile), [selectedFile]);
const activeMaterial = useMemo(() => {
return materials.find((item) => item.id === activeMaterialId) || null;
......@@ -310,8 +397,8 @@ export default function ResourceBookingPage() {
archiveCategory:
activeCategory === ALL_CATEGORY_KEY ? '' : activeCategory,
tag: tagFilter,
page: 0,
pageSize: PAGE_SIZE,
page,
pageSize,
});
if (cancelled) {
......@@ -329,9 +416,7 @@ export default function ResourceBookingPage() {
? mergeCategoryOptions([], result.materials || [])
: prev,
);
setPage(0);
setTotal(result.total || 0);
setHasMore(Boolean(result.hasMore));
setError(result.error || '');
setIsDemoMode(Boolean(result.isDemoMode));
setLoading(false);
......@@ -349,7 +434,7 @@ export default function ResourceBookingPage() {
return () => {
cancelled = true;
};
}, [activeCategory, activeMaterialId, deferredQuery, sourceAppFilter, tagFilter]);
}, [activeCategory, activeMaterialId, deferredQuery, page, pageSize, sourceAppFilter, tagFilter]);
const sourceOptions = useMemo(() => {
const dynamicOptions = (dashboard.sourceSuggestions || []).filter(Boolean);
......@@ -367,34 +452,23 @@ export default function ResourceBookingPage() {
const tagSuggestions = dashboard.tagSuggestions || [];
const archiveCategorySuggestions = dashboard.categorySuggestions || [];
const categorySections = useMemo(
() => buildCategorySections(materials),
[materials],
);
const sortedMaterials = useMemo(
() => sortMaterialsByTime(materials),
[materials],
);
const categoryTabs = useMemo(() => {
return [
const categoryTabs = useMemo(
() => [
{
key: ALL_CATEGORY_KEY,
label: '全部文件',
count: materials.length,
icon: Squares2X2Icon,
icon: FolderIcon,
},
...categoryOptions.map((item) => ({
key: item.key,
label: item.label,
count:
activeCategory === ALL_CATEGORY_KEY
? categorySections.find((section) => section.category === item.key)
?.items.length || item.count
: item.count,
count: item.count,
icon: item.icon,
})),
];
}, [activeCategory, categoryOptions, categorySections, materials.length]);
],
[categoryOptions, materials.length],
);
useEffect(() => {
if (
......@@ -405,114 +479,105 @@ export default function ResourceBookingPage() {
}
}, [activeCategory, categoryTabs]);
const visibleSections = useMemo(() => {
const matched = categorySections.find(
(item) => item.category === activeCategory,
useEffect(() => {
setSelectedMaterialIds((prev) =>
prev.filter((id) => materials.some((item) => item.id === id)),
);
}, [materials]);
return matched ? [matched] : [];
}, [activeCategory, categorySections]);
const visibleMaterials = useMemo(() => {
if (activeCategory === ALL_CATEGORY_KEY) {
return sortedMaterials;
return materials;
}
return sortedMaterials.filter(
(item) => (item.archiveCategory || '未分类') === activeCategory,
return materials.filter(
(item) =>
(item.archiveCategory || UNCATEGORIZED_CATEGORY_LABEL) === activeCategory,
);
}, [activeCategory, sortedMaterials]);
}, [activeCategory, materials]);
async function handleLoadMore() {
if (loadingMore || !hasMore) {
return;
const selectedMaterials = useMemo(
() => visibleMaterials.filter((item) => selectedMaterialIds.includes(item.id)),
[selectedMaterialIds, visibleMaterials],
);
const selectedCount = selectedMaterials.length;
const allVisibleSelected =
visibleMaterials.length > 0 && selectedCount === visibleMaterials.length;
const partiallySelected =
selectedCount > 0 && selectedCount < visibleMaterials.length;
const currentPage = page + 1;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
function handlePageChange(nextPage) {
setPage(Math.max(0, Math.min(nextPage, totalPages - 1)));
}
setLoadingMore(true);
setError('');
function handlePageSizeChange(nextPageSize) {
setPageSize(nextPageSize);
setPage(0);
}
try {
const nextPage = page + 1;
async function reloadMaterials(options = {}) {
const result = await loadMaterialPage({
keyword: deferredQuery,
sourceApp: sourceAppFilter,
archiveCategory:
activeCategory === ALL_CATEGORY_KEY ? '' : activeCategory,
tag: tagFilter,
page: nextPage,
pageSize: PAGE_SIZE,
page: options.page ?? page,
pageSize,
});
const nextMaterials = result.materials || [];
setMaterials((prev) => {
const existingIds = new Set(prev.map((item) => item.id));
const merged = [
...prev,
...nextMaterials.filter((item) => !existingIds.has(item.id)),
];
setMaterials(result.materials || []);
setDashboard(
buildMaterialDashboard(merged, {
totalCount: result.total || merged.length,
buildMaterialDashboard(result.materials || [], {
totalCount: result.total || 0,
}),
);
if (activeCategory === ALL_CATEGORY_KEY) {
setCategoryOptions(mergeCategoryOptions([], merged));
setCategoryOptions((prev) =>
activeCategory === ALL_CATEGORY_KEY
? mergeCategoryOptions([], result.materials || [])
: prev,
);
setTotal(result.total || 0);
setError(result.error || '');
setIsDemoMode(Boolean(result.isDemoMode));
return result;
}
return merged;
});
setPage(nextPage);
setTotal(result.total || total);
setHasMore(Boolean(result.hasMore));
setIsDemoMode(Boolean(result.isDemoMode));
if (result.error) {
setError(result.error);
function resetBatchEditState() {
setBatchEditForm(buildEmptyBatchEditForm());
setBatchEditFields(buildEmptyBatchFieldSelection());
}
} catch (loadMoreError) {
setError(loadMoreError?.message || '加载更多材料失败,请稍后重试。');
} finally {
setLoadingMore(false);
function resetBatchTagState() {
setBatchTagForm(buildEmptyBatchTagForm());
}
function clearSelection() {
setSelectedMaterialIds([]);
}
useEffect(() => {
const node = loadMoreRef.current;
function handleToggleMaterialSelection(material, checked) {
setSelectedMaterialIds((prev) => {
if (checked) {
return prev.includes(material.id) ? prev : [...prev, material.id];
}
if (!node || !hasMore || loading || loadingMore) {
return undefined;
return prev.filter((id) => id !== material.id);
});
}
const observer = new IntersectionObserver(
(entries) => {
const [entry] = entries;
if (!entry?.isIntersecting) {
function handleToggleSelectAll(checked) {
if (checked) {
setSelectedMaterialIds(visibleMaterials.map((item) => item.id));
return;
}
handleLoadMore();
},
{
root: null,
rootMargin: '320px 0px',
threshold: 0.1,
},
);
observer.observe(node);
return () => {
observer.disconnect();
};
}, [
activeCategory,
deferredQuery,
hasMore,
loading,
loadingMore,
page,
sourceAppFilter,
tagFilter,
]);
clearSelection();
}
function resetUploadForm() {
setSelectedFile(null);
......@@ -541,7 +606,10 @@ export default function ResourceBookingPage() {
return;
}
if (!String(uploadForm.materialTitle || '').trim()) {
const shouldRequireTitle =
!selectedFileIsZip || uploadForm.uploadMode === ARCHIVE_UPLOAD_MODE.ARCHIVE;
if (shouldRequireTitle && !String(uploadForm.materialTitle || '').trim()) {
setError('请填写材料标题。');
return;
}
......@@ -555,43 +623,32 @@ export default function ResourceBookingPage() {
file: selectedFile,
values: uploadForm,
currentUser: userInfo,
uploadMode: uploadForm.uploadMode,
});
setNotice('材料已上传并写入统一材料池。');
handleUploadDialogClose();
const result = await loadMaterialPage({
keyword: deferredQuery,
sourceApp: sourceAppFilter,
archiveCategory:
activeCategory === ALL_CATEGORY_KEY ? '' : activeCategory,
tag: tagFilter,
page: 0,
pageSize: PAGE_SIZE,
});
const createdRecords = Array.isArray(created?.records)
? created.records
: created
? [created]
: [];
setMaterials(result.materials || []);
setDashboard(
buildMaterialDashboard(result.materials || [], {
totalCount: result.total || 0,
}),
);
setCategoryOptions((prev) =>
activeCategory === ALL_CATEGORY_KEY
? mergeCategoryOptions([], result.materials || [])
: prev,
setNotice(
created?.mode === ARCHIVE_UPLOAD_MODE.EXTRACT
? `压缩包已拆分上传,成功创建 ${created.total || createdRecords.length} 条材料记录。`
: '材料已上传并写入统一材料池。',
);
setPage(0);
setTotal(result.total || 0);
setHasMore(Boolean(result.hasMore));
setIsDemoMode(Boolean(result.isDemoMode));
handleUploadDialogClose();
await reloadMaterials();
if (created?.id) {
setActiveMaterialId(created.id);
const latestRecord = createdRecords[createdRecords.length - 1];
if (latestRecord?.id) {
setActiveMaterialId(latestRecord.id);
setIsDetailOpen(true);
}
if (created?.archiveCategory) {
setActiveCategory(created.archiveCategory);
if (latestRecord?.archiveCategory) {
setActiveCategory(latestRecord.archiveCategory);
}
} catch (uploadError) {
setError(uploadError?.message || '材料上传失败,请稍后重试。');
......@@ -617,31 +674,7 @@ export default function ResourceBookingPage() {
try {
const updated = await updateMaterialRecord(activeMaterial, detailForm);
setNotice('材料信息已更新。');
const result = await loadMaterialPage({
keyword: deferredQuery,
sourceApp: sourceAppFilter,
archiveCategory:
activeCategory === ALL_CATEGORY_KEY ? '' : activeCategory,
tag: tagFilter,
page: 0,
pageSize: PAGE_SIZE,
});
setMaterials(result.materials || []);
setDashboard(
buildMaterialDashboard(result.materials || [], {
totalCount: result.total || 0,
}),
);
setCategoryOptions((prev) =>
activeCategory === ALL_CATEGORY_KEY
? mergeCategoryOptions([], result.materials || [])
: prev,
);
setPage(0);
setTotal(result.total || 0);
setHasMore(Boolean(result.hasMore));
setIsDemoMode(Boolean(result.isDemoMode));
await reloadMaterials();
setActiveMaterialId(updated.id || activeMaterial.id);
} catch (saveError) {
setError(saveError?.message || '材料信息保存失败,请稍后重试。');
......@@ -673,12 +706,185 @@ export default function ResourceBookingPage() {
window.open(activeMaterial.fileUrl, '_blank', 'noopener,noreferrer');
}
async function handleRemoveActiveMaterial() {
if (!activeMaterial) {
setError('未找到可删除的材料。');
return;
}
const confirmed = window.confirm(
`确认软删除材料“${activeMaterial.materialTitle}”吗?删除后将从当前材料池列表移除。`,
);
if (!confirmed) {
return;
}
setSaving(true);
setError('');
setNotice('');
try {
await removeMaterialRecord(activeMaterial);
setSelectedMaterialIds((prev) =>
prev.filter((id) => id !== activeMaterial.id),
);
setIsDetailOpen(false);
setActiveMaterialId('');
await reloadMaterials({
page: visibleMaterials.length === 1 && page > 0 ? page - 1 : page,
});
setNotice(`已删除材料“${activeMaterial.materialTitle}”。`);
} catch (removeError) {
setError(removeError?.message || '删除失败,请稍后重试。');
} finally {
setSaving(false);
}
}
async function handleBatchEditSubmit() {
if (selectedMaterials.length === 0) {
setError('请先勾选需要批量修改的材料。');
return;
}
if (!Object.values(batchEditFields).some(Boolean)) {
setError('请至少选择一个要批量修改的字段。');
return;
}
setSaving(true);
setError('');
setNotice('');
try {
await batchUpdateMaterialRecords(selectedMaterials, (material) => ({
materialTitle: material.materialTitle,
sourceApp: batchEditFields.sourceApp
? batchEditForm.sourceApp
: material.rawMetadata?.source_app || material.sourceApp || '',
archiveCategory: batchEditFields.archiveCategory
? batchEditForm.archiveCategory
: material.rawMetadata?.archive_category ||
material.archiveCategory ||
'',
tag: material.rawMetadata?.tag || material.tag || '',
provideDepartment: batchEditFields.provideDepartment
? batchEditForm.provideDepartment
: material.rawMetadata?.provide_department ||
material.provideDepartment ||
'',
provideTime: batchEditFields.provideTime
? batchEditForm.provideTime
: formatDateTimeLocalInput(
material.rawMetadata?.provide_time || material.provideTime,
),
isConfidential: batchEditFields.isConfidential
? batchEditForm.isConfidential
: Boolean(material.isConfidential),
isAuditDeptMaterial: batchEditFields.isAuditDeptMaterial
? batchEditForm.isAuditDeptMaterial
: Boolean(material.isAuditDeptMaterial),
}));
await reloadMaterials();
setIsBatchEditOpen(false);
resetBatchEditState();
clearSelection();
setNotice(`已批量更新 ${selectedMaterials.length} 份材料。`);
} catch (batchError) {
setError(batchError?.message || '批量修改失败,请稍后重试。');
} finally {
setSaving(false);
}
}
async function handleBatchTagSubmit() {
if (selectedMaterials.length === 0) {
setError('请先勾选需要批量打标签的材料。');
return;
}
if (!String(batchTagForm.tag || '').trim()) {
setError('请输入要批量写入的标签。');
return;
}
setSaving(true);
setError('');
setNotice('');
try {
await batchUpdateMaterialRecords(selectedMaterials, (material) => ({
materialTitle: material.materialTitle,
sourceApp: material.rawMetadata?.source_app || material.sourceApp || '',
archiveCategory:
material.rawMetadata?.archive_category || material.archiveCategory || '',
tag: String(batchTagForm.tag || '').trim(),
provideDepartment:
material.rawMetadata?.provide_department || material.provideDepartment || '',
provideTime: formatDateTimeLocalInput(
material.rawMetadata?.provide_time || material.provideTime,
),
isConfidential: Boolean(material.isConfidential),
isAuditDeptMaterial: Boolean(material.isAuditDeptMaterial),
}));
await reloadMaterials();
setIsBatchTagOpen(false);
resetBatchTagState();
clearSelection();
setNotice(
`已为 ${selectedMaterials.length} 份材料批量写入标签“${String(batchTagForm.tag || '').trim()}”。`,
);
} catch (batchError) {
setError(batchError?.message || '批量打标签失败,请稍后重试。');
} finally {
setSaving(false);
}
}
async function handleBatchDelete() {
if (selectedMaterials.length === 0) {
setError('请先勾选需要删除的材料。');
return;
}
const confirmed = window.confirm(
`确认软删除已勾选的 ${selectedMaterials.length} 份材料吗?删除后将从当前材料池列表移除。`,
);
if (!confirmed) {
return;
}
setSaving(true);
setError('');
setNotice('');
try {
await batchRemoveMaterialRecords(selectedMaterials);
clearSelection();
await reloadMaterials({
page:
visibleMaterials.length === selectedMaterials.length && page > 0
? page - 1
: page,
});
setNotice(`已批量删除 ${selectedMaterials.length} 份材料。`);
} catch (batchError) {
setError(batchError?.message || '批量删除失败,请稍后重试。');
} finally {
setSaving(false);
}
}
return (
<div className="space-y-6">
<Panel className="relative overflow-hidden p-6 sm:p-8">
<div className="absolute inset-0 bg-[linear-gradient(90deg,rgba(239,246,255,0.95)_0%,rgba(255,255,255,0.96)_48%,rgba(236,253,245,0.9)_100%),radial-gradient(circle_at_top_left,rgba(14,165,233,0.16),transparent_38%),radial-gradient(circle_at_top_right,rgba(16,185,129,0.12),transparent_32%)]" />
<div className="absolute inset-0 bg-[linear-gradient(90deg,rgba(255,241,242,0.96)_0%,rgba(255,255,255,0.97)_48%,rgba(255,245,245,0.94)_100%),radial-gradient(circle_at_top_left,rgba(225,29,72,0.16),transparent_38%),radial-gradient(circle_at_top_right,rgba(251,113,133,0.12),transparent_32%)]" />
<div className="relative">
<div className="inline-flex rounded-full border border-sky-200 bg-sky-50 px-3 py-1 text-xs font-semibold tracking-[0.2em] text-sky-700">
<div className="inline-flex rounded-full border border-rose-200 bg-rose-50 px-3 py-1 text-xs font-semibold tracking-[0.2em] text-rose-700">
MATERIAL WORKSPACE
</div>
<div className="mt-5 flex flex-col gap-6 xl:flex-row xl:items-end xl:justify-between">
......@@ -687,7 +893,7 @@ export default function ResourceBookingPage() {
同类材料放一块看,切换起来也要足够顺手
</h1>
<p className="mt-3 text-sm leading-7 text-zinc-600 sm:text-base">
现在支持“全部文件”总览,也支持按分类切换浏览。你可以先从分类层面扫一遍,再点开弹框处理单份材料的细节。
现在支持“全部文件”列表总览,也支持按分类切换浏览。你可以先从分类层面扫一遍,再点开弹框处理单份材料的细节。
</p>
</div>
......@@ -750,7 +956,7 @@ export default function ResourceBookingPage() {
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="按标题、编号、备注或文件名搜索"
placeholder="按标题、编号或文件名搜索"
/>
</InputGroup>
</div>
......@@ -837,11 +1043,7 @@ export default function ResourceBookingPage() {
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<div className="flex items-center gap-2">
{activeCategory === ALL_CATEGORY_KEY ? (
<Squares2X2Icon className="size-5 text-sky-600" />
) : (
<FolderIcon className="size-5 text-sky-600" />
)}
<div className="text-xl font-semibold tracking-tight text-zinc-950">
{activeCategory === ALL_CATEGORY_KEY ? '全部文件' : activeCategory}
</div>
......@@ -849,51 +1051,172 @@ export default function ResourceBookingPage() {
</div>
<div className="mt-2 text-sm text-zinc-500">
{activeCategory === ALL_CATEGORY_KEY
? '这里展示当前筛选条件下的全部材料,不再按分类拆块,方便连续浏览。'
: '当前只查看这个分类下的材料,点开任意卡片即可用弹框查看详情。'}
? '这里展示当前筛选条件下的全部材料,采用表格列表方便连续浏览。'
: '当前只查看这个分类下的材料,点开任意列表行即可用弹框查看详情。'}
</div>
</div>
{isDemoMode ? <Badge tone="amber">演示数据</Badge> : null}
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{visibleMaterials.map((item) => (
<MaterialCard
key={item.id}
material={item}
onOpen={openMaterialDetail}
/>
))}
<div className="mt-5 text-sm text-zinc-500">
当前第 {currentPage} / {totalPages} 页,共 {total} 条。
</div>
</Panel>
<div
ref={loadMoreRef}
className="flex min-h-24 flex-col items-center justify-center gap-3 rounded-[24px] border border-dashed border-zinc-200 bg-white/70 px-4 py-5"
>
<div className="text-sm text-zinc-500">
当前已加载 {materials.length} 份,匹配总数 {total} 份。
{hasMore
? loadingMore
? ' 正在自动加载下一页...'
: ' 继续向下滚动会自动加载下一页。'
: ' 已全部加载完成。'}
</div>
{loadingMore ? (
<div className="text-sm font-medium text-sky-700">正在加载更多...</div>
) : null}
{!hasMore ? (
<div className="text-sm font-medium text-emerald-700">已全部加载</div>
) : null}
<div className="mt-5 flex flex-col gap-3 rounded-[24px] border border-zinc-200 bg-zinc-50/80 px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="text-sm font-medium text-zinc-900">
已勾选 {selectedCount} 份材料
</div>
<div className="mt-1 text-sm text-zinc-500">
支持对当前页已勾选材料进行批量修改、批量删除和批量打标签。
</div>
)}
<Dialog open={isDetailOpen} onClose={setIsDetailOpen} size="4xl">
<DialogTitle>材料详情</DialogTitle>
</div>
<div className="flex flex-wrap gap-2">
<Button
plain
className="cursor-pointer"
onClick={clearSelection}
disabled={selectedCount === 0 || saving}
>
清空勾选
</Button>
<Button
outline
className="cursor-pointer"
onClick={() => {
resetBatchEditState();
setIsBatchEditOpen(true);
}}
disabled={selectedCount === 0 || saving}
>
批量修改
</Button>
<Button
outline
className="cursor-pointer"
onClick={() => {
resetBatchTagState();
setIsBatchTagOpen(true);
}}
disabled={selectedCount === 0 || saving}
>
批量打标签
</Button>
<Button
color="red"
className="cursor-pointer"
onClick={handleBatchDelete}
disabled={selectedCount === 0 || saving}
>
批量删除
</Button>
</div>
</div>
<Table striped dense className="[--gutter:--spacing(4)] mt-5">
<TableHead>
<TableRow>
<TableHeader>
<Checkbox
color="sky"
checked={allVisibleSelected}
indeterminate={partiallySelected}
onChange={(checked) => handleToggleSelectAll(Boolean(checked))}
aria-label="全选当前页材料"
/>
</TableHeader>
<TableHeader>材料标题</TableHeader>
<TableHeader>文件信息</TableHeader>
<TableHeader>分类 / 标签</TableHeader>
<TableHeader>来源应用</TableHeader>
<TableHeader>上传人</TableHeader>
<TableHeader>上传时间</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{visibleMaterials.map((item) => (
<MaterialListRow
key={item.id}
material={item}
onOpen={openMaterialDetail}
checked={selectedMaterialIds.includes(item.id)}
onToggle={handleToggleMaterialSelection}
/>
))}
</TableBody>
</Table>
<div className="mt-6 space-y-3">
<div className="text-sm text-zinc-500">
{visibleMaterials.length} 条当前页数据,筛选结果共 {total} 条。
</div>
<div className="flex flex-nowrap items-center gap-2 overflow-x-auto whitespace-nowrap">
<div className="flex shrink-0 items-center gap-2">
<div className="text-sm text-zinc-500">每页</div>
<div className="w-24 shrink-0">
<Select
value={String(pageSize)}
onChange={(event) => handlePageSizeChange(Number(event.target.value))}
>
{PAGE_SIZE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</Select>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
plain
className="cursor-pointer shrink-0"
disabled={page <= 0}
onClick={() => handlePageChange(page - 1)}
>
上一页
</Button>
<Button
plain
className="cursor-pointer shrink-0"
disabled={page + 1 >= totalPages}
onClick={() => handlePageChange(page + 1)}
>
下一页
</Button>
</div>
</div>
</div>
</Panel>
</div>
)}
<Dialog
open={isDetailOpen}
onClose={() => {
if (saving) {
return;
}
setIsDetailOpen(false);
}}
size="4xl"
>
<div className="flex items-start justify-between gap-4">
<div>
<DialogTitle>材料详情</DialogTitle>
<DialogDescription>
详情改成弹框后,你可以边看分类分组,边按需打开单份材料处理,不会打断当前浏览节奏。
当前详情表单已按最新对象文档拆成基础信息、提供信息与合规属性,便于边查看边维护材料元数据。
</DialogDescription>
</div>
<button
type="button"
onClick={() => setIsDetailOpen(false)}
disabled={saving}
aria-label="关闭材料详情"
className="inline-flex size-10 shrink-0 items-center justify-center rounded-full border border-zinc-200 bg-white text-zinc-500 shadow-sm transition hover:border-zinc-300 hover:bg-zinc-50 hover:text-zinc-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<XMarkIcon className="size-5" />
</button>
</div>
<DialogBody>
{activeMaterial ? (
<div className="space-y-6">
......@@ -923,6 +1246,25 @@ export default function ResourceBookingPage() {
上传于 {activeMaterial.uploadedAtLabel}
</div>
</div>
<div>
<div className="text-xs font-semibold tracking-[0.18em] text-zinc-500">
归档标签
</div>
<div className="mt-2 flex flex-wrap gap-2">
<Badge tone="sky">{activeMaterial.archiveCategory}</Badge>
{activeMaterial.tag ? (
<Badge tone="emerald">{activeMaterial.tag}</Badge>
) : (
<Badge>未设置标签</Badge>
)}
{activeMaterial.isConfidential ? (
<Badge tone="amber">涉密</Badge>
) : null}
{activeMaterial.isAuditDeptMaterial ? (
<Badge tone="sky">审计部材料</Badge>
) : null}
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
......@@ -944,9 +1286,15 @@ export default function ResourceBookingPage() {
</div>
</div>
<div className="space-y-4">
<FormSection
eyebrow="BASIC INFO"
title="基础信息"
description="这里维护材料的主展示信息和材料池检索字段。"
>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">材料标题</div>
<FieldLabel required>材料标题</FieldLabel>
<Input
value={detailForm.materialTitle}
onChange={(event) =>
......@@ -958,20 +1306,7 @@ export default function ResourceBookingPage() {
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">材料编号</div>
<Input
value={detailForm.materialCode}
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
materialCode: event.target.value,
}))
}
placeholder="可选"
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">来源应用</div>
<FieldLabel>来源应用</FieldLabel>
<Input
list="source-app-suggestions"
value={detailForm.sourceApp}
......@@ -981,10 +1316,11 @@ export default function ResourceBookingPage() {
sourceApp: event.target.value,
}))
}
placeholder="可选"
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">材料分类</div>
<FieldLabel>材料分类</FieldLabel>
<Input
list="archive-category-suggestions"
value={detailForm.archiveCategory}
......@@ -994,10 +1330,11 @@ export default function ResourceBookingPage() {
archiveCategory: event.target.value,
}))
}
placeholder="可选"
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">标签分组</div>
<FieldLabel>标签分组</FieldLabel>
<Input
list="tag-suggestions"
value={detailForm.tag}
......@@ -1007,36 +1344,96 @@ export default function ResourceBookingPage() {
tag: event.target.value,
}))
}
placeholder="例如:项目一期"
/>
</div>
</div>
</FormSection>
<FormSection
eyebrow="PROVIDE INFO"
title="提供信息"
description="对应对象文档中的提供部门与提供时间,方便后续核对材料来源链路。"
>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">文件摘要</div>
<FieldLabel>提供部门</FieldLabel>
<Input
value={detailForm.fileHash}
value={detailForm.provideDepartment}
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
fileHash: event.target.value,
provideDepartment: event.target.value,
}))
}
placeholder="可选,如 sha256 值"
placeholder="可选"
/>
</div>
<div className="sm:col-span-2">
<div className="mb-2 text-sm font-medium text-zinc-600">备注</div>
<Textarea
rows={5}
value={detailForm.remark}
<div>
<FieldLabel>提供时间</FieldLabel>
<Input
type="datetime-local"
value={detailForm.provideTime}
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
remark: event.target.value,
provideTime: event.target.value,
}))
}
/>
</div>
</div>
</FormSection>
<FormSection
eyebrow="COMPLIANCE"
title="合规属性"
description="布尔类字段改用 Catalyst Checkbox,对应对象定义中的合规标识。"
>
<div className="grid gap-4 sm:grid-cols-2">
<CheckboxField className="rounded-2xl border border-zinc-200 bg-white px-4 py-3">
<Checkbox
color="sky"
checked={detailForm.isConfidential}
onChange={(checked) =>
setDetailForm((prev) => ({
...prev,
isConfidential: Boolean(checked),
}))
}
placeholder="补充说明这份材料的用途、来源背景或归档要求"
/>
<div>
<div data-slot="label" className="text-sm font-medium text-zinc-900">
是否涉密
</div>
<div data-slot="description" className="text-sm text-zinc-500">
标记后可在详情中快速识别涉密材料。
</div>
</div>
</CheckboxField>
<CheckboxField className="rounded-2xl border border-zinc-200 bg-white px-4 py-3">
<Checkbox
color="sky"
checked={detailForm.isAuditDeptMaterial}
onChange={(checked) =>
setDetailForm((prev) => ({
...prev,
isAuditDeptMaterial: Boolean(checked),
}))
}
/>
<div>
<div data-slot="label" className="text-sm font-medium text-zinc-900">
是否审计部材料
</div>
<div data-slot="description" className="text-sm text-zinc-500">
用于区分审计部自有材料与外部提供材料。
</div>
</div>
</CheckboxField>
</div>
</FormSection>
</div>
</div>
<datalist id="source-app-suggestions">
......@@ -1056,12 +1453,12 @@ export default function ResourceBookingPage() {
</DialogBody>
<DialogActions>
<Button
plain
color="red"
className="cursor-pointer"
onClick={() => setIsDetailOpen(false)}
disabled={saving}
onClick={handleRemoveActiveMaterial}
disabled={saving || !activeMaterial}
>
关闭
{saving ? '处理中...' : '删除材料'}
</Button>
<Button
color="blue"
......@@ -1074,15 +1471,347 @@ export default function ResourceBookingPage() {
</DialogActions>
</Dialog>
<Dialog
open={isBatchEditOpen}
onClose={() => {
if (saving) {
return;
}
setIsBatchEditOpen(false);
}}
size="3xl"
>
<DialogTitle>批量修改材料</DialogTitle>
<DialogDescription>
仅会覆盖你勾选“同步更新”的字段,未勾选字段会保留各材料当前值。
</DialogDescription>
<DialogBody>
<div className="space-y-6">
<div className="rounded-[24px] border border-sky-100 bg-sky-50 px-4 py-3 text-sm text-sky-700">
当前共选中 {selectedCount} 份材料,批量修改会写回 `am_material.metadata`。
</div>
<FormSection
eyebrow="BATCH FIELDS"
title="批量覆盖字段"
description="先勾选要更新的字段,再填写统一值,避免误把空值覆盖到所有材料。"
>
<div className="grid gap-4 sm:grid-cols-2">
<CheckboxField className="rounded-2xl border border-zinc-200 bg-white px-4 py-3">
<Checkbox
color="sky"
checked={batchEditFields.sourceApp}
onChange={(checked) =>
setBatchEditFields((prev) => ({
...prev,
sourceApp: Boolean(checked),
}))
}
/>
<div>
<div data-slot="label" className="text-sm font-medium text-zinc-900">
批量更新来源应用
</div>
<div data-slot="description" className="text-sm text-zinc-500">
对应 `metadata.source_app`。
</div>
</div>
</CheckboxField>
<div>
<FieldLabel>来源应用</FieldLabel>
<Input
list="source-app-suggestions"
value={batchEditForm.sourceApp}
onChange={(event) =>
setBatchEditForm((prev) => ({
...prev,
sourceApp: event.target.value,
}))
}
placeholder="可留空,用于统一清空"
/>
</div>
<CheckboxField className="rounded-2xl border border-zinc-200 bg-white px-4 py-3">
<Checkbox
color="sky"
checked={batchEditFields.archiveCategory}
onChange={(checked) =>
setBatchEditFields((prev) => ({
...prev,
archiveCategory: Boolean(checked),
}))
}
/>
<div>
<div data-slot="label" className="text-sm font-medium text-zinc-900">
批量更新材料分类
</div>
<div data-slot="description" className="text-sm text-zinc-500">
对应 `metadata.archive_category`。
</div>
</div>
</CheckboxField>
<div>
<FieldLabel>材料分类</FieldLabel>
<Input
list="archive-category-suggestions"
value={batchEditForm.archiveCategory}
onChange={(event) =>
setBatchEditForm((prev) => ({
...prev,
archiveCategory: event.target.value,
}))
}
placeholder="可留空,用于统一清空"
/>
</div>
<CheckboxField className="rounded-2xl border border-zinc-200 bg-white px-4 py-3">
<Checkbox
color="sky"
checked={batchEditFields.provideDepartment}
onChange={(checked) =>
setBatchEditFields((prev) => ({
...prev,
provideDepartment: Boolean(checked),
}))
}
/>
<div>
<div data-slot="label" className="text-sm font-medium text-zinc-900">
批量更新提供部门
</div>
<div data-slot="description" className="text-sm text-zinc-500">
对应 `metadata.provide_department`。
</div>
</div>
</CheckboxField>
<div>
<FieldLabel>提供部门</FieldLabel>
<Input
value={batchEditForm.provideDepartment}
onChange={(event) =>
setBatchEditForm((prev) => ({
...prev,
provideDepartment: event.target.value,
}))
}
placeholder="可留空,用于统一清空"
/>
</div>
<CheckboxField className="rounded-2xl border border-zinc-200 bg-white px-4 py-3">
<Checkbox
color="sky"
checked={batchEditFields.provideTime}
onChange={(checked) =>
setBatchEditFields((prev) => ({
...prev,
provideTime: Boolean(checked),
}))
}
/>
<div>
<div data-slot="label" className="text-sm font-medium text-zinc-900">
批量更新提供时间
</div>
<div data-slot="description" className="text-sm text-zinc-500">
提交时会统一转换为 ISO 8601 字符串。
</div>
</div>
</CheckboxField>
<div>
<FieldLabel>提供时间</FieldLabel>
<Input
type="datetime-local"
value={batchEditForm.provideTime}
onChange={(event) =>
setBatchEditForm((prev) => ({
...prev,
provideTime: event.target.value,
}))
}
/>
</div>
</div>
</FormSection>
<FormSection
eyebrow="COMPLIANCE"
title="合规属性"
description="布尔字段也按“先选择,再覆盖”的方式批量修改。"
>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-3 rounded-[24px] border border-zinc-200 bg-white p-4">
<CheckboxField>
<Checkbox
color="sky"
checked={batchEditFields.isConfidential}
onChange={(checked) =>
setBatchEditFields((prev) => ({
...prev,
isConfidential: Boolean(checked),
}))
}
/>
<div>
<div data-slot="label" className="text-sm font-medium text-zinc-900">
批量更新是否涉密
</div>
<div data-slot="description" className="text-sm text-zinc-500">
勾选后,将下面的值统一写入 `is_confidential`。
</div>
</div>
</CheckboxField>
<CheckboxField>
<Checkbox
color="sky"
checked={batchEditForm.isConfidential}
onChange={(checked) =>
setBatchEditForm((prev) => ({
...prev,
isConfidential: Boolean(checked),
}))
}
/>
<div>
<div data-slot="label" className="text-sm font-medium text-zinc-900">
目标值:涉密
</div>
</div>
</CheckboxField>
</div>
<div className="space-y-3 rounded-[24px] border border-zinc-200 bg-white p-4">
<CheckboxField>
<Checkbox
color="sky"
checked={batchEditFields.isAuditDeptMaterial}
onChange={(checked) =>
setBatchEditFields((prev) => ({
...prev,
isAuditDeptMaterial: Boolean(checked),
}))
}
/>
<div>
<div data-slot="label" className="text-sm font-medium text-zinc-900">
批量更新是否审计部材料
</div>
<div data-slot="description" className="text-sm text-zinc-500">
勾选后,将下面的值统一写入 `is_audit_dept_material`。
</div>
</div>
</CheckboxField>
<CheckboxField>
<Checkbox
color="sky"
checked={batchEditForm.isAuditDeptMaterial}
onChange={(checked) =>
setBatchEditForm((prev) => ({
...prev,
isAuditDeptMaterial: Boolean(checked),
}))
}
/>
<div>
<div data-slot="label" className="text-sm font-medium text-zinc-900">
目标值:审计部材料
</div>
</div>
</CheckboxField>
</div>
</div>
</FormSection>
</div>
</DialogBody>
<DialogActions>
<Button
plain
className="cursor-pointer"
onClick={() => setIsBatchEditOpen(false)}
disabled={saving}
>
取消
</Button>
<Button
color="blue"
className="cursor-pointer"
onClick={handleBatchEditSubmit}
disabled={saving || selectedCount === 0}
>
{saving ? '保存中...' : '确认批量修改'}
</Button>
</DialogActions>
</Dialog>
<Dialog
open={isBatchTagOpen}
onClose={() => {
if (saving) {
return;
}
setIsBatchTagOpen(false);
}}
size="2xl"
>
<DialogTitle>批量打标签</DialogTitle>
<DialogDescription>
为当前勾选材料统一写入同一个标签值,提交时会覆盖原 `metadata.tag`。
</DialogDescription>
<DialogBody>
<div className="space-y-5">
<div className="rounded-[24px] border border-emerald-100 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
当前共选中 {selectedCount} 份材料。标签允许自由输入,但建议保持同一批次命名一致。
</div>
<div>
<FieldLabel required>统一标签</FieldLabel>
<Input
list="tag-suggestions"
value={batchTagForm.tag}
onChange={(event) =>
setBatchTagForm({
tag: event.target.value,
})
}
placeholder="例如:待补证据 / 项目一期 / 归档完成"
/>
</div>
</div>
</DialogBody>
<DialogActions>
<Button
plain
className="cursor-pointer"
onClick={() => setIsBatchTagOpen(false)}
disabled={saving}
>
取消
</Button>
<Button
color="blue"
className="cursor-pointer"
onClick={handleBatchTagSubmit}
disabled={saving || selectedCount === 0}
>
{saving ? '保存中...' : '确认批量打标签'}
</Button>
</DialogActions>
</Dialog>
<Dialog open={isUploadOpen} onClose={handleUploadDialogClose} size="3xl">
<DialogTitle>上传材料</DialogTitle>
<DialogDescription>
先上传文件,再补齐标题、分类和标签,后续在统一材料池里会更容易检索。
创建页已按最新对象文档补齐字段,上传完成后会统一写入 `am_material.metadata`。
</DialogDescription>
<DialogBody>
<div className="space-y-6">
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">选择文件</div>
<FormSection
eyebrow="FILE PICKER"
title="上传文件"
description="先完成文件选择,系统会自动尝试用文件名回填材料标题。"
>
<div className="rounded-[28px] border border-dashed border-sky-200 bg-sky-50/60 p-5">
<input
ref={fileInputRef}
......@@ -1091,13 +1820,23 @@ export default function ResourceBookingPage() {
onChange={(event) => {
const nextFile = event.target.files?.[0] || null;
setSelectedFile(nextFile);
setUploadForm((prev) => {
const nextMode = isZipFile(nextFile)
? prev.uploadMode
: ARCHIVE_UPLOAD_MODE.ARCHIVE;
const shouldPrefillTitle =
nextFile &&
!prev.materialTitle &&
nextMode === ARCHIVE_UPLOAD_MODE.ARCHIVE;
if (nextFile && !uploadForm.materialTitle) {
setUploadForm((prev) => ({
return {
...prev,
materialTitle: inferTitleFromFile(nextFile),
}));
}
uploadMode: nextMode,
materialTitle: shouldPrefillTitle
? inferTitleFromFile(nextFile)
: prev.materialTitle,
};
});
}}
/>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
......@@ -1110,7 +1849,7 @@ export default function ResourceBookingPage() {
? `${formatFileSize(selectedFile.size)} · ${
selectedFile.type || '未知类型'
}`
: '支持上传图片、PDF、Office 文档等常见审计材料'}
: '支持上传图片、PDF、Office 文档,以及 ZIP 压缩包'}
</div>
</div>
<button
......@@ -1122,39 +1861,112 @@ export default function ResourceBookingPage() {
选择文件
</button>
</div>
{selectedFileIsZip ? (
<div className="mt-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
当前选择的是压缩包,可按场景选择“整包上传”或“解压后逐文件上传”。
</div>
) : null}
</div>
</FormSection>
{selectedFileIsZip ? (
<FormSection
eyebrow="UPLOAD MODE"
title="压缩包处理方式"
description="同一个 ZIP 可以选择直接归档,或拆成多条材料记录。拆分时,下方属性会作为每个文件的通用属性。"
>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">材料标题</div>
<Input
value={uploadForm.materialTitle}
onChange={(event) =>
<button
type="button"
onClick={() =>
setUploadForm((prev) => ({
...prev,
materialTitle: event.target.value,
uploadMode: ARCHIVE_UPLOAD_MODE.ARCHIVE,
materialTitle:
prev.materialTitle || inferTitleFromFile(selectedFile),
}))
}
placeholder="必填"
required
/>
className={clsx(
'rounded-[24px] border px-5 py-4 text-left transition',
uploadForm.uploadMode === ARCHIVE_UPLOAD_MODE.ARCHIVE
? 'border-sky-300 bg-sky-50 shadow-sm'
: 'border-zinc-200 bg-white hover:border-zinc-300 hover:bg-zinc-50',
)}
>
<div className="text-sm font-semibold text-zinc-900">整包上传</div>
<div className="mt-1 text-sm leading-6 text-zinc-500">
将 ZIP 当成 1 个材料文件归档,材料标题必填,适合保留完整压缩包。
</div>
</button>
<button
type="button"
onClick={() =>
setUploadForm((prev) => ({
...prev,
uploadMode: ARCHIVE_UPLOAD_MODE.EXTRACT,
}))
}
className={clsx(
'rounded-[24px] border px-5 py-4 text-left transition',
uploadForm.uploadMode === ARCHIVE_UPLOAD_MODE.EXTRACT
? 'border-sky-300 bg-sky-50 shadow-sm'
: 'border-zinc-200 bg-white hover:border-zinc-300 hover:bg-zinc-50',
)}
>
<div className="text-sm font-semibold text-zinc-900">解压后逐文件上传</div>
<div className="mt-1 text-sm leading-6 text-zinc-500">
ZIP 内有几个文件,就创建几条材料记录;材料标题会自动取每个文件名。
</div>
</button>
</div>
</FormSection>
) : null}
<FormSection
eyebrow="BASIC INFO"
title="基础信息"
description={
selectedFileIsZip && uploadForm.uploadMode === ARCHIVE_UPLOAD_MODE.EXTRACT
? '这部分字段会作为压缩包内每个文件的通用属性一并写入材料主档。'
: '这部分字段直接影响材料主档展示与搜索。'
}
>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">材料编号</div>
<FieldLabel
required={
!selectedFileIsZip ||
uploadForm.uploadMode === ARCHIVE_UPLOAD_MODE.ARCHIVE
}
>
材料标题
</FieldLabel>
<Input
value={uploadForm.materialCode}
value={uploadForm.materialTitle}
onChange={(event) =>
setUploadForm((prev) => ({
...prev,
materialCode: event.target.value,
materialTitle: event.target.value,
}))
}
placeholder="可选"
placeholder={
selectedFileIsZip &&
uploadForm.uploadMode === ARCHIVE_UPLOAD_MODE.EXTRACT
? '拆分上传时自动按文件名生成'
: '必填'
}
required={
!selectedFileIsZip ||
uploadForm.uploadMode === ARCHIVE_UPLOAD_MODE.ARCHIVE
}
disabled={
selectedFileIsZip &&
uploadForm.uploadMode === ARCHIVE_UPLOAD_MODE.EXTRACT
}
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">来源应用</div>
<FieldLabel>来源应用</FieldLabel>
<Input
list="source-app-suggestions"
value={uploadForm.sourceApp}
......@@ -1168,7 +1980,7 @@ export default function ResourceBookingPage() {
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">材料分类</div>
<FieldLabel>材料分类</FieldLabel>
<Input
list="archive-category-suggestions"
value={uploadForm.archiveCategory}
......@@ -1181,48 +1993,132 @@ export default function ResourceBookingPage() {
placeholder="可选,例如 审计证据"
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">文件摘要</div>
<div className="sm:col-span-2">
<FieldLabel>标签分组</FieldLabel>
<Input
value={uploadForm.fileHash}
list="tag-suggestions"
value={uploadForm.tag}
onChange={(event) =>
setUploadForm((prev) => ({
...prev,
fileHash: event.target.value,
tag: event.target.value,
}))
}
placeholder="可选,如 sha256 值"
placeholder="例如:项目一期"
/>
</div>
</div>
</FormSection>
<FormSection
eyebrow="PROVIDE INFO"
title="提供信息"
description="与对象文档保持一致,用于记录材料由谁、何时提供。"
>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">标签分组</div>
<FieldLabel>提供部门</FieldLabel>
<Input
list="tag-suggestions"
value={uploadForm.tag}
value={uploadForm.provideDepartment}
onChange={(event) =>
setUploadForm((prev) => ({
...prev,
tag: event.target.value,
provideDepartment: event.target.value,
}))
}
placeholder="例如:项目一期"
placeholder="可选"
/>
</div>
<div className="sm:col-span-2">
<div className="mb-2 text-sm font-medium text-zinc-600">备注</div>
<Textarea
rows={4}
value={uploadForm.remark}
<div>
<FieldLabel>提供时间</FieldLabel>
<Input
type="datetime-local"
value={uploadForm.provideTime}
onChange={(event) =>
setUploadForm((prev) => ({
...prev,
remark: event.target.value,
provideTime: event.target.value,
}))
}
/>
</div>
</div>
</FormSection>
<FormSection
eyebrow="COMPLIANCE"
title="合规属性"
description="布尔字段统一使用 Checkbox,方便快速录入与后续筛查。"
>
<div className="grid gap-4 sm:grid-cols-2">
<CheckboxField className="rounded-2xl border border-zinc-200 bg-white px-4 py-3">
<Checkbox
color="sky"
checked={uploadForm.isConfidential}
onChange={(checked) =>
setUploadForm((prev) => ({
...prev,
isConfidential: Boolean(checked),
}))
}
placeholder="补充这份材料的背景信息、用途或上传说明"
/>
<div>
<div data-slot="label" className="text-sm font-medium text-zinc-900">
是否涉密
</div>
<div data-slot="description" className="text-sm text-zinc-500">
用于标记是否需要更严格的查看与流转控制。
</div>
</div>
</CheckboxField>
<CheckboxField className="rounded-2xl border border-zinc-200 bg-white px-4 py-3">
<Checkbox
color="sky"
checked={uploadForm.isAuditDeptMaterial}
onChange={(checked) =>
setUploadForm((prev) => ({
...prev,
isAuditDeptMaterial: Boolean(checked),
}))
}
/>
<div>
<div data-slot="label" className="text-sm font-medium text-zinc-900">
是否审计部材料
</div>
<div data-slot="description" className="text-sm text-zinc-500">
标记后可区分审计部自产材料与外部提供材料。
</div>
</div>
</CheckboxField>
</div>
</FormSection>
<FormSection
eyebrow="REMARKS"
title="补充说明"
description="这里汇总当前录入的材料属性,便于提交前快速确认。"
>
<div className="grid gap-4">
<div>
<FieldLabel>录入说明</FieldLabel>
<Textarea
value={[
uploadForm.sourceApp ? `来源应用:${uploadForm.sourceApp}` : '',
uploadForm.archiveCategory
? `材料分类:${uploadForm.archiveCategory}`
: '',
uploadForm.tag ? `标签分组:${uploadForm.tag}` : '',
]
.filter(Boolean)
.join('\n')}
readOnly
resizable={false}
className="min-h-24"
/>
</div>
</div>
</FormSection>
</div>
<datalist id="source-app-suggestions">
......
import { UserContext } from '@/wrapper/Auth';
import {
ArrowTrendingUpIcon,
BuildingOffice2Icon,
CircleStackIcon,
DocumentTextIcon,
FolderOpenIcon,
TagIcon,
ShieldCheckIcon,
ShieldExclamationIcon,
} from '@heroicons/react/24/outline';
import clsx from 'clsx';
import ReactECharts from 'echarts-for-react';
import { useContext, useEffect, useMemo, useState } from 'react';
import {
buildMaterialDashboard,
......@@ -15,6 +18,8 @@ import {
loadMaterialRecords,
} from '../materialService';
const CHART_COLORS = ['#e11d48', '#fb7185', '#f59e0b', '#f97316', '#9f1239', '#be123c'];
function Panel({ className, children }) {
return (
<section
......@@ -74,30 +79,57 @@ function SectionHeader({ title, description }) {
);
}
function DistributionRow({ label, value, total, accent = 'bg-sky-500' }) {
const percentage = total > 0 ? Math.max(8, Math.round((value / total) * 100)) : 0;
function TagPill({ children, tone = 'sky' }) {
const toneClass =
tone === 'emerald'
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
: tone === 'amber'
? 'border-amber-200 bg-amber-50 text-amber-700'
: 'border-sky-200 bg-sky-50 text-sky-700';
return (
<div>
<div className="mb-2 flex items-center justify-between text-sm text-zinc-600">
<span>{label}</span>
<span>{value} 份</span>
</div>
<div className="h-2.5 rounded-full bg-zinc-100">
<span
className={clsx(
'inline-flex rounded-full border px-3 py-1 text-xs font-medium',
toneClass,
)}
>
{children}
</span>
);
}
function ChartPanel({ option, className = '', emptyText = '暂无图表数据' }) {
const hasSeriesData = Array.isArray(option?.series)
? option.series.some((seriesItem) => {
if (Array.isArray(seriesItem?.data)) {
return seriesItem.data.length > 0;
}
return false;
})
: false;
if (!hasSeriesData) {
return (
<div
className={clsx('h-2.5 rounded-full', accent)}
style={{ width: `${percentage}%` }}
/>
</div>
className={clsx(
'flex h-[320px] items-center justify-center rounded-[24px] border border-dashed border-zinc-200 bg-zinc-50 text-sm text-zinc-400',
className,
)}
>
{emptyText}
</div>
);
}
}
function TagPill({ children }) {
return (
<span className="inline-flex rounded-full border border-sky-200 bg-sky-50 px-3 py-1 text-xs font-medium text-sky-700">
{children}
</span>
<ReactECharts
option={option}
notMerge
lazyUpdate
className={clsx('h-[320px] w-full', className)}
/>
);
}
......@@ -141,12 +173,260 @@ export default function ResourceStatPage() {
);
}, [userInfo]);
const sourceDistributionOption = useMemo(() => {
const sourceGroups = dashboard.sourceGroups.slice(0, 6);
return {
color: CHART_COLORS,
grid: {
top: 16,
right: 16,
bottom: 12,
left: 80,
containLabel: true,
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
xAxis: {
type: 'value',
splitLine: {
lineStyle: {
color: '#e4e4e7',
type: 'dashed',
},
},
axisLabel: {
color: '#71717a',
},
},
yAxis: {
type: 'category',
data: sourceGroups.map((item) => item.label),
axisTick: {
show: false,
},
axisLine: {
show: false,
},
axisLabel: {
color: '#27272a',
fontSize: 12,
},
},
series: [
{
type: 'bar',
data: sourceGroups.map((item, index) => ({
value: item.count,
itemStyle: {
color: CHART_COLORS[index % CHART_COLORS.length],
borderRadius: [0, 10, 10, 0],
},
})),
barWidth: 16,
label: {
show: true,
position: 'right',
color: '#0f172a',
formatter: '{c} 份',
},
},
],
};
}, [dashboard.sourceGroups]);
const categoryDistributionOption = useMemo(() => {
const categoryGroups = dashboard.categoryGroups.slice(0, 6);
return {
color: CHART_COLORS,
tooltip: {
trigger: 'item',
formatter: ({ name, value, percent }) => `${name}<br/>${value} 份 · ${percent}%`,
},
legend: {
bottom: 0,
left: 'center',
icon: 'circle',
itemWidth: 10,
textStyle: {
color: '#52525b',
},
},
series: [
{
type: 'pie',
radius: ['38%', '72%'],
center: ['50%', '44%'],
avoidLabelOverlap: true,
padAngle: 2,
itemStyle: {
borderColor: '#ffffff',
borderWidth: 4,
},
label: {
show: true,
color: '#18181b',
formatter: ({ name, percent }) => `${name}\n${percent}%`,
},
labelLine: {
length: 10,
length2: 10,
},
data: categoryGroups.map((item) => ({
name: item.label,
value: item.count,
})),
},
],
graphic: [
{
type: 'text',
left: 'center',
top: '37%',
style: {
text: '分类热度',
fill: '#71717a',
fontSize: 13,
fontWeight: 500,
},
},
{
type: 'text',
left: 'center',
top: '45%',
style: {
text: `${dashboard.uniqueCategoryCount}`,
fill: '#09090b',
fontSize: 26,
fontWeight: 700,
},
},
],
};
}, [dashboard.categoryGroups, dashboard.uniqueCategoryCount]);
const governanceOption = useMemo(() => {
const totalCount = dashboard.totalCount || 0;
const incompleteCount = Math.max(totalCount - dashboard.providedInfoCompleteCount, 0);
const plainCount = Math.max(
totalCount - dashboard.confidentialCount - dashboard.auditDeptCount,
0,
);
return {
color: ['#e11d48', '#f59e0b', '#fb7185', '#10b981'],
tooltip: {
trigger: 'item',
},
series: [
{
type: 'pie',
radius: ['54%', '76%'],
center: ['28%', '48%'],
startAngle: 90,
label: {
show: false,
},
labelLine: {
show: false,
},
data: [
{
name: '提供信息完整',
value: dashboard.providedInfoCompleteCount,
},
{
name: '待补录提供信息',
value: incompleteCount,
},
],
},
{
type: 'pie',
radius: ['20%', '36%'],
center: ['28%', '48%'],
startAngle: 90,
label: {
show: false,
},
labelLine: {
show: false,
},
data: [
{
name: '涉密材料',
value: dashboard.confidentialCount,
itemStyle: {
color: '#fb7185',
},
},
{
name: '审计部材料',
value: dashboard.auditDeptCount,
itemStyle: {
color: '#10b981',
},
},
{
name: '其它材料',
value: plainCount,
itemStyle: {
color: '#e4e4e7',
},
},
],
},
],
graphic: [
{
type: 'text',
left: '20%',
top: '43%',
style: {
text: `${totalCount}`,
fill: '#09090b',
fontSize: 24,
fontWeight: 700,
textAlign: 'center',
},
},
{
type: 'text',
left: '18%',
top: '53%',
style: {
text: '材料总数',
fill: '#71717a',
fontSize: 12,
fontWeight: 500,
textAlign: 'center',
},
},
],
};
}, [
dashboard.auditDeptCount,
dashboard.confidentialCount,
dashboard.providedInfoCompleteCount,
dashboard.totalCount,
]);
const topCategoryCards = useMemo(
() => dashboard.categoryGroups.slice(0, 4),
[dashboard.categoryGroups],
);
return (
<div className="space-y-6">
<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(14,165,233,0.18),_transparent_45%),radial-gradient(circle_at_top_right,_rgba(16,185,129,0.12),_transparent_35%)]" />
<div className="absolute inset-x-0 top-0 h-40 bg-[radial-gradient(circle_at_top_left,_rgba(225,29,72,0.18),_transparent_45%),radial-gradient(circle_at_top_right,_rgba(251,113,133,0.14),_transparent_35%)]" />
<div className="relative">
<div className="inline-flex rounded-full border border-sky-200 bg-sky-50 px-3 py-1 text-xs font-semibold tracking-[0.2em] text-sky-700">
<div className="inline-flex rounded-full border border-rose-200 bg-rose-50 px-3 py-1 text-xs font-semibold tracking-[0.2em] text-rose-700">
MATERIAL OVERVIEW
</div>
<div className="mt-5 flex flex-col gap-6 xl:flex-row xl:items-end xl:justify-between">
......@@ -155,7 +435,7 @@ export default function ResourceStatPage() {
{welcomeLabel},这里是统一材料池的全局视图
</h1>
<p className="mt-3 text-sm leading-7 text-zinc-600 sm:text-base">
总览页重点帮你快速看清材料规模、来源分布和最近上传动态,方便先判断数据是否完整,再进入归档页做细节管理。
总览页重点帮你快速看清材料规模、来源分布、提供信息完整度和合规标记状态,方便先判断数据是否完整,再进入归档页做细节管理。
</p>
</div>
......@@ -202,7 +482,7 @@ export default function ResourceStatPage() {
</div>
</Panel>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<MetricCard
icon={DocumentTextIcon}
label="材料总数"
......@@ -217,18 +497,43 @@ export default function ResourceStatPage() {
hint="跨应用汇入的来源数量"
tone="green"
/>
<MetricCard
icon={BuildingOffice2Icon}
label="提供信息完整"
value={dashboard.providedInfoCompleteCount}
hint={`已补齐部门和时间,当前覆盖 ${
dashboard.totalCount > 0
? `${Math.round((dashboard.providedInfoCompleteCount / dashboard.totalCount) * 100)}%`
: '0%'
}`}
tone="amber"
/>
<MetricCard
icon={ShieldExclamationIcon}
label="涉密材料"
value={dashboard.confidentialCount}
hint="用于快速识别需要更严格流转控制的文件"
tone="rose"
/>
<MetricCard
icon={ShieldCheckIcon}
label="审计部材料"
value={dashboard.auditDeptCount}
hint="区分审计部自产材料与外部提供材料"
tone="blue"
/>
<MetricCard
icon={FolderOpenIcon}
label="分类数量"
value={dashboard.uniqueCategoryCount}
hint="按用户自定义分类归档"
tone="amber"
tone="green"
/>
<MetricCard
icon={ArrowTrendingUpIcon}
label="待完善"
value={dashboard.attentionMaterials.length}
hint="缺少编号或标签的材料"
hint="缺少来源、分类、标签或提供信息的材料"
tone="rose"
/>
</div>
......@@ -239,26 +544,27 @@ export default function ResourceStatPage() {
title="来源应用分布"
description="帮助判断材料是否已经从项目管理、问题整改等应用顺利汇入统一材料池。"
/>
<div className="space-y-4">
{dashboard.sourceGroups.length > 0 ? (
dashboard.sourceGroups.map((item, index) => (
<DistributionRow
<ChartPanel option={sourceDistributionOption} emptyText="暂无来源分布数据" />
<div className="mt-4 grid gap-3 sm:grid-cols-3">
{dashboard.sourceGroups.slice(0, 3).map((item, index) => (
<div
key={item.key}
label={item.label}
value={item.count}
total={dashboard.totalCount}
accent={
index % 3 === 1
? 'bg-emerald-500'
: index % 3 === 2
? 'bg-amber-500'
: 'bg-sky-500'
}
/>
))
) : (
<div className="text-sm text-zinc-400">暂无来源分布数据</div>
)}
className="rounded-[20px] border border-zinc-200 bg-zinc-50/80 px-4 py-3"
>
<div className="text-xs font-semibold tracking-[0.18em] text-zinc-500">
TOP {index + 1}
</div>
<div className="mt-2 truncate text-sm font-semibold text-zinc-950">
{item.label}
</div>
<div className="mt-1 text-xs text-zinc-500">
{item.count} 份,占比{' '}
{dashboard.totalCount > 0
? `${Math.round((item.count / dashboard.totalCount) * 100)}%`
: '0%'}
</div>
</div>
))}
</div>
</Panel>
......@@ -267,9 +573,10 @@ export default function ResourceStatPage() {
title="分类热度"
description="按当前材料数量快速观察哪些分类最常用,也方便检查是否出现大量未分类材料。"
/>
<div className="grid gap-3 sm:grid-cols-2">
{dashboard.categoryGroups.length > 0 ? (
dashboard.categoryGroups.map((item, index) => (
<ChartPanel option={categoryDistributionOption} emptyText="暂无分类数据" />
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{topCategoryCards.length > 0 ? (
topCategoryCards.map((item, index) => (
<div
key={item.key}
className={clsx(
......@@ -321,6 +628,16 @@ export default function ResourceStatPage() {
<div className="mt-2 text-sm text-zinc-500">
{item.sourceAppLabel} · {item.uploaderLabel} · {item.fileSizeLabel}
</div>
<div className="mt-2 flex flex-wrap gap-2">
{item.isConfidential ? <TagPill tone="amber">涉密</TagPill> : null}
{item.isAuditDeptMaterial ? (
<TagPill tone="emerald">审计部材料</TagPill>
) : null}
{item.provideDepartment ? (
<TagPill>{item.provideDepartment}</TagPill>
) : null}
{item.tag ? <TagPill>{item.tag}</TagPill> : null}
</div>
</div>
<div className="text-sm text-zinc-500">{formatDateTime(item.uploadedAt)}</div>
</div>
......@@ -334,30 +651,62 @@ export default function ResourceStatPage() {
<div className="space-y-6">
<Panel className="p-6">
<SectionHeader
title="自定义标签"
description="标签能承接用户的分组习惯,适合快速做阶段、专题或批次管理。"
title="提供与合规"
description="根据最新字段快速看清提供信息补录进度,以及涉密、审计部材料的标记情况。"
/>
<div className="grid gap-4 lg:grid-cols-[1.15fr_0.85fr]">
<ChartPanel
option={governanceOption}
className="h-[300px]"
emptyText="暂无提供与合规数据"
/>
<div className="flex flex-wrap gap-2">
{dashboard.tagGroups.length > 0 ? (
dashboard.tagGroups.map((item) => (
<div className="space-y-3">
<div className="rounded-[22px] border border-zinc-200 bg-zinc-50/80 px-4 py-4">
<div className="text-sm text-zinc-500">已补齐提供信息</div>
<div className="mt-2 text-2xl font-semibold text-zinc-950">
{dashboard.providedInfoCompleteCount}
</div>
<div className="mt-1 text-xs text-zinc-500">
缺少部门 {dashboard.missingProvideDepartmentCount},缺少时间{' '}
{dashboard.missingProvideTimeCount}
</div>
</div>
<div className="rounded-[22px] border border-zinc-200 bg-white px-4 py-4">
<div className="text-sm font-medium text-zinc-700">主要提供部门</div>
<div className="mt-3 flex flex-wrap gap-2">
{dashboard.provideDepartmentGroups.length > 0 ? (
dashboard.provideDepartmentGroups.slice(0, 4).map((item) => (
<span
key={item.key}
className="inline-flex rounded-full border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700 shadow-sm"
className="inline-flex rounded-full border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-700"
>
<TagIcon className="mr-2 size-4 text-sky-600" />
{item.label} · {item.count}
</span>
))
) : (
<div className="text-sm text-zinc-400">当前还没有可展示的标签</div>
<div className="text-sm text-zinc-400">当前还没有已登记的提供部门</div>
)}
</div>
</div>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<TagPill tone="amber">涉密 {dashboard.confidentialCount}</TagPill>
<TagPill tone="emerald">
审计部材料 {dashboard.auditDeptCount}
</TagPill>
</div>
<div className="mt-5 rounded-[22px] border border-dashed border-zinc-200 bg-zinc-50/70 px-4 py-4 text-sm text-zinc-600">
图表外圈表示提供信息补录情况,内圈用于区分涉密材料、审计部材料与其它材料,便于先看合规密度,再判断是否需要回到归档页补录。
</div>
</Panel>
<Panel className="p-6">
<SectionHeader
title="待完善清单"
description="优先处理缺少标签、缺少编号或分类不清晰的材料,后续检索体验会更稳定。"
description="优先处理缺少来源、分类、标签或提供信息的材料,后续检索和追溯会更稳定。"
/>
<div className="space-y-3">
{dashboard.attentionMaterials.length > 0 ? (
......@@ -370,9 +719,7 @@ export default function ResourceStatPage() {
{item.materialTitle}
</div>
<div className="mt-2 text-xs leading-6 text-amber-800">
{!item.materialCode ? '缺少材料编号' : ''}
{!item.materialCode && !item.tag ? ' · ' : ''}
{!item.tag ? '缺少标签分组' : ''}
{item.attentionReasons.join(' · ')}
</div>
</div>
))
......
......@@ -41,9 +41,7 @@
| 字段标题 | 字段别名 | 类型 | 必填 | 多值 | 主键 | 默认值 | 备注 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 材料标题 | `material_title` | `text` | 是 | 否 | 否 | 无 | 主展示字段 |
| 材料编号 | `material_code` | `text` | 否 | 否 | 否 | 无 | 一期先不设主键,避免卡流程 |
| 文件对象 | `file_asset` | `file` | 是 | 否 | 否 | 无 | 上传接口返回对象 |
| 文件摘要 | `file_hash` | `text` | 否 | 否 | 否 | 无 | 后续做去重时使用 |
| 来源应用 | `source_app` | `text` | 否 | 否 | 否 | 无 | 材料最初来源 |
| 材料分类 | `archive_category` | `text` | 否 | 否 | 否 | 无 | 用户自定义分类 |
| 上传人 | `uploader` | `user` | 是 | 否 | 否 | 无 | 当前登录人 |
......
......@@ -166,19 +166,16 @@
"content_type": "text",
"metadata": {
"material_title": "审计底稿",
"material_code": "AM-2026-001",
"file_asset": {
"name": "审计底稿.pdf",
"type": "application/pdf",
"size": 248120,
"url": "https://oss.example.com/audit/xxx.pdf"
},
"file_hash": "sha256:xxxx",
"source_app": "中台",
"archive_category": "试试水",
"uploader": "69b12a47e92b1de77c94a9f5",
"uploaded_at": "2026-05-09T08:30:00.000Z",
"remark": "统一材料池文件",
"tag": "项目一期"
}
}
......@@ -216,19 +213,16 @@
"content_type": "text",
"metadata": {
"material_title": "测试1",
"material_code": "111",
"file_asset": {
"name": "项目管理_字段.xlsx",
"type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"size": 9888,
"url": "https://oss.streams7.com/xinyuan/3WCqMeKoiqOc.xlsx"
},
"file_hash": "test",
"source_app": "中台",
"archive_category": "试试水",
"uploader": "64b6575b80bdbd9b3d65d09b",
"uploaded_at": "2026-05-08T16:00:00.000Z",
"remark": "测试",
"tag": "ttt"
},
"subject": "am_material"
......
......@@ -99,34 +99,38 @@ type Record = {
| 字段标签 | 字段别名 | 字段类型 | 是否主键 | 是否多值 | 是否必填 | 说明 |
| --- | --- | --- | --- | --- | --- | --- |
| 材料标题 | `material_title` | text | 否 | 否 | 是 | 业务展示主标题 |
| 材料编号 | `material_code` | text | 否 | 否 | 否 | 如后续需要编码管理再启用 |
| 文件对象 | `file_asset` | file | 否 | 否 | 是 | 上传接口返回的文件对象 |
| 文件摘要 | `file_hash` | text | 否 | 否 | 否 | 用于后续查重,可先留空 |
| 来源应用 | `source_app` | text | 否 | 否 | 否 | 材料最初产生于哪个应用 |
| 材料分类 | `archive_category` | text | 否 | 否 | 否 | 用户自定义分类 |
| 上传人 | `uploader` | user | 否 | 否 | 是 | 当前登录用户 |
| 上传时间 | `uploaded_at` | date | 否 | 否 | 是 | 创建主档时间 |
| 备注 | `remark` | text | 否 | 否 | 否 | 管理说明 |
| 备注 | `remark` | text | 否 | 否 | 否 | 材料补充说明 |
| 标签 | `tag` | text | 否 | 否 | 否 | 用户自定义分组标签 |
| 提供部门 | `provide_department` | text | 否 | 否 | 否 | |
| 提供时间 | `provide_time` | date | 否 | 否 | 否 | |
| 是否涉密 | `is_confidential` | boolean | 否 | 否 | 否 | |
| 是否审计部材料 | `is_audit_dept_material` | boolean | 否 | 否 | 否 | |
### 3.1.1 metadata 示例
```json
{
"material_title": "审计访谈会议纪要",
"material_code": "AM-2026-001",
"file_asset": {
"name": "审计访谈会议纪要.pdf",
"type": "application/pdf",
"size": 248120,
"url": "https://oss.example.com/audit/meeting-note.pdf"
},
"file_hash": "sha256:xxxx",
"source_app": "project_manager_xy",
"archive_category": "审计证据",
"uploader": "69b12a47e92b1de77c94a9f5",
"uploaded_at": "2026-05-09T08:30:00.000Z",
"remark": "上传后可被各审计应用统一查看",
"tag": "项目一期"
"remark": "会议纪要已补签字页",
"tag": "项目一期",
"provide_department": "审计部",
"provide_time": "2026-05-08T16:00:00.000Z",
"is_confidential": false,
"is_audit_dept_material": true
}
```
......@@ -7,6 +7,34 @@
"Helvetica Neue",
Arial,
sans-serif;
--color-sky-50: #fff1f2;
--color-sky-100: #ffe4e6;
--color-sky-200: #fecdd3;
--color-sky-300: #fda4af;
--color-sky-400: #fb7185;
--color-sky-500: #f43f5e;
--color-sky-600: #e11d48;
--color-sky-700: #be123c;
--color-sky-800: #9f1239;
--color-sky-900: #881337;
--color-blue-50: #fff1f2;
--color-blue-100: #ffe4e6;
--color-blue-200: #fecdd3;
--color-blue-300: #fda4af;
--color-blue-400: #fb7185;
--color-blue-500: #f43f5e;
--color-blue-600: #e11d48;
--color-blue-700: #be123c;
--color-blue-800: #9f1239;
--color-blue-900: #881337;
--color-cyan-50: #fff1f2;
--color-cyan-100: #ffe4e6;
--color-cyan-200: #fecdd3;
--color-cyan-300: #fda4af;
--color-cyan-400: #fb7185;
--color-cyan-500: #f43f5e;
--color-cyan-600: #e11d48;
--color-cyan-700: #be123c;
}
:root {
......
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