Commit 6f4521a3 authored by David Yang's avatar David Yang

update: ui

parent 90f936e8
...@@ -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' ? (
......
...@@ -20,7 +20,7 @@ const colors = { ...@@ -20,7 +20,7 @@ const colors = {
teal: 'bg-teal-500/15 text-teal-700 group-data-hover:bg-teal-500/25', teal: 'bg-teal-500/15 text-teal-700 group-data-hover:bg-teal-500/25',
cyan: 'bg-cyan-400/20 text-cyan-700 group-data-hover:bg-cyan-400/30', cyan: 'bg-cyan-400/20 text-cyan-700 group-data-hover:bg-cyan-400/30',
sky: 'bg-sky-500/15 text-sky-700 group-data-hover:bg-sky-500/25', sky: 'bg-sky-500/15 text-sky-700 group-data-hover:bg-sky-500/25',
blue: 'bg-blue-500/15 text-blue-700 group-data-hover:bg-blue-500/25', blue: 'bg-[var(--pm-brand)]/15 text-[var(--pm-brand-strong)] group-data-hover:bg-[var(--pm-brand)]/25',
indigo: indigo:
'bg-indigo-500/15 text-indigo-700 group-data-hover:bg-indigo-500/25', 'bg-indigo-500/15 text-indigo-700 group-data-hover:bg-indigo-500/25',
violet: violet:
...@@ -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',
...@@ -95,7 +95,7 @@ const colors = { ...@@ -95,7 +95,7 @@ const colors = {
teal: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-teal-600)] [--checkbox-checked-border:var(--color-teal-700)]/90', teal: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-teal-600)] [--checkbox-checked-border:var(--color-teal-700)]/90',
cyan: '[--checkbox-check:var(--color-cyan-950)] [--checkbox-checked-bg:var(--color-cyan-300)] [--checkbox-checked-border:var(--color-cyan-400)]/80', cyan: '[--checkbox-check:var(--color-cyan-950)] [--checkbox-checked-bg:var(--color-cyan-300)] [--checkbox-checked-border:var(--color-cyan-400)]/80',
sky: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-sky-500)] [--checkbox-checked-border:var(--color-sky-600)]/80', sky: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-sky-500)] [--checkbox-checked-border:var(--color-sky-600)]/80',
blue: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-blue-600)] [--checkbox-checked-border:var(--color-blue-700)]/90', blue: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--pm-brand)] [--checkbox-checked-border:var(--pm-brand-strong)]',
indigo: indigo:
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-indigo-500)] [--checkbox-checked-border:var(--color-indigo-600)]/90', '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-indigo-500)] [--checkbox-checked-border:var(--color-indigo-600)]/90',
violet: violet:
......
...@@ -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',
...@@ -96,7 +96,7 @@ const colors = { ...@@ -96,7 +96,7 @@ const colors = {
teal: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-teal-600)] [--radio-checked-border:var(--color-teal-700)]/90', teal: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-teal-600)] [--radio-checked-border:var(--color-teal-700)]/90',
cyan: '[--radio-checked-bg:var(--color-cyan-300)] [--radio-checked-border:var(--color-cyan-400)]/80 [--radio-checked-indicator:var(--color-cyan-950)]', cyan: '[--radio-checked-bg:var(--color-cyan-300)] [--radio-checked-border:var(--color-cyan-400)]/80 [--radio-checked-indicator:var(--color-cyan-950)]',
sky: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-sky-500)] [--radio-checked-border:var(--color-sky-600)]/80', sky: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-sky-500)] [--radio-checked-border:var(--color-sky-600)]/80',
blue: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-blue-600)] [--radio-checked-border:var(--color-blue-700)]/90', blue: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--pm-brand)] [--radio-checked-border:var(--pm-brand-strong)]',
indigo: indigo:
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-indigo-500)] [--radio-checked-border:var(--color-indigo-600)]/90', '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-indigo-500)] [--radio-checked-border:var(--color-indigo-600)]/90',
violet: violet:
......
'use client';
import clsx from 'clsx';
export function Segment({ className, children }) {
return (
<div
className={clsx(
'inline-flex items-center gap-1 rounded-full border border-zinc-200 bg-zinc-100/80 p-1',
className,
)}
>
{children}
</div>
);
}
export function SegmentOption({
active = false,
className,
children,
...props
}) {
return (
<button
type="button"
{...props}
className={clsx(
'inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-all',
active
? 'bg-white text-sky-700 shadow-sm ring-1 ring-sky-200'
: 'text-zinc-600 hover:bg-white/80 hover:text-zinc-900',
className,
)}
>
{children}
</button>
);
}
...@@ -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',
])} ])}
......
...@@ -101,8 +101,8 @@ const colors = { ...@@ -101,8 +101,8 @@ const colors = {
'[--switch:white] [--switch-ring:var(--color-sky-600)]/80 [--switch-shadow:var(--color-sky-900)]/20', '[--switch:white] [--switch-ring:var(--color-sky-600)]/80 [--switch-shadow:var(--color-sky-900)]/20',
], ],
blue: [ blue: [
'[--switch-bg-ring:var(--color-blue-700)]/90 [--switch-bg:var(--color-blue-600)]', '[--switch-bg-ring:var(--pm-brand-strong)] [--switch-bg:var(--pm-brand)]',
'[--switch:white] [--switch-ring:var(--color-blue-700)]/90 [--switch-shadow:var(--color-blue-900)]/20', '[--switch:white] [--switch-ring:var(--pm-brand-strong)] [--switch-shadow:var(--pm-brand-strong)]/20',
], ],
indigo: [ indigo: [
'[--switch-bg-ring:var(--color-indigo-600)]/90 [--switch-bg:var(--color-indigo-500)]', '[--switch-bg-ring:var(--color-indigo-600)]/90 [--switch-bg:var(--color-indigo-500)]',
...@@ -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: #b42318;
--pm-brand-strong: #007bb8; --pm-brand-strong: #991b1b;
--pm-surface: #ffffff; --pm-surface: #ffffff;
--pm-bg: #eef5f9; --pm-bg: #f8fafc;
--pm-sidebar: #0f172a; --pm-sidebar: #0f172a;
--pm-sidebar-soft: #1e293b; --pm-sidebar-soft: #1e293b;
--pm-text: #0f172a; --pm-text: #0f172a;
...@@ -17,7 +17,10 @@ body, ...@@ -17,7 +17,10 @@ body,
body { body {
margin: 0; margin: 0;
background: linear-gradient(180deg, #f8fbfd 0%, #eef5f9 100%); background:
radial-gradient(circle at top left, rgba(251, 191, 188, 0.22), transparent 26%),
radial-gradient(circle at bottom right, rgba(251, 207, 232, 0.2), transparent 30%),
#f8fafc;
color: var(--pm-text); color: var(--pm-text);
font-family: font-family:
"PingFang SC", "PingFang SC",
......
import xyLogo from '@/assets/xy-logo.png';
import xyLogoSmall from '@/assets/xy-logo_small.png'; import xyLogoSmall from '@/assets/xy-logo_small.png';
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
import { import {
...@@ -220,7 +219,7 @@ export default function ProjectManagerLayout() { ...@@ -220,7 +219,7 @@ export default function ProjectManagerLayout() {
}, [location.pathname]); }, [location.pathname]);
return ( return (
<div className="flex h-screen overflow-hidden bg-zinc-50"> <div className="flex h-screen overflow-hidden bg-[radial-gradient(circle_at_top_left,_rgba(251,191,188,0.22),_transparent_26%),radial-gradient(circle_at_bottom_right,_rgba(251,207,232,0.2),_transparent_30%),#f8fafc]">
<aside <aside
className={clsx( className={clsx(
'relative z-30 hidden shrink-0 flex-col border-r border-zinc-800 bg-zinc-900 p-5 lg:flex', 'relative z-30 hidden shrink-0 flex-col border-r border-zinc-800 bg-zinc-900 p-5 lg:flex',
...@@ -230,19 +229,16 @@ export default function ProjectManagerLayout() { ...@@ -230,19 +229,16 @@ export default function ProjectManagerLayout() {
<div <div
className={clsx( className={clsx(
'mb-7 flex items-center', 'mb-7 flex items-center',
sidebarCollapsed ? 'justify-center' : 'gap-3 px-1', sidebarCollapsed ? 'justify-center' : 'px-1',
)} )}
> >
<img {sidebarCollapsed ? (
src={sidebarCollapsed ? xyLogoSmall : xyLogo} <img
alt="XY Logo" src={xyLogoSmall}
className={clsx( alt="XY Logo"
'shrink-0 object-contain', className="h-9 w-auto max-w-[52px] shrink-0 object-contain"
sidebarCollapsed />
? 'h-9 w-auto max-w-[52px]' ) : null}
: 'h-5 w-auto max-w-[128px]',
)}
/>
{!sidebarCollapsed ? ( {!sidebarCollapsed ? (
<h1 className="truncate text-sm font-semibold tracking-[0.02em] text-white/95"> <h1 className="truncate text-sm font-semibold tracking-[0.02em] text-white/95">
{APP_NAME} {APP_NAME}
...@@ -300,12 +296,7 @@ export default function ProjectManagerLayout() { ...@@ -300,12 +296,7 @@ export default function ProjectManagerLayout() {
close={() => setIsMobileSidebarOpen(false)} close={() => setIsMobileSidebarOpen(false)}
> >
<div className="px-5 pt-6"> <div className="px-5 pt-6">
<div className="flex items-center gap-2"> <div className="flex items-center">
<img
src={xyLogo}
alt="XY Logo"
className="h-5 w-auto max-w-[128px] shrink-0 object-contain"
/>
<h1 className="text-base font-semibold tracking-[0.02em] text-white"> <h1 className="text-base font-semibold tracking-[0.02em] text-white">
{APP_NAME} {APP_NAME}
</h1> </h1>
......
...@@ -49,11 +49,12 @@ ...@@ -49,11 +49,12 @@
关键字段: 关键字段:
- `label`:案例标题 - `label`:案例标题
- `category`:分类与案例性质主维度
- `tag`:标签
- `case_type`:案例类型,列表与导入模板的主展示字段 - `case_type`:案例类型,列表与导入模板的主展示字段
- `case_attribute`:案例属性 - `case_attribute`:案例属性
- `category`:案例性质
- `case_details`:案例详情正文 - `case_details`:案例详情正文
- `case_warning`:案件警示 - `case_warning`:案例警示
- `keyword`:关键字,用于检索和热度统计 - `keyword`:关键字,用于检索和热度统计
- `case_source`:案例来源 - `case_source`:案例来源
- `case_level`:案例等级 - `case_level`:案例等级
...@@ -67,7 +68,7 @@ ...@@ -67,7 +68,7 @@
1. 用户进入 `案例管理` 1. 用户进入 `案例管理`
2. 点击 `手动新建` 2. 点击 `手动新建`
3. 填写案例类型、案例属性、案例性质、案例详情、案件警示、关键字、案例来源、案例等级、案例年份 3. 填写标题、分类、标签、案例类型、案例属性、案例详情、案例警示、关键字、案例来源、案例等级、案例年份
4. 前端调用 `POST /record` 4. 前端调用 `POST /record`
5. 成功后生成一条 `risk_case_library` 记录 5. 成功后生成一条 `risk_case_library` 记录
6. 页面顶部提示创建成功,并刷新当前列表 6. 页面顶部提示创建成功,并刷新当前列表
...@@ -77,19 +78,21 @@ ...@@ -77,19 +78,21 @@
1. 用户进入 `案例管理` 1. 用户进入 `案例管理`
2. 点击 `Excel 导入` 2. 点击 `Excel 导入`
3. 上传 `.xlsx`、`.xls` 或 `.csv` 3. 在导入弹框中可先点击 `导出模板` 下载标准模板,再上传 `.xlsx`、`.xls` 或 `.csv`
4. 前端读取首个工作表并解析表头 4. 前端读取首个工作表并解析表头
5. Excel 表头按以下模板解析: 5. Excel 表头按以下模板解析:
- `案例类型` → `metadata.case_type`,同时作为 `title` 与 `metadata.label` - `标题` → `metadata.label`,同时作为 `title`
- `分类` → `metadata.category`
- `标签` → `metadata.tag`
- `案例类型` → `metadata.case_type`;若 `标题` 为空,可退化为 `title`
- `案例属性` → `metadata.case_attribute` - `案例属性` → `metadata.case_attribute`
- `案例性质` → `metadata.category`
- `案例详情` → `content`、`metadata.case_details` - `案例详情` → `content`、`metadata.case_details`
- `案件警示` → `metadata.case_warning` - `案例警示` → `metadata.case_warning`
- `关键字` → `metadata.keyword`,同时回填 `metadata.tag` 以兼容现有检索 - `关键字` → `metadata.keyword`,同时回填 `metadata.tag` 以兼容现有检索
- `案例来源` → `metadata.case_source` - `案例来源` → `metadata.case_source`
- `案例等级` → `metadata.case_level` - `案例等级` → `metadata.case_level`
- `案例年份` → `metadata.case_year` - `案例年份` → `metadata.case_year`
6. 前端兼容旧模板字段名,但新导入导出统一使用上述 9 列模板 6. 前端兼容旧模板字段名,但新导入导出统一使用上述 11 列模板
7. 前端逐条调用 `POST /record` 批量落库 7. 前端逐条调用 `POST /record` 批量落库
8. 导入完成后反馈成功条数和失败条数 8. 导入完成后反馈成功条数和失败条数
...@@ -98,12 +101,14 @@ ...@@ -98,12 +101,14 @@
1. 用户进入 `案例管理` 1. 用户进入 `案例管理`
2. 点击 `Excel 批量导出` 2. 点击 `Excel 批量导出`
3. 前端按当前搜索词、案例性质与关键字筛选条件查询全部案例 3. 前端按当前搜索词、案例性质与关键字筛选条件查询全部案例
4. 导出文件统一输出以下 9 列: 4. 导出文件统一输出以下 11 列:
- `标题`
- `分类`
- `标签`
- `案例类型` - `案例类型`
- `案例属性` - `案例属性`
- `案例性质`
- `案例详情` - `案例详情`
- `案件警示` - `案例警示`
- `关键字` - `关键字`
- `案例来源` - `案例来源`
- `案例等级` - `案例等级`
...@@ -113,20 +118,32 @@ ...@@ -113,20 +118,32 @@
### 4.4 浏览与检索案例 ### 4.4 浏览与检索案例
1. 用户进入 `案例管理` 1. 用户进入 `案例管理`
2. 通过搜索框检索案例类型、案例属性、案例详情、案件警示、关键字、案例来源、案例等级 2. 通过搜索框检索标题、案例类型、案例属性、案例详情、案例警示、关键字、案例来源、案例等级
3. 通过案例性质标签快速切换当前视图 3. 通过案例性质标签快速切换当前视图
4. 通过关键字输入框进一步缩小范围,前端同时兼容历史 `metadata.tag` 4. 通过关键字输入框进一步缩小范围,前端同时兼容历史 `metadata.tag`
5. 页面以卡片方式展示案例摘要 5. 页面顶部提供 `Segment` 视图切换,可在“卡片视图 / 表格视图”之间切换
6. 点击卡片后,以弹框方式查看并编辑详情 6. 卡片视图用于浏览案例摘要,点击卡片后以弹框方式查看并编辑详情
7. 表格视图展示标题、分类、标签、案例类型、关键字、来源、等级、年份、更新时间等字段
8. 表格视图支持单选、全选当前已加载案例,并显示当前勾选数量
### 4.5 编辑案例 ### 4.5 编辑案例
1. 用户在列表中打开某个案例详情 1. 用户在列表中打开某个案例详情
2. 修改案例类型、案例属性、案例性质、案例详情、案件警示、关键字、案例来源、案例等级、案例年份 2. 修改标题、分类、标签、案例类型、案例属性、案例详情、案例警示、关键字、案例来源、案例等级、案例年份
3. 前端调用 `PUT /record/:name` 3. 前端调用 `PUT /record/:name`
4. 保存成功后刷新列表与统计数据 4. 保存成功后刷新列表与统计数据
### 4.6 删除案例 ### 4.6 批量修改案例
1. 用户切换到表格视图
2. 勾选一个或多个案例
3. 点击 `批量修改`
4. 在弹框中填写需要统一覆盖的字段
5. 未填写字段保持原值不变
6. 前端逐条调用 `PUT /record/:name`
7. 保存成功后刷新列表;若部分失败,反馈失败条目
### 4.7 删除案例
1. 用户在案例详情内点击删除 1. 用户在案例详情内点击删除
2. 页面弹出二次确认 2. 页面弹出二次确认
...@@ -134,6 +151,15 @@ ...@@ -134,6 +151,15 @@
4. 删除后从当前列表中移除 4. 删除后从当前列表中移除
5. 默认走软删除,不直接做物理删除 5. 默认走软删除,不直接做物理删除
### 4.8 表格批量删除与勾选导出
1. 用户切换到表格视图
2. 勾选一个或多个案例
3. 可点击 `导出勾选`,前端直接将当前勾选记录导出为 `.xlsx`
4. 可点击 `批量删除`,页面弹出二次确认
5. 前端逐条调用 `DELETE /record/:name`
6. 删除后刷新列表,并保留失败项提示
--- ---
## 5. 联动规则 ## 5. 联动规则
...@@ -143,15 +169,16 @@ ...@@ -143,15 +169,16 @@
- 案例库统一读取 `risk_case_library` - 案例库统一读取 `risk_case_library`
- 所有业务字段均放在 `metadata` 中 - 所有业务字段均放在 `metadata` 中
- `title` 与 `metadata.label` 保持同步,便于复用统一查询能力 - `title` 与 `metadata.label` 保持同步,便于复用统一查询能力
- `title` 为空时可回退使用 `metadata.case_type`,再退化为 `metadata.case_details` 摘要
### 5.2 Excel 导入字段规则 ### 5.2 Excel 导入字段规则
- 未识别到的列忽略,不中断导入 - 未识别到的列忽略,不中断导入
- 空行直接跳过 - 空行直接跳过
- `案例类型` 为空时,使用 `标题` 或 `案例详情` 摘要自动生成 `title` - `标题` 为空时,优先使用 `案例类型`,再回退为 `案例详情` 摘要自动生成 `title`
- 所有文本字段统一按字符串保存 - 所有文本字段统一按字符串保存
- `案例年份` 优先按数字写入,不可解析时保留原字符串 - `案例年份` 优先按数字写入,不可解析时保留原字符串
- 导出模板固定为 9 列标准表头,便于线下统一维护 - 导出模板固定为 11 列标准表头,便于线下统一维护
### 5.3 筛选规则 ### 5.3 筛选规则
...@@ -191,10 +218,17 @@ ...@@ -191,10 +218,17 @@
- 搜索框 - 搜索框
- 案例性质切换 - 案例性质切换
- 关键字筛选 - 关键字筛选
- `Segment` 视图切换(卡片 / 表格)
- 手动新建按钮 - 手动新建按钮
- Excel 导入按钮 - Excel 导入按钮
- 导入弹框中的导出模板按钮
- Excel 导出按钮 - Excel 导出按钮
- 案例卡片列表 - 案例卡片列表
- 案例表格列表
- 表格勾选与全选
- 批量修改按钮
- 勾选导出按钮
- 批量删除按钮
- 详情编辑弹框 - 详情编辑弹框
- 删除确认弹框 - 删除确认弹框
...@@ -203,11 +237,13 @@ ...@@ -203,11 +237,13 @@
建议展示: 建议展示:
- 标题 - 标题
- 分类 / 标签
- 案例类型 - 案例类型
- 案例属性
- 案例详情全文 - 案例详情全文
- 案例性质 - 案例性质
- 关键字 - 关键字
- 案件警示 - 案例警示
- 案例来源 / 案例等级 / 案例年份 - 案例来源 / 案例等级 / 案例年份
- 案例详情字数 - 案例详情字数
- 创建时间 / 更新时间 - 创建时间 / 更新时间
......
...@@ -11,6 +11,20 @@ import * as XLSX from 'xlsx'; ...@@ -11,6 +11,20 @@ import * as XLSX from 'xlsx';
export const CASE_SUBJECT = 'risk_case_library'; export const CASE_SUBJECT = 'risk_case_library';
export const DEFAULT_PAGE_SIZE = 24; export const DEFAULT_PAGE_SIZE = 24;
const CASE_EXPORT_HEADERS = [
'标题',
'分类',
'标签',
'案例类型',
'案例属性',
'案例详情',
'案例警示',
'关键字',
'案例来源',
'案例等级',
'案例年份',
];
const DEMO_CASES = [ const DEMO_CASES = [
{ {
_id: 'demo-case-1', _id: 'demo-case-1',
...@@ -23,10 +37,17 @@ const DEMO_CASES = [ ...@@ -23,10 +37,17 @@ const DEMO_CASES = [
'某项目在高峰采购期内,多次通过拆分订单绕过审批阈值,导致同一供应商在短期内累计发生异常采购金额。复盘发现,审批节点设置与例外授权规则未同步更新,最终形成流程漏洞。', '某项目在高峰采购期内,多次通过拆分订单绕过审批阈值,导致同一供应商在短期内累计发生异常采购金额。复盘发现,审批节点设置与例外授权规则未同步更新,最终形成流程漏洞。',
metadata: { metadata: {
label: '采购审批绕行典型案例', label: '采购审批绕行典型案例',
content:
'某项目在高峰采购期内,多次通过拆分订单绕过审批阈值,导致同一供应商在短期内累计发生异常采购金额。复盘发现,审批节点设置与例外授权规则未同步更新,最终形成流程漏洞。',
category: '合规风险', category: '合规风险',
tag: '采购审批', tag: '采购审批',
case_type: '审批绕行',
case_attribute: '流程绕行',
case_details:
'某项目在高峰采购期内,多次通过拆分订单绕过审批阈值,导致同一供应商在短期内累计发生异常采购金额。复盘发现,审批节点设置与例外授权规则未同步更新,最终形成流程漏洞。',
case_warning: '涉及审批阈值、例外授权和供应商集中度的场景应设置联合预警。',
keyword: '采购审批, 拆单',
case_source: '内部审计复盘',
case_level: '高',
case_year: 2024,
}, },
}, },
{ {
...@@ -40,10 +61,17 @@ const DEMO_CASES = [ ...@@ -40,10 +61,17 @@ const DEMO_CASES = [
'部门负责人以“紧急签约”为由临时借出印章,未在台账中完整登记用途和归还时间,后续合同版本与审批留痕不一致。最终在复盘中确认,印章借用、复核与回收链条均缺少双人校验。', '部门负责人以“紧急签约”为由临时借出印章,未在台账中完整登记用途和归还时间,后续合同版本与审批留痕不一致。最终在复盘中确认,印章借用、复核与回收链条均缺少双人校验。',
metadata: { metadata: {
label: '印章借用失控案例', label: '印章借用失控案例',
content:
'部门负责人以“紧急签约”为由临时借出印章,未在台账中完整登记用途和归还时间,后续合同版本与审批留痕不一致。最终在复盘中确认,印章借用、复核与回收链条均缺少双人校验。',
category: '操作风险', category: '操作风险',
tag: '印章管理', tag: '印章管理',
case_type: '印章借用失控',
case_attribute: '授权管理',
case_details:
'部门负责人以“紧急签约”为由临时借出印章,未在台账中完整登记用途和归还时间,后续合同版本与审批留痕不一致。最终在复盘中确认,印章借用、复核与回收链条均缺少双人校验。',
case_warning: '印章借用、回收与留痕链路必须双人复核并形成闭环。',
keyword: '印章, 台账, 紧急签约',
case_source: '合规检查复盘',
case_level: '中',
case_year: 2025,
}, },
}, },
{ {
...@@ -57,10 +85,17 @@ const DEMO_CASES = [ ...@@ -57,10 +85,17 @@ const DEMO_CASES = [
'财务负责人通过虚构往来科目和短期借款名义转移资金,月末再以内部调账方式冲回,导致多轮抽样测试均未及时发现异常。案件暴露出资金支付复核与异常波动分析两道防线同时失效。', '财务负责人通过虚构往来科目和短期借款名义转移资金,月末再以内部调账方式冲回,导致多轮抽样测试均未及时发现异常。案件暴露出资金支付复核与异常波动分析两道防线同时失效。',
metadata: { metadata: {
label: '资金挪用预警案例', label: '资金挪用预警案例',
content:
'财务负责人通过虚构往来科目和短期借款名义转移资金,月末再以内部调账方式冲回,导致多轮抽样测试均未及时发现异常。案件暴露出资金支付复核与异常波动分析两道防线同时失效。',
category: '舞弊风险', category: '舞弊风险',
tag: '资金管理', tag: '资金管理',
case_type: '资金挪用',
case_attribute: '舞弊预警',
case_details:
'财务负责人通过虚构往来科目和短期借款名义转移资金,月末再以内部调账方式冲回,导致多轮抽样测试均未及时发现异常。案件暴露出资金支付复核与异常波动分析两道防线同时失效。',
case_warning: '资金支付复核应与月末异常波动分析交叉验证。',
keyword: '资金管理, 异常调账',
case_source: '内部通报',
case_level: '高',
case_year: 2025,
}, },
}, },
{ {
...@@ -74,10 +109,17 @@ const DEMO_CASES = [ ...@@ -74,10 +109,17 @@ const DEMO_CASES = [
'项目团队为赶进度直接沿用历史供应商名单,新签供应商未重新完成资质核验,导致营业执照、关联关系与受益所有人信息长期缺失。后续排查时已无法还原当时的准入审批依据。', '项目团队为赶进度直接沿用历史供应商名单,新签供应商未重新完成资质核验,导致营业执照、关联关系与受益所有人信息长期缺失。后续排查时已无法还原当时的准入审批依据。',
metadata: { metadata: {
label: '供应商准入资料缺失案例', label: '供应商准入资料缺失案例',
content:
'项目团队为赶进度直接沿用历史供应商名单,新签供应商未重新完成资质核验,导致营业执照、关联关系与受益所有人信息长期缺失。后续排查时已无法还原当时的准入审批依据。',
category: '尽职调查', category: '尽职调查',
tag: '供应商准入', tag: '供应商准入',
case_type: '准入资料缺失',
case_attribute: '供应商管理',
case_details:
'项目团队为赶进度直接沿用历史供应商名单,新签供应商未重新完成资质核验,导致营业执照、关联关系与受益所有人信息长期缺失。后续排查时已无法还原当时的准入审批依据。',
case_warning: '供应商准入资料缺失会直接削弱后续尽调和责任追溯能力。',
keyword: '供应商, 尽调, 资质核验',
case_source: '专项排查',
case_level: '中',
case_year: 2024,
}, },
}, },
{ {
...@@ -91,19 +133,33 @@ const DEMO_CASES = [ ...@@ -91,19 +133,33 @@ const DEMO_CASES = [
'运维团队在夜间批量发布期间共享高权限账号密码,虽然缩短了操作路径,但审计日志无法区分责任人。问题暴露出特权账号借用、审批、回收与日志关联机制均不完整。', '运维团队在夜间批量发布期间共享高权限账号密码,虽然缩短了操作路径,但审计日志无法区分责任人。问题暴露出特权账号借用、审批、回收与日志关联机制均不完整。',
metadata: { metadata: {
label: '高权限账号共享案例', label: '高权限账号共享案例',
content:
'运维团队在夜间批量发布期间共享高权限账号密码,虽然缩短了操作路径,但审计日志无法区分责任人。问题暴露出特权账号借用、审批、回收与日志关联机制均不完整。',
category: '信息安全', category: '信息安全',
tag: '权限控制', tag: '权限控制',
case_type: '高权限账号共享',
case_attribute: '账号治理',
case_details:
'运维团队在夜间批量发布期间共享高权限账号密码,虽然缩短了操作路径,但审计日志无法区分责任人。问题暴露出特权账号借用、审批、回收与日志关联机制均不完整。',
case_warning: '高权限账号必须做到实名借用、短时授权和日志可追溯。',
keyword: '特权账号, 权限控制, 审计日志',
case_source: '安全审计',
case_level: '高',
case_year: 2023,
}, },
}, },
]; ];
const IMPORT_FIELD_ALIASES = { const IMPORT_FIELD_ALIASES = {
label: ['label', 'title', '标题', '案例标题', '名称'], label: ['label', 'title', '标题', '案例标题', '名称'],
content: ['content', '内容', '正文', '案例内容', '描述', '详情'], category: ['category', '分类', '案例性质', '类别'],
category: ['category', '分类', '类别'], tag: ['tag', '标签', '标记'],
tag: ['tag', '标签', '关键词', '标记'], caseType: ['case_type', '案例类型', '类型'],
caseAttribute: ['case_attribute', '案例属性', '属性'],
caseDetails: ['case_details', '案例详情', '内容', '正文', '案例内容', '描述', '详情'],
caseWarning: ['case_warning', '案例警示', '案件警示', '警示'],
keyword: ['keyword', '关键字', '关键词'],
caseSource: ['case_source', '案例来源', '来源'],
caseLevel: ['case_level', '案例等级', '等级'],
caseYear: ['case_year', '案例年份', '年份'],
}; };
function escapeRegExp(value = '') { function escapeRegExp(value = '') {
...@@ -215,9 +271,16 @@ export function createCaseTitle(values = {}) { ...@@ -215,9 +271,16 @@ export function createCaseTitle(values = {}) {
return label; return label;
} }
const content = String(values.content || '').replace(/\s+/g, ' ').trim(); const caseType = String(values.caseType || values.case_type || '').trim();
if (content) { if (caseType) {
return content.slice(0, 28); return caseType;
}
const caseDetails = String(values.caseDetails || values.case_details || values.content || '')
.replace(/\s+/g, ' ')
.trim();
if (caseDetails) {
return caseDetails.slice(0, 28);
} }
return '未命名案例'; return '未命名案例';
...@@ -238,7 +301,13 @@ function buildKeywordFilter(keyword = '') { ...@@ -238,7 +301,13 @@ function buildKeywordFilter(keyword = '') {
$or: [ $or: [
{ title: regex }, { title: regex },
{ 'metadata.label': regex }, { 'metadata.label': regex },
{ 'metadata.content': regex }, { 'metadata.case_type': regex },
{ 'metadata.case_attribute': regex },
{ 'metadata.case_details': regex },
{ 'metadata.case_warning': regex },
{ 'metadata.keyword': regex },
{ 'metadata.case_source': regex },
{ 'metadata.case_level': regex },
{ 'metadata.category': regex }, { 'metadata.category': regex },
{ 'metadata.tag': regex }, { 'metadata.tag': regex },
], ],
...@@ -292,20 +361,40 @@ function hasActiveFilters(filters = {}) { ...@@ -292,20 +361,40 @@ function hasActiveFilters(filters = {}) {
export function normalizeCaseRecord(record = {}) { export function normalizeCaseRecord(record = {}) {
const metadata = record?.metadata || {}; const metadata = record?.metadata || {};
const label = String(metadata.label || record.title || '').trim(); const label = String(metadata.label || record.title || '').trim();
const content = String(metadata.content || record.content || '').trim(); const caseType = String(metadata.case_type || '').trim();
const caseAttribute = String(metadata.case_attribute || '').trim();
const caseDetails = String(metadata.case_details || metadata.content || record.content || '').trim();
const caseWarning = String(metadata.case_warning || '').trim();
const keyword = String(metadata.keyword || '').trim();
const caseSource = String(metadata.case_source || '').trim();
const caseLevel = String(metadata.case_level || '').trim();
const category = String(metadata.category || '').trim(); const category = String(metadata.category || '').trim();
const tag = String(metadata.tag || '').trim(); const tag = String(metadata.tag || '').trim();
const caseYearValue = metadata.case_year;
const caseYear =
caseYearValue === null || caseYearValue === undefined || caseYearValue === ''
? ''
: String(caseYearValue).trim();
const createdAt = record.created_at || ''; const createdAt = record.created_at || '';
const updatedAt = record.updated_at || createdAt || ''; const updatedAt = record.updated_at || createdAt || '';
const textLength = String(content).replace(/\s+/g, '').length; const textLength = String(caseDetails).replace(/\s+/g, '').length;
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: createCaseTitle({ label, content }), title: createCaseTitle({ label, caseType, caseDetails }),
label: label || createCaseTitle({ content }), label: label || createCaseTitle({ caseType, caseDetails }),
content, caseType,
contentPreview: createSummary(content), caseAttribute,
caseDetails,
caseWarning,
keyword,
caseSource,
caseLevel,
caseYear,
caseYearLabel: caseYear || '-',
content: caseDetails,
contentPreview: createSummary(caseDetails),
category: category || '未分类', category: category || '未分类',
tag, tag,
textLength, textLength,
...@@ -327,7 +416,18 @@ function applyClientFilters(cases, filters = {}) { ...@@ -327,7 +416,18 @@ function applyClientFilters(cases, filters = {}) {
return cases.filter((item) => { return cases.filter((item) => {
const matchesKeyword = const matchesKeyword =
!keyword || !keyword ||
[item.label, item.content, item.category, item.tag] [
item.label,
item.caseType,
item.caseAttribute,
item.caseDetails,
item.caseWarning,
item.keyword,
item.caseSource,
item.caseLevel,
item.category,
item.tag,
]
.filter(Boolean) .filter(Boolean)
.some((value) => String(value).toLowerCase().includes(keyword)); .some((value) => String(value).toLowerCase().includes(keyword));
...@@ -378,7 +478,10 @@ export function buildCaseDashboard(cases = [], options = {}) { ...@@ -378,7 +478,10 @@ export function buildCaseDashboard(cases = [], options = {}) {
.sort((left, right) => Number(right.textLength || 0) - Number(left.textLength || 0)) .sort((left, right) => Number(right.textLength || 0) - Number(left.textLength || 0))
.slice(0, 5); .slice(0, 5);
const attentionCases = cases const attentionCases = cases
.filter((item) => !item.tag || !item.category || Number(item.textLength || 0) < 80) .filter(
(item) =>
!item.keyword || !item.category || Number(item.textLength || 0) < 80,
)
.slice(0, 5); .slice(0, 5);
return { return {
...@@ -504,21 +607,42 @@ export async function loadCaseRecords(filters = {}) { ...@@ -504,21 +607,42 @@ export async function loadCaseRecords(filters = {}) {
export function buildCasePayload(values = {}) { export function buildCasePayload(values = {}) {
const label = String(values.label || '').trim(); const label = String(values.label || '').trim();
const content = String(values.content || '').trim(); const caseType = String(values.caseType || values.case_type || '').trim();
const caseAttribute = String(values.caseAttribute || values.case_attribute || '').trim();
const caseDetails = String(values.caseDetails || values.case_details || values.content || '').trim();
const caseWarning = String(values.caseWarning || values.case_warning || '').trim();
const keyword = String(values.keyword || '').trim();
const caseSource = String(values.caseSource || values.case_source || '').trim();
const caseLevel = String(values.caseLevel || values.case_level || '').trim();
const category = String(values.category || '').trim(); const category = String(values.category || '').trim();
const tag = String(values.tag || '').trim(); const tag = String(values.tag || '').trim();
const title = createCaseTitle({ label, content }); const caseYearRaw = values.caseYear ?? values.case_year ?? '';
const normalizedCaseYearText = String(caseYearRaw ?? '').trim();
const caseYear =
normalizedCaseYearText === ''
? null
: Number.isFinite(Number(normalizedCaseYearText))
? Number(normalizedCaseYearText)
: null;
const title = createCaseTitle({ label, caseType, caseDetails });
return { return {
subject: CASE_SUBJECT, subject: CASE_SUBJECT,
title, title,
content_type: 'text', content_type: 'text',
content, content: caseDetails,
metadata: { metadata: {
label, label,
content,
category, category,
tag, tag,
case_type: caseType,
case_attribute: caseAttribute,
case_details: caseDetails,
case_warning: caseWarning,
keyword,
case_source: caseSource,
case_level: caseLevel,
case_year: caseYear,
}, },
}; };
} }
...@@ -569,6 +693,137 @@ export async function deleteCaseRecord(caseItem) { ...@@ -569,6 +693,137 @@ export async function deleteCaseRecord(caseItem) {
await removeRecord(recordName); await removeRecord(recordName);
} }
async function runCaseMutationInBatches(entries = [], worker, batchSize = 5) {
const successes = [];
const failures = [];
for (let index = 0; index < entries.length; index += batchSize) {
const batch = entries.slice(index, index + batchSize);
const results = await Promise.allSettled(batch.map((entry) => worker(entry)));
results.forEach((result, batchIndex) => {
const entry = batch[batchIndex];
if (result.status === 'fulfilled') {
successes.push(result.value ?? entry);
return;
}
failures.push({
id: entry?.id || entry?.recordName || '',
label: entry?.label || entry?.title || '未命名案例',
message: result.reason?.message || '操作失败',
});
});
}
return {
successes,
failures,
};
}
export async function deleteCaseRecords(caseItems = []) {
const normalizedItems = caseItems.filter(Boolean);
const { successes, failures } = await runCaseMutationInBatches(
normalizedItems,
(caseItem) => deleteCaseRecord(caseItem),
);
return {
deletedCount: successes.length,
failedCount: failures.length,
failures,
};
}
export async function updateCaseRecords(caseItems = [], values = {}) {
const normalizedItems = caseItems.filter(Boolean);
const metadataPatch = {};
const textPatchFields = [
['label', values.label],
['category', values.category],
['tag', values.tag],
['case_type', values.caseType ?? values.case_type],
['case_attribute', values.caseAttribute ?? values.case_attribute],
['case_details', values.caseDetails ?? values.case_details],
['case_warning', values.caseWarning ?? values.case_warning],
['keyword', values.keyword],
['case_source', values.caseSource ?? values.case_source],
['case_level', values.caseLevel ?? values.case_level],
];
textPatchFields.forEach(([fieldName, fieldValue]) => {
const normalizedValue = String(fieldValue ?? '').trim();
if (normalizedValue !== '') {
metadataPatch[fieldName] = normalizedValue;
}
});
const caseYearRaw = values.caseYear ?? values.case_year;
const normalizedCaseYear = String(caseYearRaw ?? '').trim();
if (
normalizedCaseYear !== '' &&
Number.isFinite(Number(normalizedCaseYear))
) {
metadataPatch.case_year = Number(normalizedCaseYear);
}
const { successes, failures } = await runCaseMutationInBatches(
normalizedItems,
async (caseItem) => {
const recordName = caseItem?.recordName || caseItem?.id;
if (!recordName) {
throw new Error('未找到可更新的案例记录标识');
}
const nextMetadata = {
...caseItem.rawMetadata,
...metadataPatch,
};
const nextCaseDetails = String(
nextMetadata.case_details ||
nextMetadata.content ||
caseItem.rawRecord?.content ||
'',
).trim();
const nextLabel = String(nextMetadata.label || '').trim();
const nextCaseType = String(nextMetadata.case_type || '').trim();
const nextTitle = createCaseTitle({
label: nextLabel,
caseType: nextCaseType,
caseDetails: nextCaseDetails,
});
const updated = await updateRecord(recordName, {
title: nextTitle,
content_type: 'text',
content: nextCaseDetails,
metadata: nextMetadata,
});
return normalizeCaseRecord(
updated?.data ||
updated?.result || {
...caseItem.rawRecord,
title: nextTitle,
content: nextCaseDetails,
metadata: nextMetadata,
},
);
},
);
return {
updatedCases: successes,
updatedCount: successes.length,
failedCount: failures.length,
failures,
};
}
function getImportFieldValue(row = {}, aliases = []) { function getImportFieldValue(row = {}, aliases = []) {
const normalizedAliasMap = new Map( const normalizedAliasMap = new Map(
Object.entries(row).map(([key, value]) => [normalizeHeaderKey(key), value]), Object.entries(row).map(([key, value]) => [normalizeHeaderKey(key), value]),
...@@ -588,9 +843,16 @@ function normalizeWorkbookRow(row = {}, rowIndex = 0) { ...@@ -588,9 +843,16 @@ function normalizeWorkbookRow(row = {}, rowIndex = 0) {
return { return {
rowNumber: rowIndex + 2, rowNumber: rowIndex + 2,
label: getImportFieldValue(row, IMPORT_FIELD_ALIASES.label), label: getImportFieldValue(row, IMPORT_FIELD_ALIASES.label),
content: getImportFieldValue(row, IMPORT_FIELD_ALIASES.content),
category: getImportFieldValue(row, IMPORT_FIELD_ALIASES.category), category: getImportFieldValue(row, IMPORT_FIELD_ALIASES.category),
tag: getImportFieldValue(row, IMPORT_FIELD_ALIASES.tag), tag: getImportFieldValue(row, IMPORT_FIELD_ALIASES.tag),
caseType: getImportFieldValue(row, IMPORT_FIELD_ALIASES.caseType),
caseAttribute: getImportFieldValue(row, IMPORT_FIELD_ALIASES.caseAttribute),
caseDetails: getImportFieldValue(row, IMPORT_FIELD_ALIASES.caseDetails),
caseWarning: getImportFieldValue(row, IMPORT_FIELD_ALIASES.caseWarning),
keyword: getImportFieldValue(row, IMPORT_FIELD_ALIASES.keyword),
caseSource: getImportFieldValue(row, IMPORT_FIELD_ALIASES.caseSource),
caseLevel: getImportFieldValue(row, IMPORT_FIELD_ALIASES.caseLevel),
caseYear: getImportFieldValue(row, IMPORT_FIELD_ALIASES.caseYear),
}; };
} }
...@@ -615,7 +877,21 @@ export async function previewCaseWorkbook(file) { ...@@ -615,7 +877,21 @@ export async function previewCaseWorkbook(file) {
raw: false, raw: false,
}) })
.map((row, index) => normalizeWorkbookRow(row, index)) .map((row, index) => normalizeWorkbookRow(row, index))
.filter((row) => row.label || row.content || row.category || row.tag); .filter((row) =>
[
row.label,
row.category,
row.tag,
row.caseType,
row.caseAttribute,
row.caseDetails,
row.caseWarning,
row.keyword,
row.caseSource,
row.caseLevel,
row.caseYear,
].some(Boolean),
);
return { return {
sheetName, sheetName,
...@@ -665,12 +941,17 @@ export async function importCaseWorkbook(file) { ...@@ -665,12 +941,17 @@ export async function importCaseWorkbook(file) {
const preview = await previewCaseWorkbook(file); const preview = await previewCaseWorkbook(file);
if (preview.totalRows === 0) { if (preview.totalRows === 0) {
throw new Error('未识别到可导入的数据行,请检查表头是否包含标题、内容、分类、标签'); throw new Error(
'未识别到可导入的数据行,请检查表头是否包含标题、分类、标签、案例类型、案例详情等字段',
);
} }
const entries = preview.rows.map((row) => ({ const entries = preview.rows.map((row) => ({
rowNumber: row.rowNumber, rowNumber: row.rowNumber,
payload: buildCasePayload(row), payload: buildCasePayload({
...row,
tag: row.tag || row.keyword,
}),
})); }));
const { createdCases, failures } = await createRecordsInBatches(entries); const { createdCases, failures } = await createRecordsInBatches(entries);
...@@ -683,3 +964,80 @@ export async function importCaseWorkbook(file) { ...@@ -683,3 +964,80 @@ export async function importCaseWorkbook(file) {
createdCases, createdCases,
}; };
} }
async function loadAllCaseRecords(filters = {}) {
const pageSize = 200;
const mergedCases = [];
let page = 0;
let hasMore = true;
while (hasMore) {
const result = await loadCasePage({
...filters,
page,
pageSize,
});
mergedCases.push(...(result.cases || []));
hasMore = Boolean(result.hasMore);
page += 1;
}
return mergedCases;
}
function buildExportRow(caseItem = {}) {
return {
标题: caseItem.label || createCaseTitle(caseItem),
分类: caseItem.category || '',
标签: caseItem.tag || '',
案例类型: caseItem.caseType || '',
案例属性: caseItem.caseAttribute || '',
案例详情: caseItem.caseDetails || caseItem.content || '',
案例警示: caseItem.caseWarning || '',
关键字: caseItem.keyword || '',
案例来源: caseItem.caseSource || '',
案例等级: caseItem.caseLevel || '',
案例年份: caseItem.caseYear || '',
};
}
function createWorkbookFromRows(rows = [], sheetName = '案例库') {
const worksheet = XLSX.utils.json_to_sheet(rows, {
header: CASE_EXPORT_HEADERS,
});
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
return workbook;
}
export async function exportCaseWorkbook(filters = {}) {
const cases = await loadAllCaseRecords(filters);
return exportSelectedCaseWorkbook(cases);
}
export async function exportSelectedCaseWorkbook(caseItems = []) {
const rows = (caseItems || []).map((item) => buildExportRow(item));
const workbook = createWorkbookFromRows(rows, '案例库');
const fileName = `案例库批量导出_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`;
XLSX.writeFile(workbook, fileName);
return {
fileName,
count: rows.length,
};
}
export async function exportCaseTemplateWorkbook() {
const workbook = createWorkbookFromRows([], '案例导入模板');
const fileName = `案例库导入模板_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`;
XLSX.writeFile(workbook, fileName);
return {
fileName,
};
}
import { Button } from '@/catalyst-ui-kit/javascript/button'; import { Button } from '@/catalyst-ui-kit/javascript/button';
import { Checkbox } from '@/catalyst-ui-kit/javascript/checkbox';
import { import {
Dialog, Dialog,
DialogActions, DialogActions,
...@@ -6,6 +7,18 @@ import { ...@@ -6,6 +7,18 @@ 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 {
Segment,
SegmentOption,
} from '@/catalyst-ui-kit/javascript/segment';
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 { import {
ArrowDownTrayIcon, ArrowDownTrayIcon,
...@@ -14,8 +27,9 @@ import { ...@@ -14,8 +27,9 @@ import {
BookOpenIcon, BookOpenIcon,
ClipboardDocumentIcon, ClipboardDocumentIcon,
MagnifyingGlassIcon, MagnifyingGlassIcon,
PencilSquareIcon,
Squares2X2Icon, Squares2X2Icon,
TagIcon, TableCellsIcon,
TrashIcon, TrashIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import clsx from 'clsx'; import clsx from 'clsx';
...@@ -33,15 +47,23 @@ import { ...@@ -33,15 +47,23 @@ import {
createCaseRecord, createCaseRecord,
createCaseTitle, createCaseTitle,
deleteCaseRecord, deleteCaseRecord,
deleteCaseRecords,
DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZE,
formatDateTime, exportCaseTemplateWorkbook,
exportCaseWorkbook,
exportSelectedCaseWorkbook,
importCaseWorkbook, importCaseWorkbook,
loadCasePage, loadCasePage,
previewCaseWorkbook, previewCaseWorkbook,
updateCaseRecord, updateCaseRecord,
updateCaseRecords,
} from '../materialService'; } from '../materialService';
const ALL_CATEGORY_KEY = '__all__'; const ALL_CATEGORY_KEY = '__all__';
const CARD_VIEW_MODE = 'card';
const TABLE_VIEW_MODE = 'table';
const STICKY_TABLE_HEADER_CLASS_NAME =
'sticky top-0 z-10 bg-white/95 backdrop-blur supports-[backdrop-filter]:bg-white/85';
function Panel({ className, children }) { function Panel({ className, children }) {
return ( return (
...@@ -59,7 +81,7 @@ function Panel({ className, children }) { ...@@ -59,7 +81,7 @@ function Panel({ className, children }) {
function Badge({ children, tone = 'default' }) { function Badge({ children, tone = 'default' }) {
const toneClass = const toneClass =
tone === 'sky' tone === 'sky'
? 'border-sky-200 bg-sky-50 text-sky-700' ? 'border-[color:color-mix(in_srgb,var(--pm-brand)_18%,white)] bg-[color:color-mix(in_srgb,var(--pm-brand)_10%,white)] text-[var(--pm-brand)]'
: tone === 'emerald' : tone === 'emerald'
? 'border-emerald-200 bg-emerald-50 text-emerald-700' ? 'border-emerald-200 bg-emerald-50 text-emerald-700'
: tone === 'amber' : tone === 'amber'
...@@ -88,7 +110,7 @@ function CategoryTab({ active, label, count, onClick }) { ...@@ -88,7 +110,7 @@ function CategoryTab({ active, label, count, onClick }) {
className={clsx( className={clsx(
'inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-medium transition-all', 'inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-medium transition-all',
active active
? 'border-sky-300 bg-sky-50 text-sky-700 shadow-sm' ? 'border-[color:color-mix(in_srgb,var(--pm-brand)_26%,white)] bg-[color:color-mix(in_srgb,var(--pm-brand)_10%,white)] text-[var(--pm-brand)] shadow-sm'
: 'border-zinc-200 bg-white text-zinc-600 hover:border-zinc-300 hover:bg-zinc-50', : 'border-zinc-200 bg-white text-zinc-600 hover:border-zinc-300 hover:bg-zinc-50',
)} )}
> >
...@@ -96,7 +118,9 @@ function CategoryTab({ active, label, count, onClick }) { ...@@ -96,7 +118,9 @@ function CategoryTab({ active, label, count, onClick }) {
<span <span
className={clsx( className={clsx(
'rounded-full px-2 py-0.5 text-xs', 'rounded-full px-2 py-0.5 text-xs',
active ? 'bg-sky-100 text-sky-700' : 'bg-zinc-100 text-zinc-500', active
? 'bg-[color:color-mix(in_srgb,var(--pm-brand)_12%,white)] text-[var(--pm-brand)]'
: 'bg-zinc-100 text-zinc-500',
)} )}
> >
{count} {count}
...@@ -120,19 +144,23 @@ function CaseCard({ item, onOpen }) { ...@@ -120,19 +144,23 @@ function CaseCard({ item, onOpen }) {
<button <button
type="button" type="button"
onClick={() => onOpen(item)} onClick={() => onOpen(item)}
className="group flex h-full w-full flex-col rounded-[28px] border border-zinc-200 bg-white p-5 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)]" className="group flex h-full w-full flex-col rounded-[28px] border border-zinc-200 bg-white p-5 text-left shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:border-[color:color-mix(in_srgb,var(--pm-brand)_26%,white)] hover:shadow-[0_18px_45px_rgba(180,35,24,0.14)]"
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Badge tone="sky">{item.category}</Badge> <Badge tone="sky">{item.category}</Badge>
{item.caseLevel ? <Badge tone="amber">{item.caseLevel}</Badge> : null}
{item.tag ? <Badge tone="emerald">{item.tag}</Badge> : null} {item.tag ? <Badge tone="emerald">{item.tag}</Badge> : null}
</div> </div>
<div className="text-lg font-semibold tracking-tight text-zinc-950"> <div className="text-lg font-semibold tracking-tight text-zinc-950">
{item.label || '未命名案例'} {item.label || '未命名案例'}
</div> </div>
{item.caseType ? (
<div className="text-sm font-medium text-zinc-500">{item.caseType}</div>
) : null}
</div> </div>
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-[linear-gradient(135deg,rgba(14,165,233,0.12),rgba(16,185,129,0.12))] text-sky-700"> <div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-[linear-gradient(135deg,rgba(180,35,24,0.12),rgba(244,63,94,0.12))] text-[var(--pm-brand)]">
<BookOpenIcon className="size-5" /> <BookOpenIcon className="size-5" />
</div> </div>
</div> </div>
...@@ -143,6 +171,7 @@ function CaseCard({ item, onOpen }) { ...@@ -143,6 +171,7 @@ function CaseCard({ item, onOpen }) {
<div className="mt-5 flex flex-wrap items-center gap-3 text-xs text-zinc-500"> <div className="mt-5 flex flex-wrap items-center gap-3 text-xs text-zinc-500">
<span>{item.textLengthLabel}</span> <span>{item.textLengthLabel}</span>
{item.caseYear ? <span>{item.caseYear} 年</span> : null}
<span>更新于 {item.updatedAtLabel}</span> <span>更新于 {item.updatedAtLabel}</span>
</div> </div>
</button> </button>
...@@ -152,9 +181,16 @@ function CaseCard({ item, onOpen }) { ...@@ -152,9 +181,16 @@ function CaseCard({ item, onOpen }) {
function buildEmptyCaseForm() { function buildEmptyCaseForm() {
return { return {
label: '', label: '',
content: '',
category: '', category: '',
tag: '', tag: '',
caseType: '',
caseAttribute: '',
caseDetails: '',
caseWarning: '',
keyword: '',
caseSource: '',
caseLevel: '',
caseYear: '',
}; };
} }
...@@ -165,12 +201,49 @@ function buildCaseForm(caseItem) { ...@@ -165,12 +201,49 @@ function buildCaseForm(caseItem) {
return { return {
label: caseItem.label || '', label: caseItem.label || '',
content: caseItem.content || '',
category: caseItem.category === '未分类' ? '' : caseItem.category || '', category: caseItem.category === '未分类' ? '' : caseItem.category || '',
tag: caseItem.tag || '', tag: caseItem.tag || '',
caseType: caseItem.caseType || '',
caseAttribute: caseItem.caseAttribute || '',
caseDetails: caseItem.caseDetails || '',
caseWarning: caseItem.caseWarning || '',
keyword: caseItem.keyword || '',
caseSource: caseItem.caseSource || '',
caseLevel: caseItem.caseLevel || '',
caseYear: caseItem.caseYear || '',
};
}
function buildEmptyBatchEditForm() {
return {
category: '',
tag: '',
caseType: '',
caseAttribute: '',
keyword: '',
caseSource: '',
caseLevel: '',
caseYear: '',
caseWarning: '',
}; };
} }
function hasBatchEditValues(values = {}) {
return Object.values(values).some((value) => String(value ?? '').trim() !== '');
}
function buildBatchEditPayload(values = {}) {
return Object.entries(values).reduce((accumulator, [key, value]) => {
const normalizedValue = String(value ?? '').trim();
if (normalizedValue !== '') {
accumulator[key] = normalizedValue;
}
return accumulator;
}, {});
}
function buildImportPreviewRows(preview) { function buildImportPreviewRows(preview) {
return preview?.previewRows || []; return preview?.previewRows || [];
} }
...@@ -182,9 +255,11 @@ export default function ResourceBookingPage() { ...@@ -182,9 +255,11 @@ export default function ResourceBookingPage() {
const deferredQuery = useDeferredValue(query); const deferredQuery = useDeferredValue(query);
const [tagFilter, setTagFilter] = useState(''); const [tagFilter, setTagFilter] = useState('');
const [activeCategory, setActiveCategory] = useState(ALL_CATEGORY_KEY); const [activeCategory, setActiveCategory] = useState(ALL_CATEGORY_KEY);
const [viewMode, setViewMode] = useState(CARD_VIEW_MODE);
const [cases, setCases] = useState([]); const [cases, setCases] = useState([]);
const [dashboard, setDashboard] = useState(() => buildCaseDashboard([])); const [dashboard, setDashboard] = useState(() => buildCaseDashboard([]));
const [activeCaseId, setActiveCaseId] = useState(''); const [activeCaseId, setActiveCaseId] = useState('');
const [selectedCaseIds, setSelectedCaseIds] = useState([]);
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [hasMore, setHasMore] = useState(false); const [hasMore, setHasMore] = useState(false);
...@@ -193,17 +268,25 @@ export default function ResourceBookingPage() { ...@@ -193,17 +268,25 @@ export default function ResourceBookingPage() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [batchSaving, setBatchSaving] = useState(false);
const [batchDeleting, setBatchDeleting] = useState(false);
const [previewingImport, setPreviewingImport] = useState(false); const [previewingImport, setPreviewingImport] = useState(false);
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
const [exporting, setExporting] = useState(false);
const [exportingSelected, setExportingSelected] = useState(false);
const [exportingTemplate, setExportingTemplate] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [notice, setNotice] = useState(''); const [notice, setNotice] = useState('');
const [isDemoMode, setIsDemoMode] = useState(false); const [isDemoMode, setIsDemoMode] = useState(false);
const [isCreateOpen, setIsCreateOpen] = useState(false); const [isCreateOpen, setIsCreateOpen] = useState(false);
const [isDetailOpen, setIsDetailOpen] = useState(false); const [isDetailOpen, setIsDetailOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false); const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [isBatchDeleteOpen, setIsBatchDeleteOpen] = useState(false);
const [isBatchEditOpen, setIsBatchEditOpen] = useState(false);
const [isImportOpen, setIsImportOpen] = useState(false); const [isImportOpen, setIsImportOpen] = useState(false);
const [createForm, setCreateForm] = useState(buildEmptyCaseForm); const [createForm, setCreateForm] = useState(buildEmptyCaseForm);
const [detailForm, setDetailForm] = useState(buildEmptyCaseForm); const [detailForm, setDetailForm] = useState(buildEmptyCaseForm);
const [batchEditForm, setBatchEditForm] = useState(buildEmptyBatchEditForm);
const [importFile, setImportFile] = useState(null); const [importFile, setImportFile] = useState(null);
const [importPreview, setImportPreview] = useState(null); const [importPreview, setImportPreview] = useState(null);
...@@ -234,6 +317,22 @@ export default function ResourceBookingPage() { ...@@ -234,6 +317,22 @@ export default function ResourceBookingPage() {
return cases.filter((item) => item.category === activeCategory); return cases.filter((item) => item.category === activeCategory);
}, [activeCategory, cases]); }, [activeCategory, cases]);
const selectedCaseIdSet = useMemo(
() => new Set(selectedCaseIds),
[selectedCaseIds],
);
const selectedCases = useMemo(() => {
return visibleCases.filter((item) => selectedCaseIdSet.has(item.id));
}, [selectedCaseIdSet, visibleCases]);
const allVisibleSelected =
visibleCases.length > 0 &&
visibleCases.every((item) => selectedCaseIdSet.has(item.id));
const partiallyVisibleSelected =
visibleCases.some((item) => selectedCaseIdSet.has(item.id)) &&
!allVisibleSelected;
const previewRows = useMemo( const previewRows = useMemo(
() => buildImportPreviewRows(importPreview), () => buildImportPreviewRows(importPreview),
[importPreview], [importPreview],
...@@ -252,6 +351,14 @@ export default function ResourceBookingPage() { ...@@ -252,6 +351,14 @@ export default function ResourceBookingPage() {
} }
}, [activeCategory, categoryTabs]); }, [activeCategory, categoryTabs]);
useEffect(() => {
const visibleIds = new Set(visibleCases.map((item) => item.id));
setSelectedCaseIds((current) =>
current.filter((caseId) => visibleIds.has(caseId)),
);
}, [visibleCases]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
...@@ -402,6 +509,10 @@ export default function ResourceBookingPage() { ...@@ -402,6 +509,10 @@ export default function ResourceBookingPage() {
setCreateForm(buildEmptyCaseForm()); setCreateForm(buildEmptyCaseForm());
} }
function resetBatchEditForm() {
setBatchEditForm(buildEmptyBatchEditForm());
}
function resetImportState() { function resetImportState() {
setImportFile(null); setImportFile(null);
setImportPreview(null); setImportPreview(null);
...@@ -417,9 +528,32 @@ export default function ResourceBookingPage() { ...@@ -417,9 +528,32 @@ export default function ResourceBookingPage() {
setNotice(''); setNotice('');
} }
function handleToggleCaseSelection(caseId, checked) {
setSelectedCaseIds((current) => {
if (checked) {
return current.includes(caseId) ? current : [...current, caseId];
}
return current.filter((item) => item !== caseId);
});
}
function handleToggleSelectAll(checked) {
if (checked) {
setSelectedCaseIds(visibleCases.map((item) => item.id));
return;
}
setSelectedCaseIds([]);
}
async function handleCreateSubmit() { async function handleCreateSubmit() {
if (!String(createForm.label || '').trim() && !String(createForm.content || '').trim()) { if (
setError('请至少填写标题或内容。'); !String(createForm.label || '').trim() &&
!String(createForm.caseType || '').trim() &&
!String(createForm.caseDetails || '').trim()
) {
setError('请至少填写标题、案例类型或案例详情中的一项。');
return; return;
} }
...@@ -447,8 +581,12 @@ export default function ResourceBookingPage() { ...@@ -447,8 +581,12 @@ export default function ResourceBookingPage() {
return; return;
} }
if (!String(detailForm.label || '').trim() && !String(detailForm.content || '').trim()) { if (
setError('请至少填写标题或内容。'); !String(detailForm.label || '').trim() &&
!String(detailForm.caseType || '').trim() &&
!String(detailForm.caseDetails || '').trim()
) {
setError('请至少填写标题、案例类型或案例详情中的一项。');
return; return;
} }
...@@ -490,6 +628,95 @@ export default function ResourceBookingPage() { ...@@ -490,6 +628,95 @@ export default function ResourceBookingPage() {
} }
} }
async function handleBatchDeleteCases() {
if (selectedCases.length === 0) {
return;
}
setBatchDeleting(true);
setError('');
setNotice('');
try {
const result = await deleteCaseRecords(selectedCases);
if (selectedCaseIdSet.has(activeCaseId)) {
setIsDetailOpen(false);
}
setIsBatchDeleteOpen(false);
await refreshCurrentList();
const failedIds = result.failures
.map((item) => item.id)
.filter(Boolean);
setSelectedCaseIds(failedIds);
setNotice(
result.failedCount > 0
? `已删除 ${result.deletedCount} 条案例,${result.failedCount} 条删除失败。`
: `已批量删除 ${result.deletedCount} 条案例。`,
);
if (result.failedCount > 0) {
setError(
`删除失败:${result.failures[0]?.label || '部分案例'},${result.failures[0]?.message || '请稍后重试。'}`,
);
}
} catch (deleteError) {
setError(deleteError?.message || '批量删除失败,请稍后重试。');
} finally {
setBatchDeleting(false);
}
}
async function handleBatchEditSubmit() {
if (selectedCases.length === 0) {
return;
}
if (!hasBatchEditValues(batchEditForm)) {
setError('请至少填写一个要批量覆盖的字段。');
return;
}
setBatchSaving(true);
setError('');
setNotice('');
try {
const result = await updateCaseRecords(
selectedCases,
buildBatchEditPayload(batchEditForm),
);
setIsBatchEditOpen(false);
resetBatchEditForm();
await refreshCurrentList();
const failedIds = result.failures
.map((item) => item.id)
.filter(Boolean);
setSelectedCaseIds(failedIds);
setNotice(
result.failedCount > 0
? `已批量更新 ${result.updatedCount} 条案例,${result.failedCount} 条更新失败。`
: `已批量更新 ${result.updatedCount} 条案例。`,
);
if (result.failedCount > 0) {
setError(
`更新失败:${result.failures[0]?.label || '部分案例'},${result.failures[0]?.message || '请稍后重试。'}`,
);
}
} catch (updateError) {
setError(updateError?.message || '批量修改失败,请稍后重试。');
} finally {
setBatchSaving(false);
}
}
async function handleCopyCaseContent() { async function handleCopyCaseContent() {
if (!activeCase) { if (!activeCase) {
return; return;
...@@ -497,9 +724,9 @@ export default function ResourceBookingPage() { ...@@ -497,9 +724,9 @@ export default function ResourceBookingPage() {
try { try {
await navigator.clipboard.writeText( await navigator.clipboard.writeText(
`${activeCase.label}\n\n${activeCase.content}`, `${activeCase.label}\n\n${activeCase.caseDetails || ''}`,
); );
setNotice('案例标题和正文已复制。'); setNotice('案例标题和详情已复制。');
} catch (_error) { } catch (_error) {
setError('复制失败,请检查浏览器权限。'); setError('复制失败,请检查浏览器权限。');
} }
...@@ -562,10 +789,63 @@ export default function ResourceBookingPage() { ...@@ -562,10 +789,63 @@ export default function ResourceBookingPage() {
} }
} }
async function handleExportCases() {
setExporting(true);
setError('');
setNotice('');
try {
const result = await exportCaseWorkbook({
keyword: deferredQuery,
category: activeCategory === ALL_CATEGORY_KEY ? '' : activeCategory,
tag: tagFilter,
});
setNotice(`已导出 ${result.count} 条案例到 ${result.fileName}。`);
} catch (exportError) {
setError(exportError?.message || '案例导出失败,请稍后重试。');
} finally {
setExporting(false);
}
}
async function handleExportSelectedCases() {
if (selectedCases.length === 0) {
return;
}
setExportingSelected(true);
setError('');
setNotice('');
try {
const result = await exportSelectedCaseWorkbook(selectedCases);
setNotice(`已导出 ${result.count} 条勾选案例到 ${result.fileName}。`);
} catch (exportError) {
setError(exportError?.message || '勾选案例导出失败,请稍后重试。');
} finally {
setExportingSelected(false);
}
}
async function handleExportTemplate() {
setExportingTemplate(true);
setError('');
setNotice('');
try {
const result = await exportCaseTemplateWorkbook();
setNotice(`导入模板已导出:${result.fileName}。`);
} catch (templateError) {
setError(templateError?.message || '导出模板失败,请稍后重试。');
} finally {
setExportingTemplate(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(105deg,rgba(255,251,235,0.95)_0%,rgba(255,255,255,0.96)_42%,rgba(239,246,255,0.94)_100%),radial-gradient(circle_at_top_left,rgba(245,158,11,0.14),transparent_36%),radial-gradient(circle_at_top_right,rgba(14,165,233,0.16),transparent_34%)]" /> <div className="absolute inset-0 bg-[linear-gradient(135deg,rgba(255,255,255,0.96),rgba(255,241,242,0.94)),radial-gradient(circle_at_top_left,rgba(251,191,188,0.2),transparent_36%),radial-gradient(circle_at_top_right,rgba(251,113,133,0.16),transparent_34%)]" />
<div className="relative"> <div className="relative">
<div className="mt-5 flex flex-col gap-6 xl:flex-row xl:items-center xl:justify-between"> <div className="mt-5 flex flex-col gap-6 xl:flex-row xl:items-center xl:justify-between">
<div className="max-w-3xl"> <div className="max-w-3xl">
...@@ -582,7 +862,7 @@ export default function ResourceBookingPage() { ...@@ -582,7 +862,7 @@ export default function ResourceBookingPage() {
快捷操作 快捷操作
</div> </div>
</div> </div>
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-[linear-gradient(145deg,rgba(14,165,233,0.14),rgba(245,158,11,0.14))] text-sky-700"> <div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-[linear-gradient(145deg,rgba(180,35,24,0.14),rgba(244,63,94,0.14))] text-[var(--pm-brand)]">
<BookOpenIcon className="size-5" /> <BookOpenIcon className="size-5" />
</div> </div>
</div> </div>
...@@ -612,6 +892,15 @@ export default function ResourceBookingPage() { ...@@ -612,6 +892,15 @@ export default function ResourceBookingPage() {
<ArrowUpTrayIcon className="size-4" /> <ArrowUpTrayIcon className="size-4" />
Excel 批量导入 Excel 批量导入
</Button> </Button>
<Button
outline
className="w-full justify-center whitespace-nowrap bg-white/80 cursor-pointer"
onClick={handleExportCases}
disabled={exporting || loading}
>
<ArrowDownTrayIcon className="size-4" />
{exporting ? '导出中...' : 'Excel 批量导出'}
</Button>
<Button <Button
plain plain
className="w-full justify-center whitespace-nowrap text-zinc-600 cursor-pointer" className="w-full justify-center whitespace-nowrap text-zinc-600 cursor-pointer"
...@@ -659,7 +948,7 @@ export default function ResourceBookingPage() { ...@@ -659,7 +948,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>
...@@ -699,30 +988,252 @@ export default function ResourceBookingPage() { ...@@ -699,30 +988,252 @@ export default function ResourceBookingPage() {
</Panel> </Panel>
{loading ? ( {loading ? (
<Panel className="p-6 text-sm text-sky-700">正在加载案例工作台...</Panel> <Panel className="p-6 text-sm text-[var(--pm-brand)]">正在加载案例工作台...</Panel>
) : visibleCases.length === 0 ? ( ) : visibleCases.length === 0 ? (
<EmptyStatePage title="当前筛选条件下还没有案例" /> <EmptyStatePage title="当前筛选条件下还没有案例" />
) : ( ) : (
<div className="space-y-6"> <div className="space-y-6">
<Panel className="p-5 sm:p-6"> <Panel className="p-5 sm:p-6">
<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-4 xl:flex-row xl:items-start xl:justify-between">
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Squares2X2Icon className="size-5 text-sky-600" /> {viewMode === CARD_VIEW_MODE ? (
<Squares2X2Icon className="size-5 text-[var(--pm-brand)]" />
) : (
<TableCellsIcon className="size-5 text-[var(--pm-brand)]" />
)}
<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>
<Badge tone="sky">{visibleCases.length} 条</Badge> <Badge tone="sky">{visibleCases.length} 条</Badge>
{isDemoMode ? <Badge tone="amber">演示数据</Badge> : null} {isDemoMode ? <Badge tone="amber">演示数据</Badge> : null}
</div> </div>
{viewMode === TABLE_VIEW_MODE ? (
<div className="mt-2 text-sm text-zinc-500">
已勾选 {selectedCases.length} 条,支持批量修改、导出和删除。
</div>
) : (
<div className="mt-2 text-sm text-zinc-500">
卡片视图用于快速浏览,切换到表格视图后可勾选批量操作。
</div>
)}
</div> </div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> <div className="flex flex-col gap-3 xl:items-end">
{visibleCases.map((item) => ( <Segment>
<CaseCard key={item.id} item={item} onOpen={openCaseDetail} /> <SegmentOption
))} active={viewMode === CARD_VIEW_MODE}
onClick={() => setViewMode(CARD_VIEW_MODE)}
>
<Squares2X2Icon className="size-4" />
卡片视图
</SegmentOption>
<SegmentOption
active={viewMode === TABLE_VIEW_MODE}
onClick={() => setViewMode(TABLE_VIEW_MODE)}
>
<TableCellsIcon className="size-4" />
表格视图
</SegmentOption>
</Segment>
{viewMode === TABLE_VIEW_MODE ? (
<div className="flex flex-wrap gap-2 xl:justify-end">
<Button
outline
className="cursor-pointer whitespace-nowrap bg-white/90"
onClick={() => {
setError('');
setNotice('');
resetBatchEditForm();
setIsBatchEditOpen(true);
}}
disabled={selectedCases.length === 0 || batchSaving}
>
<PencilSquareIcon className="size-4" />
批量修改
</Button>
<Button
outline
className="cursor-pointer whitespace-nowrap bg-white/90"
onClick={handleExportSelectedCases}
disabled={selectedCases.length === 0 || exportingSelected}
>
<ArrowDownTrayIcon className="size-4" />
{exportingSelected ? '导出中...' : '导出勾选'}
</Button>
<Button
color="red"
className="cursor-pointer whitespace-nowrap"
onClick={() => {
setError('');
setNotice('');
setIsBatchDeleteOpen(true);
}}
disabled={selectedCases.length === 0 || batchDeleting}
>
<TrashIcon className="size-4" />
批量删除
</Button>
<Button
plain
className="cursor-pointer whitespace-nowrap text-zinc-600"
onClick={() => setSelectedCaseIds([])}
disabled={selectedCases.length === 0}
>
清空勾选
</Button>
</div>
) : null}
</div>
</div> </div>
{viewMode === CARD_VIEW_MODE ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{visibleCases.map((item) => (
<CaseCard key={item.id} item={item} onOpen={openCaseDetail} />
))}
</div>
) : (
<div className="overflow-hidden rounded-[24px] border border-zinc-200 bg-white">
<Table
bleed
dense
grid
striped
className="max-h-[36rem] overflow-auto"
>
<TableHead>
<TableRow>
<TableHeader
className={clsx(
'w-14',
STICKY_TABLE_HEADER_CLASS_NAME,
)}
>
<div className="flex items-center justify-center">
<Checkbox
checked={allVisibleSelected}
indeterminate={partiallyVisibleSelected}
onChange={handleToggleSelectAll}
aria-label="全选当前表格案例"
/>
</div>
</TableHeader>
<TableHeader className={STICKY_TABLE_HEADER_CLASS_NAME}>
标题
</TableHeader>
<TableHeader className={STICKY_TABLE_HEADER_CLASS_NAME}>
分类
</TableHeader>
<TableHeader className={STICKY_TABLE_HEADER_CLASS_NAME}>
标签
</TableHeader>
<TableHeader className={STICKY_TABLE_HEADER_CLASS_NAME}>
案例类型
</TableHeader>
<TableHeader className={STICKY_TABLE_HEADER_CLASS_NAME}>
关键字
</TableHeader>
<TableHeader className={STICKY_TABLE_HEADER_CLASS_NAME}>
案例来源
</TableHeader>
<TableHeader className={STICKY_TABLE_HEADER_CLASS_NAME}>
等级 / 年份
</TableHeader>
<TableHeader className={STICKY_TABLE_HEADER_CLASS_NAME}>
更新时间
</TableHeader>
<TableHeader
className={clsx(
'w-28',
STICKY_TABLE_HEADER_CLASS_NAME,
)}
>
操作
</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{visibleCases.map((item) => {
const checked = selectedCaseIdSet.has(item.id);
return (
<TableRow
key={item.id}
className={clsx(
checked &&
'bg-[color:color-mix(in_srgb,var(--pm-brand)_8%,white)]',
)}
>
<TableCell>
<div className="flex items-center justify-center">
<Checkbox
checked={checked}
onChange={(nextChecked) =>
handleToggleCaseSelection(item.id, nextChecked)
}
aria-label={`勾选案例 ${item.label || '未命名案例'}`}
/>
</div>
</TableCell>
<TableCell className="align-top whitespace-normal">
<button
type="button"
onClick={() => openCaseDetail(item)}
className="text-left"
>
<div className="font-medium text-zinc-950 transition hover:text-[var(--pm-brand)]">
{item.label || '未命名案例'}
</div>
<div className="mt-1 max-w-xl text-sm leading-6 text-zinc-500 line-clamp-2">
{item.contentPreview}
</div>
</button>
</TableCell>
<TableCell>{item.category || '-'}</TableCell>
<TableCell className="whitespace-normal">
{item.tag || '-'}
</TableCell>
<TableCell className="whitespace-normal">
<div>{item.caseType || '-'}</div>
{item.caseAttribute ? (
<div className="mt-1 text-xs text-zinc-500">
{item.caseAttribute}
</div>
) : null}
</TableCell>
<TableCell className="max-w-52 whitespace-normal text-zinc-600">
{item.keyword || '-'}
</TableCell>
<TableCell className="whitespace-normal text-zinc-600">
{item.caseSource || '-'}
</TableCell>
<TableCell className="text-zinc-600">
<div>{item.caseLevel || '-'}</div>
<div className="mt-1 text-xs text-zinc-500">
{item.caseYear ? `${item.caseYear} 年` : '-'}
</div>
</TableCell>
<TableCell className="text-zinc-600">
{item.updatedAtLabel}
</TableCell>
<TableCell>
<Button
plain
className="cursor-pointer whitespace-nowrap text-[var(--pm-brand)]"
onClick={() => openCaseDetail(item)}
>
查看详情
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</Panel> </Panel>
<div <div
...@@ -738,7 +1249,7 @@ export default function ResourceBookingPage() { ...@@ -738,7 +1249,7 @@ export default function ResourceBookingPage() {
: ' 已全部加载完成。'} : ' 已全部加载完成。'}
</div> </div>
{loadingMore ? ( {loadingMore ? (
<div className="text-sm font-medium text-sky-700">正在加载更多...</div> <div className="text-sm font-medium text-[var(--pm-brand)]">正在加载更多...</div>
) : null} ) : null}
{!hasMore ? ( {!hasMore ? (
<div className="text-sm font-medium text-emerald-700">已全部加载</div> <div className="text-sm font-medium text-emerald-700">已全部加载</div>
...@@ -803,23 +1314,116 @@ export default function ResourceBookingPage() { ...@@ -803,23 +1314,116 @@ export default function ResourceBookingPage() {
placeholder="例如:采购审批" placeholder="例如:采购审批"
/> />
</div> </div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">案例类型</div>
<Input
value={createForm.caseType}
onChange={(event) =>
setCreateForm((prev) => ({
...prev,
caseType: event.target.value,
}))
}
placeholder="例如:审批绕行"
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">案例属性</div>
<Input
value={createForm.caseAttribute}
onChange={(event) =>
setCreateForm((prev) => ({
...prev,
caseAttribute: event.target.value,
}))
}
placeholder="例如:流程绕行"
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">关键字</div>
<Input
value={createForm.keyword}
onChange={(event) =>
setCreateForm((prev) => ({
...prev,
keyword: event.target.value,
}))
}
placeholder="例如:采购审批, 拆单"
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">案例来源</div>
<Input
value={createForm.caseSource}
onChange={(event) =>
setCreateForm((prev) => ({
...prev,
caseSource: event.target.value,
}))
}
placeholder="例如:内部审计复盘"
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">案例等级</div>
<Input
value={createForm.caseLevel}
onChange={(event) =>
setCreateForm((prev) => ({
...prev,
caseLevel: event.target.value,
}))
}
placeholder="例如:高"
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">案例年份</div>
<Input
type="number"
value={createForm.caseYear}
onChange={(event) =>
setCreateForm((prev) => ({
...prev,
caseYear: event.target.value,
}))
}
placeholder="例如:2024"
/>
</div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<div className="mb-2 flex items-center justify-between text-sm font-medium text-zinc-600"> <div className="mb-2 flex items-center justify-between text-sm font-medium text-zinc-600">
<span>内容</span> <span>案例详情</span>
<span className="text-xs text-zinc-400"> <span className="text-xs text-zinc-400">
预估主标题:{createCaseTitle(createForm)} 预估主标题:{createCaseTitle(createForm)}
</span> </span>
</div> </div>
<Textarea <Textarea
rows={12} rows={12}
value={createForm.content} value={createForm.caseDetails}
onChange={(event) => onChange={(event) =>
setCreateForm((prev) => ({ setCreateForm((prev) => ({
...prev, ...prev,
content: event.target.value, caseDetails: event.target.value,
})) }))
} }
placeholder="粘贴或录入完整案例正文" placeholder="粘贴或录入完整案例详情"
/>
</div>
<div className="sm:col-span-2">
<div className="mb-2 text-sm font-medium text-zinc-600">案例警示</div>
<Textarea
rows={4}
value={createForm.caseWarning}
onChange={(event) =>
setCreateForm((prev) => ({
...prev,
caseWarning: event.target.value,
}))
}
placeholder="提炼本案例的风险警示或控制建议"
/> />
</div> </div>
</div> </div>
...@@ -854,17 +1458,32 @@ export default function ResourceBookingPage() { ...@@ -854,17 +1458,32 @@ export default function ResourceBookingPage() {
<div className="space-y-6"> <div className="space-y-6">
<div className="grid gap-6 xl:grid-cols-[0.8fr_1.2fr]"> <div className="grid gap-6 xl:grid-cols-[0.8fr_1.2fr]">
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-[26px] border border-zinc-200 bg-[linear-gradient(145deg,rgba(255,251,235,0.9),rgba(239,246,255,0.9))] p-5"> <div className="rounded-[26px] border border-zinc-200 bg-[linear-gradient(145deg,rgba(255,255,255,0.96),rgba(255,241,242,0.9))] p-5">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Badge tone="sky">{activeCase.category}</Badge> <Badge tone="sky">{activeCase.category}</Badge>
{activeCase.caseLevel ? <Badge tone="amber">{activeCase.caseLevel}</Badge> : null}
{activeCase.tag ? <Badge tone="emerald">{activeCase.tag}</Badge> : null} {activeCase.tag ? <Badge tone="emerald">{activeCase.tag}</Badge> : null}
</div> </div>
<div className="mt-4 text-2xl font-semibold tracking-tight text-zinc-950"> <div className="mt-4 text-2xl font-semibold tracking-tight text-zinc-950">
{activeCase.label} {activeCase.label}
</div> </div>
{activeCase.caseType ? (
<div className="mt-3 text-sm font-medium text-zinc-500">
{activeCase.caseType}
{activeCase.caseAttribute
? ` / ${activeCase.caseAttribute}`
: ''}
</div>
) : null}
<div className="mt-4 text-sm leading-7 text-zinc-600"> <div className="mt-4 text-sm leading-7 text-zinc-600">
{activeCase.contentPreview} {activeCase.contentPreview}
</div> </div>
{activeCase.caseWarning ? (
<div className="mt-4 rounded-[20px] border border-amber-200 bg-amber-50/80 px-4 py-3 text-sm leading-6 text-amber-900">
<div className="font-medium">案例警示</div>
<div className="mt-1">{activeCase.caseWarning}</div>
</div>
) : null}
</div> </div>
<div className="grid gap-3 rounded-[24px] border border-zinc-200 bg-zinc-50/80 p-4"> <div className="grid gap-3 rounded-[24px] border border-zinc-200 bg-zinc-50/80 p-4">
...@@ -876,6 +1495,28 @@ export default function ResourceBookingPage() { ...@@ -876,6 +1495,28 @@ export default function ResourceBookingPage() {
{activeCase.textLengthLabel} {activeCase.textLengthLabel}
</div> </div>
</div> </div>
<div>
<div className="text-xs font-semibold tracking-[0.18em] text-zinc-500">
标签与关键字
</div>
<div className="mt-2 text-sm text-zinc-700">
标签:{activeCase.tag || '-'}
</div>
<div className="mt-1 text-sm text-zinc-500">
关键字:{activeCase.keyword || '-'}
</div>
</div>
<div>
<div className="text-xs font-semibold tracking-[0.18em] text-zinc-500">
来源与等级
</div>
<div className="mt-2 text-sm text-zinc-700">
来源:{activeCase.caseSource || '-'}
</div>
<div className="mt-1 text-sm text-zinc-500">
等级:{activeCase.caseLevel || '-'} / 年份:{activeCase.caseYear || '-'}
</div>
</div>
<div> <div>
<div className="text-xs font-semibold tracking-[0.18em] text-zinc-500"> <div className="text-xs font-semibold tracking-[0.18em] text-zinc-500">
时间信息 时间信息
...@@ -894,7 +1535,7 @@ export default function ResourceBookingPage() { ...@@ -894,7 +1535,7 @@ export default function ResourceBookingPage() {
className="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700 shadow-sm transition hover:border-zinc-300 hover:bg-zinc-50" className="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700 shadow-sm transition hover:border-zinc-300 hover:bg-zinc-50"
> >
<ClipboardDocumentIcon className="size-4" /> <ClipboardDocumentIcon className="size-4" />
复制正文 复制详情
</button> </button>
<button <button
type="button" type="button"
...@@ -947,20 +1588,106 @@ export default function ResourceBookingPage() { ...@@ -947,20 +1588,106 @@ export default function ResourceBookingPage() {
} }
/> />
</div> </div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">案例类型</div>
<Input
value={detailForm.caseType}
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
caseType: event.target.value,
}))
}
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">案例属性</div>
<Input
value={detailForm.caseAttribute}
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
caseAttribute: event.target.value,
}))
}
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">关键字</div>
<Input
value={detailForm.keyword}
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
keyword: event.target.value,
}))
}
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">案例来源</div>
<Input
value={detailForm.caseSource}
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
caseSource: event.target.value,
}))
}
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">案例等级</div>
<Input
value={detailForm.caseLevel}
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
caseLevel: event.target.value,
}))
}
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">案例年份</div>
<Input
type="number"
value={detailForm.caseYear}
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
caseYear: event.target.value,
}))
}
/>
</div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<div className="mb-2 flex items-center justify-between text-sm font-medium text-zinc-600"> <div className="mb-2 flex items-center justify-between text-sm font-medium text-zinc-600">
<span>内容</span> <span>案例详情</span>
<span className="text-xs text-zinc-400"> <span className="text-xs text-zinc-400">
当前 {String(detailForm.content || '').replace(/\s+/g, '').length} 字 当前 {String(detailForm.caseDetails || '').replace(/\s+/g, '').length} 字
</span> </span>
</div> </div>
<Textarea <Textarea
rows={16} rows={16}
value={detailForm.content} value={detailForm.caseDetails}
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
caseDetails: event.target.value,
}))
}
/>
</div>
<div className="sm:col-span-2">
<div className="mb-2 text-sm font-medium text-zinc-600">案例警示</div>
<Textarea
rows={4}
value={detailForm.caseWarning}
onChange={(event) => onChange={(event) =>
setDetailForm((prev) => ({ setDetailForm((prev) => ({
...prev, ...prev,
content: event.target.value, caseWarning: event.target.value,
})) }))
} }
/> />
...@@ -992,6 +1719,197 @@ export default function ResourceBookingPage() { ...@@ -992,6 +1719,197 @@ export default function ResourceBookingPage() {
</DialogActions> </DialogActions>
</Dialog> </Dialog>
<Dialog
open={isBatchEditOpen}
onClose={setIsBatchEditOpen}
size="4xl"
>
<DialogTitle>批量修改案例</DialogTitle>
<DialogBody>
<div className="space-y-5">
<div className="rounded-[22px] border border-[color:color-mix(in_srgb,var(--pm-brand)_18%,white)] bg-[color:color-mix(in_srgb,var(--pm-brand)_8%,white)] px-4 py-4 text-sm text-[var(--pm-brand-strong)]">
当前已勾选 {selectedCases.length} 条案例。仅会覆盖你填写的字段,留空字段保持原值不变。
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">分类</div>
<Input
list="case-category-suggestions"
value={batchEditForm.category}
onChange={(event) =>
setBatchEditForm((prev) => ({
...prev,
category: event.target.value,
}))
}
placeholder="填写后将批量覆盖分类"
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">标签</div>
<Input
list="case-tag-suggestions"
value={batchEditForm.tag}
onChange={(event) =>
setBatchEditForm((prev) => ({
...prev,
tag: event.target.value,
}))
}
placeholder="填写后将批量覆盖标签"
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">案例类型</div>
<Input
value={batchEditForm.caseType}
onChange={(event) =>
setBatchEditForm((prev) => ({
...prev,
caseType: event.target.value,
}))
}
placeholder="填写后将批量覆盖案例类型"
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">案例属性</div>
<Input
value={batchEditForm.caseAttribute}
onChange={(event) =>
setBatchEditForm((prev) => ({
...prev,
caseAttribute: event.target.value,
}))
}
placeholder="填写后将批量覆盖案例属性"
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">关键字</div>
<Input
value={batchEditForm.keyword}
onChange={(event) =>
setBatchEditForm((prev) => ({
...prev,
keyword: event.target.value,
}))
}
placeholder="填写后将批量覆盖关键字"
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">案例来源</div>
<Input
value={batchEditForm.caseSource}
onChange={(event) =>
setBatchEditForm((prev) => ({
...prev,
caseSource: event.target.value,
}))
}
placeholder="填写后将批量覆盖案例来源"
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">案例等级</div>
<Input
value={batchEditForm.caseLevel}
onChange={(event) =>
setBatchEditForm((prev) => ({
...prev,
caseLevel: event.target.value,
}))
}
placeholder="填写后将批量覆盖案例等级"
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">案例年份</div>
<Input
type="number"
value={batchEditForm.caseYear}
onChange={(event) =>
setBatchEditForm((prev) => ({
...prev,
caseYear: event.target.value,
}))
}
placeholder="例如:2026"
/>
</div>
<div className="sm:col-span-2">
<div className="mb-2 text-sm font-medium text-zinc-600">案例警示</div>
<Textarea
rows={4}
value={batchEditForm.caseWarning}
onChange={(event) =>
setBatchEditForm((prev) => ({
...prev,
caseWarning: event.target.value,
}))
}
placeholder="填写后将批量覆盖案例警示"
/>
</div>
</div>
</div>
</DialogBody>
<DialogActions>
<Button
plain
className="cursor-pointer"
onClick={() => {
setIsBatchEditOpen(false);
resetBatchEditForm();
}}
disabled={batchSaving}
>
取消
</Button>
<Button
color="blue"
className="cursor-pointer"
onClick={handleBatchEditSubmit}
disabled={batchSaving || selectedCases.length === 0}
>
{batchSaving ? '批量修改中...' : '确认批量修改'}
</Button>
</DialogActions>
</Dialog>
<Dialog
open={isBatchDeleteOpen}
onClose={setIsBatchDeleteOpen}
size="lg"
>
<DialogTitle>确认批量删除案例</DialogTitle>
<DialogBody>
<div className="rounded-[22px] border border-rose-200 bg-rose-50 px-4 py-4 text-sm text-rose-800">
即将删除 {selectedCases.length} 条勾选案例。删除后会从当前列表移除,请确认是否继续。
</div>
</DialogBody>
<DialogActions>
<Button
plain
className="cursor-pointer"
onClick={() => setIsBatchDeleteOpen(false)}
disabled={batchDeleting}
>
取消
</Button>
<Button
color="red"
className="cursor-pointer"
onClick={handleBatchDeleteCases}
disabled={batchDeleting || selectedCases.length === 0}
>
{batchDeleting ? '删除中...' : '确认批量删除'}
</Button>
</DialogActions>
</Dialog>
<Dialog <Dialog
open={isDeleteOpen} open={isDeleteOpen}
onClose={setIsDeleteOpen} onClose={setIsDeleteOpen}
...@@ -1027,7 +1945,7 @@ export default function ResourceBookingPage() { ...@@ -1027,7 +1945,7 @@ export default function ResourceBookingPage() {
<DialogTitle>Excel 导入案例</DialogTitle> <DialogTitle>Excel 导入案例</DialogTitle>
<DialogBody> <DialogBody>
<div className="space-y-6"> <div className="space-y-6">
<div className="rounded-[28px] border border-dashed border-sky-200 bg-sky-50/60 p-5"> <div className="rounded-[28px] border border-dashed border-[color:color-mix(in_srgb,var(--pm-brand)_16%,white)] bg-[color:color-mix(in_srgb,var(--pm-brand)_6%,white)] p-5">
<input <input
ref={importFileInputRef} ref={importFileInputRef}
type="file" type="file"
...@@ -1050,15 +1968,34 @@ export default function ResourceBookingPage() { ...@@ -1050,15 +1968,34 @@ export default function ResourceBookingPage() {
: `已识别工作表 ${importPreview.sheetName},共 ${importPreview.totalRows} 行可导入数据`} : `已识别工作表 ${importPreview.sheetName},共 ${importPreview.totalRows} 行可导入数据`}
</div> </div>
) : null} ) : null}
<div className="mt-2 text-xs leading-6 text-zinc-500">
支持先导出标准模板,再按模板回填 Excel 后导入。
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={handleExportTemplate}
disabled={exportingTemplate}
className={clsx(
'inline-flex items-center justify-center rounded-full border px-4 py-2 text-sm font-medium shadow-sm transition',
exportingTemplate
? 'cursor-not-allowed border-zinc-200 bg-zinc-100 text-zinc-400'
: 'border-emerald-200 bg-white text-emerald-700 hover:border-emerald-300 hover:bg-emerald-50',
)}
>
<ArrowDownTrayIcon className="mr-2 size-4" />
{exportingTemplate ? '导出中...' : '导出模板'}
</button>
<button
type="button"
onClick={() => importFileInputRef.current?.click()}
className="inline-flex items-center justify-center rounded-full border border-[color:color-mix(in_srgb,var(--pm-brand)_16%,white)] bg-white px-4 py-2 text-sm font-medium text-[var(--pm-brand)] shadow-sm transition hover:border-[color:color-mix(in_srgb,var(--pm-brand)_24%,white)] hover:bg-[color:color-mix(in_srgb,var(--pm-brand)_6%,white)]"
>
<ArrowUpTrayIcon className="mr-2 size-4" />
选择文件
</button>
</div> </div>
<button
type="button"
onClick={() => importFileInputRef.current?.click()}
className="inline-flex items-center justify-center rounded-full border border-sky-200 bg-white px-4 py-2 text-sm font-medium text-sky-700 shadow-sm transition hover:border-sky-300 hover:bg-sky-50"
>
<ArrowDownTrayIcon className="mr-2 size-4" />
选择文件
</button>
</div> </div>
</div> </div>
...@@ -1077,9 +2014,10 @@ export default function ResourceBookingPage() { ...@@ -1077,9 +2014,10 @@ export default function ResourceBookingPage() {
<tr> <tr>
<th className="px-4 py-3 font-medium">行号</th> <th className="px-4 py-3 font-medium">行号</th>
<th className="px-4 py-3 font-medium">标题</th> <th className="px-4 py-3 font-medium">标题</th>
<th className="px-4 py-3 font-medium">案例类型</th>
<th className="px-4 py-3 font-medium">分类</th> <th className="px-4 py-3 font-medium">分类</th>
<th className="px-4 py-3 font-medium">标签</th> <th className="px-4 py-3 font-medium">标签</th>
<th className="px-4 py-3 font-medium">内容预览</th> <th className="px-4 py-3 font-medium">案例详情预览</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-zinc-100"> <tbody className="divide-y divide-zinc-100">
...@@ -1089,12 +2027,15 @@ export default function ResourceBookingPage() { ...@@ -1089,12 +2027,15 @@ export default function ResourceBookingPage() {
<td className="px-4 py-3 font-medium text-zinc-900"> <td className="px-4 py-3 font-medium text-zinc-900">
{row.label || '未填写标题'} {row.label || '未填写标题'}
</td> </td>
<td className="px-4 py-3 text-zinc-600">
{row.caseType || '-'}
</td>
<td className="px-4 py-3 text-zinc-600"> <td className="px-4 py-3 text-zinc-600">
{row.category || '-'} {row.category || '-'}
</td> </td>
<td className="px-4 py-3 text-zinc-600">{row.tag || '-'}</td> <td className="px-4 py-3 text-zinc-600">{row.tag || '-'}</td>
<td className="px-4 py-3 text-zinc-500"> <td className="px-4 py-3 text-zinc-500">
<div className="line-clamp-2">{row.content || '-'}</div> <div className="line-clamp-2">{row.caseDetails || '-'}</div>
</td> </td>
</tr> </tr>
))} ))}
......
...@@ -34,7 +34,7 @@ ...@@ -34,7 +34,7 @@
建议说明: 建议说明:
- 一条记录代表一个案例 - 一条记录代表一个案例
- 主要承载案例类型、案例属性、案例性质、案例详情、案件警示、关键字、案例来源、案例等级、案例年份 - 主要承载标题、分类、标签、案例类型、案例属性、案例详情、案例警示、关键字、案例来源、案例等级、案例年份
## 3.2 字段清单 ## 3.2 字段清单
......
...@@ -68,7 +68,8 @@ type Record = { ...@@ -68,7 +68,8 @@ type Record = {
### 2.2 `Record.title` ### 2.2 `Record.title`
- 建议优先取 `metadata.label` - 建议优先取 `metadata.label`
- 若 `label` 为空,可退化为 `metadata.case_details` 前 24~32 个字符摘要 - 若 `label` 为空,可回退为 `metadata.case_type`
- 若仍为空,可退化为 `metadata.case_details` 前 24~32 个字符摘要
- 再兜底为 `未命名案例` - 再兜底为 `未命名案例`
### 2.3 搜索与展示建议 ### 2.3 搜索与展示建议
...@@ -76,6 +77,7 @@ type Record = { ...@@ -76,6 +77,7 @@ type Record = {
- 列表主标题使用 `label` - 列表主标题使用 `label`
- 卡片或详情摘要使用 `case_details` - 卡片或详情摘要使用 `case_details`
- 案例性质筛选读取 `category` - 案例性质筛选读取 `category`
- 标签筛选读取 `tag`
- 关键字筛选读取 `keyword`,历史数据可兼容读取 `tag` - 关键字筛选读取 `keyword`,历史数据可兼容读取 `tag`
- 关键词检索优先覆盖 `label`、`keyword`、`case_details`、`case_warning` - 关键词检索优先覆盖 `label`、`keyword`、`case_details`、`case_warning`
...@@ -93,25 +95,49 @@ type Record = { ...@@ -93,25 +95,49 @@ type Record = {
- `label` 用作主展示标题 - `label` 用作主展示标题
- `category` 用于主题归类 - `category` 用于主题归类
- `tag` 用于更灵活的检索和二次分组 - `tag` 用于标签化管理和快速筛选
- `case_type` 用于案例主分类或类型归纳
- `case_attribute` 用于补充案例属性、归属维度或风险特征
- `case_details` 为案例详情正文,通常为几百到上千字 - `case_details` 为案例详情正文,通常为几百到上千字
- `case_warning` 用于提炼关键警示信息
- `keyword` 用于检索和热度统计,可与历史 `tag` 并存
- `case_year` 建议存储为数字类型年份,便于筛选和排序 - `case_year` 建议存储为数字类型年份,便于筛选和排序
| 字段标签 | 字段别名 | 字段类型 | 是否主键 | 是否多值 | 是否必填 | 说明 | | 字段标签 | 字段别名 | 字段类型 | 默认值 | 是否主键 | 是否多值 | 是否必填 | 搜索 | 聚合 | 说明 |
| --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 标题 | `label` | text | 否 | 否 | 否 | 案例展示主标题 | | 标题 | `label` | `text` | `null` | 否 | 否 | 否 | 否 | 否 | 案例展示主标题 |
| 分类 | `category` | text | 否 | 否 | 否 | 案例分类,如舞弊风险、合规风险 | | 分类 | `category` | `text` | `null` | 否 | 否 | 否 | 否 | 否 | 案例分类,如舞弊风险、合规风险 |
| 标签 | `tag` | text | 否 | 否 | 否 | 标签或关键词,如资金管理、采购审批 | | 标签 | `tag` | `text` | `null` | 否 | 否 | 否 | 否 | 否 | 标签字段,用于快速筛选或兼容旧数据 |
| 案例类型 | `case_type` | text | 否 | 否 | 否 | 案例类型,如违规操作、警示通报 | | 案例类型 | `case_type` | `text` | `null` | 否 | 否 | 否 | 否 | 否 | 案例类型,如审批绕行、权限滥用 |
| 案例属性 | `case_attribute` | text | 否 | 否 | 否 | 案例属性或归属维度 | | 案例属性 | `case_attribute` | `text` | `null` | 否 | 否 | 否 | 否 | 否 | 案例属性或归属维度 |
| 案例详情 | `case_details` | long_text | 否 | 否 | 否 | 案例详细内容正文 | | 案例详情 | `case_details` | `long_text` | `null` | 否 | 否 | 否 | 否 | 否 | 案例详细内容正文 |
| 案例警示 | `case_warning` | text | 否 | 否 | 否 | 案例提炼出的警示或风险提示 | | 案例警示 | `case_warning` | `text` | `null` | 否 | 否 | 否 | 否 | 否 | 案例提炼出的警示或风险提示 |
| 关键字 | `keyword` | text | 否 | 否 | 否 | 检索关键字,支持人工维护 | | 关键字 | `keyword` | `text` | `null` | 否 | 否 | 否 | 否 | 否 | 检索关键字,支持人工维护 |
| 案例来源 | `case_source` | text | 否 | 否 | 否 | 案例来源,如监管通报、内部复盘 | | 案例来源 | `case_source` | `text` | `null` | 否 | 否 | 否 | 否 | 否 | 案例来源,如监管通报、内部复盘 |
| 案例等级 | `case_level` | text | 否 | 否 | 否 | 案例风险等级或分级 | | 案例等级 | `case_level` | `text` | `null` | 否 | 否 | 否 | 否 | 否 | 案例风险等级或分级 |
| 案例年份 | `case_year` | number | 否 | 否 | 否 | 案例发生或归档年份 | | 案例年份 | `case_year` | `number` | `null` | 否 | 否 | 否 | 否 | 否 | 案例发生或归档年份 |
### 3.1.1 metadata 示例 ### 3.1.1 原始字段结构
以下结构与当前前端表单、保存 payload 和动态字段定义保持一致:
```json
[
{ "name": "label", "label": "标题", "type": "text", "default": null, "multiple": false, "required": false, "primary": false, "search": { "enabled": false }, "aggregation": { "enabled": false }, "settings": {} },
{ "name": "category", "label": "分类", "type": "text", "default": null, "multiple": false, "required": false, "primary": false, "search": { "enabled": false }, "aggregation": { "enabled": false }, "settings": {} },
{ "name": "tag", "label": "标签", "type": "text", "default": null, "multiple": false, "required": false, "primary": false, "search": { "enabled": false }, "aggregation": { "enabled": false }, "settings": {} },
{ "name": "case_type", "label": "案例类型", "type": "text", "default": null, "multiple": false, "required": false, "primary": false, "search": { "enabled": false }, "aggregation": { "enabled": false }, "settings": {} },
{ "name": "case_attribute", "label": "案例属性", "type": "text", "default": null, "multiple": false, "required": false, "primary": false, "search": { "enabled": false }, "aggregation": { "enabled": false }, "settings": {} },
{ "name": "case_details", "label": "案例详情", "type": "long_text", "default": null, "multiple": false, "required": false, "primary": false, "search": { "enabled": false }, "aggregation": { "enabled": false }, "settings": {} },
{ "name": "case_warning", "label": "案例警示", "type": "text", "default": null, "multiple": false, "required": false, "primary": false, "search": { "enabled": false }, "aggregation": { "enabled": false }, "settings": {} },
{ "name": "keyword", "label": "关键字", "type": "text", "default": null, "multiple": false, "required": false, "primary": false, "search": { "enabled": false }, "aggregation": { "enabled": false }, "settings": {} },
{ "name": "case_source", "label": "案例来源", "type": "text", "default": null, "multiple": false, "required": false, "primary": false, "search": { "enabled": false }, "aggregation": { "enabled": false }, "settings": {} },
{ "name": "case_level", "label": "案例等级", "type": "text", "default": null, "multiple": false, "required": false, "primary": false, "search": { "enabled": false }, "aggregation": { "enabled": false }, "settings": {} },
{ "name": "case_year", "label": "案例年份", "type": "number", "default": null, "multiple": false, "required": false, "primary": false, "search": { "enabled": false }, "aggregation": { "enabled": false }, "settings": {} }
]
```
### 3.1.2 metadata 示例
```json ```json
{ {
......
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