feat: polish solution ui and design docs
This commit is contained in:
@@ -32,6 +32,10 @@ export function Router() {
|
||||
return <SolutionListPage />
|
||||
}
|
||||
|
||||
if (route === '/tickets') {
|
||||
return <TicketListPage />
|
||||
}
|
||||
|
||||
if (route === '/tickets/new') {
|
||||
return <TicketCreatePage />
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Slot } from "radix-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
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: {
|
||||
variant: {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Slot } from "radix-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
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: {
|
||||
variant: {
|
||||
@@ -13,24 +13,22 @@ const buttonVariants = cva(
|
||||
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",
|
||||
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:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
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",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
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",
|
||||
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",
|
||||
lg: "h-9 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 px-2 text-xs",
|
||||
sm: "h-7 gap-1 px-2.5 text-xs",
|
||||
lg: "h-9 gap-1.5 px-4",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"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 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-xs": "size-6",
|
||||
"icon-sm": "size-7",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -12,7 +12,7 @@ function Card({
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@@ -25,7 +25,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="card-header"
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@@ -38,7 +38,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
"text-base font-medium leading-snug",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -73,7 +73,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-(--card-spacing)", className)}
|
||||
className={cn("px-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -84,7 +84,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
type={type}
|
||||
data-slot="input"
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -42,7 +42,7 @@ function SelectTrigger({
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@@ -67,7 +67,7 @@ function SelectContent({
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
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}
|
||||
align={align}
|
||||
{...props}
|
||||
@@ -76,7 +76,7 @@ function SelectContent({
|
||||
<SelectPrimitive.Viewport
|
||||
data-position={position}
|
||||
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" && ""
|
||||
)}
|
||||
>
|
||||
@@ -110,7 +110,7 @@ function SelectItem({
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { AppEmptyState } from '@/components/app/AppEmptyState'
|
||||
import { AppSection } from '@/components/app/AppSection'
|
||||
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 type { Solution } from './types'
|
||||
@@ -17,32 +18,32 @@ export function SolutionLinkPanel({ ticketId }: { ticketId: number }) {
|
||||
|
||||
async function handleLink(solution: Solution, linkType: string) {
|
||||
await linkSolutionToTicket(solution.id, ticketId, linkType)
|
||||
setMessage(`${solution.title} linked as ${linkType}`)
|
||||
setMessage(`已将「${solution.title}」标记为${linkType === 'adopted' ? '已采纳' : '推荐方案'}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-sm font-medium">Reusable Solutions</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3">
|
||||
<AppSection title="关联解决方案" description="从已发布方案中选择推荐或采纳项。">
|
||||
<div className="grid gap-3">
|
||||
{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) => (
|
||||
<div className="grid gap-2 rounded-md border border-border p-3" key={solution.id}>
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={() => void handleLink(solution, 'referenced')}>
|
||||
Reference
|
||||
推荐方案
|
||||
</Button>
|
||||
<Button onClick={() => void handleLink(solution, 'adopted')}>Adopt</Button>
|
||||
<Button onClick={() => void handleLink(solution, 'adopted')}>已采纳</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppSection>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,46 +1,131 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { AppDataTable, type AppDataTableColumn } from '@/components/app/AppDataTable'
|
||||
import { AppEmptyState } from '@/components/app/AppEmptyState'
|
||||
import { AppFilterBar } from '@/components/app/AppFilterBar'
|
||||
import { AppPageHeader } from '@/components/app/AppPageHeader'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
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 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() {
|
||||
const [solutions, setSolutions] = React.useState<Solution[]>([])
|
||||
const [status, setStatus] = React.useState('all')
|
||||
const [q, setQ] = React.useState('')
|
||||
const [error, setError] = React.useState('')
|
||||
|
||||
React.useEffect(() => {
|
||||
listSolutions()
|
||||
listSolutions(status === 'all' ? {} : { status })
|
||||
.then(setSolutions)
|
||||
.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 (
|
||||
<main className="min-h-screen bg-background p-6 text-foreground">
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-4">
|
||||
<AppPageHeader title="Solutions" description="Reusable support solutions linked to ticket handling." />
|
||||
{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">
|
||||
{solutions.map((solution) => (
|
||||
<Card key={solution.id}>
|
||||
<CardContent className="grid gap-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-semibold">{solution.title}</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={solution.status === 'published' ? 'success' : 'default'}>{solution.status}</Badge>
|
||||
<Badge variant="info">Used {solution.usage_count}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{solution.problem_signature}</p>
|
||||
<p className="text-sm">{solution.solution}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-4">
|
||||
<AppPageHeader title="解决方案库" description="沉淀可复用的问题特征、排查步骤和处理方案。" />
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">全部方案</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-foreground">{solutions.length}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">已发布</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-foreground">
|
||||
{solutions.filter((solution) => solution.status === 'published').length}
|
||||
</p>
|
||||
</CardContent>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</main>
|
||||
)
|
||||
|
||||
@@ -1,50 +1,42 @@
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "@fontsource-variable/geist";
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--background: 210 20% 98%;
|
||||
--foreground: 222 25% 12%;
|
||||
--surface: 0 0% 100%;
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--border: oklch(0.922 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--muted: 210 18% 94%;
|
||||
--muted-foreground: 215 12% 42%;
|
||||
--border: 214 18% 86%;
|
||||
--primary: 213 52% 32%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--success: 151 55% 36%;
|
||||
--warning: 38 86% 48%;
|
||||
--danger: 0 70% 50%;
|
||||
--info: 204 76% 44%;
|
||||
--ai-accent: 186 52% 36%;
|
||||
--radius: 0.625rem;
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 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.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--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);
|
||||
--radius: 0.5rem;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222 25% 12%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222 25% 12%;
|
||||
--secondary: 210 18% 94%;
|
||||
--secondary-foreground: 222 25% 12%;
|
||||
--accent: 210 18% 94%;
|
||||
--accent-foreground: 222 25% 12%;
|
||||
--destructive: 0 70% 50%;
|
||||
--input: 214 18% 86%;
|
||||
--ring: 213 52% 32%;
|
||||
--sidebar: 0 0% 100%;
|
||||
--sidebar-foreground: 222 25% 12%;
|
||||
--sidebar-primary: 213 52% 32%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 210 18% 94%;
|
||||
--sidebar-accent-foreground: 222 25% 12%;
|
||||
--sidebar-border: 214 18% 86%;
|
||||
--sidebar-ring: 213 52% 32%;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -53,49 +45,5 @@
|
||||
color: hsl(var(--foreground));
|
||||
font-family:
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,25 @@ const config: Config = {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
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))',
|
||||
warning: 'hsl(var(--warning))',
|
||||
danger: 'hsl(var(--danger))',
|
||||
|
||||
Reference in New Issue
Block a user