refactor: reorganize project by component
This commit is contained in:
@@ -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] || []
|
||||
);
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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 测试");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user