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:
+92
-67
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 关联控件 id;helper 与 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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal:radix 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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user