feat: api client, auth store, login, authed shell, data-table + CRUD kit
This commit is contained in:
@@ -1,44 +0,0 @@
|
||||
export default function Footer() {
|
||||
const year = new Date().getFullYear()
|
||||
|
||||
return (
|
||||
<footer className="mt-20 border-t border-[var(--line)] px-4 pb-14 pt-10 text-[var(--sea-ink-soft)]">
|
||||
<div className="page-wrap flex flex-col items-center justify-between gap-4 text-center sm:flex-row sm:text-left">
|
||||
<p className="m-0 text-sm">
|
||||
© {year} Your name here. All rights reserved.
|
||||
</p>
|
||||
<p className="island-kicker m-0">Built with TanStack Start</p>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-center gap-4">
|
||||
<a
|
||||
href="https://x.com/tan_stack"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-xl p-2 text-[var(--sea-ink-soft)] transition hover:bg-[var(--link-bg-hover)] hover:text-[var(--sea-ink)]"
|
||||
>
|
||||
<span className="sr-only">Follow TanStack on X</span>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" width="32" height="32">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12.6 1h2.2L10 6.48 15.64 15h-4.41L7.78 9.82 3.23 15H1l5.14-5.84L.72 1h4.52l3.12 4.73L12.6 1zm-.77 12.67h1.22L4.57 2.26H3.26l8.57 11.41z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/TanStack"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-xl p-2 text-[var(--sea-ink-soft)] transition hover:bg-[var(--link-bg-hover)] hover:text-[var(--sea-ink)]"
|
||||
>
|
||||
<span className="sr-only">Go to TanStack GitHub</span>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" width="32" height="32">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import ThemeToggle from './ThemeToggle'
|
||||
|
||||
export default function Header() {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-[var(--line)] bg-[var(--header-bg)] px-4 backdrop-blur-lg">
|
||||
<nav className="page-wrap flex flex-wrap items-center gap-x-3 gap-y-2 py-3 sm:py-4">
|
||||
<h2 className="m-0 flex-shrink-0 text-base font-semibold tracking-tight">
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center gap-2 rounded-full border border-[var(--chip-line)] bg-[var(--chip-bg)] px-3 py-1.5 text-sm text-[var(--sea-ink)] no-underline shadow-[0_8px_24px_rgba(30,90,72,0.08)] sm:px-4 sm:py-2"
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full bg-[linear-gradient(90deg,#56c6be,#7ed3bf)]" />
|
||||
TanStack Start
|
||||
</Link>
|
||||
</h2>
|
||||
|
||||
<div className="order-3 flex w-full flex-wrap items-center gap-x-4 gap-y-1 pb-1 text-sm font-semibold sm:order-none sm:w-auto sm:flex-nowrap sm:pb-0">
|
||||
<Link
|
||||
to="/"
|
||||
className="nav-link"
|
||||
activeProps={{ className: 'nav-link is-active' }}
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
<Link
|
||||
to="/about"
|
||||
className="nav-link"
|
||||
activeProps={{ className: 'nav-link is-active' }}
|
||||
>
|
||||
About
|
||||
</Link>
|
||||
<a
|
||||
href="https://tanstack.com/start/latest/docs/framework/react/overview"
|
||||
className="nav-link"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Docs
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-1.5 sm:gap-2">
|
||||
<a
|
||||
href="https://x.com/tan_stack"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="hidden rounded-xl p-2 text-[var(--sea-ink-soft)] transition hover:bg-[var(--link-bg-hover)] hover:text-[var(--sea-ink)] sm:block"
|
||||
>
|
||||
<span className="sr-only">Follow TanStack on X</span>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" width="24" height="24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12.6 1h2.2L10 6.48 15.64 15h-4.41L7.78 9.82 3.23 15H1l5.14-5.84L.72 1h4.52l3.12 4.73L12.6 1zm-.77 12.67h1.22L4.57 2.26H3.26l8.57 11.41z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/TanStack"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="hidden rounded-xl p-2 text-[var(--sea-ink-soft)] transition hover:bg-[var(--link-bg-hover)] hover:text-[var(--sea-ink)] sm:block"
|
||||
>
|
||||
<span className="sr-only">Go to TanStack GitHub</span>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" width="24" height="24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
type ThemeMode = 'light' | 'dark' | 'auto'
|
||||
|
||||
function getInitialMode(): ThemeMode {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'auto'
|
||||
}
|
||||
|
||||
const stored = window.localStorage.getItem('theme')
|
||||
if (stored === 'light' || stored === 'dark' || stored === 'auto') {
|
||||
return stored
|
||||
}
|
||||
|
||||
return 'auto'
|
||||
}
|
||||
|
||||
function applyThemeMode(mode: ThemeMode) {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
const resolved = mode === 'auto' ? (prefersDark ? 'dark' : 'light') : mode
|
||||
|
||||
document.documentElement.classList.remove('light', 'dark')
|
||||
document.documentElement.classList.add(resolved)
|
||||
|
||||
if (mode === 'auto') {
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-theme', mode)
|
||||
}
|
||||
|
||||
document.documentElement.style.colorScheme = resolved
|
||||
}
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const [mode, setMode] = useState<ThemeMode>('auto')
|
||||
|
||||
useEffect(() => {
|
||||
const initialMode = getInitialMode()
|
||||
setMode(initialMode)
|
||||
applyThemeMode(initialMode)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'auto') {
|
||||
return
|
||||
}
|
||||
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const onChange = () => applyThemeMode('auto')
|
||||
|
||||
media.addEventListener('change', onChange)
|
||||
return () => {
|
||||
media.removeEventListener('change', onChange)
|
||||
}
|
||||
}, [mode])
|
||||
|
||||
function toggleMode() {
|
||||
const nextMode: ThemeMode =
|
||||
mode === 'light' ? 'dark' : mode === 'dark' ? 'auto' : 'light'
|
||||
setMode(nextMode)
|
||||
applyThemeMode(nextMode)
|
||||
window.localStorage.setItem('theme', nextMode)
|
||||
}
|
||||
|
||||
const label =
|
||||
mode === 'auto'
|
||||
? 'Theme mode: auto (system). Click to switch to light mode.'
|
||||
: `Theme mode: ${mode}. Click to switch mode.`
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMode}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className="rounded-full border border-[var(--chip-line)] bg-[var(--chip-bg)] px-3 py-1.5 text-sm font-semibold text-[var(--sea-ink)] shadow-[0_8px_22px_rgba(30,90,72,0.08)] transition hover:-translate-y-0.5"
|
||||
>
|
||||
{mode === 'auto' ? 'Auto' : mode === 'dark' ? 'Dark' : 'Light'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
trigger: React.ReactNode
|
||||
title: string
|
||||
description?: string
|
||||
confirmLabel?: string
|
||||
destructive?: boolean
|
||||
onConfirm: () => Promise<void> | void
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
trigger,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = '确认',
|
||||
destructive = false,
|
||||
onConfirm,
|
||||
}: ConfirmDialogProps) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
{description && <AlertDialogDescription>{description}</AlertDialogDescription>}
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={busy}
|
||||
className={destructive ? 'bg-destructive text-white hover:bg-destructive/90' : undefined}
|
||||
onClick={async (event) => {
|
||||
event.preventDefault()
|
||||
setBusy(true)
|
||||
try {
|
||||
await onConfirm()
|
||||
// close via Escape-equivalent: AlertDialogAction default closes,
|
||||
// but we prevented it — dispatch close by clicking cancel sibling.
|
||||
;(event.target as HTMLElement)
|
||||
.closest('[role="alertdialog"]')
|
||||
?.querySelector<HTMLButtonElement>('[data-slot="alert-dialog-cancel"]')
|
||||
?.click()
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{busy ? '处理中…' : confirmLabel}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
} from '@tanstack/react-table'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import type { Pagination } from '@/api/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
|
||||
interface DataTableProps<T> {
|
||||
columns: ColumnDef<T, any>[]
|
||||
data: T[]
|
||||
pagination?: Pagination
|
||||
loading?: boolean
|
||||
emptyText?: string
|
||||
onPageChange?: (page: number) => void
|
||||
onPageSizeChange?: (pageSize: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-paginated table bound to the backend's Pagination envelope
|
||||
* (page / page_size request params).
|
||||
*/
|
||||
export function DataTable<T>({
|
||||
columns,
|
||||
data,
|
||||
pagination,
|
||||
loading = false,
|
||||
emptyText = '暂无数据',
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
}: DataTableProps<T>) {
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
{columns.map((_, j) => (
|
||||
<TableCell key={j}>
|
||||
<Skeleton className="h-4 w-full max-w-32" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : table.getRowModel().rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center text-muted-foreground">
|
||||
{emptyText}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<div>共 {pagination.total_count} 条</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{onPageSizeChange && (
|
||||
<Select
|
||||
value={String(pagination.page_size)}
|
||||
onValueChange={(value) => onPageSizeChange(Number(value))}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[10, 20, 50, 100].map((size) => (
|
||||
<SelectItem key={size} value={String(size)}>
|
||||
{size} 条 / 页
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
disabled={!pagination.prev_page || loading}
|
||||
onClick={() => onPageChange?.(pagination.prev_page ?? 1)}
|
||||
aria-label="上一页"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<span className="min-w-16 text-center tabular-nums">
|
||||
{pagination.current_page} / {Math.max(pagination.total_pages, 1)}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
disabled={!pagination.next_page || loading}
|
||||
onClick={() => onPageChange?.(pagination.next_page ?? 1)}
|
||||
aria-label="下一页"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
Bot,
|
||||
FolderTree,
|
||||
Hash,
|
||||
Inbox,
|
||||
KeyRound,
|
||||
Languages,
|
||||
LayoutList,
|
||||
ListChecks,
|
||||
Newspaper,
|
||||
Palette,
|
||||
ScrollText,
|
||||
Smartphone,
|
||||
Sparkles,
|
||||
} from 'lucide-react'
|
||||
|
||||
export interface NavItem {
|
||||
label: string
|
||||
to: string
|
||||
icon: LucideIcon
|
||||
/** hide when the user lacks this permission (super-admin always passes) */
|
||||
permission?: string
|
||||
}
|
||||
|
||||
export interface NavGroup {
|
||||
label: string
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
export const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
label: '分类管理',
|
||||
items: [
|
||||
{ label: '分类树', to: '/categories', icon: FolderTree },
|
||||
{ label: '待审核分类', to: '/categories/pending', icon: ListChecks },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '内容工作室',
|
||||
items: [
|
||||
{ label: '机器人作者', to: '/studio/robot-authors', icon: Bot },
|
||||
{ label: '文章系列', to: '/studio/series', icon: LayoutList },
|
||||
{ label: '文章', to: '/studio/articles', icon: Newspaper },
|
||||
{ label: '子话题', to: '/studio/subtopics', icon: Sparkles },
|
||||
{ label: '分类简报', to: '/studio/category-briefs', icon: ScrollText },
|
||||
{ label: '风格预设', to: '/studio/style-presets', icon: Palette },
|
||||
{
|
||||
label: 'Agent 接入',
|
||||
to: '/studio/agent-tokens',
|
||||
icon: KeyRound,
|
||||
permission: 'admin:studio:agent-token:issue',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '用户与系统',
|
||||
items: [
|
||||
{ label: 'SID 管理', to: '/sid', icon: Hash },
|
||||
{ label: '语言设置', to: '/locales', icon: Languages },
|
||||
{ label: '推送设备', to: '/devices', icon: Smartphone },
|
||||
{ label: '机器人会话', to: '/robots', icon: Inbox },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
interface PageHeaderProps {
|
||||
title: string
|
||||
description?: string
|
||||
actions?: React.ReactNode
|
||||
}
|
||||
|
||||
export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="mb-6 flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
|
||||
{description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState } from 'react'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
interface ShowOnceTokenDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: string
|
||||
description?: string
|
||||
token: string | null
|
||||
/** extra command/snippet shown below the token (e.g. claude mcp add …) */
|
||||
snippet?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a freshly issued token exactly once. The value is not retrievable
|
||||
* again after closing — the copy affordance is the whole point.
|
||||
*/
|
||||
export function ShowOnceTokenDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description = '此令牌仅完整显示一次,请立即复制保存。',
|
||||
token,
|
||||
snippet,
|
||||
}: ShowOnceTokenDialogProps) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const copy = async (value: string) => {
|
||||
await navigator.clipboard.writeText(value)
|
||||
setCopied(true)
|
||||
toast.success('已复制到剪贴板')
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-start gap-2 rounded-md border bg-muted/50 p-3">
|
||||
<code className="min-w-0 flex-1 break-all font-mono text-xs">{token}</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={() => token && copy(token)}
|
||||
aria-label="复制令牌"
|
||||
>
|
||||
{copied ? <Check className="size-4" /> : <Copy className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
{snippet && (
|
||||
<pre className="overflow-x-auto rounded-md border bg-muted/50 p-3 font-mono text-xs">
|
||||
{snippet}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => onOpenChange(false)}>我已保存</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const TONES: Record<string, string> = {
|
||||
// generic
|
||||
active: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-400',
|
||||
approved: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-400',
|
||||
published: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-400',
|
||||
ready: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-400',
|
||||
complete: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-400',
|
||||
pending: 'bg-amber-500/15 text-amber-700 dark:text-amber-400',
|
||||
drafting: 'bg-blue-500/12 text-blue-700 dark:text-blue-400',
|
||||
drafted: 'bg-blue-500/12 text-blue-700 dark:text-blue-400',
|
||||
in_progress: 'bg-blue-500/12 text-blue-700 dark:text-blue-400',
|
||||
profiling: 'bg-blue-500/12 text-blue-700 dark:text-blue-400',
|
||||
scheduled: 'bg-violet-500/12 text-violet-700 dark:text-violet-400',
|
||||
planned: 'bg-muted text-muted-foreground',
|
||||
paused: 'bg-muted text-muted-foreground',
|
||||
archived: 'bg-muted text-muted-foreground',
|
||||
rejected: 'bg-red-500/12 text-red-700 dark:text-red-400',
|
||||
}
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
active: '启用',
|
||||
approved: '已通过',
|
||||
published: '已发布',
|
||||
ready: '就绪',
|
||||
complete: '已完成',
|
||||
pending: '待处理',
|
||||
drafting: '撰写中',
|
||||
drafted: '已完稿',
|
||||
in_progress: '进行中',
|
||||
profiling: '设定中',
|
||||
scheduled: '已排期',
|
||||
planned: '已规划',
|
||||
paused: '已暂停',
|
||||
archived: '已归档',
|
||||
rejected: '已驳回',
|
||||
}
|
||||
|
||||
export function StatusPill({ status, className }: { status: string; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-2 py-0.5 font-mono text-xs font-medium',
|
||||
TONES[status] ?? 'bg-muted text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{LABELS[status] ?? status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user