feat: polish solution ui and design docs
This commit is contained in:
@@ -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`
|
||||||
|
|||||||
@@ -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 />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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))',
|
||||||
|
|||||||
Reference in New Issue
Block a user