refactor: reorganize project by component

This commit is contained in:
2026-09-18 19:00:31 +08:00
parent 03ee6e3d6c
commit 2275075d7e
140 changed files with 3692 additions and 3275 deletions
+337
View File
@@ -0,0 +1,337 @@
// 401 = 未认证:抹去凭证并引导到登录页。开发代理下 401 由后端直接返回。
const unauthorized = () => {
localStorage.removeItem("creatorhub.auth");
if (!location.hash.startsWith("#/login")) location.hash = "#/login";
return Object.assign(new Error("认证已过期,请重新登录"), { status: 401 });
};
async function request(path = "", options) {
const auth = localStorage.getItem("creatorhub.auth");
const headers = { ...options?.headers };
if (auth) headers.Authorization = `Basic ${btoa(auth)}`;
let response;
try {
response = await fetch(`/api${path}`, { ...options, headers });
} catch (networkError) {
throw Object.assign(new Error(`网络请求失败:${networkError.message}`), {
status: 0,
});
}
if (response.status === 401) throw unauthorized();
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw Object.assign(
new Error(body.error || `请求失败 (${response.status})`),
{ status: response.status, body },
);
}
if (response.status === 204) return null;
const contentType = response.headers?.get?.("content-type") || "";
if (contentType.toLowerCase().includes("text/html")) {
throw Object.assign(new Error("API 返回了非 JSON 响应,请检查控制面路由"), {
status: response.status,
});
}
return response.json();
}
const jsonOptions = (method, data) => ({
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
const unsupported = (resource, operation) =>
Promise.reject(new Error(`${resource} 不支持 ${operation}`));
const resourcePaths = {
browsers: "/browsers",
"browser-versions": "/browser-versions",
gateways: "/gateways",
accounts: "/phase-a/accounts",
drafts: "/phase-a/drafts",
confirmations: "/phase-a/confirmations",
tasks: "/phase-a/tasks",
attempts: "/phase-a/attempts",
audit: "/phase-a/audit",
"network-exits": "/network-exits",
"creator-accounts": "/creator/accounts",
"creator-competitors": "/creator/competitors",
"creator-competitor-preview": "/creator/competitors/preview",
"creator-works": "/creator/works",
"creator-comments": "/creator/comments",
"creator-leads": "/creator/leads",
"creator-rules": "/creator/rules",
"creator-operations": "/creator/operations",
"creator-conversations": "/creator/conversations",
"creator-events": "/creator/events",
"creator-listeners": "/creator/listeners",
};
const withID = (record, fallback) => ({
...record,
id: record.id ?? record.alias ?? record.version ?? record.name ?? fallback,
});
export const dataProvider = {
// Refine v5 契约:所有方法收单个参数对象(getList 收 {resource, pagination, filters, meta})。
async getList({ resource, pagination = {}, filters = [], meta = {} }) {
const path = resourcePaths[resource];
if (!path) return unsupported(resource, "getList");
// filters 为 [{field, value, operator}];meta 是 Refine 透传的查询上下文。
const valueOf = (field) => {
const direct = meta?.[field];
if (direct !== undefined) return direct;
const entry = filters?.find((item) => item.field === field);
return entry?.value !== undefined &&
entry.value !== null &&
entry.value !== ""
? entry.value
: undefined;
};
const query = new URLSearchParams(
filterKeys(resource).flatMap((field) =>
valueOf(field) !== undefined ? [[field, valueOf(field)]] : [],
),
);
if (resource === "audit" || resource.startsWith("creator-")) {
const currentPage = pagination.currentPage || pagination.page;
const pageSize = pagination.pageSize || pagination.perPage;
if (currentPage || pageSize) {
query.set("page", currentPage || 1);
query.set("page_size", pageSize || 25);
}
}
const records = await request(`${path}${query.size ? `?${query}` : ""}`);
const data = Array.isArray(records) ? records : records.data;
const result = {
data: data.map((record, index) => withID(record, index)),
total: records.total ?? data.length,
};
if (!Array.isArray(records) && Object.hasOwn(records, "has_next")) {
result.hasNext = Boolean(records.has_next);
}
return result;
},
async getOne({ resource, id }) {
const path = resourcePaths[resource];
if (
!path ||
![
"accounts",
"network-exits",
"browsers",
"drafts",
"confirmations",
"tasks",
"attempts",
"creator-accounts",
"creator-competitors",
"creator-works",
"creator-comments",
"creator-rules",
"creator-operations",
].includes(resource)
)
return unsupported(resource, "getOne");
const record = await request(`${path}/${encodeURIComponent(id)}`);
return { data: withID(record, id) };
},
async create({ resource, variables }) {
const path = resourcePaths[resource];
if (!path) return unsupported(resource, "create");
const created = await request(path, jsonOptions("POST", variables));
return {
data: withID(
{ ...variables, ...created },
variables.id ?? variables.alias,
),
};
},
async update({ resource, id, variables }) {
const path = resourcePaths[resource];
if (!path || resource === "browsers")
return unsupported(resource, "update");
// browser-versions 的 PUT 不接受 version 字段(路径已携带),透传其余字段。
const { version: _ignored, ...rest } = variables ?? {};
await request(
`${path}/${encodeURIComponent(id)}`,
jsonOptions("PUT", rest),
);
return { data: { ...rest, id } };
},
async deleteOne({ resource, id }) {
const path = resourcePaths[resource];
if (
!path ||
!["browser-versions", "accounts", "creator-competitors"].includes(resource)
)
return unsupported(resource, "deleteOne");
await request(`${path}/${encodeURIComponent(id)}`, { method: "DELETE" });
return { data: { id } };
},
getMany: (resource) => unsupported(resource, "getMany"),
getManyReference: (resource) => unsupported(resource, "getManyReference"),
updateMany: (resource) => unsupported(resource, "updateMany"),
deleteMany: (resource) => unsupported(resource, "deleteMany"),
// 环境的领域动作保持显式动词,不伪装成 CRUD update。
async browserAction(alias, action, data) {
if (action === "upgrade") {
await request(
`/browsers/${encodeURIComponent(alias)}/upgrade`,
jsonOptions("POST", data),
);
return;
}
const paths = {
start: [`/browsers/${encodeURIComponent(alias)}/start`, "POST"],
stop: [`/browsers/${encodeURIComponent(alias)}/stop`, "POST"],
recycle: [`/browsers/${encodeURIComponent(alias)}`, "DELETE"],
};
const target = paths[action];
if (!target) throw new Error(`未知运行环境操作: ${action}`);
await request(target[0], { method: target[1] });
},
async accountAction(id, action) {
if (action !== "pause" && action !== "resume")
throw new Error(`未知账号操作: ${action}`);
await request(`/phase-a/accounts/${encodeURIComponent(id)}/${action}`, {
method: "POST",
});
},
createDraft(accountID, content) {
return request(
"/phase-a/drafts",
jsonOptions("POST", { account_id: accountID, content }),
);
},
confirmDraft(draftID, accountVersion, draftVersion) {
return request(
"/phase-a/confirmations",
jsonOptions("POST", {
draft_id: draftID,
account_version: accountVersion,
draft_version: draftVersion,
}),
);
},
enqueueConfirmation(confirmationID) {
return request(
"/phase-a/tasks",
jsonOptions("POST", { confirmation_id: confirmationID }),
);
},
async taskAction(id, action, data) {
if (!["verify", "resume", "finish", "cancel"].includes(action))
throw new Error(`未知任务操作: ${action}`);
const options = data ? jsonOptions("POST", data) : { method: "POST" };
await request(
`/phase-a/tasks/${encodeURIComponent(id)}/${action}`,
options,
);
},
async networkExitAction(id, action) {
if (!["check", "disable", "enable"].includes(action))
throw new Error(`未知网络出口操作: ${action}`);
return request(`/network-exits/${encodeURIComponent(id)}/${action}`, {
method: "POST",
});
},
creatorRequest(path, options) {
return request(path, options);
},
creatorAction(path, data) {
return request(
path,
data === undefined ? { method: "POST" } : jsonOptions("POST", data),
);
},
creatorCreate(path, data) {
return request(path, jsonOptions("POST", data));
},
creatorGet(path) {
return request(path);
},
creatorSyncConversation(id, limit = 200) {
return request(
`/creator/conversations/${encodeURIComponent(id)}/sync?limit=${encodeURIComponent(limit)}`,
{ method: "POST" },
);
},
async creatorSubscribe(path, onMessage, signal, onStatus) {
const auth = localStorage.getItem("creatorhub.auth");
const headers = {};
if (auth) headers.Authorization = `Basic ${btoa(auth)}`;
const response = await fetch(`/api${path}`, { headers, signal });
if (response.status === 401) throw unauthorized();
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw Object.assign(
new Error(body.error || `请求失败 (${response.status})`),
{ status: response.status, body },
);
}
if (!response.body) throw new Error("业务更新流不可用");
const reader = response.body.getReader();
onStatus?.("connected");
const decoder = new TextDecoder();
let buffer = "";
const dispatch = (frame) => {
if (frame.split(/\r\n|\n|\r/).some((line) => line.startsWith("data:")))
onMessage(frame);
};
while (true) {
const { value, done } = await reader.read();
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
let match;
while ((match = buffer.match(/\r\n\r\n|\n\n|\r\r/))) {
dispatch(buffer.slice(0, match.index));
buffer = buffer.slice(match.index + match[0].length);
}
if (done) {
if (buffer.trim()) dispatch(buffer);
onStatus?.("disconnected");
return;
}
}
},
creatorUpdate(path, data) {
return request(path, jsonOptions("PUT", data));
},
};
function filterKeys(resource) {
return (
{
drafts: ["account_id"],
confirmations: ["draft_id"],
tasks: ["account_id", "draft_id", "state"],
audit: [
"account_id",
"task_id",
"attempt_id",
"browser_env_alias",
"network_exit_id",
"event_type",
"from",
"to",
],
"creator-competitors": ["platform"],
"creator-works": [
"platform",
"source_id",
"source_type",
"published_after",
"published_before",
"min_likes",
"min_comments",
"min_shares",
],
"creator-comments": ["platform", "work_id"],
"creator-leads": ["platform"],
"creator-operations": ["account_id"],
"creator-conversations": ["account_id"],
"creator-events": ["account_id"],
}[resource] || []
);
}
+339
View File
@@ -0,0 +1,339 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { dataProvider } from "./dataProvider";
afterEach(() => vi.unstubAllGlobals());
describe("dataProvider", () => {
it("maps the existing browser list into refine records", async () => {
const fetch = vi
.fn()
.mockResolvedValue(
new Response(
JSON.stringify([{ name: "account-a", state: "running" }]),
{ status: 200 },
),
);
vi.stubGlobal("fetch", fetch);
await expect(
dataProvider.getList({ resource: "browsers" }),
).resolves.toEqual({
data: [{ id: "account-a", name: "account-a", state: "running" }],
total: 1,
});
expect(fetch).toHaveBeenCalledWith("/api/browsers", { headers: {} });
});
it("attaches Basic credentials from localStorage", async () => {
localStorage.setItem("creatorhub.auth", "user:pass");
const fetch = vi
.fn()
.mockResolvedValue(new Response("[]", { status: 200 }));
vi.stubGlobal("fetch", fetch);
await dataProvider.getList({ resource: "browsers" });
expect(fetch).toHaveBeenCalledWith("/api/browsers", {
headers: { Authorization: "Basic dXNlcjpwYXNz" },
});
localStorage.removeItem("creatorhub.auth");
});
it("clears credentials and redirects to login on 401", async () => {
localStorage.setItem("creatorhub.auth", "user:pass");
const fetch = vi.fn().mockResolvedValue(new Response("", { status: 401 }));
vi.stubGlobal("fetch", fetch);
await expect(
dataProvider.getList({ resource: "browsers" }),
).rejects.toMatchObject({ status: 401 });
expect(localStorage.getItem("creatorhub.auth")).toBeNull();
expect(location.hash).toBe("#/login");
});
it.each([
["accounts", "/api/phase-a/accounts"],
["network-exits", "/api/network-exits"],
])("loads %s for the environment create contract", async (resource, path) => {
const fetch = vi
.fn()
.mockResolvedValue(new Response('[{"id":"record-1"}]', { status: 200 }));
vi.stubGlobal("fetch", fetch);
await expect(dataProvider.getList({ resource })).resolves.toMatchObject({
data: [{ id: "record-1" }],
});
expect(fetch).toHaveBeenCalledWith(path, { headers: {} });
});
it.each([
["accounts", "account-a", "/api/phase-a/accounts/account-a"],
["network-exits", "exit/one", "/api/network-exits/exit%2Fone"],
["browsers", "environment-one", "/api/browsers/environment-one"],
["drafts", "draft/one", "/api/phase-a/drafts/draft%2Fone"],
[
"confirmations",
"confirmation/one",
"/api/phase-a/confirmations/confirmation%2Fone",
],
["tasks", "task/one", "/api/phase-a/tasks/task%2Fone"],
["attempts", "attempt/one", "/api/phase-a/attempts/attempt%2Fone"],
])("loads %s detail through its stable API path", async (resource, id, path) => {
const fetch = vi
.fn()
.mockResolvedValue(new Response(JSON.stringify({ id }), { status: 200 }));
vi.stubGlobal("fetch", fetch);
await expect(dataProvider.getOne({ resource, id })).resolves.toEqual({
data: { id },
});
expect(fetch).toHaveBeenCalledWith(path, { headers: {} });
});
it("creates an account without a client-generated technical id", async () => {
const fetch = vi
.fn()
.mockResolvedValue(
new Response('{"id":"account-generated"}', { status: 201 }),
);
vi.stubGlobal("fetch", fetch);
const data = {
name: "店铺一号",
platform: "douyin",
platform_account_key: "shop-a",
tags: ["主账号"],
cookies: "sessionid=value",
};
await expect(
dataProvider.create({ resource: "accounts", variables: data }),
).resolves.toEqual({ data: { ...data, id: "account-generated" } });
expect(fetch).toHaveBeenCalledWith(
"/api/phase-a/accounts",
expect.objectContaining({ method: "POST" }),
);
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual(data);
});
it("filters drafts by account through the server list contract", async () => {
const fetch = vi
.fn()
.mockResolvedValue(new Response("[]", { status: 200 }));
vi.stubGlobal("fetch", fetch);
await dataProvider.getList({
resource: "drafts",
filters: [{ field: "account_id", value: "account-a" }],
});
expect(fetch).toHaveBeenCalledWith(
"/api/phase-a/drafts?account_id=account-a",
{ headers: {} },
);
});
it("maps paginated audit filters without exposing a CRUD mutation", async () => {
const fetch = vi
.fn()
.mockResolvedValue(
new Response(
'{"data":[{"id":7,"event_type":"task_verified"}],"total":31}',
{ status: 200 },
),
);
vi.stubGlobal("fetch", fetch);
await expect(
dataProvider.getList({
resource: "audit",
pagination: { currentPage: 2, pageSize: 25 },
filters: [
{ field: "task_id", value: "task-a" },
{ field: "attempt_id", value: "attempt-a" },
{ field: "browser_env_alias", value: "env-a" },
{ field: "network_exit_id", value: "exit-a" },
],
}),
).resolves.toEqual({
data: [{ id: 7, event_type: "task_verified" }],
total: 31,
});
expect(fetch).toHaveBeenCalledWith(
"/api/phase-a/audit?task_id=task-a&attempt_id=attempt-a&browser_env_alias=env-a&network_exit_id=exit-a&page=2&page_size=25",
{ headers: {} },
);
});
it("keeps generated draft, confirmation and enqueue identifiers out of user input", async () => {
const fetch = vi
.fn()
.mockImplementation(() =>
Promise.resolve(new Response('{"id":"generated"}', { status: 201 })),
);
vi.stubGlobal("fetch", fetch);
await dataProvider.createDraft("account-a", "hello");
await dataProvider.confirmDraft("draft-a", 2, 3);
await dataProvider.enqueueConfirmation("confirmation-a");
expect(
fetch.mock.calls.map(([path, options]) => [
path,
JSON.parse(options.body),
]),
).toEqual([
["/api/phase-a/drafts", { account_id: "account-a", content: "hello" }],
[
"/api/phase-a/confirmations",
{ draft_id: "draft-a", account_version: 2, draft_version: 3 },
],
["/api/phase-a/tasks", { confirmation_id: "confirmation-a" }],
]);
});
it.each([
["start", "/api/browsers/account-a/start", "POST"],
["stop", "/api/browsers/account-a/stop", "POST"],
["recycle", "/api/browsers/account-a", "DELETE"],
])("keeps %s as an explicit domain action", async (action, path, method) => {
const fetch = vi
.fn()
.mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal("fetch", fetch);
await dataProvider.browserAction("account-a", action);
expect(fetch).toHaveBeenCalledWith(
path,
expect.objectContaining({ method }),
);
});
it("sends upgrade with the version payload", async () => {
const fetch = vi
.fn()
.mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal("fetch", fetch);
await dataProvider.browserAction("account-a", "upgrade", {
version: "144.0.0.1",
});
expect(fetch).toHaveBeenCalledWith(
"/api/browsers/account-a/upgrade",
expect.objectContaining({
method: "POST",
body: '{"version":"144.0.0.1"}',
}),
);
});
it("preserves API error messages and status codes", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ error: "网关不可用" }), {
status: 502,
}),
),
);
await expect(
dataProvider.getList({ resource: "browsers" }),
).rejects.toMatchObject({ message: "网关不可用", status: 502 });
});
it.each([
409, 503,
])("preserves structured action errors for status %s", async (status) => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValue(
new Response(
JSON.stringify({
error: "blocked",
reason_code: "network_exit_unhealthy",
}),
{ status },
),
),
);
await expect(
dataProvider.accountAction("account-a", "resume"),
).rejects.toMatchObject({
message: "blocked",
status,
body: { error: "blocked", reason_code: "network_exit_unhealthy" },
});
});
it.each([
["pause", "/api/phase-a/accounts/account-a/pause"],
["resume", "/api/phase-a/accounts/account-a/resume"],
])("sends explicit account action %s", async (action, path) => {
const fetch = vi
.fn()
.mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal("fetch", fetch);
await dataProvider.accountAction("account-a", action);
expect(fetch).toHaveBeenCalledWith(path, { method: "POST", headers: {} });
});
it("keeps verification and recovery as separate task actions", async () => {
const fetch = vi
.fn()
.mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal("fetch", fetch);
await dataProvider.taskAction("task/a", "verify", {
result: "not_executed",
});
await dataProvider.taskAction("task/a", "resume");
expect(fetch.mock.calls).toEqual([
[
"/api/phase-a/tasks/task%2Fa/verify",
expect.objectContaining({
method: "POST",
body: '{"result":"not_executed"}',
}),
],
["/api/phase-a/tasks/task%2Fa/resume", { method: "POST", headers: {} }],
]);
});
});
describe("creatorSubscribe SSE framing", () => {
it("keeps split UTF-8 messages and accepts CRLF framing", async () => {
const encoder = new TextEncoder();
const chunks = [
'data: {"text":"北',
'京"}\r\n\r',
'\ndata: {"text":"ok"}\n\n',
];
const body = new ReadableStream({
start(controller) {
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk));
controller.close();
},
});
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.resolve({ status: 200, ok: true, body })),
);
const messages = [];
const statuses = [];
await dataProvider.creatorSubscribe(
"/creator/updates",
(frame) => messages.push(frame),
new AbortController().signal,
(status) => statuses.push(status),
);
expect(messages).toEqual(['data: {"text":"北京"}', 'data: {"text":"ok"}']);
expect(statuses).toEqual(["connected", "disconnected"]);
});
});
+39
View File
@@ -0,0 +1,39 @@
import { useEffect } from 'react'
export function useTitle(title) {
useEffect(() => { document.title = title }, [title])
}
// 401 之外的业务错误(404/409/503…)直接展示后端 message;401 在 dataProvider 里转跳登录页。
export function displayError(error) {
return error?.message || '操作失败'
}
export function conflictError(error, fallback) {
if (error?.status === 409) return `冲突(409):${error.body?.reason || fallback}`
if (error?.status === 503) return `资源未就绪(503):${error.message}`
return displayError(error)
}
export function useUnsavedChanges(dirty) {
useEffect(() => {
if (!dirty) return undefined
const beforeUnload = (event) => {
event.preventDefault()
event.returnValue = ""
}
const click = (event) => {
const anchor = event.target.closest?.("a[href]")
if (!anchor || !anchor.getAttribute("href")?.startsWith("#/")) return
if (window.confirm("当前内容尚未保存,确定离开吗?")) return
event.preventDefault()
event.stopPropagation()
}
window.addEventListener("beforeunload", beforeUnload)
document.addEventListener("click", click, true)
return () => {
window.removeEventListener("beforeunload", beforeUnload)
document.removeEventListener("click", click, true)
}
}, [dirty])
}
+41
View File
@@ -0,0 +1,41 @@
import { afterEach, describe, expect, it } from "vitest";
import { render } from "@testing-library/react";
import { conflictError, displayError, useTitle } from "./hooks";
describe("hooks helpers", () => {
afterEach(() => {
document.title = "";
});
it("returns backend messages and fallback text", () => {
expect(displayError({ message: "backend failed" })).toBe("backend failed");
expect(displayError({})).toBe("操作失败");
});
it("formats conflict and unavailable errors", () => {
expect(
conflictError(
{ status: 409, body: { reason: "version changed" } },
"retry",
),
).toBe("冲突(409):version changed");
expect(conflictError({ status: 503, message: "offline" }, "retry")).toBe(
"资源未就绪(503):offline",
);
expect(conflictError({ status: 400, message: "bad input" }, "retry")).toBe(
"bad input",
);
expect(conflictError({ status: 409, body: {} }, "retry")).toBe(
"冲突(409):retry",
);
});
it("updates the document title", () => {
function TitleProbe() {
useTitle("CreatorHub 测试");
return null;
}
render(<TitleProbe />);
expect(document.title).toBe("CreatorHub 测试");
});
});
+761
View File
@@ -0,0 +1,761 @@
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));
}
export function apiMessage(error) {
return error?.message || "操作失败";
}
export function conflictMessage(error, fallback) {
if (error?.status === 409)
return `冲突(409):${error?.body?.error || fallback}`;
if (error?.status === 503)
return `环境不可用(503):${error?.body?.reason || error.message}`;
return apiMessage(error);
}
export const dateTime = (value) => new Date(value).toLocaleString("zh-CN");
/**
* 页面加载/错误/空态的统一外壳。
*/
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>
);
}
if (error) {
return (
<Alert variant="destructive" className="mb-6">
<p>{apiMessage(error)}</p>
{onRetry ? (
<Button variant="outline" size="sm" onClick={onRetry}>
{retryLabel}
</Button>
) : null}
</Alert>
);
}
if (empty) return <EmptyState text={emptyText} />;
return children;
}
export function EmptyState({ text, children }) {
return (
<Card>
<CardContent className="py-14 text-center">
<p className="text-sm text-muted">{text}</p>
{children}
</CardContent>
</Card>
);
}
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];
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}
>
{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",
};
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}
disabled={busy || props.disabled}
>
{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}
/>
);
}
export function CardContent({ className, ...props }) {
return <div className={cn("p-5", className)} {...props} />;
}
export function PageHeader({ icon, title, description, children }) {
return (
<header className="mb-7 flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<h1 className="flex items-center gap-2.5 text-2xl font-bold tracking-tight">
{icon ? <i className={icon} aria-hidden="true" /> : null}
{title}
</h1>
{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}
</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 control = children;
return (
<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>
{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}
</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}
/>
);
}
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}
/>
);
}
/**
* TAG 标签输入:回车或逗号确认一个胶囊,可退格删除;label 通过 id 关联内层输入框。
*/
export function TagInput({
id,
value,
onChange,
placeholder = "回车确认",
invalid,
className,
...props
}) {
const [draft, setDraft] = useState("");
const commit = (raw) => {
const tags = raw
.split(/[,,]/)
.map((tag) => tag.trim())
.filter(Boolean);
setDraft("");
if (tags.length) onChange(Array.from(new Set(value.concat(tags))));
};
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));
}
};
return (
<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
? options
: [{ value: emptyOption.value, label: emptyOption.label }, ...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}
role="combobox"
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={`${id}-listbox`}
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",
)}
>
<span className="truncate">{label}</span>
<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"
>
{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>
))}
</ul>
) : null}
</div>
);
}
/**
* Modal:radix Dialog 风格的轻实现。
*/
export function Modal({
open,
onClose,
title,
children,
footer,
labelledBy,
size = "md",
}) {
const restoreFocus = useRef(null);
useEffect(() => {
if (open) {
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]);
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" };
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="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>
</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}
</div>
</div>,
document.body,
);
}
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>
</>
}
>
<p className="text-sm text-ink">{body}</p>
</Modal>
);
}
export function Copyable({ value, className }) {
const copy = () => navigator.clipboard?.writeText(value);
return (
<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>
</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"
/>
<span className="min-w-0">{children}</span>
</label>
);
}
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",
)}
/>
</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];
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"
/>
{label}
{sub ? (
<span className="text-[10px] font-normal opacity-75">{sub}</span>
) : null}
</span>
);
}
/**
* 紧凑表格:列头 + 行渲染。资源域信息密度高,适合 table。列过宽时横向滚动。
*/
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 }}>
<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>
))}
</tr>
</thead>
<tbody>
{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>
);
})}
</tbody>
</table>
</div>
);
}
export function DetailList({ rows }) {
return (
<dl className="space-y-2.5 text-sm">
{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>
</div>
))}
</dl>
);
}