338 lines
11 KiB
JavaScript
338 lines
11 KiB
JavaScript
// 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-images": "/browser-images",
|
||
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-images 的 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-images", "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] || []
|
||
);
|
||
}
|