feat: polish solution ui and design docs

This commit is contained in:
hailin
2026-07-01 11:52:07 +08:00
parent cba807befb
commit 5f558832f1
12 changed files with 216 additions and 142 deletions

View File

@@ -2,9 +2,11 @@
## Principles ## Principles
- Build product screens from shared tokens and primitives. - Build product screens from shadcn/ui primitives and project-owned App components.
- Keep operational pages dense, quiet, and optimized for repeated use. - Keep operational pages dense, quiet, and optimized for repeated use.
- Use semantic status colors for ticket state, priority, audit, AI, and system feedback. - Use semantic status colors for ticket state, priority, audit, AI, and system feedback.
- User-facing frontend text must be Chinese-first. Internal TypeScript names and API field names may stay English.
- Business pages must not create one-off table, filter, detail, status, or priority patterns.
## Tokens ## Tokens
@@ -19,18 +21,35 @@ Core tokens:
## Components ## Components
Base UI primitives: shadcn/ui primitives live in `frontend/src/components/ui` and should remain close to generated shadcn APIs:
- `Button` - `Button`
- `Badge` - `Badge`
- `Card`, `CardHeader`, `CardContent` - `Card`, `CardHeader`, `CardContent`, `CardTitle`, `CardDescription`
- `Input`, `Textarea`, `Select`
- `Table`
- `Dialog`, `Sheet`, `DropdownMenu`, `Tabs`, `Tooltip`, `Skeleton`, `Sonner`
Product components: Product App components live in `frontend/src/components/app` and are the preferred page-level API:
- `AppSidebar`
- `AppTopbar`
- `AppPageHeader` - `AppPageHeader`
- `AppDataTable`
- `AppFilterBar`
- `AppDetailLayout`
- `AppSection`
- `AppEmptyState`
- `AppStatusBadge` - `AppStatusBadge`
- `AppPriorityBadge` - `AppPriorityBadge`
Business pages should use:
- `AppDataTable` for repeated table/list surfaces.
- `AppFilterBar` for search and filter controls.
- `AppDetailLayout` for detail pages with main content and side panels.
- `AppStatusBadge` and `AppPriorityBadge` for ticket lifecycle and priority presentation.
Preview page: Preview page:
- `frontend/src/features/design-system/DesignSystemPreview.tsx` - `frontend/src/features/design-system/DesignSystemPreview.tsx`

View File

@@ -32,6 +32,10 @@ export function Router() {
return <SolutionListPage /> return <SolutionListPage />
} }
if (route === '/tickets') {
return <TicketListPage />
}
if (route === '/tickets/new') { if (route === '/tickets/new') {
return <TicketCreatePage /> return <TicketCreatePage />
} }

View File

@@ -5,7 +5,7 @@ import { Slot } from "radix-ui"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const badgeVariants = cva( const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", "inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-md border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-colors focus-visible:ring-2 focus-visible:ring-ring/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{ {
variants: { variants: {
variant: { variant: {

View File

@@ -5,7 +5,7 @@ import { Slot } from "radix-ui"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const buttonVariants = cva( const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "inline-flex shrink-0 select-none items-center justify-center whitespace-nowrap rounded-md border border-transparent text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{ {
variants: { variants: {
variant: { variant: {
@@ -13,24 +13,22 @@ const buttonVariants = cva(
outline: outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary: secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", "bg-secondary text-secondary-foreground hover:bg-muted",
ghost: ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive: destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:ring-destructive/20",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
default: default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", "h-8 gap-1.5 px-3",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", xs: "h-6 gap-1 px-2 text-xs",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", sm: "h-7 gap-1 px-2.5 text-xs",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", lg: "h-9 gap-1.5 px-4",
icon: "size-8", icon: "size-8",
"icon-xs": "icon-xs": "size-6",
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", "icon-sm": "size-7",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9", "icon-lg": "size-9",
}, },
}, },

View File

@@ -12,7 +12,7 @@ function Card({
data-slot="card" data-slot="card"
data-size={size} data-size={size}
className={cn( className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", "flex flex-col gap-4 overflow-hidden rounded-lg border border-border bg-card py-4 text-sm text-card-foreground shadow-sm",
className className
)} )}
{...props} {...props}
@@ -25,7 +25,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
<div <div
data-slot="card-header" data-slot="card-header"
className={cn( className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)", "grid auto-rows-min items-start gap-1 px-4",
className className
)} )}
{...props} {...props}
@@ -38,7 +38,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
<div <div
data-slot="card-title" data-slot="card-title"
className={cn( className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm", "text-base font-medium leading-snug",
className className
)} )}
{...props} {...props}
@@ -73,7 +73,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-content" data-slot="card-content"
className={cn("px-(--card-spacing)", className)} className={cn("px-4", className)}
{...props} {...props}
/> />
) )
@@ -84,7 +84,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
<div <div
data-slot="card-footer" data-slot="card-footer"
className={cn( className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)", "flex items-center rounded-b-lg border-t bg-muted/50 p-4",
className className
)} )}
{...props} {...props}

View File

@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", "h-8 w-full min-w-0 rounded-md border border-input bg-transparent px-2.5 py-1 text-base outline-none transition-colors file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 md:text-sm",
className className
)} )}
{...props} {...props}

View File

@@ -42,7 +42,7 @@ function SelectTrigger({
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={size}
className={cn( className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "flex h-8 w-fit select-none items-center justify-between gap-1.5 whitespace-nowrap rounded-md border border-input bg-transparent px-2.5 py-1 text-sm outline-none transition-colors focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50 data-placeholder:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
className className
)} )}
{...props} {...props}
@@ -67,7 +67,7 @@ function SelectContent({
<SelectPrimitive.Content <SelectPrimitive.Content
data-slot="select-content" data-slot="select-content"
data-align-trigger={position === "item-aligned"} data-align-trigger={position === "item-aligned"}
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )} className={cn("relative z-50 max-h-96 min-w-36 overflow-x-hidden overflow-y-auto rounded-md bg-popover text-popover-foreground shadow-md ring-1 ring-border", className)}
position={position} position={position}
align={align} align={align}
{...props} {...props}
@@ -76,7 +76,7 @@ function SelectContent({
<SelectPrimitive.Viewport <SelectPrimitive.Viewport
data-position={position} data-position={position}
className={cn( className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)", "w-full",
position === "popper" && "" position === "popper" && ""
)} )}
> >
@@ -110,7 +110,7 @@ function SelectItem({
<SelectPrimitive.Item <SelectPrimitive.Item
data-slot="select-item" data-slot="select-item"
className={cn( className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", "relative flex w-full cursor-default select-none items-center gap-1.5 rounded-md py-1 pl-1.5 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className className
)} )}
{...props} {...props}

View File

@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
<textarea <textarea
data-slot="textarea" data-slot="textarea"
className={cn( className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", "flex min-h-24 w-full rounded-md border border-input bg-transparent px-2.5 py-2 text-base outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 md:text-sm",
className className
)} )}
{...props} {...props}

View File

@@ -1,8 +1,9 @@
import * as React from 'react' import * as React from 'react'
import { AppEmptyState } from '@/components/app/AppEmptyState' import { AppEmptyState } from '@/components/app/AppEmptyState'
import { AppSection } from '@/components/app/AppSection'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader } from '@/components/ui/card' import { Badge } from '@/components/ui/badge'
import { linkSolutionToTicket, listSolutions } from './api' import { linkSolutionToTicket, listSolutions } from './api'
import type { Solution } from './types' import type { Solution } from './types'
@@ -17,32 +18,32 @@ export function SolutionLinkPanel({ ticketId }: { ticketId: number }) {
async function handleLink(solution: Solution, linkType: string) { async function handleLink(solution: Solution, linkType: string) {
await linkSolutionToTicket(solution.id, ticketId, linkType) await linkSolutionToTicket(solution.id, ticketId, linkType)
setMessage(`${solution.title} linked as ${linkType}`) setMessage(`已将「${solution.title}」标记为${linkType === 'adopted' ? '已采纳' : '推荐方案'}`)
} }
return ( return (
<Card> <AppSection title="关联解决方案" description="从已发布方案中选择推荐或采纳项。">
<CardHeader> <div className="grid gap-3">
<h2 className="text-sm font-medium">Reusable Solutions</h2>
</CardHeader>
<CardContent className="grid gap-3">
{message ? <p className="text-sm text-muted-foreground">{message}</p> : null} {message ? <p className="text-sm text-muted-foreground">{message}</p> : null}
{solutions.length === 0 ? <AppEmptyState title="No published solutions" /> : null} {solutions.length === 0 ? <AppEmptyState title="暂无关联方案" description="发布解决方案后,可在这里推荐或采纳。" /> : null}
{solutions.map((solution) => ( {solutions.map((solution) => (
<div className="grid gap-2 rounded-md border border-border p-3" key={solution.id}> <div className="grid gap-2 rounded-md border border-border p-3" key={solution.id}>
<div> <div>
<p className="text-sm font-medium">{solution.title}</p> <div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium">{solution.title}</p>
<Badge variant="info">引用 {solution.usage_count}</Badge>
</div>
<p className="text-sm text-muted-foreground">{solution.problem_signature}</p> <p className="text-sm text-muted-foreground">{solution.problem_signature}</p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="secondary" onClick={() => void handleLink(solution, 'referenced')}> <Button variant="secondary" onClick={() => void handleLink(solution, 'referenced')}>
Reference 推荐方案
</Button> </Button>
<Button onClick={() => void handleLink(solution, 'adopted')}>Adopt</Button> <Button onClick={() => void handleLink(solution, 'adopted')}>已采纳</Button>
</div> </div>
</div> </div>
))} ))}
</CardContent> </div>
</Card> </AppSection>
) )
} }

View File

@@ -1,46 +1,131 @@
import * as React from 'react' import * as React from 'react'
import { AppDataTable, type AppDataTableColumn } from '@/components/app/AppDataTable'
import { AppEmptyState } from '@/components/app/AppEmptyState' import { AppEmptyState } from '@/components/app/AppEmptyState'
import { AppFilterBar } from '@/components/app/AppFilterBar'
import { AppPageHeader } from '@/components/app/AppPageHeader' import { AppPageHeader } from '@/components/app/AppPageHeader'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { formatDateTime } from '@/lib/format'
import { listSolutions } from './api' import { listSolutions } from './api'
import type { Solution } from './types' import type { Solution } from './types'
const STATUS_LABELS: Record<string, string> = {
published: '已发布',
draft: '草稿'
}
const columns: AppDataTableColumn<Solution>[] = [
{
key: 'title',
header: '方案标题',
cell: (solution) => (
<div>
<p className="font-medium text-foreground">{solution.title}</p>
<p className="mt-1 line-clamp-1 text-sm text-muted-foreground">{solution.problem_signature}</p>
</div>
)
},
{
key: 'status',
header: '状态',
cell: (solution) => <Badge variant={solution.status === 'published' ? 'success' : 'secondary'}>{STATUS_LABELS[solution.status] ?? solution.status}</Badge>
},
{
key: 'related_system',
header: '关联系统',
cell: (solution) => <span className="text-muted-foreground">{solution.related_system || '-'}</span>
},
{
key: 'usage_count',
header: '引用次数',
cell: (solution) => <Badge variant="info">{solution.usage_count}</Badge>
},
{
key: 'updated_at',
header: '更新时间',
cell: (solution) => <span className="text-muted-foreground">{formatDateTime(solution.updated_at)}</span>
}
]
export function SolutionListPage() { export function SolutionListPage() {
const [solutions, setSolutions] = React.useState<Solution[]>([]) const [solutions, setSolutions] = React.useState<Solution[]>([])
const [status, setStatus] = React.useState('all')
const [q, setQ] = React.useState('')
const [error, setError] = React.useState('') const [error, setError] = React.useState('')
React.useEffect(() => { React.useEffect(() => {
listSolutions() listSolutions(status === 'all' ? {} : { status })
.then(setSolutions) .then(setSolutions)
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : '加载失败')) .catch((reason: unknown) => setError(reason instanceof Error ? reason.message : '加载失败'))
}, []) }, [status])
const visibleSolutions = q.trim()
? solutions.filter((solution) => {
const keyword = q.trim().toLowerCase()
return `${solution.title} ${solution.problem_signature} ${solution.solution}`.toLowerCase().includes(keyword)
})
: solutions
return ( return (
<main className="min-h-screen bg-background p-6 text-foreground"> <main className="min-h-screen bg-background p-6 text-foreground">
<div className="mx-auto flex max-w-6xl flex-col gap-4"> <div className="mx-auto flex max-w-7xl flex-col gap-4">
<AppPageHeader title="Solutions" description="Reusable support solutions linked to ticket handling." /> <AppPageHeader title="解决方案库" description="沉淀可复用的问题特征、排查步骤和处理方案。" />
{error ? <AppEmptyState title="Unable to load solutions" description={error} /> : null}
{!error && solutions.length === 0 ? <AppEmptyState title="No solutions" description="Published reusable fixes will appear here." /> : null} <div className="grid gap-3 md:grid-cols-3">
<div className="grid gap-3"> <Card>
{solutions.map((solution) => ( <CardContent>
<Card key={solution.id}> <p className="text-sm text-muted-foreground">全部方案</p>
<CardContent className="grid gap-2"> <p className="mt-2 text-2xl font-semibold text-foreground">{solutions.length}</p>
<div className="flex items-center justify-between gap-3"> </CardContent>
<h2 className="text-sm font-semibold">{solution.title}</h2> </Card>
<div className="flex items-center gap-2"> <Card>
<Badge variant={solution.status === 'published' ? 'success' : 'default'}>{solution.status}</Badge> <CardContent>
<Badge variant="info">Used {solution.usage_count}</Badge> <p className="text-sm text-muted-foreground">已发布</p>
</div> <p className="mt-2 text-2xl font-semibold text-foreground">
</div> {solutions.filter((solution) => solution.status === 'published').length}
<p className="text-sm text-muted-foreground">{solution.problem_signature}</p> </p>
<p className="text-sm">{solution.solution}</p> </CardContent>
</CardContent> </Card>
</Card> <Card>
))} <CardContent>
<p className="text-sm text-muted-foreground">累计引用</p>
<p className="mt-2 text-2xl font-semibold text-foreground">
{solutions.reduce((total, solution) => total + solution.usage_count, 0)}
</p>
</CardContent>
</Card>
</div> </div>
<AppFilterBar actions={<Button variant="outline" onClick={() => { setQ(''); setStatus('all') }}>重置筛选</Button>}>
<Input placeholder="搜索解决方案" value={q} onChange={(event) => setQ(event.target.value)} />
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="w-full">
<SelectValue placeholder="全部状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="published">已发布</SelectItem>
<SelectItem value="draft">草稿</SelectItem>
</SelectContent>
</Select>
</AppFilterBar>
{error ? <AppEmptyState title="无法加载解决方案" description={error} /> : null}
{!error ? (
<AppDataTable
columns={columns}
rows={visibleSolutions}
getRowKey={(solution) => solution.id}
emptyTitle="暂无解决方案"
emptyDescription="已发布的可复用处理方案会显示在这里。"
footer={<p className="text-sm text-muted-foreground">当前显示 {visibleSolutions.length} 条方案</p>}
/>
) : null}
</div> </div>
</main> </main>
) )

View File

@@ -1,50 +1,42 @@
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base { @layer base {
:root { :root {
--background: oklch(1 0 0); --background: 210 20% 98%;
--foreground: oklch(0.145 0 0); --foreground: 222 25% 12%;
--surface: 0 0% 100%; --surface: 0 0% 100%;
--muted: oklch(0.97 0 0); --muted: 210 18% 94%;
--muted-foreground: oklch(0.556 0 0); --muted-foreground: 215 12% 42%;
--border: oklch(0.922 0 0); --border: 214 18% 86%;
--primary: oklch(0.205 0 0); --primary: 213 52% 32%;
--primary-foreground: oklch(0.985 0 0); --primary-foreground: 0 0% 100%;
--success: 151 55% 36%; --success: 151 55% 36%;
--warning: 38 86% 48%; --warning: 38 86% 48%;
--danger: 0 70% 50%; --danger: 0 70% 50%;
--info: 204 76% 44%; --info: 204 76% 44%;
--ai-accent: 186 52% 36%; --ai-accent: 186 52% 36%;
--radius: 0.625rem; --radius: 0.5rem;
--card: oklch(1 0 0); --card: 0 0% 100%;
--card-foreground: oklch(0.145 0 0); --card-foreground: 222 25% 12%;
--popover: oklch(1 0 0); --popover: 0 0% 100%;
--popover-foreground: oklch(0.145 0 0); --popover-foreground: 222 25% 12%;
--secondary: oklch(0.97 0 0); --secondary: 210 18% 94%;
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: 222 25% 12%;
--accent: oklch(0.97 0 0); --accent: 210 18% 94%;
--accent-foreground: oklch(0.205 0 0); --accent-foreground: 222 25% 12%;
--destructive: oklch(0.577 0.245 27.325); --destructive: 0 70% 50%;
--input: oklch(0.922 0 0); --input: 214 18% 86%;
--ring: oklch(0.708 0 0); --ring: 213 52% 32%;
--chart-1: oklch(0.87 0 0); --sidebar: 0 0% 100%;
--chart-2: oklch(0.556 0 0); --sidebar-foreground: 222 25% 12%;
--chart-3: oklch(0.439 0 0); --sidebar-primary: 213 52% 32%;
--chart-4: oklch(0.371 0 0); --sidebar-primary-foreground: 0 0% 100%;
--chart-5: oklch(0.269 0 0); --sidebar-accent: 210 18% 94%;
--sidebar: oklch(0.985 0 0); --sidebar-accent-foreground: 222 25% 12%;
--sidebar-foreground: oklch(0.145 0 0); --sidebar-border: 214 18% 86%;
--sidebar-primary: oklch(0.205 0 0); --sidebar-ring: 213 52% 32%;
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
} }
body { body {
@@ -53,49 +45,5 @@
color: hsl(var(--foreground)); color: hsl(var(--foreground));
font-family: font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
@apply bg-background text-foreground;
} }
.theme { }
--font-heading: var(--font-sans);
--font-sans: 'Geist Variable', sans-serif;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
* {
@apply border-border outline-ring/50;
}
html {
@apply font-sans;
}
}

View File

@@ -18,6 +18,25 @@ const config: Config = {
DEFAULT: 'hsl(var(--primary))', DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))' foreground: 'hsl(var(--primary-foreground))'
}, },
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
destructive: 'hsl(var(--destructive))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
success: 'hsl(var(--success))', success: 'hsl(var(--success))',
warning: 'hsl(var(--warning))', warning: 'hsl(var(--warning))',
danger: 'hsl(var(--danger))', danger: 'hsl(var(--danger))',