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

feat: 完善审计材料中心

parent 58f53090
...@@ -13,6 +13,9 @@ ...@@ -13,6 +13,9 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.6",
"jszip": "^3.10.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"tailwindcss": "^4.2.0", "tailwindcss": "^4.2.0",
...@@ -10230,6 +10233,36 @@ ...@@ -10230,6 +10233,36 @@
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT" "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": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
...@@ -12462,6 +12495,12 @@ ...@@ -12462,6 +12495,12 @@
"node": ">=0.10.0" "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": { "node_modules/immer": {
"version": "8.0.4", "version": "8.0.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-8.0.4.tgz", "resolved": "https://registry.npmjs.org/immer/-/immer-8.0.4.tgz",
...@@ -13608,6 +13647,54 @@ ...@@ -13608,6 +13647,54 @@
"node": ">=4.0" "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": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
...@@ -13725,6 +13812,15 @@ ...@@ -13725,6 +13812,15 @@
"node": ">= 0.8.0" "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": { "node_modules/lightningcss": {
"version": "1.32.0", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
...@@ -18491,6 +18587,12 @@ ...@@ -18491,6 +18587,12 @@
"integrity": "sha512-9SQdmsyz4HSP+3gs6PJzhkaMEg+6zTlu9oxIghnwUX3eq+ajq4ft5egl0iyR55LAmO/UwvU8NgIWs/ZyQMa6dw==", "integrity": "sha512-9SQdmsyz4HSP+3gs6PJzhkaMEg+6zTlu9oxIghnwUX3eq+ajq4ft5egl0iyR55LAmO/UwvU8NgIWs/ZyQMa6dw==",
"license": "MIT" "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": { "node_modules/slash": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
...@@ -21161,6 +21263,21 @@ ...@@ -21161,6 +21263,21 @@
"peerDependencies": { "peerDependencies": {
"zod": "^3.18.0" "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 @@ ...@@ -15,6 +15,9 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.6",
"jszip": "^3.10.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"tailwindcss": "^4.2.0", "tailwindcss": "^4.2.0",
......
...@@ -43,7 +43,7 @@ export const AvatarButton = forwardRef(function AvatarButton( ...@@ -43,7 +43,7 @@ export const AvatarButton = forwardRef(function AvatarButton(
let classes = clsx( let classes = clsx(
className, className,
square ? 'rounded-[20%]' : 'rounded-full', 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' ? ( return typeof props.href === 'string' ? (
......
...@@ -54,7 +54,7 @@ export const BadgeButton = forwardRef(function BadgeButton( ...@@ -54,7 +54,7 @@ export const BadgeButton = forwardRef(function BadgeButton(
) { ) {
let classes = clsx( let classes = clsx(
className, 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' ? ( return typeof props.href === 'string' ? (
......
...@@ -57,7 +57,7 @@ const base = [ ...@@ -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%)]', 'after:absolute after:inset-0 after:rounded-[calc(0.3125rem-1px)] after:shadow-[inset_0_1px_--theme(--color-white/15%)]',
, ,
// Focus ring // 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 // Disabled state
'group-data-disabled:opacity-50', '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', '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({ ...@@ -38,7 +38,7 @@ export function Combobox({
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo // Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
, ,
// Focus ring // 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 // Disabled state
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none', 'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
// Invalid state // Invalid state
...@@ -133,7 +133,7 @@ export function ComboboxOption({ children, className, ...props }) { ...@@ -133,7 +133,7 @@ export function ComboboxOption({ children, className, ...props }) {
// Typography // Typography
'text-base/6 text-zinc-950 sm:text-sm/6 forced-colors:text-[CanvasText]', 'text-base/6 text-zinc-950 sm:text-sm/6 forced-colors:text-[CanvasText]',
// Focus // 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 colors mode
'forced-color-adjust-none forced-colors:data-focus:bg-[Highlight] forced-colors:data-focus:text-[HighlightText]', 'forced-color-adjust-none forced-colors:data-focus:bg-[Highlight] forced-colors:data-focus:text-[HighlightText]',
// Disabled // Disabled
......
...@@ -51,7 +51,7 @@ export function DropdownItem({ className, ...props }) { ...@@ -51,7 +51,7 @@ export function DropdownItem({ className, ...props }) {
// Text styles // Text styles
'text-left text-base/6 text-zinc-950 sm:text-sm/6 forced-colors:text-[CanvasText]', 'text-left text-base/6 text-zinc-950 sm:text-sm/6 forced-colors:text-[CanvasText]',
// Focus // Focus
'data-focus:bg-blue-500 data-focus:text-white', 'data-focus:bg-[var(--pm-brand)] data-focus:text-white',
// Disabled state // Disabled state
'data-disabled:opacity-50', 'data-disabled:opacity-50',
// Forced colors mode // Forced colors mode
......
...@@ -38,7 +38,7 @@ export const Input = forwardRef(function Input( ...@@ -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 // Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
, ,
// Focus ring // 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 // Disabled state
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none', '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 ...@@ -22,7 +22,7 @@ export function Listbox({ className, placeholder, autoFocus, 'aria-label': ariaL
// Hide default focus styles // Hide default focus styles
'focus:outline-hidden', 'focus:outline-hidden',
// Focus ring // 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 // Disabled state
'data-disabled:opacity-50 data-disabled:before:bg-zinc-950/5 data-disabled:before:shadow-none', '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 }) { ...@@ -115,7 +115,7 @@ export function ListboxOption({ children, className, ...props }) {
// Typography // Typography
'text-base/6 text-zinc-950 sm:text-sm/6 forced-colors:text-[CanvasText]', 'text-base/6 text-zinc-950 sm:text-sm/6 forced-colors:text-[CanvasText]',
// Focus // 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 colors mode
'forced-color-adjust-none forced-colors:data-focus:bg-[Highlight] forced-colors:data-focus:text-[HighlightText]', 'forced-color-adjust-none forced-colors:data-focus:bg-[Highlight] forced-colors:data-focus:text-[HighlightText]',
// Disabled // Disabled
......
...@@ -61,7 +61,7 @@ const base = [ ...@@ -61,7 +61,7 @@ const base = [
// Indicator color (dark mode) // Indicator color (dark mode)
, ,
// Focus ring // 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 // Disabled state
'group-data-disabled:opacity-50', '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', '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 ...@@ -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 // Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
, ,
// Focus ring // 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 // Disabled state
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none', '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 }) { ...@@ -148,7 +148,7 @@ export function Switch({ color = 'dark/zinc', className, ...props }) {
// Checked // Checked
'data-checked:bg-(--switch-bg) data-checked:ring-(--switch-bg-ring)', 'data-checked:bg-(--switch-bg) data-checked:ring-(--switch-bg-ring)',
// Focus // 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 // Hover
'data-hover:ring-black/15 data-hover:data-checked:ring-(--switch-bg-ring)', '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 }) { ...@@ -70,7 +70,7 @@ export function TableRow({ href, target, title, className, ...props }) {
className={clsx( className={clsx(
className, className,
href && 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', striped && 'even:bg-zinc-950/2.5',
href && striped && 'hover:bg-zinc-950/5', href && striped && 'hover:bg-zinc-950/5',
href && !striped && 'hover:bg-zinc-950/2.5', href && !striped && 'hover:bg-zinc-950/2.5',
......
...@@ -15,7 +15,7 @@ export const Textarea = forwardRef(function Textarea({ className, resizable = tr ...@@ -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 // Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
, ,
// Focus ring // 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 // Disabled state
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none', 'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
])} ])}
......
:root { :root {
--pm-brand: #009cd9; --pm-brand: #c81e3a;
--pm-brand-strong: #007bb8; --pm-brand-strong: #9f1239;
--pm-surface: #ffffff; --pm-surface: #ffffff;
--pm-bg: #eef5f9; --pm-bg: #fff1f2;
--pm-sidebar: #0f172a; --pm-sidebar: #0f172a;
--pm-sidebar-soft: #1e293b; --pm-sidebar-soft: #1e293b;
--pm-text: #0f172a; --pm-text: #0f172a;
...@@ -17,7 +17,7 @@ body, ...@@ -17,7 +17,7 @@ body,
body { body {
margin: 0; margin: 0;
background: linear-gradient(180deg, #f8fbfd 0%, #eef5f9 100%); background: linear-gradient(180deg, #fff7f7 0%, #fff1f2 100%);
color: var(--pm-text); color: var(--pm-text);
font-family: font-family:
"PingFang SC", "PingFang SC",
......
...@@ -32,11 +32,11 @@ export default function LoginPage() { ...@@ -32,11 +32,11 @@ export default function LoginPage() {
const backgroundStyle = useMemo(() => { const backgroundStyle = useMemo(() => {
const overlay = 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) { if (!site?.background) {
return { 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() { ...@@ -96,12 +96,12 @@ export default function LoginPage() {
return ( return (
<div <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} 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"> <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"> <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"> <div className="relative z-10 max-w-xl">
<p className="text-xs font-medium uppercase tracking-[0.45em] text-white/60"> <p className="text-xs font-medium uppercase tracking-[0.45em] text-white/60">
Streams Style Login Streams Style Login
...@@ -137,7 +137,7 @@ export default function LoginPage() { ...@@ -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"> <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="w-full max-w-md">
<div className="mb-8"> <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 Sign In
</p> </p>
<h2 className="mt-3 text-3xl font-semibold text-slate-900"> <h2 className="mt-3 text-3xl font-semibold text-slate-900">
...@@ -154,7 +154,7 @@ export default function LoginPage() { ...@@ -154,7 +154,7 @@ export default function LoginPage() {
账号 账号
</label> </label>
<input <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} value={identity}
onChange={(event) => setIdentity(event.target.value)} onChange={(event) => setIdentity(event.target.value)}
disabled={loading} disabled={loading}
...@@ -169,7 +169,7 @@ export default function LoginPage() { ...@@ -169,7 +169,7 @@ export default function LoginPage() {
</label> </label>
<input <input
type="password" 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} value={password}
onChange={(event) => setPassword(event.target.value)} onChange={(event) => setPassword(event.target.value)}
disabled={loading} disabled={loading}
...@@ -184,7 +184,7 @@ export default function LoginPage() { ...@@ -184,7 +184,7 @@ export default function LoginPage() {
</label> </label>
<div className="flex gap-3"> <div className="flex gap-3">
<input <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} value={captchaCode}
onChange={(event) => setCaptchaCode(event.target.value)} onChange={(event) => setCaptchaCode(event.target.value)}
disabled={loading} disabled={loading}
...@@ -198,7 +198,7 @@ export default function LoginPage() { ...@@ -198,7 +198,7 @@ export default function LoginPage() {
refreshCaptcha(); refreshCaptcha();
}} }}
disabled={loading || captchaLoading} 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 ? ( {captchaData?.image ? (
<span <span
...@@ -221,7 +221,7 @@ export default function LoginPage() { ...@@ -221,7 +221,7 @@ export default function LoginPage() {
<button <button
type="submit" type="submit"
disabled={loading || captchaLoading} 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 ? '登录中...' : '登录'} {loading ? '登录中...' : '登录'}
</button> </button>
......
export default function EmptyStatePage({ title, description }) { export default function EmptyStatePage({ title, description }) {
return ( 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)]"> <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 -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-cyan-50 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="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 COMING SOON
</div> </div>
<h2 className="mt-6 text-3xl font-semibold text-slate-900">{title}</h2> <h2 className="mt-6 text-3xl font-semibold text-slate-900">{title}</h2>
......
import rsmLogo from '@/assets/rsm_logo.svg'; import xyLogo from '@/assets/xy-logo.png';
import rsmLogoSmall from '@/assets/rsm_logo_small.svg'; import xyLogoSmall from '@/assets/xy-logo_small.png';
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
import { import {
ArchiveBoxIcon, ArchiveBoxIcon,
...@@ -234,8 +234,8 @@ export default function ProjectManagerLayout() { ...@@ -234,8 +234,8 @@ export default function ProjectManagerLayout() {
)} )}
> >
<img <img
src={sidebarCollapsed ? rsmLogoSmall : rsmLogo} src={sidebarCollapsed ? xyLogoSmall : xyLogo}
alt="RSM Logo" alt="XY Logo"
className={clsx( className={clsx(
'shrink-0 object-contain', 'shrink-0 object-contain',
sidebarCollapsed sidebarCollapsed
...@@ -302,8 +302,8 @@ export default function ProjectManagerLayout() { ...@@ -302,8 +302,8 @@ export default function ProjectManagerLayout() {
<div className="px-5 pt-6"> <div className="px-5 pt-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<img <img
src={rsmLogo} src={xyLogo}
alt="RSM Logo" alt="XY Logo"
className="h-5 w-auto max-w-[128px] shrink-0 object-contain" className="h-5 w-auto max-w-[128px] shrink-0 object-contain"
/> />
<h1 className="text-base font-semibold tracking-[0.02em] text-white"> <h1 className="text-base font-semibold tracking-[0.02em] text-white">
......
...@@ -31,9 +31,9 @@ ...@@ -31,9 +31,9 @@
- 提供全局检索入口 - 提供全局检索入口
- `材料归档` - `材料归档`
- 上传材料 - 上传材料
- 编辑标题、标签、分类、备注 - 编辑标题、标签、分类
- 查看材料列表与详情 - 查看材料列表与详情
- 支持“全部文件”汇总视图与按分类切换浏览 - 支持“全部文件”列表视图与按分类切换浏览
- 材料详情使用弹框查看与编辑 - 材料详情使用弹框查看与编辑
--- ---
...@@ -51,7 +51,11 @@ ...@@ -51,7 +51,11 @@
- `source_app`:来源应用,可选 - `source_app`:来源应用,可选
- `archive_category`:分类,可选 - `archive_category`:分类,可选
- `uploader` / `uploaded_at`:上传人、上传时间 - `uploader` / `uploaded_at`:上传人、上传时间
- `remark`:备注,可选
- `tag`:用户自定义分组标签 - `tag`:用户自定义分组标签
- `provide_department` / `provide_time`:材料提供部门、提供时间
- `is_confidential`:是否涉密
- `is_audit_dept_material`:是否审计部材料
--- ---
...@@ -60,12 +64,17 @@ ...@@ -60,12 +64,17 @@
### 4.1 在材料中心上传本地材料 ### 4.1 在材料中心上传本地材料
1. 用户进入 `材料归档` 1. 用户进入 `材料归档`
2. 选择本地文件 2. 选择本地文件;若选择的是 `ZIP` 压缩包,可进一步选择上传模式:
3. 先调用上传接口上传二进制文件 - `整包上传`:将压缩包本身作为 1 份材料处理
4. 上传成功后,得到 `{name, type, size, url}` 文件对象 - `解压后逐文件上传`:按压缩包内文件逐个建档
5. 再创建一条 `am_material` 记录 3. 用户补充标题、标签、分类、提供部门、提供时间、是否涉密、是否审计部材料等信息
6. 用户补充标题、标签、分类、备注等信息,其中来源应用和分类允许留空 4. 先调用上传接口上传二进制文件
7. 材料进入统一材料池,可被其他审计应用统一查看 5. 上传成功后,得到 `{name, type, size, url}` 文件对象
6. 再创建 `am_material` 记录:
- 普通文件或 `整包上传`:创建 1 条记录
- `解压后逐文件上传`:压缩包内有多少个文件,就创建多少条记录
7. 当使用 `解压后逐文件上传` 时,上传表单中的来源应用、分类、标签、提供信息、合规属性等字段作为每个文件的通用属性一并写入
8. 材料进入统一材料池,可被其他审计应用统一查看
### 4.2 在其他业务应用上传材料 ### 4.2 在其他业务应用上传材料
...@@ -79,14 +88,26 @@ ...@@ -79,14 +88,26 @@
1. 用户进入 `材料归档` 1. 用户进入 `材料归档`
2. 按关键词、标签、来源应用筛选材料 2. 按关键词、标签、来源应用筛选材料
3. 页面提供“全部文件”与“分类切换”两种浏览方式 3. 页面提供“全部文件”与“分类切换”两种浏览方式
4. 在“全部文件”视图下,直接平铺展示当前筛选条件下的全部材料 4. 列表默认每页 20 条,支持切换为 50 或 100 条
5. 切换到某个分类标签后,只查看该分类下的材料 5. 用户通过上一页 / 下一页进行分页浏览
5. 点击某份材料后,以弹框方式打开详情 6. 在“全部文件”视图下,直接以列表展示当前筛选条件下的全部材料
6. 弹框内查看: 7. 切换到某个分类标签后,只查看该分类下的材料
8. `未分类` 仅表示 `metadata.archive_category` 为空、空字符串或缺失,不作为真实分类值写回查询条件
9. 列表支持当前页批量勾选,并提供“全选当前页”能力
10. 勾选后可执行以下批量操作:
- 批量修改:按字段选择是否覆盖来源应用、分类、提供部门、提供时间、是否涉密、是否审计部材料
- 批量打标签:统一覆盖 `metadata.tag`
- 批量删除:对已勾选材料执行软删除
11. 点击某份材料后,以弹框方式打开详情
12. 弹框内查看:
- 文件预览或下载入口 - 文件预览或下载入口
- 标签与分类 - 标签与分类
- 来源信息 - 来源信息
7. 管理员或有权限用户可在弹框中修改分类、标签、备注 - 提供部门、提供时间
- 涉密标记、审计部材料标记
13. 管理员或有权限用户可在弹框中修改分类、标签
14. 详情弹框提供单个删除入口,删除前需二次确认,确认后对当前材料执行软删除并关闭弹框
15. 详情弹框中的编辑区域按“基础信息 / 提供信息 / 合规属性”三段式维护,减少字段混排
### 4.4 删除或归档材料 ### 4.4 删除或归档材料
...@@ -129,7 +150,17 @@ ...@@ -129,7 +150,17 @@
- 本周新增材料数 - 本周新增材料数
- 来源应用分布 - 来源应用分布
- 分类分布 - 分类分布
- 提供信息完整度
- 涉密材料 / 审计部材料概览
- 最近上传材料 - 最近上传材料
- 待完善材料提醒
当前工作台推荐采用“图表 + 风险清单”布局:
- 来源应用分布使用柱状图,便于快速判断材料是否已从各业务应用顺利汇入
- 分类分布使用环形图,便于观察常用分类与未分类占比
- 提供与合规信息使用双层环图或类似图形,突出补录完成度、涉密材料、审计部材料情况
- 最近上传与待完善材料以列表方式承接,方便后续直接进入归档页处理
### 6.2 材料归档 ### 6.2 材料归档
...@@ -140,7 +171,14 @@ ...@@ -140,7 +171,14 @@
- 来源应用筛选 - 来源应用筛选
- 分类切换标签页 - 分类切换标签页
- 材料列表表格 - 材料列表表格
- 当前页批量勾选工具条
- 材料详情弹框 - 材料详情弹框
- 批量修改弹框
- 批量打标签弹框
- 创建与编辑弹框中的分区式表单:
- 基础信息区
- 提供信息区
- 合规属性区
### 6.3 材料详情 ### 6.3 材料详情
...@@ -151,6 +189,8 @@ ...@@ -151,6 +189,8 @@
- 标签 - 标签
- 分类 - 分类
- 来源应用 - 来源应用
- 提供部门 / 提供时间
- 是否涉密 / 是否审计部材料
- 上传人 / 上传时间 - 上传人 / 上传时间
- 下载或预览入口 - 下载或预览入口
...@@ -165,6 +205,10 @@ ...@@ -165,6 +205,10 @@
2. 创建材料主档: 2. 创建材料主档:
- `POST /record` - `POST /record`
- `subject = am_material` - `subject = am_material`
3. ZIP 上传约定:
- 选择 `整包上传` 时,按普通文件流程调用 1 次 `POST /upload` 和 1 次 `POST /record`
- 选择 `解压后逐文件上传` 时,前端先在本地解析 ZIP,再对压缩包内每个文件分别调用 `POST /upload` 和 `POST /record`
- 拆分上传时,当前表单填写的公共属性需复制到每一条新建材料记录的 `metadata`
### 7.2 查询材料池 ### 7.2 查询材料池
...@@ -175,13 +219,29 @@ ...@@ -175,13 +219,29 @@
- `metadata.archive_category` - `metadata.archive_category`
- `metadata.tag` - `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 份时,不应一次性全量拉取 - 当材料数量超过 1000 份时,不应一次性全量拉取
- 归档页首屏只加载一页数据,建议每页 36 条左右 - 归档页首屏只加载一页数据,默认每页 20 条,可切换为 50 或 100 条
- 用户滚动到列表底部时,自动请求下一页 - 用户通过分页按钮切换页码,不再使用滚动加载
- 当用户切换分类时,按当前分类重新从第一页加载 - 当用户切换分类、关键词、来源或页大小时,按当前条件重新从第一页加载
- “全部文件”视图按页平铺追加结果 - “全部文件”视图按页展示结果
- 总数统计通过 `count` 接口单独获取,不依赖前端已加载条数推断 - 总数统计通过 `count` 接口单独获取,不依赖前端已加载条数推断
--- ---
...@@ -190,8 +250,14 @@ ...@@ -190,8 +250,14 @@
- 上传文件失败时,不创建 `am_material` - 上传文件失败时,不创建 `am_material`
- 创建 `am_material` 失败时,需要提示用户重试,并避免出现“文件已传但列表无记录”的静默失败 - 创建 `am_material` 失败时,需要提示用户重试,并避免出现“文件已传但列表无记录”的静默失败
- ZIP 拆分上传若中途失败,需要明确提示已成功创建的记录条数以及失败文件名,便于用户补传
- 标签允许自由输入,但需控制重复标签与空标签 - 标签允许自由输入,但需控制重复标签与空标签
- 分类和标签用于支持用户自定义分组 - 分类和标签用于支持用户自定义分组
- 批量修改仅允许覆盖用户显式勾选的字段,未勾选字段必须保留原值,避免误覆盖
- 批量删除前必须二次确认,默认仍按软删除处理
- 当前版本的批量勾选范围限定在“当前筛选结果的当前页”,切换分页后需要重新确认勾选结果
- `provide_time` 提交时统一转为 `ISO 8601`
- 布尔字段 `is_confidential`、`is_audit_dept_material` 需要以 true/false 写入 `metadata`
--- ---
......
import { import {
batchRemoveRecords,
countRecords, countRecords,
createRecord, createRecord,
queryRecords, queryRecords,
removeRecord,
updateRecord, updateRecord,
uploadFile, uploadFile,
} from '@/services/DataService'; } from '@/services/DataService';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import JSZip from 'jszip';
export const MATERIAL_SUBJECT = 'am_material'; 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 = { const SOURCE_APP_LABEL_MAP = {
audit_material_center: '材料中心', audit_material_center: '材料中心',
...@@ -24,6 +32,8 @@ export const SOURCE_APP_SUGGESTIONS = [ ...@@ -24,6 +32,8 @@ export const SOURCE_APP_SUGGESTIONS = [
{ value: '中台', label: '中台' }, { value: '中台', label: '中台' },
]; ];
const ARCHIVE_IGNORE_PATTERNS = [/^__MACOSX\//, /\/\.DS_Store$/i, /^\.DS_Store$/i];
const DEMO_MATERIALS = [ const DEMO_MATERIALS = [
{ {
_id: 'demo-material-1', _id: 'demo-material-1',
...@@ -34,14 +44,12 @@ const DEMO_MATERIALS = [ ...@@ -34,14 +44,12 @@ const DEMO_MATERIALS = [
updated_at: '2026-05-08T10:30:00.000Z', updated_at: '2026-05-08T10:30:00.000Z',
metadata: { metadata: {
material_title: '函证回函扫描件', material_title: '函证回函扫描件',
material_code: 'AM-2026-001',
file_asset: { file_asset: {
name: '函证回函扫描件.pdf', name: '函证回函扫描件.pdf',
type: 'application/pdf', type: 'application/pdf',
size: 2438210, size: 2438210,
url: 'https://example.com/materials/demo-1.pdf', url: 'https://example.com/materials/demo-1.pdf',
}, },
file_hash: 'sha256:demo001',
source_app: 'project_manager_xy', source_app: 'project_manager_xy',
archive_category: '审计证据', archive_category: '审计证据',
uploader: { uploader: {
...@@ -49,7 +57,6 @@ const DEMO_MATERIALS = [ ...@@ -49,7 +57,6 @@ const DEMO_MATERIALS = [
display_name: '杨康', display_name: '杨康',
}, },
uploaded_at: '2026-05-08T09:12:00.000Z', uploaded_at: '2026-05-08T09:12:00.000Z',
remark: '用于审计项目立项资料归档',
tag: '项目一期', tag: '项目一期',
}, },
}, },
...@@ -62,14 +69,12 @@ const DEMO_MATERIALS = [ ...@@ -62,14 +69,12 @@ const DEMO_MATERIALS = [
updated_at: '2026-05-07T05:15:00.000Z', updated_at: '2026-05-07T05:15:00.000Z',
metadata: { metadata: {
material_title: '项目启动会纪要', material_title: '项目启动会纪要',
material_code: 'AM-2026-002',
file_asset: { file_asset: {
name: '项目启动会纪要.docx', name: '项目启动会纪要.docx',
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
size: 481204, size: 481204,
url: 'https://example.com/materials/demo-2.docx', url: 'https://example.com/materials/demo-2.docx',
}, },
file_hash: 'sha256:demo002',
source_app: 'audit_material_center', source_app: 'audit_material_center',
archive_category: '项目资料', archive_category: '项目资料',
uploader: { uploader: {
...@@ -77,7 +82,6 @@ const DEMO_MATERIALS = [ ...@@ -77,7 +82,6 @@ const DEMO_MATERIALS = [
display_name: '杨康', display_name: '杨康',
}, },
uploaded_at: '2026-05-07T04:30:00.000Z', uploaded_at: '2026-05-07T04:30:00.000Z',
remark: '便于项目组成员统一查看',
tag: '启动会', tag: '启动会',
}, },
}, },
...@@ -90,14 +94,12 @@ const DEMO_MATERIALS = [ ...@@ -90,14 +94,12 @@ const DEMO_MATERIALS = [
updated_at: '2026-05-06T01:20:00.000Z', updated_at: '2026-05-06T01:20:00.000Z',
metadata: { metadata: {
material_title: '整改证明附件', material_title: '整改证明附件',
material_code: 'AM-2026-003',
file_asset: { file_asset: {
name: '整改证明附件.jpg', name: '整改证明附件.jpg',
type: 'image/jpeg', type: 'image/jpeg',
size: 984122, size: 984122,
url: 'https://images.unsplash.com/photo-1455390582262-044cdead277a?auto=format&fit=crop&w=900&q=80', url: 'https://images.unsplash.com/photo-1455390582262-044cdead277a?auto=format&fit=crop&w=900&q=80',
}, },
file_hash: 'sha256:demo003',
source_app: 'issue_rectification', source_app: 'issue_rectification',
archive_category: '整改资料', archive_category: '整改资料',
uploader: { uploader: {
...@@ -105,7 +107,6 @@ const DEMO_MATERIALS = [ ...@@ -105,7 +107,6 @@ const DEMO_MATERIALS = [
display_name: '整改专员', display_name: '整改专员',
}, },
uploaded_at: '2026-05-05T07:48:00.000Z', uploaded_at: '2026-05-05T07:48:00.000Z',
remark: '整改闭环资料,方便统一追踪',
tag: '闭环', tag: '闭环',
}, },
}, },
...@@ -118,14 +119,12 @@ const DEMO_MATERIALS = [ ...@@ -118,14 +119,12 @@ const DEMO_MATERIALS = [
updated_at: '2026-05-03T02:18:00.000Z', updated_at: '2026-05-03T02:18:00.000Z',
metadata: { metadata: {
material_title: '访谈提纲', material_title: '访谈提纲',
material_code: '',
file_asset: { file_asset: {
name: '访谈提纲.pdf', name: '访谈提纲.pdf',
type: 'application/pdf', type: 'application/pdf',
size: 372110, size: 372110,
url: 'https://example.com/materials/demo-4.pdf', url: 'https://example.com/materials/demo-4.pdf',
}, },
file_hash: '',
source_app: 'audit_material_center', source_app: 'audit_material_center',
archive_category: '项目资料', archive_category: '项目资料',
uploader: { uploader: {
...@@ -133,7 +132,6 @@ const DEMO_MATERIALS = [ ...@@ -133,7 +132,6 @@ const DEMO_MATERIALS = [
display_name: '审计经理', display_name: '审计经理',
}, },
uploaded_at: '2026-05-03T02:18:00.000Z', uploaded_at: '2026-05-03T02:18:00.000Z',
remark: '供项目启动前统一沟通使用',
tag: '', tag: '',
}, },
}, },
...@@ -146,14 +144,12 @@ const DEMO_MATERIALS = [ ...@@ -146,14 +144,12 @@ const DEMO_MATERIALS = [
updated_at: '2026-05-02T06:20:00.000Z', updated_at: '2026-05-02T06:20:00.000Z',
metadata: { metadata: {
material_title: '盘点照片', material_title: '盘点照片',
material_code: 'AM-2026-005',
file_asset: { file_asset: {
name: '盘点照片.png', name: '盘点照片.png',
type: 'image/png', type: 'image/png',
size: 1540190, size: 1540190,
url: 'https://images.unsplash.com/photo-1516321318423-f06f85e504b3?auto=format&fit=crop&w=900&q=80', 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', source_app: 'project_manager_xy',
archive_category: '审计证据', archive_category: '审计证据',
uploader: { uploader: {
...@@ -161,7 +157,6 @@ const DEMO_MATERIALS = [ ...@@ -161,7 +157,6 @@ const DEMO_MATERIALS = [
display_name: '现场组', display_name: '现场组',
}, },
uploaded_at: '2026-05-01T11:11:00.000Z', uploaded_at: '2026-05-01T11:11:00.000Z',
remark: '现场盘点图像资料',
tag: '现场', tag: '现场',
}, },
}, },
...@@ -279,25 +274,29 @@ export function normalizeMaterialRecord(record = {}) { ...@@ -279,25 +274,29 @@ export function normalizeMaterialRecord(record = {}) {
const sourceApp = metadata.source_app || ''; const sourceApp = metadata.source_app || '';
const category = metadata.archive_category || ''; const category = metadata.archive_category || '';
const tag = metadata.tag || ''; const tag = metadata.tag || '';
const provideTime = metadata.provide_time || '';
return { return {
id: record._id || record.id || record.name || '', id: record._id || record.id || record.name || '',
recordName: record.name || record._id || record.id || '', recordName: record.name || record._id || record.id || '',
title: record.title || materialTitle, title: record.title || materialTitle,
materialTitle, materialTitle,
materialCode: metadata.material_code || '',
fileAsset, fileAsset,
fileName: fileAsset.name || `${materialTitle}.${inferExtension(fileAsset.type || '').toLowerCase()}`, fileName: fileAsset.name || `${materialTitle}.${inferExtension(fileAsset.type || '').toLowerCase()}`,
fileType: fileAsset.type || 'application/octet-stream', fileType: fileAsset.type || 'application/octet-stream',
fileSize: Number(fileAsset.size || 0), fileSize: Number(fileAsset.size || 0),
fileSizeLabel: formatFileSize(fileAsset.size || 0), fileSizeLabel: formatFileSize(fileAsset.size || 0),
fileUrl: fileAsset.url || '', fileUrl: fileAsset.url || '',
fileHash: metadata.file_hash || '',
sourceApp, sourceApp,
sourceAppLabel: getSourceAppLabel(sourceApp), sourceAppLabel: getSourceAppLabel(sourceApp),
archiveCategory: category || '未分类', archiveCategory: category || UNCATEGORIZED_CATEGORY_LABEL,
tag, tag,
remark: metadata.remark || '', 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, uploaderValue: uploader.value,
uploaderLabel: uploader.label, uploaderLabel: uploader.label,
uploadedAt, uploadedAt,
...@@ -311,6 +310,10 @@ export function normalizeMaterialRecord(record = {}) { ...@@ -311,6 +310,10 @@ export function normalizeMaterialRecord(record = {}) {
}; };
} }
function isUncategorizedCategory(category = '') {
return String(category || '').trim() === UNCATEGORIZED_CATEGORY_LABEL;
}
function applyClientFilters(materials, filters = {}) { function applyClientFilters(materials, filters = {}) {
const keyword = String(filters.keyword || '').trim().toLowerCase(); const keyword = String(filters.keyword || '').trim().toLowerCase();
const sourceApp = String(filters.sourceApp || '').trim(); const sourceApp = String(filters.sourceApp || '').trim();
...@@ -322,8 +325,6 @@ function applyClientFilters(materials, filters = {}) { ...@@ -322,8 +325,6 @@ function applyClientFilters(materials, filters = {}) {
!keyword || !keyword ||
[ [
item.materialTitle, item.materialTitle,
item.materialCode,
item.remark,
item.fileName, item.fileName,
item.archiveCategory, item.archiveCategory,
item.tag, item.tag,
...@@ -334,7 +335,10 @@ function applyClientFilters(materials, filters = {}) { ...@@ -334,7 +335,10 @@ function applyClientFilters(materials, filters = {}) {
const matchesSource = !sourceApp || item.sourceApp === sourceApp; const matchesSource = !sourceApp || item.sourceApp === sourceApp;
const matchesCategory = const matchesCategory =
!archiveCategory || item.archiveCategory === archiveCategory; !archiveCategory ||
(isUncategorizedCategory(archiveCategory)
? !String(item.rawMetadata?.archive_category || '').trim()
: item.archiveCategory === archiveCategory);
const matchesTag = const matchesTag =
!tag || String(item.tag || '').toLowerCase().includes(tag); !tag || String(item.tag || '').toLowerCase().includes(tag);
...@@ -397,7 +401,13 @@ function buildRecordFilter(filters = {}) { ...@@ -397,7 +401,13 @@ function buildRecordFilter(filters = {}) {
} }
if (filters.archiveCategory) { 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) { if (filters.tag) {
...@@ -440,14 +450,19 @@ export function buildMaterialDashboard(materials = [], options = {}) { ...@@ -440,14 +450,19 @@ export function buildMaterialDashboard(materials = [], options = {}) {
); );
const categoryGroups = buildGroup( const categoryGroups = buildGroup(
materials, materials,
(item) => item.archiveCategory || '未分类', (item) => item.archiveCategory || UNCATEGORIZED_CATEGORY_LABEL,
(item) => item.archiveCategory || '未分类', (item) => item.archiveCategory || UNCATEGORIZED_CATEGORY_LABEL,
); );
const tagGroups = buildGroup( const tagGroups = buildGroup(
materials.filter((item) => item.tag), materials.filter((item) => item.tag),
(item) => item.tag, (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] const recentMaterials = [...materials]
.sort((left, right) => .sort((left, right) =>
...@@ -457,8 +472,42 @@ export function buildMaterialDashboard(materials = [], options = {}) { ...@@ -457,8 +472,42 @@ export function buildMaterialDashboard(materials = [], options = {}) {
) )
.slice(0, 6); .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 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); .slice(0, 5);
const uniqueSourceCount = new Set( const uniqueSourceCount = new Set(
...@@ -475,9 +524,15 @@ export function buildMaterialDashboard(materials = [], options = {}) { ...@@ -475,9 +524,15 @@ export function buildMaterialDashboard(materials = [], options = {}) {
recentCount, recentCount,
uniqueSourceCount, uniqueSourceCount,
uniqueCategoryCount, uniqueCategoryCount,
confidentialCount,
auditDeptCount,
providedInfoCompleteCount,
missingProvideDepartmentCount,
missingProvideTimeCount,
sourceGroups, sourceGroups,
categoryGroups, categoryGroups,
tagGroups, tagGroups,
provideDepartmentGroups,
recentMaterials, recentMaterials,
attentionMaterials, attentionMaterials,
sourceSuggestions: [ sourceSuggestions: [
...@@ -636,52 +691,187 @@ function getUploadAsset(uploadResult, file) { ...@@ -636,52 +691,187 @@ function getUploadAsset(uploadResult, file) {
}; };
} }
export function inferTitleFromFile(file) { export function isZipFile(file) {
const filename = file?.name || ''; const filename = String(file?.name || '').toLowerCase();
return filename.replace(/\.[^.]+$/, '') || '未命名材料'; const type = String(file?.type || '').toLowerCase();
return filename.endsWith('.zip') || type === 'application/zip' || type === 'application/x-zip-compressed';
} }
export async function createMaterialRecord({ function shouldIgnoreArchiveEntry(entryName = '') {
file, return ARCHIVE_IGNORE_PATTERNS.some((pattern) => pattern.test(entryName));
values, }
currentUser,
}) { function inferMimeTypeFromName(filename = '') {
if (!file) { const normalized = String(filename).toLowerCase();
throw new Error('请先选择要上传的材料文件');
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); async function extractFilesFromArchive(file) {
const fileAsset = getUploadAsset(uploadResult, file); const zip = await JSZip.loadAsync(file);
const entries = [];
if (!fileAsset.url) { await Promise.all(
throw new Error('文件上传成功,但未获取到可用的文件地址'); 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 = const materialTitle =
String(values.materialTitle || '').trim() || inferTitleFromFile(file); String(materialTitleOverride || values.materialTitle || '').trim() ||
const payload = { inferTitleFromFile(file);
return {
subject: MATERIAL_SUBJECT, subject: MATERIAL_SUBJECT,
title: materialTitle, title: materialTitle,
content_type: 'text', content_type: 'text',
metadata: { metadata: {
material_title: materialTitle, material_title: materialTitle,
material_code: String(values.materialCode || '').trim(),
file_asset: fileAsset,
file_hash: String(values.fileHash || '').trim(),
source_app: String(values.sourceApp || '').trim(), source_app: String(values.sourceApp || '').trim(),
archive_category: String(values.archiveCategory || '').trim(), archive_category: String(values.archiveCategory || '').trim(),
uploader: uploader:
currentUser?._id || currentUser?.username || currentUser?.id || '', currentUser?._id || currentUser?.username || currentUser?.id || '',
uploaded_at: dayjs().toISOString(), uploaded_at: dayjs().toISOString(),
remark: String(values.remark || '').trim(),
tag: String(values.tag || '').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); const created = await createRecord(payload);
return normalizeMaterialRecord(created?.data || created?.result || 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) { export async function updateMaterialRecord(material, values) {
const recordName = material?.recordName || material?.id; const recordName = material?.recordName || material?.id;
...@@ -691,15 +881,16 @@ export async function updateMaterialRecord(material, values) { ...@@ -691,15 +881,16 @@ export async function updateMaterialRecord(material, values) {
const nextTitle = const nextTitle =
String(values.materialTitle || '').trim() || material.materialTitle; String(values.materialTitle || '').trim() || material.materialTitle;
const restMetadata = { ...(material.rawMetadata || {}) };
delete restMetadata.material_code;
delete restMetadata.file_hash;
const payload = { const payload = {
title: nextTitle, title: nextTitle,
content_type: 'text', content_type: 'text',
metadata: { metadata: {
...material.rawMetadata, ...restMetadata,
material_title: nextTitle, material_title: nextTitle,
material_code: String(values.materialCode || '').trim(),
file_asset: material.fileAsset, file_asset: material.fileAsset,
file_hash: material.fileHash || '',
source_app: String(values.sourceApp || '').trim(), source_app: String(values.sourceApp || '').trim(),
archive_category: String(values.archiveCategory || '').trim(), archive_category: String(values.archiveCategory || '').trim(),
uploader: uploader:
...@@ -710,8 +901,13 @@ export async function updateMaterialRecord(material, values) { ...@@ -710,8 +901,13 @@ export async function updateMaterialRecord(material, values) {
material.rawMetadata?.uploaded_at || material.rawMetadata?.uploaded_at ||
material.uploadedAt || material.uploadedAt ||
dayjs().toISOString(), dayjs().toISOString(),
remark: String(values.remark || '').trim(),
tag: String(values.tag || '').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) { ...@@ -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 { Button } from '@/catalyst-ui-kit/javascript/button';
import {
Checkbox,
CheckboxField,
} from '@/catalyst-ui-kit/javascript/checkbox';
import { import {
Dialog, Dialog,
DialogActions, DialogActions,
...@@ -7,17 +11,25 @@ import { ...@@ -7,17 +11,25 @@ import {
DialogTitle, DialogTitle,
} from '@/catalyst-ui-kit/javascript/dialog'; } from '@/catalyst-ui-kit/javascript/dialog';
import { Input, InputGroup } from '@/catalyst-ui-kit/javascript/input'; 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 { Textarea } from '@/catalyst-ui-kit/javascript/textarea';
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
import { import {
ArrowTopRightOnSquareIcon,
ArrowUpTrayIcon, ArrowUpTrayIcon,
ArrowTopRightOnSquareIcon,
ClipboardDocumentIcon, ClipboardDocumentIcon,
FolderIcon, FolderIcon,
MagnifyingGlassIcon, MagnifyingGlassIcon,
SparklesIcon, SparklesIcon,
Squares2X2Icon, XMarkIcon,
TagIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import clsx from 'clsx'; import clsx from 'clsx';
import { import {
...@@ -31,18 +43,25 @@ import { ...@@ -31,18 +43,25 @@ import {
} from 'react'; } from 'react';
import EmptyStatePage from '../EmptyStatePage'; import EmptyStatePage from '../EmptyStatePage';
import { import {
ARCHIVE_UPLOAD_MODE,
batchRemoveMaterialRecords,
batchUpdateMaterialRecords,
buildMaterialDashboard, buildMaterialDashboard,
createMaterialRecord, createMaterialRecord,
formatDateTime, formatDateTime,
formatFileSize, formatFileSize,
inferTitleFromFile, inferTitleFromFile,
isZipFile,
loadMaterialPage, loadMaterialPage,
removeMaterialRecord,
UNCATEGORIZED_CATEGORY_LABEL,
SOURCE_APP_SUGGESTIONS, SOURCE_APP_SUGGESTIONS,
updateMaterialRecord, updateMaterialRecord,
} from '../materialService'; } from '../materialService';
const ALL_CATEGORY_KEY = '__all__'; 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 }) { function Panel({ className, children }) {
return ( return (
...@@ -126,7 +145,7 @@ function FileVisual({ material, compact = false }) { ...@@ -126,7 +145,7 @@ function FileVisual({ material, compact = false }) {
return ( return (
<div <div
className={clsx( 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', compact ? 'h-18 w-18' : 'h-60 w-full',
)} )}
> >
...@@ -149,54 +168,106 @@ function FileVisual({ material, compact = false }) { ...@@ -149,54 +168,106 @@ function FileVisual({ material, compact = false }) {
); );
} }
function MaterialCard({ material, onOpen }) { function MaterialListRow({ material, onOpen, checked, onToggle }) {
return ( return (
<button <TableRow
type="button"
onClick={() => onOpen(material)} 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} /> <TableCell onClick={(event) => event.stopPropagation()}>
<div className="mt-4 flex min-h-[152px] flex-1 flex-col"> <Checkbox
<div className="flex flex-wrap items-center gap-2"> color="sky"
<div className="max-w-full truncate text-base font-semibold tracking-tight text-zinc-950"> checked={checked}
{material.materialTitle} onChange={(nextChecked) => onToggle(material, Boolean(nextChecked))}
</div> 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> <Badge tone="sky">{material.archiveCategory}</Badge>
{material.tag ? <Badge tone="emerald">{material.tag}</Badge> : null} {material.tag ? <Badge tone="emerald">{material.tag}</Badge> : <Badge>未设置标签</Badge>}
</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>
</div> </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() { function buildEmptyUploadForm() {
return { return {
materialTitle: '', materialTitle: '',
materialCode: '',
fileHash: '',
sourceApp: '', sourceApp: '',
archiveCategory: '', 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: '', 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) { function buildDetailForm(material) {
if (!material) { if (!material) {
return buildEmptyUploadForm(); return buildEmptyUploadForm();
...@@ -204,35 +275,44 @@ function buildDetailForm(material) { ...@@ -204,35 +275,44 @@ function buildDetailForm(material) {
return { return {
materialTitle: material.materialTitle || '', materialTitle: material.materialTitle || '',
materialCode: material.materialCode || '', sourceApp: material.rawMetadata?.source_app || material.sourceApp || '',
fileHash: material.fileHash || '', archiveCategory:
sourceApp: material.sourceApp || '', material.rawMetadata?.archive_category || material.archiveCategory || '',
archiveCategory: material.archiveCategory || '', tag: material.rawMetadata?.tag || material.tag || '',
remark: material.remark || '', provideDepartment:
tag: material.tag || '', 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) { function FormSection({ eyebrow, title, description, children }) {
const groups = materials.reduce((accumulator, item) => { return (
const key = item.archiveCategory || '未分类'; <section className="rounded-[24px] border border-zinc-200 bg-zinc-50/70 p-4 sm:p-5">
if (!accumulator[key]) { <div className="max-w-2xl">
accumulator[key] = []; <div className="text-[11px] font-semibold tracking-[0.24em] text-zinc-500">
} {eyebrow}
accumulator[key].push(item); </div>
return accumulator; <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) function FieldLabel({ children, required = false }) {
.map(([category, items]) => ({ return (
category, <div className="mb-2 flex items-center gap-1 text-sm font-medium text-zinc-600">
items: items.sort((left, right) => <span>{children}</span>
String(right.uploadedAt || right.updatedAt).localeCompare( {required ? <span className="text-rose-500">*</span> : null}
String(left.uploadedAt || left.updatedAt), </div>
), );
),
}))
.sort((left, right) => right.items.length - left.items.length);
} }
function sortMaterialsByTime(materials) { function sortMaterialsByTime(materials) {
...@@ -249,7 +329,7 @@ function mergeCategoryOptions(previousOptions, nextMaterials) { ...@@ -249,7 +329,7 @@ function mergeCategoryOptions(previousOptions, nextMaterials) {
); );
(nextMaterials || []).forEach((item) => { (nextMaterials || []).forEach((item) => {
const key = item.archiveCategory || '未分类'; const key = item.archiveCategory || UNCATEGORIZED_CATEGORY_LABEL;
const current = optionMap.get(key); const current = optionMap.get(key);
optionMap.set(key, { optionMap.set(key, {
key, key,
...@@ -265,21 +345,19 @@ function mergeCategoryOptions(previousOptions, nextMaterials) { ...@@ -265,21 +345,19 @@ function mergeCategoryOptions(previousOptions, nextMaterials) {
export default function ResourceBookingPage() { export default function ResourceBookingPage() {
const userInfo = useContext(UserContext); const userInfo = useContext(UserContext);
const fileInputRef = useRef(null); const fileInputRef = useRef(null);
const loadMoreRef = useRef(null);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query); const deferredQuery = useDeferredValue(query);
const [sourceAppFilter, setSourceAppFilter] = useState(''); const [sourceAppFilter, setSourceAppFilter] = useState('');
const [tagFilter, setTagFilter] = useState(''); const [tagFilter, setTagFilter] = useState('');
const [activeCategory, setActiveCategory] = useState(ALL_CATEGORY_KEY); const [activeCategory, setActiveCategory] = useState(ALL_CATEGORY_KEY);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [page, setPage] = useState(0);
const [categoryOptions, setCategoryOptions] = useState([]); const [categoryOptions, setCategoryOptions] = useState([]);
const [materials, setMaterials] = useState([]); const [materials, setMaterials] = useState([]);
const [dashboard, setDashboard] = useState(() => buildMaterialDashboard([])); const [dashboard, setDashboard] = useState(() => buildMaterialDashboard([]));
const [activeMaterialId, setActiveMaterialId] = useState(''); const [activeMaterialId, setActiveMaterialId] = useState('');
const [page, setPage] = useState(0);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
...@@ -287,9 +365,18 @@ export default function ResourceBookingPage() { ...@@ -287,9 +365,18 @@ export default function ResourceBookingPage() {
const [isDemoMode, setIsDemoMode] = useState(false); const [isDemoMode, setIsDemoMode] = useState(false);
const [isUploadOpen, setIsUploadOpen] = useState(false); const [isUploadOpen, setIsUploadOpen] = useState(false);
const [isDetailOpen, setIsDetailOpen] = useState(false); const [isDetailOpen, setIsDetailOpen] = useState(false);
const [isBatchEditOpen, setIsBatchEditOpen] = useState(false);
const [isBatchTagOpen, setIsBatchTagOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState(null); const [selectedFile, setSelectedFile] = useState(null);
const [uploadForm, setUploadForm] = useState(buildEmptyUploadForm); const [uploadForm, setUploadForm] = useState(buildEmptyUploadForm);
const [detailForm, setDetailForm] = 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(() => { const activeMaterial = useMemo(() => {
return materials.find((item) => item.id === activeMaterialId) || null; return materials.find((item) => item.id === activeMaterialId) || null;
...@@ -310,8 +397,8 @@ export default function ResourceBookingPage() { ...@@ -310,8 +397,8 @@ export default function ResourceBookingPage() {
archiveCategory: archiveCategory:
activeCategory === ALL_CATEGORY_KEY ? '' : activeCategory, activeCategory === ALL_CATEGORY_KEY ? '' : activeCategory,
tag: tagFilter, tag: tagFilter,
page: 0, page,
pageSize: PAGE_SIZE, pageSize,
}); });
if (cancelled) { if (cancelled) {
...@@ -329,9 +416,7 @@ export default function ResourceBookingPage() { ...@@ -329,9 +416,7 @@ export default function ResourceBookingPage() {
? mergeCategoryOptions([], result.materials || []) ? mergeCategoryOptions([], result.materials || [])
: prev, : prev,
); );
setPage(0);
setTotal(result.total || 0); setTotal(result.total || 0);
setHasMore(Boolean(result.hasMore));
setError(result.error || ''); setError(result.error || '');
setIsDemoMode(Boolean(result.isDemoMode)); setIsDemoMode(Boolean(result.isDemoMode));
setLoading(false); setLoading(false);
...@@ -349,7 +434,7 @@ export default function ResourceBookingPage() { ...@@ -349,7 +434,7 @@ export default function ResourceBookingPage() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [activeCategory, activeMaterialId, deferredQuery, sourceAppFilter, tagFilter]); }, [activeCategory, activeMaterialId, deferredQuery, page, pageSize, sourceAppFilter, tagFilter]);
const sourceOptions = useMemo(() => { const sourceOptions = useMemo(() => {
const dynamicOptions = (dashboard.sourceSuggestions || []).filter(Boolean); const dynamicOptions = (dashboard.sourceSuggestions || []).filter(Boolean);
...@@ -367,34 +452,23 @@ export default function ResourceBookingPage() { ...@@ -367,34 +452,23 @@ export default function ResourceBookingPage() {
const tagSuggestions = dashboard.tagSuggestions || []; const tagSuggestions = dashboard.tagSuggestions || [];
const archiveCategorySuggestions = dashboard.categorySuggestions || []; const archiveCategorySuggestions = dashboard.categorySuggestions || [];
const categorySections = useMemo( const categoryTabs = useMemo(
() => buildCategorySections(materials), () => [
[materials],
);
const sortedMaterials = useMemo(
() => sortMaterialsByTime(materials),
[materials],
);
const categoryTabs = useMemo(() => {
return [
{ {
key: ALL_CATEGORY_KEY, key: ALL_CATEGORY_KEY,
label: '全部文件', label: '全部文件',
count: materials.length, count: materials.length,
icon: Squares2X2Icon, icon: FolderIcon,
}, },
...categoryOptions.map((item) => ({ ...categoryOptions.map((item) => ({
key: item.key, key: item.key,
label: item.label, label: item.label,
count: count: item.count,
activeCategory === ALL_CATEGORY_KEY
? categorySections.find((section) => section.category === item.key)
?.items.length || item.count
: item.count,
icon: item.icon, icon: item.icon,
})), })),
]; ],
}, [activeCategory, categoryOptions, categorySections, materials.length]); [categoryOptions, materials.length],
);
useEffect(() => { useEffect(() => {
if ( if (
...@@ -405,114 +479,105 @@ export default function ResourceBookingPage() { ...@@ -405,114 +479,105 @@ export default function ResourceBookingPage() {
} }
}, [activeCategory, categoryTabs]); }, [activeCategory, categoryTabs]);
const visibleSections = useMemo(() => { useEffect(() => {
const matched = categorySections.find( setSelectedMaterialIds((prev) =>
(item) => item.category === activeCategory, prev.filter((id) => materials.some((item) => item.id === id)),
); );
}, [materials]);
return matched ? [matched] : [];
}, [activeCategory, categorySections]);
const visibleMaterials = useMemo(() => { const visibleMaterials = useMemo(() => {
if (activeCategory === ALL_CATEGORY_KEY) { if (activeCategory === ALL_CATEGORY_KEY) {
return sortedMaterials; return materials;
} }
return sortedMaterials.filter( return materials.filter(
(item) => (item.archiveCategory || '未分类') === activeCategory, (item) =>
(item.archiveCategory || UNCATEGORIZED_CATEGORY_LABEL) === activeCategory,
); );
}, [activeCategory, sortedMaterials]); }, [activeCategory, materials]);
async function handleLoadMore() { const selectedMaterials = useMemo(
if (loadingMore || !hasMore) { () => visibleMaterials.filter((item) => selectedMaterialIds.includes(item.id)),
return; [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); function handlePageSizeChange(nextPageSize) {
setError(''); setPageSize(nextPageSize);
setPage(0);
}
try { async function reloadMaterials(options = {}) {
const nextPage = page + 1;
const result = await loadMaterialPage({ const result = await loadMaterialPage({
keyword: deferredQuery, keyword: deferredQuery,
sourceApp: sourceAppFilter, sourceApp: sourceAppFilter,
archiveCategory: archiveCategory:
activeCategory === ALL_CATEGORY_KEY ? '' : activeCategory, activeCategory === ALL_CATEGORY_KEY ? '' : activeCategory,
tag: tagFilter, tag: tagFilter,
page: nextPage, page: options.page ?? page,
pageSize: PAGE_SIZE, pageSize,
}); });
const nextMaterials = result.materials || []; setMaterials(result.materials || []);
setMaterials((prev) => {
const existingIds = new Set(prev.map((item) => item.id));
const merged = [
...prev,
...nextMaterials.filter((item) => !existingIds.has(item.id)),
];
setDashboard( setDashboard(
buildMaterialDashboard(merged, { buildMaterialDashboard(result.materials || [], {
totalCount: result.total || merged.length, totalCount: result.total || 0,
}), }),
); );
if (activeCategory === ALL_CATEGORY_KEY) { setCategoryOptions((prev) =>
setCategoryOptions(mergeCategoryOptions([], merged)); activeCategory === ALL_CATEGORY_KEY
? mergeCategoryOptions([], result.materials || [])
: prev,
);
setTotal(result.total || 0);
setError(result.error || '');
setIsDemoMode(Boolean(result.isDemoMode));
return result;
} }
return merged; function resetBatchEditState() {
}); setBatchEditForm(buildEmptyBatchEditForm());
setPage(nextPage); setBatchEditFields(buildEmptyBatchFieldSelection());
setTotal(result.total || total);
setHasMore(Boolean(result.hasMore));
setIsDemoMode(Boolean(result.isDemoMode));
if (result.error) {
setError(result.error);
} }
} catch (loadMoreError) {
setError(loadMoreError?.message || '加载更多材料失败,请稍后重试。'); function resetBatchTagState() {
} finally { setBatchTagForm(buildEmptyBatchTagForm());
setLoadingMore(false);
} }
function clearSelection() {
setSelectedMaterialIds([]);
} }
useEffect(() => { function handleToggleMaterialSelection(material, checked) {
const node = loadMoreRef.current; setSelectedMaterialIds((prev) => {
if (checked) {
return prev.includes(material.id) ? prev : [...prev, material.id];
}
if (!node || !hasMore || loading || loadingMore) { return prev.filter((id) => id !== material.id);
return undefined; });
} }
const observer = new IntersectionObserver( function handleToggleSelectAll(checked) {
(entries) => { if (checked) {
const [entry] = entries; setSelectedMaterialIds(visibleMaterials.map((item) => item.id));
if (!entry?.isIntersecting) {
return; return;
} }
handleLoadMore();
},
{
root: null,
rootMargin: '320px 0px',
threshold: 0.1,
},
);
observer.observe(node); clearSelection();
}
return () => {
observer.disconnect();
};
}, [
activeCategory,
deferredQuery,
hasMore,
loading,
loadingMore,
page,
sourceAppFilter,
tagFilter,
]);
function resetUploadForm() { function resetUploadForm() {
setSelectedFile(null); setSelectedFile(null);
...@@ -541,7 +606,10 @@ export default function ResourceBookingPage() { ...@@ -541,7 +606,10 @@ export default function ResourceBookingPage() {
return; return;
} }
if (!String(uploadForm.materialTitle || '').trim()) { const shouldRequireTitle =
!selectedFileIsZip || uploadForm.uploadMode === ARCHIVE_UPLOAD_MODE.ARCHIVE;
if (shouldRequireTitle && !String(uploadForm.materialTitle || '').trim()) {
setError('请填写材料标题。'); setError('请填写材料标题。');
return; return;
} }
...@@ -555,43 +623,32 @@ export default function ResourceBookingPage() { ...@@ -555,43 +623,32 @@ export default function ResourceBookingPage() {
file: selectedFile, file: selectedFile,
values: uploadForm, values: uploadForm,
currentUser: userInfo, currentUser: userInfo,
uploadMode: uploadForm.uploadMode,
}); });
setNotice('材料已上传并写入统一材料池。'); const createdRecords = Array.isArray(created?.records)
handleUploadDialogClose(); ? created.records
const result = await loadMaterialPage({ : created
keyword: deferredQuery, ? [created]
sourceApp: sourceAppFilter, : [];
archiveCategory:
activeCategory === ALL_CATEGORY_KEY ? '' : activeCategory,
tag: tagFilter,
page: 0,
pageSize: PAGE_SIZE,
});
setMaterials(result.materials || []); setNotice(
setDashboard( created?.mode === ARCHIVE_UPLOAD_MODE.EXTRACT
buildMaterialDashboard(result.materials || [], { ? `压缩包已拆分上传,成功创建 ${created.total || createdRecords.length} 条材料记录。`
totalCount: result.total || 0, : '材料已上传并写入统一材料池。',
}),
);
setCategoryOptions((prev) =>
activeCategory === ALL_CATEGORY_KEY
? mergeCategoryOptions([], result.materials || [])
: prev,
); );
setPage(0); handleUploadDialogClose();
setTotal(result.total || 0); await reloadMaterials();
setHasMore(Boolean(result.hasMore));
setIsDemoMode(Boolean(result.isDemoMode));
if (created?.id) { const latestRecord = createdRecords[createdRecords.length - 1];
setActiveMaterialId(created.id);
if (latestRecord?.id) {
setActiveMaterialId(latestRecord.id);
setIsDetailOpen(true); setIsDetailOpen(true);
} }
if (created?.archiveCategory) { if (latestRecord?.archiveCategory) {
setActiveCategory(created.archiveCategory); setActiveCategory(latestRecord.archiveCategory);
} }
} catch (uploadError) { } catch (uploadError) {
setError(uploadError?.message || '材料上传失败,请稍后重试。'); setError(uploadError?.message || '材料上传失败,请稍后重试。');
...@@ -617,31 +674,7 @@ export default function ResourceBookingPage() { ...@@ -617,31 +674,7 @@ export default function ResourceBookingPage() {
try { try {
const updated = await updateMaterialRecord(activeMaterial, detailForm); const updated = await updateMaterialRecord(activeMaterial, detailForm);
setNotice('材料信息已更新。'); setNotice('材料信息已更新。');
const result = await loadMaterialPage({ await reloadMaterials();
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));
setActiveMaterialId(updated.id || activeMaterial.id); setActiveMaterialId(updated.id || activeMaterial.id);
} catch (saveError) { } catch (saveError) {
setError(saveError?.message || '材料信息保存失败,请稍后重试。'); setError(saveError?.message || '材料信息保存失败,请稍后重试。');
...@@ -673,12 +706,185 @@ export default function ResourceBookingPage() { ...@@ -673,12 +706,185 @@ export default function ResourceBookingPage() {
window.open(activeMaterial.fileUrl, '_blank', 'noopener,noreferrer'); 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<Panel className="relative overflow-hidden p-6 sm:p-8"> <Panel className="relative overflow-hidden p-6 sm:p-8">
<div className="absolute inset-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="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 MATERIAL WORKSPACE
</div> </div>
<div className="mt-5 flex flex-col gap-6 xl:flex-row xl:items-end xl:justify-between"> <div className="mt-5 flex flex-col gap-6 xl:flex-row xl:items-end xl:justify-between">
...@@ -687,7 +893,7 @@ export default function ResourceBookingPage() { ...@@ -687,7 +893,7 @@ export default function ResourceBookingPage() {
同类材料放一块看,切换起来也要足够顺手 同类材料放一块看,切换起来也要足够顺手
</h1> </h1>
<p className="mt-3 text-sm leading-7 text-zinc-600 sm:text-base"> <p className="mt-3 text-sm leading-7 text-zinc-600 sm:text-base">
现在支持“全部文件”总览,也支持按分类切换浏览。你可以先从分类层面扫一遍,再点开弹框处理单份材料的细节。 现在支持“全部文件”列表总览,也支持按分类切换浏览。你可以先从分类层面扫一遍,再点开弹框处理单份材料的细节。
</p> </p>
</div> </div>
...@@ -750,7 +956,7 @@ export default function ResourceBookingPage() { ...@@ -750,7 +956,7 @@ export default function ResourceBookingPage() {
<Input <Input
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
placeholder="按标题、编号、备注或文件名搜索" placeholder="按标题、编号或文件名搜索"
/> />
</InputGroup> </InputGroup>
</div> </div>
...@@ -837,11 +1043,7 @@ export default function ResourceBookingPage() { ...@@ -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 className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div> <div>
<div className="flex items-center gap-2"> <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" /> <FolderIcon className="size-5 text-sky-600" />
)}
<div className="text-xl font-semibold tracking-tight text-zinc-950"> <div className="text-xl font-semibold tracking-tight text-zinc-950">
{activeCategory === ALL_CATEGORY_KEY ? '全部文件' : activeCategory} {activeCategory === ALL_CATEGORY_KEY ? '全部文件' : activeCategory}
</div> </div>
...@@ -849,51 +1051,172 @@ export default function ResourceBookingPage() { ...@@ -849,51 +1051,172 @@ export default function ResourceBookingPage() {
</div> </div>
<div className="mt-2 text-sm text-zinc-500"> <div className="mt-2 text-sm text-zinc-500">
{activeCategory === ALL_CATEGORY_KEY {activeCategory === ALL_CATEGORY_KEY
? '这里展示当前筛选条件下的全部材料,不再按分类拆块,方便连续浏览。' ? '这里展示当前筛选条件下的全部材料,采用表格列表方便连续浏览。'
: '当前只查看这个分类下的材料,点开任意卡片即可用弹框查看详情。'} : '当前只查看这个分类下的材料,点开任意列表行即可用弹框查看详情。'}
</div> </div>
</div> </div>
{isDemoMode ? <Badge tone="amber">演示数据</Badge> : null} {isDemoMode ? <Badge tone="amber">演示数据</Badge> : null}
</div> </div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> <div className="mt-5 text-sm text-zinc-500">
{visibleMaterials.map((item) => ( 当前第 {currentPage} / {totalPages} 页,共 {total} 条。
<MaterialCard
key={item.id}
material={item}
onOpen={openMaterialDetail}
/>
))}
</div> </div>
</Panel>
<div <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">
ref={loadMoreRef} <div>
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 font-medium text-zinc-900">
> 已勾选 {selectedCount} 份材料
<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> </div>
<div className="mt-1 text-sm text-zinc-500">
支持对当前页已勾选材料进行批量修改、批量删除和批量打标签。
</div> </div>
)} </div>
<div className="flex flex-wrap gap-2">
<Dialog open={isDetailOpen} onClose={setIsDetailOpen} size="4xl"> <Button
<DialogTitle>材料详情</DialogTitle> 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>
详情改成弹框后,你可以边看分类分组,边按需打开单份材料处理,不会打断当前浏览节奏。 当前详情表单已按最新对象文档拆成基础信息、提供信息与合规属性,便于边查看边维护材料元数据。
</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> <DialogBody>
{activeMaterial ? ( {activeMaterial ? (
<div className="space-y-6"> <div className="space-y-6">
...@@ -923,6 +1246,25 @@ export default function ResourceBookingPage() { ...@@ -923,6 +1246,25 @@ export default function ResourceBookingPage() {
上传于 {activeMaterial.uploadedAtLabel} 上传于 {activeMaterial.uploadedAtLabel}
</div> </div>
</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"> <div className="flex flex-wrap gap-2">
<button <button
type="button" type="button"
...@@ -944,9 +1286,15 @@ export default function ResourceBookingPage() { ...@@ -944,9 +1286,15 @@ export default function ResourceBookingPage() {
</div> </div>
</div> </div>
<div className="space-y-4">
<FormSection
eyebrow="BASIC INFO"
title="基础信息"
description="这里维护材料的主展示信息和材料池检索字段。"
>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div> <div>
<div className="mb-2 text-sm font-medium text-zinc-600">材料标题</div> <FieldLabel required>材料标题</FieldLabel>
<Input <Input
value={detailForm.materialTitle} value={detailForm.materialTitle}
onChange={(event) => onChange={(event) =>
...@@ -958,20 +1306,7 @@ export default function ResourceBookingPage() { ...@@ -958,20 +1306,7 @@ export default function ResourceBookingPage() {
/> />
</div> </div>
<div> <div>
<div className="mb-2 text-sm font-medium text-zinc-600">材料编号</div> <FieldLabel>来源应用</FieldLabel>
<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>
<Input <Input
list="source-app-suggestions" list="source-app-suggestions"
value={detailForm.sourceApp} value={detailForm.sourceApp}
...@@ -981,10 +1316,11 @@ export default function ResourceBookingPage() { ...@@ -981,10 +1316,11 @@ export default function ResourceBookingPage() {
sourceApp: event.target.value, sourceApp: event.target.value,
})) }))
} }
placeholder="可选"
/> />
</div> </div>
<div> <div>
<div className="mb-2 text-sm font-medium text-zinc-600">材料分类</div> <FieldLabel>材料分类</FieldLabel>
<Input <Input
list="archive-category-suggestions" list="archive-category-suggestions"
value={detailForm.archiveCategory} value={detailForm.archiveCategory}
...@@ -994,10 +1330,11 @@ export default function ResourceBookingPage() { ...@@ -994,10 +1330,11 @@ export default function ResourceBookingPage() {
archiveCategory: event.target.value, archiveCategory: event.target.value,
})) }))
} }
placeholder="可选"
/> />
</div> </div>
<div> <div>
<div className="mb-2 text-sm font-medium text-zinc-600">标签分组</div> <FieldLabel>标签分组</FieldLabel>
<Input <Input
list="tag-suggestions" list="tag-suggestions"
value={detailForm.tag} value={detailForm.tag}
...@@ -1007,36 +1344,96 @@ export default function ResourceBookingPage() { ...@@ -1007,36 +1344,96 @@ export default function ResourceBookingPage() {
tag: event.target.value, tag: event.target.value,
})) }))
} }
placeholder="例如:项目一期"
/> />
</div> </div>
</div>
</FormSection>
<FormSection
eyebrow="PROVIDE INFO"
title="提供信息"
description="对应对象文档中的提供部门与提供时间,方便后续核对材料来源链路。"
>
<div className="grid gap-4 sm:grid-cols-2">
<div> <div>
<div className="mb-2 text-sm font-medium text-zinc-600">文件摘要</div> <FieldLabel>提供部门</FieldLabel>
<Input <Input
value={detailForm.fileHash} value={detailForm.provideDepartment}
onChange={(event) => onChange={(event) =>
setDetailForm((prev) => ({ setDetailForm((prev) => ({
...prev, ...prev,
fileHash: event.target.value, provideDepartment: event.target.value,
})) }))
} }
placeholder="可选,如 sha256 值" placeholder="可选"
/> />
</div> </div>
<div className="sm:col-span-2"> <div>
<div className="mb-2 text-sm font-medium text-zinc-600">备注</div> <FieldLabel>提供时间</FieldLabel>
<Textarea <Input
rows={5} type="datetime-local"
value={detailForm.remark} value={detailForm.provideTime}
onChange={(event) => onChange={(event) =>
setDetailForm((prev) => ({ setDetailForm((prev) => ({
...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>
</div> </div>
</CheckboxField>
</div>
</FormSection>
</div>
</div> </div>
<datalist id="source-app-suggestions"> <datalist id="source-app-suggestions">
...@@ -1056,12 +1453,12 @@ export default function ResourceBookingPage() { ...@@ -1056,12 +1453,12 @@ export default function ResourceBookingPage() {
</DialogBody> </DialogBody>
<DialogActions> <DialogActions>
<Button <Button
plain color="red"
className="cursor-pointer" className="cursor-pointer"
onClick={() => setIsDetailOpen(false)} onClick={handleRemoveActiveMaterial}
disabled={saving} disabled={saving || !activeMaterial}
> >
关闭 {saving ? '处理中...' : '删除材料'}
</Button> </Button>
<Button <Button
color="blue" color="blue"
...@@ -1074,15 +1471,347 @@ export default function ResourceBookingPage() { ...@@ -1074,15 +1471,347 @@ export default function ResourceBookingPage() {
</DialogActions> </DialogActions>
</Dialog> </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"> <Dialog open={isUploadOpen} onClose={handleUploadDialogClose} size="3xl">
<DialogTitle>上传材料</DialogTitle> <DialogTitle>上传材料</DialogTitle>
<DialogDescription> <DialogDescription>
先上传文件,再补齐标题、分类和标签,后续在统一材料池里会更容易检索。 创建页已按最新对象文档补齐字段,上传完成后会统一写入 `am_material.metadata`。
</DialogDescription> </DialogDescription>
<DialogBody> <DialogBody>
<div className="space-y-6"> <div className="space-y-6">
<div> <FormSection
<div className="mb-2 text-sm font-medium text-zinc-600">选择文件</div> eyebrow="FILE PICKER"
title="上传文件"
description="先完成文件选择,系统会自动尝试用文件名回填材料标题。"
>
<div className="rounded-[28px] border border-dashed border-sky-200 bg-sky-50/60 p-5"> <div className="rounded-[28px] border border-dashed border-sky-200 bg-sky-50/60 p-5">
<input <input
ref={fileInputRef} ref={fileInputRef}
...@@ -1091,13 +1820,23 @@ export default function ResourceBookingPage() { ...@@ -1091,13 +1820,23 @@ export default function ResourceBookingPage() {
onChange={(event) => { onChange={(event) => {
const nextFile = event.target.files?.[0] || null; const nextFile = event.target.files?.[0] || null;
setSelectedFile(nextFile); 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) { return {
setUploadForm((prev) => ({
...prev, ...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"> <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
...@@ -1110,7 +1849,7 @@ export default function ResourceBookingPage() { ...@@ -1110,7 +1849,7 @@ export default function ResourceBookingPage() {
? `${formatFileSize(selectedFile.size)} · ${ ? `${formatFileSize(selectedFile.size)} · ${
selectedFile.type || '未知类型' selectedFile.type || '未知类型'
}` }`
: '支持上传图片、PDF、Office 文档等常见审计材料'} : '支持上传图片、PDF、Office 文档,以及 ZIP 压缩包'}
</div> </div>
</div> </div>
<button <button
...@@ -1122,39 +1861,112 @@ export default function ResourceBookingPage() { ...@@ -1122,39 +1861,112 @@ export default function ResourceBookingPage() {
选择文件 选择文件
</button> </button>
</div> </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> </div>
) : null}
</div> </div>
</FormSection>
{selectedFileIsZip ? (
<FormSection
eyebrow="UPLOAD MODE"
title="压缩包处理方式"
description="同一个 ZIP 可以选择直接归档,或拆成多条材料记录。拆分时,下方属性会作为每个文件的通用属性。"
>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div> <button
<div className="mb-2 text-sm font-medium text-zinc-600">材料标题</div> type="button"
<Input onClick={() =>
value={uploadForm.materialTitle}
onChange={(event) =>
setUploadForm((prev) => ({ setUploadForm((prev) => ({
...prev, ...prev,
materialTitle: event.target.value, uploadMode: ARCHIVE_UPLOAD_MODE.ARCHIVE,
materialTitle:
prev.materialTitle || inferTitleFromFile(selectedFile),
})) }))
} }
placeholder="必填" className={clsx(
required '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> </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>
<div className="mb-2 text-sm font-medium text-zinc-600">材料编号</div> <FieldLabel
required={
!selectedFileIsZip ||
uploadForm.uploadMode === ARCHIVE_UPLOAD_MODE.ARCHIVE
}
>
材料标题
</FieldLabel>
<Input <Input
value={uploadForm.materialCode} value={uploadForm.materialTitle}
onChange={(event) => onChange={(event) =>
setUploadForm((prev) => ({ setUploadForm((prev) => ({
...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> <div>
<div className="mb-2 text-sm font-medium text-zinc-600">来源应用</div> <FieldLabel>来源应用</FieldLabel>
<Input <Input
list="source-app-suggestions" list="source-app-suggestions"
value={uploadForm.sourceApp} value={uploadForm.sourceApp}
...@@ -1168,7 +1980,7 @@ export default function ResourceBookingPage() { ...@@ -1168,7 +1980,7 @@ export default function ResourceBookingPage() {
/> />
</div> </div>
<div> <div>
<div className="mb-2 text-sm font-medium text-zinc-600">材料分类</div> <FieldLabel>材料分类</FieldLabel>
<Input <Input
list="archive-category-suggestions" list="archive-category-suggestions"
value={uploadForm.archiveCategory} value={uploadForm.archiveCategory}
...@@ -1181,48 +1993,132 @@ export default function ResourceBookingPage() { ...@@ -1181,48 +1993,132 @@ export default function ResourceBookingPage() {
placeholder="可选,例如 审计证据" placeholder="可选,例如 审计证据"
/> />
</div> </div>
<div> <div className="sm:col-span-2">
<div className="mb-2 text-sm font-medium text-zinc-600">文件摘要</div> <FieldLabel>标签分组</FieldLabel>
<Input <Input
value={uploadForm.fileHash} list="tag-suggestions"
value={uploadForm.tag}
onChange={(event) => onChange={(event) =>
setUploadForm((prev) => ({ setUploadForm((prev) => ({
...prev, ...prev,
fileHash: event.target.value, tag: event.target.value,
})) }))
} }
placeholder="可选,如 sha256 值" placeholder="例如:项目一期"
/> />
</div> </div>
</div>
</FormSection>
<FormSection
eyebrow="PROVIDE INFO"
title="提供信息"
description="与对象文档保持一致,用于记录材料由谁、何时提供。"
>
<div className="grid gap-4 sm:grid-cols-2">
<div> <div>
<div className="mb-2 text-sm font-medium text-zinc-600">标签分组</div> <FieldLabel>提供部门</FieldLabel>
<Input <Input
list="tag-suggestions" value={uploadForm.provideDepartment}
value={uploadForm.tag}
onChange={(event) => onChange={(event) =>
setUploadForm((prev) => ({ setUploadForm((prev) => ({
...prev, ...prev,
tag: event.target.value, provideDepartment: event.target.value,
})) }))
} }
placeholder="例如:项目一期" placeholder="可选"
/> />
</div> </div>
<div className="sm:col-span-2"> <div>
<div className="mb-2 text-sm font-medium text-zinc-600">备注</div> <FieldLabel>提供时间</FieldLabel>
<Textarea <Input
rows={4} type="datetime-local"
value={uploadForm.remark} value={uploadForm.provideTime}
onChange={(event) => onChange={(event) =>
setUploadForm((prev) => ({ setUploadForm((prev) => ({
...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>
</div> </div>
</FormSection>
</div> </div>
<datalist id="source-app-suggestions"> <datalist id="source-app-suggestions">
......
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
import { import {
ArrowTrendingUpIcon, ArrowTrendingUpIcon,
BuildingOffice2Icon,
CircleStackIcon, CircleStackIcon,
DocumentTextIcon, DocumentTextIcon,
FolderOpenIcon, FolderOpenIcon,
TagIcon, ShieldCheckIcon,
ShieldExclamationIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import clsx from 'clsx'; import clsx from 'clsx';
import ReactECharts from 'echarts-for-react';
import { useContext, useEffect, useMemo, useState } from 'react'; import { useContext, useEffect, useMemo, useState } from 'react';
import { import {
buildMaterialDashboard, buildMaterialDashboard,
...@@ -15,6 +18,8 @@ import { ...@@ -15,6 +18,8 @@ import {
loadMaterialRecords, loadMaterialRecords,
} from '../materialService'; } from '../materialService';
const CHART_COLORS = ['#e11d48', '#fb7185', '#f59e0b', '#f97316', '#9f1239', '#be123c'];
function Panel({ className, children }) { function Panel({ className, children }) {
return ( return (
<section <section
...@@ -74,30 +79,57 @@ function SectionHeader({ title, description }) { ...@@ -74,30 +79,57 @@ function SectionHeader({ title, description }) {
); );
} }
function DistributionRow({ label, value, total, accent = 'bg-sky-500' }) { function TagPill({ children, tone = 'sky' }) {
const percentage = total > 0 ? Math.max(8, Math.round((value / total) * 100)) : 0; 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 ( return (
<div> <span
<div className="mb-2 flex items-center justify-between text-sm text-zinc-600"> className={clsx(
<span>{label}</span> 'inline-flex rounded-full border px-3 py-1 text-xs font-medium',
<span>{value} 份</span> toneClass,
</div> )}
<div className="h-2.5 rounded-full bg-zinc-100"> >
{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 <div
className={clsx('h-2.5 rounded-full', accent)} className={clsx(
style={{ width: `${percentage}%` }} 'flex h-[320px] items-center justify-center rounded-[24px] border border-dashed border-zinc-200 bg-zinc-50 text-sm text-zinc-400',
/> className,
</div> )}
>
{emptyText}
</div> </div>
); );
} }
function TagPill({ children }) {
return ( 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"> <ReactECharts
{children} option={option}
</span> notMerge
lazyUpdate
className={clsx('h-[320px] w-full', className)}
/>
); );
} }
...@@ -141,12 +173,260 @@ export default function ResourceStatPage() { ...@@ -141,12 +173,260 @@ export default function ResourceStatPage() {
); );
}, [userInfo]); }, [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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<Panel className="relative overflow-hidden p-6 sm:p-8"> <Panel className="relative overflow-hidden p-6 sm:p-8">
<div className="absolute inset-x-0 top-0 h-40 bg-[radial-gradient(circle_at_top_left,_rgba(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="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 MATERIAL OVERVIEW
</div> </div>
<div className="mt-5 flex flex-col gap-6 xl:flex-row xl:items-end xl:justify-between"> <div className="mt-5 flex flex-col gap-6 xl:flex-row xl:items-end xl:justify-between">
...@@ -155,7 +435,7 @@ export default function ResourceStatPage() { ...@@ -155,7 +435,7 @@ export default function ResourceStatPage() {
{welcomeLabel},这里是统一材料池的全局视图 {welcomeLabel},这里是统一材料池的全局视图
</h1> </h1>
<p className="mt-3 text-sm leading-7 text-zinc-600 sm:text-base"> <p className="mt-3 text-sm leading-7 text-zinc-600 sm:text-base">
总览页重点帮你快速看清材料规模、来源分布和最近上传动态,方便先判断数据是否完整,再进入归档页做细节管理。 总览页重点帮你快速看清材料规模、来源分布、提供信息完整度和合规标记状态,方便先判断数据是否完整,再进入归档页做细节管理。
</p> </p>
</div> </div>
...@@ -202,7 +482,7 @@ export default function ResourceStatPage() { ...@@ -202,7 +482,7 @@ export default function ResourceStatPage() {
</div> </div>
</Panel> </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 <MetricCard
icon={DocumentTextIcon} icon={DocumentTextIcon}
label="材料总数" label="材料总数"
...@@ -217,18 +497,43 @@ export default function ResourceStatPage() { ...@@ -217,18 +497,43 @@ export default function ResourceStatPage() {
hint="跨应用汇入的来源数量" hint="跨应用汇入的来源数量"
tone="green" 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 <MetricCard
icon={FolderOpenIcon} icon={FolderOpenIcon}
label="分类数量" label="分类数量"
value={dashboard.uniqueCategoryCount} value={dashboard.uniqueCategoryCount}
hint="按用户自定义分类归档" hint="按用户自定义分类归档"
tone="amber" tone="green"
/> />
<MetricCard <MetricCard
icon={ArrowTrendingUpIcon} icon={ArrowTrendingUpIcon}
label="待完善" label="待完善"
value={dashboard.attentionMaterials.length} value={dashboard.attentionMaterials.length}
hint="缺少编号或标签的材料" hint="缺少来源、分类、标签或提供信息的材料"
tone="rose" tone="rose"
/> />
</div> </div>
...@@ -239,26 +544,27 @@ export default function ResourceStatPage() { ...@@ -239,26 +544,27 @@ export default function ResourceStatPage() {
title="来源应用分布" title="来源应用分布"
description="帮助判断材料是否已经从项目管理、问题整改等应用顺利汇入统一材料池。" description="帮助判断材料是否已经从项目管理、问题整改等应用顺利汇入统一材料池。"
/> />
<div className="space-y-4"> <ChartPanel option={sourceDistributionOption} emptyText="暂无来源分布数据" />
{dashboard.sourceGroups.length > 0 ? ( <div className="mt-4 grid gap-3 sm:grid-cols-3">
dashboard.sourceGroups.map((item, index) => ( {dashboard.sourceGroups.slice(0, 3).map((item, index) => (
<DistributionRow <div
key={item.key} key={item.key}
label={item.label} className="rounded-[20px] border border-zinc-200 bg-zinc-50/80 px-4 py-3"
value={item.count} >
total={dashboard.totalCount} <div className="text-xs font-semibold tracking-[0.18em] text-zinc-500">
accent={ TOP {index + 1}
index % 3 === 1 </div>
? 'bg-emerald-500' <div className="mt-2 truncate text-sm font-semibold text-zinc-950">
: index % 3 === 2 {item.label}
? 'bg-amber-500' </div>
: 'bg-sky-500' <div className="mt-1 text-xs text-zinc-500">
} {item.count} 份,占比{' '}
/> {dashboard.totalCount > 0
)) ? `${Math.round((item.count / dashboard.totalCount) * 100)}%`
) : ( : '0%'}
<div className="text-sm text-zinc-400">暂无来源分布数据</div> </div>
)} </div>
))}
</div> </div>
</Panel> </Panel>
...@@ -267,9 +573,10 @@ export default function ResourceStatPage() { ...@@ -267,9 +573,10 @@ export default function ResourceStatPage() {
title="分类热度" title="分类热度"
description="按当前材料数量快速观察哪些分类最常用,也方便检查是否出现大量未分类材料。" description="按当前材料数量快速观察哪些分类最常用,也方便检查是否出现大量未分类材料。"
/> />
<div className="grid gap-3 sm:grid-cols-2"> <ChartPanel option={categoryDistributionOption} emptyText="暂无分类数据" />
{dashboard.categoryGroups.length > 0 ? ( <div className="mt-4 grid gap-3 sm:grid-cols-2">
dashboard.categoryGroups.map((item, index) => ( {topCategoryCards.length > 0 ? (
topCategoryCards.map((item, index) => (
<div <div
key={item.key} key={item.key}
className={clsx( className={clsx(
...@@ -321,6 +628,16 @@ export default function ResourceStatPage() { ...@@ -321,6 +628,16 @@ export default function ResourceStatPage() {
<div className="mt-2 text-sm text-zinc-500"> <div className="mt-2 text-sm text-zinc-500">
{item.sourceAppLabel} · {item.uploaderLabel} · {item.fileSizeLabel} {item.sourceAppLabel} · {item.uploaderLabel} · {item.fileSizeLabel}
</div> </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>
<div className="text-sm text-zinc-500">{formatDateTime(item.uploadedAt)}</div> <div className="text-sm text-zinc-500">{formatDateTime(item.uploadedAt)}</div>
</div> </div>
...@@ -334,30 +651,62 @@ export default function ResourceStatPage() { ...@@ -334,30 +651,62 @@ export default function ResourceStatPage() {
<div className="space-y-6"> <div className="space-y-6">
<Panel className="p-6"> <Panel className="p-6">
<SectionHeader <SectionHeader
title="自定义标签" title="提供与合规"
description="标签能承接用户的分组习惯,适合快速做阶段、专题或批次管理。" 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"> <div className="space-y-3">
{dashboard.tagGroups.length > 0 ? ( <div className="rounded-[22px] border border-zinc-200 bg-zinc-50/80 px-4 py-4">
dashboard.tagGroups.map((item) => ( <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 <span
key={item.key} 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} {item.label} · {item.count}
</span> </span>
)) ))
) : ( ) : (
<div className="text-sm text-zinc-400">当前还没有可展示的标签</div> <div className="text-sm text-zinc-400">当前还没有已登记的提供部门</div>
)} )}
</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>
<Panel className="p-6"> <Panel className="p-6">
<SectionHeader <SectionHeader
title="待完善清单" title="待完善清单"
description="优先处理缺少标签、缺少编号或分类不清晰的材料,后续检索体验会更稳定。" description="优先处理缺少来源、分类、标签或提供信息的材料,后续检索和追溯会更稳定。"
/> />
<div className="space-y-3"> <div className="space-y-3">
{dashboard.attentionMaterials.length > 0 ? ( {dashboard.attentionMaterials.length > 0 ? (
...@@ -370,9 +719,7 @@ export default function ResourceStatPage() { ...@@ -370,9 +719,7 @@ export default function ResourceStatPage() {
{item.materialTitle} {item.materialTitle}
</div> </div>
<div className="mt-2 text-xs leading-6 text-amber-800"> <div className="mt-2 text-xs leading-6 text-amber-800">
{!item.materialCode ? '缺少材料编号' : ''} {item.attentionReasons.join(' · ')}
{!item.materialCode && !item.tag ? ' · ' : ''}
{!item.tag ? '缺少标签分组' : ''}
</div> </div>
</div> </div>
)) ))
......
...@@ -41,9 +41,7 @@ ...@@ -41,9 +41,7 @@
| 字段标题 | 字段别名 | 类型 | 必填 | 多值 | 主键 | 默认值 | 备注 | | 字段标题 | 字段别名 | 类型 | 必填 | 多值 | 主键 | 默认值 | 备注 |
| --- | --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- | --- |
| 材料标题 | `material_title` | `text` | 是 | 否 | 否 | 无 | 主展示字段 | | 材料标题 | `material_title` | `text` | 是 | 否 | 否 | 无 | 主展示字段 |
| 材料编号 | `material_code` | `text` | 否 | 否 | 否 | 无 | 一期先不设主键,避免卡流程 |
| 文件对象 | `file_asset` | `file` | 是 | 否 | 否 | 无 | 上传接口返回对象 | | 文件对象 | `file_asset` | `file` | 是 | 否 | 否 | 无 | 上传接口返回对象 |
| 文件摘要 | `file_hash` | `text` | 否 | 否 | 否 | 无 | 后续做去重时使用 |
| 来源应用 | `source_app` | `text` | 否 | 否 | 否 | 无 | 材料最初来源 | | 来源应用 | `source_app` | `text` | 否 | 否 | 否 | 无 | 材料最初来源 |
| 材料分类 | `archive_category` | `text` | 否 | 否 | 否 | 无 | 用户自定义分类 | | 材料分类 | `archive_category` | `text` | 否 | 否 | 否 | 无 | 用户自定义分类 |
| 上传人 | `uploader` | `user` | 是 | 否 | 否 | 无 | 当前登录人 | | 上传人 | `uploader` | `user` | 是 | 否 | 否 | 无 | 当前登录人 |
......
...@@ -166,19 +166,16 @@ ...@@ -166,19 +166,16 @@
"content_type": "text", "content_type": "text",
"metadata": { "metadata": {
"material_title": "审计底稿", "material_title": "审计底稿",
"material_code": "AM-2026-001",
"file_asset": { "file_asset": {
"name": "审计底稿.pdf", "name": "审计底稿.pdf",
"type": "application/pdf", "type": "application/pdf",
"size": 248120, "size": 248120,
"url": "https://oss.example.com/audit/xxx.pdf" "url": "https://oss.example.com/audit/xxx.pdf"
}, },
"file_hash": "sha256:xxxx",
"source_app": "中台", "source_app": "中台",
"archive_category": "试试水", "archive_category": "试试水",
"uploader": "69b12a47e92b1de77c94a9f5", "uploader": "69b12a47e92b1de77c94a9f5",
"uploaded_at": "2026-05-09T08:30:00.000Z", "uploaded_at": "2026-05-09T08:30:00.000Z",
"remark": "统一材料池文件",
"tag": "项目一期" "tag": "项目一期"
} }
} }
...@@ -216,19 +213,16 @@ ...@@ -216,19 +213,16 @@
"content_type": "text", "content_type": "text",
"metadata": { "metadata": {
"material_title": "测试1", "material_title": "测试1",
"material_code": "111",
"file_asset": { "file_asset": {
"name": "项目管理_字段.xlsx", "name": "项目管理_字段.xlsx",
"type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"size": 9888, "size": 9888,
"url": "https://oss.streams7.com/xinyuan/3WCqMeKoiqOc.xlsx" "url": "https://oss.streams7.com/xinyuan/3WCqMeKoiqOc.xlsx"
}, },
"file_hash": "test",
"source_app": "中台", "source_app": "中台",
"archive_category": "试试水", "archive_category": "试试水",
"uploader": "64b6575b80bdbd9b3d65d09b", "uploader": "64b6575b80bdbd9b3d65d09b",
"uploaded_at": "2026-05-08T16:00:00.000Z", "uploaded_at": "2026-05-08T16:00:00.000Z",
"remark": "测试",
"tag": "ttt" "tag": "ttt"
}, },
"subject": "am_material" "subject": "am_material"
......
...@@ -99,34 +99,38 @@ type Record = { ...@@ -99,34 +99,38 @@ type Record = {
| 字段标签 | 字段别名 | 字段类型 | 是否主键 | 是否多值 | 是否必填 | 说明 | | 字段标签 | 字段别名 | 字段类型 | 是否主键 | 是否多值 | 是否必填 | 说明 |
| --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- |
| 材料标题 | `material_title` | text | 否 | 否 | 是 | 业务展示主标题 | | 材料标题 | `material_title` | text | 否 | 否 | 是 | 业务展示主标题 |
| 材料编号 | `material_code` | text | 否 | 否 | 否 | 如后续需要编码管理再启用 |
| 文件对象 | `file_asset` | file | 否 | 否 | 是 | 上传接口返回的文件对象 | | 文件对象 | `file_asset` | file | 否 | 否 | 是 | 上传接口返回的文件对象 |
| 文件摘要 | `file_hash` | text | 否 | 否 | 否 | 用于后续查重,可先留空 |
| 来源应用 | `source_app` | text | 否 | 否 | 否 | 材料最初产生于哪个应用 | | 来源应用 | `source_app` | text | 否 | 否 | 否 | 材料最初产生于哪个应用 |
| 材料分类 | `archive_category` | text | 否 | 否 | 否 | 用户自定义分类 | | 材料分类 | `archive_category` | text | 否 | 否 | 否 | 用户自定义分类 |
| 上传人 | `uploader` | user | 否 | 否 | 是 | 当前登录用户 | | 上传人 | `uploader` | user | 否 | 否 | 是 | 当前登录用户 |
| 上传时间 | `uploaded_at` | date | 否 | 否 | 是 | 创建主档时间 | | 上传时间 | `uploaded_at` | date | 否 | 否 | 是 | 创建主档时间 |
| 备注 | `remark` | text | 否 | 否 | 否 | 管理说明 | | 备注 | `remark` | text | 否 | 否 | 否 | 材料补充说明 |
| 标签 | `tag` | text | 否 | 否 | 否 | 用户自定义分组标签 | | 标签 | `tag` | text | 否 | 否 | 否 | 用户自定义分组标签 |
| 提供部门 | `provide_department` | text | 否 | 否 | 否 | |
| 提供时间 | `provide_time` | date | 否 | 否 | 否 | |
| 是否涉密 | `is_confidential` | boolean | 否 | 否 | 否 | |
| 是否审计部材料 | `is_audit_dept_material` | boolean | 否 | 否 | 否 | |
### 3.1.1 metadata 示例 ### 3.1.1 metadata 示例
```json ```json
{ {
"material_title": "审计访谈会议纪要", "material_title": "审计访谈会议纪要",
"material_code": "AM-2026-001",
"file_asset": { "file_asset": {
"name": "审计访谈会议纪要.pdf", "name": "审计访谈会议纪要.pdf",
"type": "application/pdf", "type": "application/pdf",
"size": 248120, "size": 248120,
"url": "https://oss.example.com/audit/meeting-note.pdf" "url": "https://oss.example.com/audit/meeting-note.pdf"
}, },
"file_hash": "sha256:xxxx",
"source_app": "project_manager_xy", "source_app": "project_manager_xy",
"archive_category": "审计证据", "archive_category": "审计证据",
"uploader": "69b12a47e92b1de77c94a9f5", "uploader": "69b12a47e92b1de77c94a9f5",
"uploaded_at": "2026-05-09T08:30:00.000Z", "uploaded_at": "2026-05-09T08:30:00.000Z",
"remark": "上传后可被各审计应用统一查看", "remark": "会议纪要已补签字页",
"tag": "项目一期" "tag": "项目一期",
"provide_department": "审计部",
"provide_time": "2026-05-08T16:00:00.000Z",
"is_confidential": false,
"is_audit_dept_material": true
} }
``` ```
...@@ -7,6 +7,34 @@ ...@@ -7,6 +7,34 @@
"Helvetica Neue", "Helvetica Neue",
Arial, Arial,
sans-serif; 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 { :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