feat(accounts): TAGS 改为标签输入、账号列表改 table、内容区域全宽

- 创建账号弹窗 TAGS 字段使用 TagInput 胶囊标签输入(回车/逗号确认,退格删除,自动去重)
- 账号列表由卡片栅格改为 Table(账号/平台/授权/账号状态/固定资源/可调度)
- 布局移除 max-w-[1280px] 限制,内容区域全宽

验证:npm run test 66/66 通过,npm run build 成功,go test ./... 全 ok
This commit is contained in:
2026-09-02 18:28:26 +08:00
parent 61543a4a61
commit 24d2451f0c
4 changed files with 816 additions and 296 deletions
+92 -67
View File
@@ -15,6 +15,8 @@ import {
PageState,
Select,
StatusPill,
Table,
TagInput,
Textarea,
conflictMessage,
} from "./lib/ui.jsx";
@@ -24,7 +26,7 @@ const createInitial = {
name: "",
platform: "",
platform_account_key: "",
tags: "",
tags: [],
cookies: "",
};
@@ -126,10 +128,7 @@ function AccountCreateModal({ open, onClose, onSubmit, busy, error }) {
name: form.name.trim(),
platform: form.platform.trim(),
platform_account_key: form.platform_account_key.trim(),
tags: form.tags
.split(/[,]/)
.map((tag) => tag.trim())
.filter(Boolean),
tags: form.tags,
};
// cookies 非必填:留空代表创建后走扫码登录,凭据由后续同步链路补齐
if (form.cookies.trim()) data.cookies = form.cookies.trim();
@@ -216,12 +215,16 @@ function AccountCreateModal({ open, onClose, onSubmit, busy, error }) {
}
/>
</Field>
<Field id="account-tags" label="TAGS" helper="多个标签用逗号分隔">
<Input
<Field
id="account-tags"
label="TAGS"
helper="回车或逗号确认,退格删除末位标签"
>
<TagInput
id="account-tags"
value={form.tags}
onChange={(event) => update("tags", event.target.value)}
placeholder="如:主账号,直播"
onChange={(tags) => update("tags", tags)}
placeholder="如:主账号"
/>
</Field>
<Field
@@ -245,54 +248,8 @@ function AccountCreateModal({ open, onClose, onSubmit, busy, error }) {
);
}
function AccountCard({ account, binding, bindingsError }) {
const readiness = accountReadiness(account, binding, bindingsError);
return (
<Card>
<CardContent className="space-y-3">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<Link
to={`/accounts/${account.id}`}
className="anywhere font-semibold text-ink hover:text-primary"
>
{account.name}
</Link>
<p className="anywhere text-xs text-muted">
{account.platform_account_key} · {account.platform}
</p>
</div>
<ReadinessPill
readiness={readiness}
paused={account.runtime_status === "paused"}
/>
</div>
<div className="text-sm">
<span className="text-muted">授权</span>
{account.authorization_status === "authorized" ? "已授权" : "已撤销"}
<span className="text-muted"> · 账号</span>
{account.runtime_status === "active" ? "启用" : "暂停"}
</div>
<div className="text-sm">
<span className="text-muted">固定资源</span>
{bindingsError ? (
"状态未知(环境数据不可用)"
) : (
<span className="anywhere">
{binding?.name || "未绑定运行环境"} ·{" "}
{binding?.network_exit_id || "未绑定出口"}
</span>
)}
</div>
<Link
to={`/accounts/${account.id}`}
className="inline-flex text-sm font-medium text-primary hover:underline"
>
查看账号
</Link>
</CardContent>
</Card>
);
function platformLabel(value) {
return platforms.find((platform) => platform.value === value)?.label || value;
}
export function AccountList() {
@@ -378,16 +335,84 @@ export function AccountList() {
empty={accounts.length === 0}
emptyText="暂无账号。创建账号后,再前往运行环境绑定健康网络出口。"
>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{accounts.map((account) => (
<AccountCard
key={account.id}
account={account}
binding={bindingByAccount.get(account.id)}
bindingsError={!!bindingsError}
/>
))}
</div>
<Table
columns={[
{
header: "账号",
width: "22%",
render: (account) => (
<div className="min-w-0">
<Link
to={`/accounts/${account.id}`}
className="anywhere font-semibold text-ink hover:text-primary"
>
{account.name}
</Link>
<p className="anywhere text-xs text-muted">
{account.platform_account_key}
</p>
</div>
),
},
{
header: "平台",
width: "10%",
render: (account) => platformLabel(account.platform),
},
{
header: "授权",
width: "10%",
render: (account) =>
account.authorization_status === "authorized"
? "已授权"
: "已撤销",
},
{
header: "账号状态",
width: "14%",
render: (account) =>
account.runtime_status === "active" ? "启用" : "暂停",
},
{
header: "固定资源",
width: "24%",
render: (account) => {
const binding = bindingByAccount.get(account.id);
return bindingsError ? (
<span className="text-sm text-muted">
状态未知环境数据不可用
</span>
) : (
<div className="min-w-0">
<p className="anywhere">
{binding?.name || "未绑定运行环境"}
</p>
<p className="anywhere text-xs text-muted">
{binding?.network_exit_id || "未绑定出口"}
</p>
</div>
);
},
},
{
header: "可调度",
width: "20%",
render: (account) => (
<ReadinessPill
readiness={accountReadiness(
account,
bindingByAccount.get(account.id),
!!bindingsError,
)}
paused={account.runtime_status === "paused"}
/>
),
},
]}
rows={accounts}
rowKey={(account) => account.id}
minWidth="760px"
/>
</PageState>
<AccountCreateModal
open={createOpen}
+17 -10
View File
@@ -66,6 +66,14 @@ function provider(overrides = {}) {
};
}
// 向 TAGS 标签输入框逐个录入标签:输入文本后回车成胶囊
function typeTags(input, texts) {
for (const text of texts) {
fireEvent.change(input, { target: { value: text } });
fireEvent.keyDown(input, { key: "Enter" });
}
}
const queryClient = () =>
new QueryClient({ defaultOptions: { queries: { retry: false } } });
@@ -98,13 +106,11 @@ function renderDetail(dataProvider) {
describe("AccountList", () => {
it("offers four platforms and submits only the account creation fields", async () => {
const dataProvider = provider({
create: vi
.fn()
.mockRejectedValue(
httpError("conflict", 409, {
reason_code: "duplicate_platform_account",
}),
),
create: vi.fn().mockRejectedValue(
httpError("conflict", 409, {
reason_code: "duplicate_platform_account",
}),
),
});
renderList(dataProvider);
await screen.findAllByText("店铺一号");
@@ -126,9 +132,10 @@ describe("AccountList", () => {
fireEvent.change(within(dialog).getByRole("textbox", { name: "账号 ID" }), {
target: { value: "shop-new" },
});
fireEvent.change(within(dialog).getByRole("textbox", { name: "TAGS" }), {
target: { value: "主账号,直播" },
});
typeTags(within(dialog).getByRole("textbox", { name: "TAGS" }), [
"主账号",
"直播",
]);
fireEvent.change(within(dialog).getByRole("textbox", { name: "Cookies" }), {
target: { value: "sessionid=value; token=second" },
});
+124 -47
View File
@@ -1,93 +1,170 @@
import { useEffect, useState } from 'react'
import { Link, Outlet, useLocation } from 'react-router'
import { cn } from './lib/ui.jsx'
import { useEffect, useState } from "react";
import { Link, Outlet, useLocation } from "react-router";
import { cn } from "./lib/ui.jsx";
const expandedWidth = 240
const collapsedWidth = 76
const expandedWidth = 240;
const collapsedWidth = 76;
const menu = [
{ section: '运营', items: [
{ to: '/accounts', icon: 'ri-account-circle-line', label: '社媒账号' },
{ to: '/tasks', icon: 'ri-task-line', label: '任务中心' },
{ to: '/audit', icon: 'ri-history-line', label: '审计记录' },
] },
{ section: '资源', items: [
{ to: '/browsers', icon: 'ri-hard-drive-3-line', label: '运行环境' },
{ to: '/network-exits', icon: 'ri-earth-line', label: '网络出口' },
{ to: '/browser-images', icon: 'ri-inbox-line', label: '镜像版本' },
{ to: '/gateways', icon: 'ri-router-line', label: '网关管理' },
] },
]
{
section: "运营",
items: [
{ to: "/accounts", icon: "ri-account-circle-line", label: "社媒账号" },
{ to: "/tasks", icon: "ri-task-line", label: "任务中心" },
{ to: "/audit", icon: "ri-history-line", label: "审计记录" },
],
},
{
section: "资源",
items: [
{ to: "/browsers", icon: "ri-hard-drive-3-line", label: "运行环境" },
{ to: "/network-exits", icon: "ri-earth-line", label: "网络出口" },
{ to: "/browser-images", icon: "ri-inbox-line", label: "镜像版本" },
{ to: "/gateways", icon: "ri-router-line", label: "网关管理" },
],
},
];
function MenuLink({ to, icon, label, collapsed, onNavigate }) {
const { pathname } = useLocation()
const active = pathname === to || pathname.startsWith(`${to}/`)
const { pathname } = useLocation();
const active = pathname === to || pathname.startsWith(`${to}/`);
return (
<Link to={to} aria-label={label} onClick={onNavigate} title={collapsed ? label : undefined}
className={cn('flex h-10 items-center gap-3 rounded-md px-3 text-sm font-medium no-underline transition-colors', collapsed && 'justify-center',
active ? 'bg-primary text-white' : 'text-[#a9b7cc] hover:bg-sidebar-hover hover:text-white')}>
<i className={cn(icon, 'text-lg')} aria-hidden="true" />
<Link
to={to}
aria-label={label}
onClick={onNavigate}
title={collapsed ? label : undefined}
className={cn(
"flex h-10 items-center gap-3 rounded-md px-3 text-sm font-medium no-underline transition-colors",
collapsed && "justify-center",
active
? "bg-primary text-white"
: "text-[#a9b7cc] hover:bg-sidebar-hover hover:text-white",
)}
>
<i className={cn(icon, "text-lg")} aria-hidden="true" />
{collapsed ? null : <span className="truncate">{label}</span>}
</Link>
)
);
}
function Sidebar({ collapsed, onNavigate }) {
return (
<nav aria-label="主导航" className="flex h-full flex-col overflow-y-auto px-3 pt-4 pb-16">
<Link to="/accounts" className="mb-4 flex h-11 items-center gap-3 px-2 no-underline" onClick={onNavigate} aria-label="CreatorHub 首页">
<nav
aria-label="主导航"
className="flex h-full flex-col overflow-y-auto px-3 pt-4 pb-16"
>
<Link
to="/accounts"
className="mb-4 flex h-11 items-center gap-3 px-2 no-underline"
onClick={onNavigate}
aria-label="CreatorHub 首页"
>
<i className="ri-stack-line text-2xl text-primary" aria-hidden="true" />
{collapsed ? null : <span className="text-lg font-bold text-white">CreatorHub</span>}
{collapsed ? null : (
<span className="text-lg font-bold text-white">CreatorHub</span>
)}
</Link>
{menu.map(({ section, items }) => (
<div key={section} className="mb-3">
{collapsed ? null : <p className="mb-1 px-3 text-[11px] font-semibold uppercase tracking-wider text-[#7f94b2]">{section}</p>}
{collapsed ? null : (
<p className="mb-1 px-3 text-[11px] font-semibold uppercase tracking-wider text-[#7f94b2]">
{section}
</p>
)}
<div className="space-y-1">
{items.map(item => <MenuLink key={item.to} {...item} collapsed={collapsed} onNavigate={onNavigate} />)}
{items.map((item) => (
<MenuLink
key={item.to}
{...item}
collapsed={collapsed}
onNavigate={onNavigate}
/>
))}
</div>
</div>
))}
</nav>
)
);
}
export function CreatorHubLayout({ children }) {
const [collapsed, setCollapsed] = useState(false)
const [mobileOpen, setMobileOpen] = useState(false)
const { pathname } = useLocation()
const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const { pathname } = useLocation();
useEffect(() => { setMobileOpen(false) }, [pathname])
useEffect(() => {
setMobileOpen(false);
}, [pathname]);
const width = collapsed ? collapsedWidth : expandedWidth
const desktop = typeof window !== 'undefined' && window.matchMedia('(min-width: 768px)').matches
const menuLabel = desktop ? (collapsed ? '展开导航' : '收起导航') : '打开导航'
const width = collapsed ? collapsedWidth : expandedWidth;
const desktop =
typeof window !== "undefined" &&
window.matchMedia("(min-width: 768px)").matches;
const menuLabel = desktop
? collapsed
? "展开导航"
: "收起导航"
: "打开导航";
return (
<div className="flex min-h-screen bg-[#f7f8fa]">
<aside className="fixed inset-y-0 left-0 z-30 hidden bg-sidebar md:block" style={{ width }}>
<aside
className="fixed inset-y-0 left-0 z-30 hidden bg-sidebar md:block"
style={{ width }}
>
<Sidebar collapsed={collapsed} />
<button type="button" onClick={() => setCollapsed(value => !value)} className="absolute bottom-6 right-4 flex size-8 items-center justify-center rounded-md border border-[#35506f] text-[#a9b7cc] hover:text-white" aria-label={collapsed ? '展开导航' : '收起导航'}>
<i className={collapsed ? 'ri-arrow-right-s-line' : 'ri-arrow-left-s-line'} aria-hidden="true" />
<button
type="button"
onClick={() => setCollapsed((value) => !value)}
className="absolute bottom-6 right-4 flex size-8 items-center justify-center rounded-md border border-[#35506f] text-[#a9b7cc] hover:text-white"
aria-label={collapsed ? "展开导航" : "收起导航"}
>
<i
className={
collapsed ? "ri-arrow-right-s-line" : "ri-arrow-left-s-line"
}
aria-hidden="true"
/>
</button>
</aside>
{mobileOpen ? (
<div className="fixed inset-0 z-40 md:hidden">
<div className="absolute inset-0 bg-black/40" onClick={() => setMobileOpen(false)} />
<aside className="absolute inset-y-0 left-0 bg-sidebar" style={{ width: expandedWidth }}>
<Sidebar collapsed={false} onNavigate={() => setMobileOpen(false)} />
<div
className="absolute inset-0 bg-black/40"
onClick={() => setMobileOpen(false)}
/>
<aside
className="absolute inset-y-0 left-0 bg-sidebar"
style={{ width: expandedWidth }}
>
<Sidebar
collapsed={false}
onNavigate={() => setMobileOpen(false)}
/>
</aside>
</div>
) : null}
<div className="flex min-w-0 flex-1 flex-col md:pl-[var(--sidebar)]" style={{ '--sidebar': `${width}px` }}>
<div
className="flex min-w-0 flex-1 flex-col md:pl-[var(--sidebar)]"
style={{ "--sidebar": `${width}px` }}
>
<header className="sticky top-0 z-20 flex h-14 items-center border-b border-hairline bg-white px-4">
<button type="button" aria-label={menuLabel} onClick={() => (desktop ? setCollapsed(value => !value) : setMobileOpen(true))} className="rounded-md p-2 text-muted hover:bg-[#f5f7fa]">
<button
type="button"
aria-label={menuLabel}
onClick={() =>
desktop ? setCollapsed((value) => !value) : setMobileOpen(true)
}
className="rounded-md p-2 text-muted hover:bg-[#f5f7fa]"
>
<i className="ri-menu-line text-xl" aria-hidden="true" />
</button>
</header>
<main className="mx-auto w-full max-w-[1280px] min-w-0 flex-1 px-4 py-8 sm:px-6 lg:px-10">
<main className="w-full min-w-0 flex-1 px-4 py-8 sm:px-6 lg:px-10">
<Outlet />
</main>
</div>
</div>
)
);
}
+583 -172
View File
@@ -1,41 +1,61 @@
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
import { createPortal } from 'react-dom'
import { useEffect, useRef, useState } from 'react'
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { createPortal } from "react-dom";
import { useEffect, useRef, useState } from "react";
export function cn(...inputs) {
return twMerge(clsx(inputs))
return twMerge(clsx(inputs));
}
export function apiMessage(error) {
return error?.message || '操作失败'
return error?.message || "操作失败";
}
export function conflictMessage(error, fallback) {
if (error?.status === 409) return `冲突(409):${fallback}`
if (error?.status === 503) return `环境不可用(503):${error.message}`
return apiMessage(error)
if (error?.status === 409) return `冲突(409):${fallback}`;
if (error?.status === 503) return `环境不可用(503):${error.message}`;
return apiMessage(error);
}
export const dateTime = value => new Date(value).toLocaleString('zh-CN')
export const dateTime = (value) => new Date(value).toLocaleString("zh-CN");
/**
* 页面加载/错误/空态的统一外壳。
*/
export function PageState({ pending, error, empty, emptyText, retryLabel = '重试', onRetry, children }) {
export function PageState({
pending,
error,
empty,
emptyText,
retryLabel = "重试",
onRetry,
children,
}) {
if (pending) {
return <div className="flex items-center justify-center py-20" role="status" aria-label="正在加载"><i className="ri-loader-4-line animate-spin text-2xl text-muted" /></div>
return (
<div
className="flex items-center justify-center py-20"
role="status"
aria-label="正在加载"
>
<i className="ri-loader-4-line animate-spin text-2xl text-muted" />
</div>
);
}
if (error) {
return (
<Alert variant="destructive" className="mb-6">
<p>{apiMessage(error)}</p>
{onRetry ? <Button variant="outline" size="sm" onClick={onRetry}>{retryLabel}</Button> : null}
{onRetry ? (
<Button variant="outline" size="sm" onClick={onRetry}>
{retryLabel}
</Button>
) : null}
</Alert>
)
);
}
if (empty) return <EmptyState text={emptyText} />
return children
if (empty) return <EmptyState text={emptyText} />;
return children;
}
export function EmptyState({ text, children }) {
@@ -46,45 +66,90 @@ export function EmptyState({ text, children }) {
{children}
</CardContent>
</Card>
)
);
}
export function Alert({ variant = 'info', className, children, ...props }) {
export function Alert({ variant = "info", className, children, ...props }) {
const styles = {
info: 'text-muted bg-[#f5f7fa] border-hairline',
success: 'text-success bg-[#f0f9f4] border-[#b6dfc8]',
warning: 'text-warning bg-[#fdf6ec] border-[#eedcbb]',
destructive: 'text-danger bg-[#fdf1f0] border-[#f1c7c2]',
}[variant]
info: "text-muted bg-[#f5f7fa] border-hairline",
success: "text-success bg-[#f0f9f4] border-[#b6dfc8]",
warning: "text-warning bg-[#fdf6ec] border-[#eedcbb]",
destructive: "text-danger bg-[#fdf1f0] border-[#f1c7c2]",
}[variant];
return (
<div role="alert" className={cn('flex flex-wrap items-center gap-3 rounded-lg border px-4 py-3 text-sm', styles, className)} {...props}>
<div
role="alert"
className={cn(
"flex flex-wrap items-center gap-3 rounded-lg border px-4 py-3 text-sm",
styles,
className,
)}
{...props}
>
{children}
</div>
)
);
}
const buttonVariants = {
primary: 'bg-primary text-white hover:bg-primary-dark disabled:bg-primary/50',
outline: 'border border-[#c4ccd9] bg-white text-ink hover:bg-[#f5f7fa] disabled:text-muted/60 disabled:border-hairline disabled:bg-white',
ghost: 'text-primary hover:bg-primary/10 disabled:text-primary/50',
danger: 'border border-[#e4b7b2] bg-white text-danger hover:bg-[#fdf1f0] disabled:text-danger/50',
}
primary: "bg-primary text-white hover:bg-primary-dark disabled:bg-primary/50",
outline:
"border border-[#c4ccd9] bg-white text-ink hover:bg-[#f5f7fa] disabled:text-muted/60 disabled:border-hairline disabled:bg-white",
ghost: "text-primary hover:bg-primary/10 disabled:text-primary/50",
danger:
"border border-[#e4b7b2] bg-white text-danger hover:bg-[#fdf1f0] disabled:text-danger/50",
};
export function Button({ variant = 'outline', size = 'md', type = 'button', icon, as: Component = 'button', className, busy, busyText, children, ...props }) {
const sizes = { sm: 'h-8 px-3 text-xs', md: 'h-10 px-4 text-sm' }
export function Button({
variant = "outline",
size = "md",
type = "button",
icon,
as: Component = "button",
className,
busy,
busyText,
children,
...props
}) {
const sizes = { sm: "h-8 px-3 text-xs", md: "h-10 px-4 text-sm" };
return (
<Component type={Component === 'button' ? type : undefined} className={cn('inline-flex shrink-0 items-center justify-center gap-1.5 rounded-md font-medium no-underline transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:pointer-events-none', sizes[size], buttonVariants[variant], className)} {...props}>
{busy ? (busyText || '处理中…') : <>{icon ? <i className={icon} aria-hidden="true" /> : null}{children}</>}
<Component
type={Component === "button" ? type : undefined}
className={cn(
"inline-flex shrink-0 items-center justify-center gap-1.5 rounded-md font-medium no-underline transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:pointer-events-none",
sizes[size],
buttonVariants[variant],
className,
)}
{...props}
>
{busy ? (
busyText || "处理中…"
) : (
<>
{icon ? <i className={icon} aria-hidden="true" /> : null}
{children}
</>
)}
</Component>
)
);
}
export function Card({ className, ...props }) {
return <div className={cn('min-w-0 rounded-lg border border-hairline bg-white', className)} {...props} />
return (
<div
className={cn(
"min-w-0 rounded-lg border border-hairline bg-white",
className,
)}
{...props}
/>
);
}
export function CardContent({ className, ...props }) {
return <div className={cn('p-5', className)} {...props} />
return <div className={cn("p-5", className)} {...props} />;
}
export function PageHeader({ icon, title, description, children }) {
@@ -95,89 +160,241 @@ export function PageHeader({ icon, title, description, children }) {
{icon ? <i className={icon} aria-hidden="true" /> : null}
{title}
</h1>
{description ? <p className="mt-1.5 text-sm text-muted">{description}</p> : null}
{description ? (
<p className="mt-1.5 text-sm text-muted">{description}</p>
) : null}
</div>
{children ? <div className="flex flex-wrap items-center gap-3">{children}</div> : null}
{children ? (
<div className="flex flex-wrap items-center gap-3">{children}</div>
) : null}
</header>
)
);
}
/**
* 受控 Input / Select / Textarea。label 关联控件 idhelper 与 error 文案同槽位。
*/
export function Field({ id, label, required, helper, error, children, className }) {
const helpId = helper || error ? `${id}-help` : undefined
const describedBy = error ? `${id}-error` : helpId
const control = children
export function Field({
id,
label,
required,
helper,
error,
children,
className,
}) {
const helpId = helper || error ? `${id}-help` : undefined;
const describedBy = error ? `${id}-error` : helpId;
const control = children;
return (
<div className={cn('min-w-0 space-y-1.5', className)}>
<div className={cn("min-w-0 space-y-1.5", className)}>
<label htmlFor={id} className="block text-sm font-medium text-ink">
{label} {required ? <span className="text-danger" aria-hidden="true">*</span> : null}
{label}{" "}
{required ? (
<span className="text-danger" aria-hidden="true">
*
</span>
) : null}
</label>
{control}
{error ? <p id={`${id}-error`} className="text-xs text-danger">{error}</p> : helper ? <p id={helpId} className="text-xs text-muted">{helper}</p> : null}
{error ? (
<p id={`${id}-error`} className="text-xs text-danger">
{error}
</p>
) : helper ? (
<p id={helpId} className="text-xs text-muted">
{helper}
</p>
) : null}
</div>
)
);
}
export function Input({ invalid, className, ...props }) {
return <input className={cn('h-10 w-full rounded-md border bg-white px-3 text-sm text-ink placeholder:text-muted/60 focus:border-primary focus:outline-none disabled:bg-[#f5f7fa] disabled:text-muted', invalid ? 'border-danger' : 'border-[#c4ccd9]', className)} aria-invalid={invalid || undefined} {...props} />
return (
<input
className={cn(
"h-10 w-full rounded-md border bg-white px-3 text-sm text-ink placeholder:text-muted/60 focus:border-primary focus:outline-none disabled:bg-[#f5f7fa] disabled:text-muted",
invalid ? "border-danger" : "border-[#c4ccd9]",
className,
)}
aria-invalid={invalid || undefined}
{...props}
/>
);
}
export function Textarea({ invalid, className, ...props }) {
return <textarea className={cn('min-h-[96px] w-full rounded-md border bg-white px-3 py-2 text-sm text-ink placeholder:text-muted/60 focus:border-primary focus:outline-none disabled:bg-[#f5f7fa] disabled:text-muted', invalid ? 'border-danger' : 'border-[#c4ccd9]', className)} aria-invalid={invalid || undefined} {...props} />
return (
<textarea
className={cn(
"min-h-[96px] w-full rounded-md border bg-white px-3 py-2 text-sm text-ink placeholder:text-muted/60 focus:border-primary focus:outline-none disabled:bg-[#f5f7fa] disabled:text-muted",
invalid ? "border-danger" : "border-[#c4ccd9]",
className,
)}
aria-invalid={invalid || undefined}
{...props}
/>
);
}
export function Select({ id, value, onChange, options, placeholder = '请选择', emptyOption, disabled, invalid, className }) {
const [open, setOpen] = useState(false)
const [active, setActive] = useState(-1)
const root = useRef(null)
const selected = options.find(option => option.value === value) || (value === '' ? null : { value, label: value })
const label = selected ? selected.label : placeholder
const items = emptyOption !== undefined ? [{ value: emptyOption.value, label: emptyOption.label }, ...options] : options
const close = () => { setOpen(false); setActive(-1) }
const commit = item => {
onChange({ target: { value: item.value } })
close()
root.current?.querySelector('button')?.focus()
}
useEffect(() => {
if (!open) return
const onDocClick = event => { if (!root.current?.contains(event.target)) close() }
document.addEventListener('mousedown', onDocClick)
return () => document.removeEventListener('mousedown', onDocClick)
}, [open])
const handleKeyDown = event => {
if (event.key === 'Escape') {
event.stopPropagation()
close()
return
/**
* TAG 标签输入:回车或逗号确认一个胶囊,可退格删除;label 通过 id 关联内层输入框。
*/
export function TagInput({
id,
value,
onChange,
placeholder = "回车确认",
invalid,
className,
...props
}) {
const [draft, setDraft] = useState("");
const commit = (raw) => {
const tag = raw.trim();
setDraft("");
if (tag)
onChange(
value
.concat(tag)
.filter((item, index, all) => all.indexOf(item) === index),
);
};
const handleKeyDown = (event) => {
if (event.key === "Enter" || event.key === ",") {
event.preventDefault();
commit(draft);
} else if (event.key === "Backspace" && !draft && value.length > 0) {
event.preventDefault();
onChange(value.slice(0, -1));
}
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
if (!open) { setOpen(true); setActive(items.findIndex(item => item.value === value)) ; return }
setActive(current => {
const next = event.key === 'ArrowDown' ? Math.min(current + 1, items.length - 1) : Math.max(current - 1, 0)
return next
})
return
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
if (open) {
const item = items[Math.max(active, 0)]
if (item) commit(item)
} else {
setOpen(true)
setActive(Math.max(items.findIndex(item => item.value === value), 0))
}
return
}
if (event.key === 'Home') setActive(0)
if (event.key === 'End') setActive(items.length - 1)
}
};
return (
<div className={cn('relative', className)} ref={root} onKeyDown={handleKeyDown}>
<div
className={cn(
"flex min-h-10 w-full flex-wrap items-center gap-1.5 rounded-md border bg-white px-2 py-1.5 text-sm focus-within:border-primary",
invalid ? "border-danger" : "border-[#c4ccd9]",
className,
)}
>
{value.map((tag) => (
<span
key={tag}
className="inline-flex max-w-full items-center gap-1 rounded bg-[#eef5ff] pl-2 pr-1 py-0.5 text-xs text-primary"
>
<span className="anywhere">{tag}</span>
<button
type="button"
aria-label={`移除标签 ${tag}`}
onClick={() => onChange(value.filter((item) => item !== tag))}
className="shrink-0 rounded p-0.5 hover:bg-primary/10"
>
<i className="ri-close-line" aria-hidden="true" />
</button>
</span>
))}
<input
id={id}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => commit(draft)}
placeholder={value.length === 0 ? placeholder : undefined}
aria-invalid={invalid || undefined}
className="min-w-12 flex-1 border-0 bg-transparent p-1 text-sm text-ink placeholder:text-muted/60 focus:outline-none"
{...props}
/>
</div>
);
}
export function Select({
id,
value,
onChange,
options,
placeholder = "请选择",
emptyOption,
disabled,
invalid,
className,
}) {
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1);
const root = useRef(null);
const selected =
options.find((option) => option.value === value) ||
(value === "" ? null : { value, label: value });
const label = selected ? selected.label : placeholder;
const items =
emptyOption !== undefined
? [{ value: emptyOption.value, label: emptyOption.label }, ...options]
: options;
const close = () => {
setOpen(false);
setActive(-1);
};
const commit = (item) => {
onChange({ target: { value: item.value } });
close();
root.current?.querySelector("button")?.focus();
};
useEffect(() => {
if (!open) return;
const onDocClick = (event) => {
if (!root.current?.contains(event.target)) close();
};
document.addEventListener("mousedown", onDocClick);
return () => document.removeEventListener("mousedown", onDocClick);
}, [open]);
const handleKeyDown = (event) => {
if (event.key === "Escape") {
event.stopPropagation();
close();
return;
}
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (!open) {
setOpen(true);
setActive(items.findIndex((item) => item.value === value));
return;
}
setActive((current) => {
const next =
event.key === "ArrowDown"
? Math.min(current + 1, items.length - 1)
: Math.max(current - 1, 0);
return next;
});
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
if (open) {
const item = items[Math.max(active, 0)];
if (item) commit(item);
} else {
setOpen(true);
setActive(
Math.max(
items.findIndex((item) => item.value === value),
0,
),
);
}
return;
}
if (event.key === "Home") setActive(0);
if (event.key === "End") setActive(items.length - 1);
};
return (
<div
className={cn("relative", className)}
ref={root}
onKeyDown={handleKeyDown}
>
<button
type="button"
id={id}
@@ -185,155 +402,349 @@ export function Select({ id, value, onChange, options, placeholder = '请选择'
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={`${id}-listbox`}
aria-activedescendant={open && active >= 0 ? `${id}-option-${active}` : undefined}
aria-activedescendant={
open && active >= 0 ? `${id}-option-${active}` : undefined
}
aria-invalid={invalid || undefined}
disabled={disabled}
onClick={() => { setOpen(value => !value); setActive(items.findIndex(item => item.value === value)) }}
className={cn('flex h-10 w-full items-center justify-between gap-2 rounded-md border bg-white px-3 text-left text-sm focus:border-primary focus:outline-none disabled:bg-[#f5f7fa] disabled:text-muted', invalid ? 'border-danger' : 'border-[#c4ccd9]', label === placeholder && 'text-muted')}
onClick={() => {
setOpen((value) => !value);
setActive(items.findIndex((item) => item.value === value));
}}
className={cn(
"flex h-10 w-full items-center justify-between gap-2 rounded-md border bg-white px-3 text-left text-sm focus:border-primary focus:outline-none disabled:bg-[#f5f7fa] disabled:text-muted",
invalid ? "border-danger" : "border-[#c4ccd9]",
label === placeholder && "text-muted",
)}
>
<span className="truncate">{label}</span>
<i className="ri-arrow-down-s-line shrink-0 text-muted" aria-hidden="true" />
<i
className="ri-arrow-down-s-line shrink-0 text-muted"
aria-hidden="true"
/>
</button>
{open ? (
<ul role="listbox" id={`${id}-listbox`} aria-labelledby={id} className="absolute z-20 mt-1 max-h-64 w-full overflow-auto rounded-md border border-hairline bg-white py-1 shadow-lg">
<ul
role="listbox"
id={`${id}-listbox`}
aria-labelledby={id}
className="absolute z-20 mt-1 max-h-64 w-full overflow-auto rounded-md border border-hairline bg-white py-1 shadow-lg"
>
{items.map((item, index) => (
<li key={item.value} id={`${id}-option-${index}`} role="option" data-value={item.value} aria-selected={value === item.value}
className={cn('cursor-pointer px-3 py-1.5 text-sm', value === item.value ? 'bg-primary/10 font-medium' : 'hover:bg-[#f5f7fa]', active === index && 'bg-primary/10')}
onClick={() => commit(item)}>{item.label}</li>
<li
key={item.value}
id={`${id}-option-${index}`}
role="option"
data-value={item.value}
aria-selected={value === item.value}
className={cn(
"cursor-pointer px-3 py-1.5 text-sm",
value === item.value
? "bg-primary/10 font-medium"
: "hover:bg-[#f5f7fa]",
active === index && "bg-primary/10",
)}
onClick={() => commit(item)}
>
{item.label}
</li>
))}
</ul>
) : null}
</div>
)
);
}
/**
* Modalradix Dialog 风格的轻实现。
*/
export function Modal({ open, onClose, title, children, footer, labelledBy, size = 'md' }) {
const restoreFocus = useRef(null)
export function Modal({
open,
onClose,
title,
children,
footer,
labelledBy,
size = "md",
}) {
const restoreFocus = useRef(null);
useEffect(() => {
if (open) {
restoreFocus.current = document.activeElement
return
restoreFocus.current = document.activeElement;
return;
}
// 关闭时把焦点还给触发元素(与 MUI/Radix Dialog 行为一致,键盘用户不会丢失位置)
const trigger = restoreFocus.current
restoreFocus.current = null
if (trigger && trigger.isConnected && typeof trigger.focus === 'function') trigger.focus()
}, [open])
const trigger = restoreFocus.current;
restoreFocus.current = null;
if (trigger && trigger.isConnected && typeof trigger.focus === "function")
trigger.focus();
}, [open]);
useEffect(() => {
if (!open) return
const onKey = event => { if (event.key === 'Escape') onClose?.() }
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [open, onClose])
if (!open) return null
const widths = { sm: 'max-w-md', md: 'max-w-lg', lg: 'max-w-2xl' }
if (!open) return;
const onKey = (event) => {
if (event.key === "Escape") onClose?.();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
const widths = { sm: "max-w-md", md: "max-w-lg", lg: "max-w-2xl" };
return createPortal(
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 p-4 sm:items-center" onMouseDown={event => { if (event.target === event.currentTarget) onClose?.() }}>
<div role="dialog" aria-modal="true" aria-labelledby={labelledBy} className={cn('relative w-full rounded-lg border border-hairline bg-white shadow-xl', widths[size])}>
<div
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 p-4 sm:items-center"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose?.();
}}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby={labelledBy}
className={cn(
"relative w-full rounded-lg border border-hairline bg-white shadow-xl",
widths[size],
)}
>
<div className="flex items-start justify-between border-b border-hairline px-5 py-4">
<h2 id={labelledBy} className="min-w-0 text-base font-semibold">{title}</h2>
<button type="button" aria-label="关闭" onClick={onClose} className="rounded-md p-1 text-muted hover:bg-[#f5f7fa]"><i className="ri-close-line" aria-hidden="true" /></button>
<h2 id={labelledBy} className="min-w-0 text-base font-semibold">
{title}
</h2>
<button
type="button"
aria-label="关闭"
onClick={onClose}
className="rounded-md p-1 text-muted hover:bg-[#f5f7fa]"
>
<i className="ri-close-line" aria-hidden="true" />
</button>
</div>
<div className="max-h-[70vh] overflow-y-auto px-5 py-4">{children}</div>
{footer ? <div className="flex justify-end gap-3 border-t border-hairline px-5 py-3.5">{footer}</div> : null}
{footer ? (
<div className="flex justify-end gap-3 border-t border-hairline px-5 py-3.5">
{footer}
</div>
) : null}
</div>
</div>,
document.body,
)
);
}
export function ConfirmDialog({ open, onClose, onConfirm, title, body, confirmLabel = '确认', busy }) {
export function ConfirmDialog({
open,
onClose,
onConfirm,
title,
body,
confirmLabel = "确认",
busy,
}) {
return (
<Modal open={open} onClose={busy ? undefined : onClose} title={title} size="sm" labelledBy="confirm-title"
footer={<>
<Button onClick={onClose} disabled={busy}>取消</Button>
<Button variant="primary" busy={busy} onClick={onConfirm}>{confirmLabel}</Button>
</>}>
<Modal
open={open}
onClose={busy ? undefined : onClose}
title={title}
size="sm"
labelledBy="confirm-title"
footer={
<>
<Button onClick={onClose} disabled={busy}>
取消
</Button>
<Button variant="primary" busy={busy} onClick={onConfirm}>
{confirmLabel}
</Button>
</>
}
>
<p className="text-sm text-ink">{body}</p>
</Modal>
)
);
}
export function Copyable({ value, className }) {
const copy = () => navigator.clipboard?.writeText(value)
const copy = () => navigator.clipboard?.writeText(value);
return (
<span className={cn('inline-flex min-w-0 items-center gap-1 text-sm', className)}>
<span
className={cn(
"inline-flex min-w-0 items-center gap-1 text-sm",
className,
)}
>
<code className="anywhere font-mono text-[13px]">{value}</code>
<button type="button" aria-label={`复制 ${value}`} onClick={copy} className="shrink-0 rounded p-0.5 text-muted hover:text-primary"><i className="ri-file-copy-line text-[13px]" aria-hidden="true" /></button>
<button
type="button"
aria-label={`复制 ${value}`}
onClick={copy}
className="shrink-0 rounded p-0.5 text-muted hover:text-primary"
>
<i className="ri-file-copy-line text-[13px]" aria-hidden="true" />
</button>
</span>
)
);
}
export function Checkbox({ id, checked, onCheckedChange, disabled, children }) {
return (
<label htmlFor={id} className="flex cursor-pointer items-start gap-2 text-sm">
<input type="checkbox" id={id} checked={checked} disabled={disabled} onChange={event => onCheckedChange(event.target.checked)} className="mt-0.5 size-4 accent-primary" />
<label
htmlFor={id}
className="flex cursor-pointer items-start gap-2 text-sm"
>
<input
type="checkbox"
id={id}
checked={checked}
disabled={disabled}
onChange={(event) => onCheckedChange(event.target.checked)}
className="mt-0.5 size-4 accent-primary"
/>
<span className="min-w-0">{children}</span>
</label>
)
);
}
export function Switch({ id, checked, onCheckedChange, disabled, 'aria-label': ariaLabel }) {
export function Switch({
id,
checked,
onCheckedChange,
disabled,
"aria-label": ariaLabel,
}) {
return (
<label htmlFor={id} className="inline-flex cursor-pointer items-center gap-2 text-sm">
<input type="checkbox" id={id} role="switch" aria-label={ariaLabel} checked={checked} disabled={disabled} onChange={event => onCheckedChange(event.target.checked)} className="sr-only" />
<span className={cn('relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors', checked ? 'bg-primary' : 'bg-[#c4ccd9]')}>
<span className={cn('absolute left-0.5 size-4 rounded-full bg-white transition-transform', checked && 'translate-x-4')} />
<label
htmlFor={id}
className="inline-flex cursor-pointer items-center gap-2 text-sm"
>
<input
type="checkbox"
id={id}
role="switch"
aria-label={ariaLabel}
checked={checked}
disabled={disabled}
onChange={(event) => onCheckedChange(event.target.checked)}
className="sr-only"
/>
<span
className={cn(
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
checked ? "bg-primary" : "bg-[#c4ccd9]",
)}
>
<span
className={cn(
"absolute left-0.5 size-4 rounded-full bg-white transition-transform",
checked && "translate-x-4",
)}
/>
</span>
</label>
)
);
}
// 枚举胶囊:运行状态、健康状态、任务状态等。
export function StatusPill({ tone, label, sub }) {
const tones = {
success: 'text-success bg-[#f0f9f4]',
info: 'text-primary bg-[#eef5ff]',
warning: 'text-warning bg-[#fdf6ec]',
danger: 'text-danger bg-[#fdf1f0]',
neutral: 'text-muted bg-[#f5f7fa]',
}[tone]
success: "text-success bg-[#f0f9f4]",
info: "text-primary bg-[#eef5ff]",
warning: "text-warning bg-[#fdf6ec]",
danger: "text-danger bg-[#fdf1f0]",
neutral: "text-muted bg-[#f5f7fa]",
}[tone];
return (
<span className={cn('inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium', tones)}>
<i className={tone === 'success' ? 'ri-checkbox-circle-fill' : tone === 'warning' ? 'ri-error-warning-fill' : tone === 'danger' ? 'ri-close-circle-fill' : 'ri-subtract-fill'} aria-hidden="true" />
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium",
tones,
)}
>
<i
className={
tone === "success"
? "ri-checkbox-circle-fill"
: tone === "warning"
? "ri-error-warning-fill"
: tone === "danger"
? "ri-close-circle-fill"
: "ri-subtract-fill"
}
aria-hidden="true"
/>
{label}
{sub ? <span className="text-[10px] font-normal opacity-75">{sub}</span> : null}
{sub ? (
<span className="text-[10px] font-normal opacity-75">{sub}</span>
) : null}
</span>
)
);
}
/**
* 紧凑表格:列头 + 行渲染。资源域信息密度高,适合 table。列过宽时横向滚动。
*/
export function Table({ columns, rows, rowKey, minWidth = '900px', onRowClick, className }) {
export function Table({
columns,
rows,
rowKey,
minWidth = "900px",
onRowClick,
className,
}) {
return (
<div className={cn('overflow-x-auto rounded-lg border border-hairline bg-white', className)}>
<table className={cn('w-full table-fixed text-sm')} style={{ minWidth }}>
<div
className={cn(
"overflow-x-auto rounded-lg border border-hairline bg-white",
className,
)}
>
<table className={cn("w-full table-fixed text-sm")} style={{ minWidth }}>
<thead>
<tr className="border-b border-hairline text-left text-xs text-muted">
{columns.map(column => (
<th key={column.header} style={{ width: column.width }} className={cn('px-4 py-2.5 font-medium', column.align === 'right' && 'text-right')}>{column.header}</th>
{columns.map((column) => (
<th
key={column.header}
style={{ width: column.width }}
className={cn(
"px-4 py-2.5 font-medium",
column.align === "right" && "text-right",
)}
>
{column.header}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map(row => {
const clickable = onRowClick ? 'cursor-pointer hover:bg-[#f7f8fa]' : ''
{rows.map((row) => {
const clickable = onRowClick
? "cursor-pointer hover:bg-[#f7f8fa]"
: "";
return (
<tr key={rowKey(row)} className={cn('border-b border-hairline last:border-0', clickable)} onClick={onRowClick ? () => onRowClick(row) : undefined}>
{columns.map(column => (
<td key={column.header} className={cn('px-4 py-3', column.align === 'right' && 'text-right')}>{column.render(row)}</td>
<tr
key={rowKey(row)}
className={cn(
"border-b border-hairline last:border-0",
clickable,
)}
onClick={onRowClick ? () => onRowClick(row) : undefined}
>
{columns.map((column) => (
<td
key={column.header}
className={cn(
"px-4 py-3",
column.align === "right" && "text-right",
)}
>
{column.render(row)}
</td>
))}
</tr>
)
);
})}
</tbody>
</table>
</div>
)
);
}
export function DetailList({ rows }) {
@@ -342,9 +753,9 @@ export function DetailList({ rows }) {
{rows.map(([term, value]) => (
<div key={term} className="flex min-w-0 flex-col gap-0.5">
<dt className="text-muted">{term}</dt>
<dd className="anywhere">{value ?? '—'}</dd>
<dd className="anywhere">{value ?? "—"}</dd>
</div>
))}
</dl>
)
);
}