feat: integrate creator hub douyin workflows
This commit is contained in:
@@ -350,6 +350,15 @@ export function AccountList() {
|
||||
render: (account) =>
|
||||
account.runtime_status === "active" ? "启用" : "暂停",
|
||||
},
|
||||
{
|
||||
header: "操作",
|
||||
width: "16%",
|
||||
render: (account) => (
|
||||
<Button size="sm" as={Link} to={`/accounts/${account.id}`}>
|
||||
查看账号
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
rows={accounts}
|
||||
rowKey={(account) => account.id}
|
||||
|
||||
@@ -73,10 +73,13 @@ export function CreatorAccountsPage() {
|
||||
const [editingStrategyID, setEditingStrategyID] = useState("");
|
||||
const [relations, setRelations] = useState([]);
|
||||
const [relationError, setRelationError] = useState(null);
|
||||
const [listener, setListener] = useState(null);
|
||||
const [listenerError, setListenerError] = useState(null);
|
||||
const loadSequence = useRef(0);
|
||||
const selectionVersion = useRef(0);
|
||||
const strategySequence = useRef(0);
|
||||
const relationSequence = useRef(0);
|
||||
const listenerSequence = useRef(0);
|
||||
const [strategyForm, setStrategyForm] = useState({
|
||||
execution_account_id: "",
|
||||
position: 1,
|
||||
@@ -130,6 +133,23 @@ export function CreatorAccountsPage() {
|
||||
if (sequence === strategySequence.current) setStrategyError(loadError);
|
||||
});
|
||||
}, [selectedID]);
|
||||
useEffect(() => {
|
||||
if (!selectedID) return;
|
||||
const sequence = ++listenerSequence.current;
|
||||
setListenerError(null);
|
||||
setListener(null);
|
||||
dataProvider
|
||||
.creatorGet(
|
||||
`/creator/listeners?account_id=${encodeURIComponent(selectedID)}`,
|
||||
)
|
||||
.then((result) => {
|
||||
if (sequence === listenerSequence.current)
|
||||
setListener(result[0] || null);
|
||||
})
|
||||
.catch((loadError) => {
|
||||
if (sequence === listenerSequence.current) setListenerError(loadError);
|
||||
});
|
||||
}, [selectedID]);
|
||||
useEffect(() => {
|
||||
if (!selectedID) return;
|
||||
const sequence = ++relationSequence.current;
|
||||
@@ -208,6 +228,40 @@ export function CreatorAccountsPage() {
|
||||
}
|
||||
}
|
||||
};
|
||||
const verifyLogin = async () => {
|
||||
if (!selected) return;
|
||||
setBusy(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const result = await dataProvider.creatorAction(
|
||||
`/creator/accounts/${encodeURIComponent(selected.id)}/verify`,
|
||||
);
|
||||
setProfiles((items) =>
|
||||
items.map((item) =>
|
||||
item.id === selected.id
|
||||
? {
|
||||
...item,
|
||||
login_status: result.status,
|
||||
login_reason: result.reason,
|
||||
login_checked_at: result.checked_at,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
setNotice({ variant: "success", text: "浏览器身份核验成功。" });
|
||||
await load();
|
||||
} catch (verifyError) {
|
||||
setNotice({
|
||||
variant: "destructive",
|
||||
text: conflictMessage(
|
||||
verifyError,
|
||||
"浏览器身份核验失败;请先在指定环境人工登录",
|
||||
),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const toggleBig = async () => {
|
||||
if (!selected) return;
|
||||
const accountID = selected.id;
|
||||
@@ -458,14 +512,37 @@ export function CreatorAccountsPage() {
|
||||
密码凭据:
|
||||
{selected.password_configured ? "已配置" : "未配置"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
监听:{listener ? listener.status : "尚未启动"}
|
||||
{listener?.boundary_at
|
||||
? ` · 边界 ${dateTime(listener.boundary_at)}`
|
||||
: " · 等待平台边界"}
|
||||
{listener?.reason ? ` · ${listener.reason}` : ""}
|
||||
</p>
|
||||
{listenerError ? (
|
||||
<p className="mt-1 text-xs text-warning">
|
||||
监听状态读取失败:
|
||||
{conflictMessage(listenerError, "请重试")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={verifyLogin}
|
||||
busy={busy}
|
||||
disabled={selected.platform !== "douyin"}
|
||||
>
|
||||
核验浏览器身份
|
||||
</Button>
|
||||
<Button
|
||||
variant={selected.big_account ? "primary" : "outline"}
|
||||
onClick={toggleBig}
|
||||
busy={busy}
|
||||
>
|
||||
{selected.big_account ? "大号模式已开启" : "开启大号模式"}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant={selected.big_account ? "primary" : "outline"}
|
||||
onClick={toggleBig}
|
||||
busy={busy}
|
||||
>
|
||||
{selected.big_account ? "大号模式已开启" : "开启大号模式"}
|
||||
</Button>
|
||||
</div>
|
||||
{notice ? (
|
||||
<Alert variant={notice.variant} className="mb-5">
|
||||
|
||||
@@ -25,6 +25,11 @@ export function CreatorCompetitorsPage() {
|
||||
const dataProvider = useDataProvider()("default");
|
||||
const [competitors, setCompetitors] = useState([]);
|
||||
const [works, setWorks] = useState([]);
|
||||
const [workPage, setWorkPage] = useState(1);
|
||||
const [workPageInfo, setWorkPageInfo] = useState({
|
||||
total: 0,
|
||||
hasNext: false,
|
||||
});
|
||||
const [accounts, setAccounts] = useState([]);
|
||||
const [platform, setPlatform] = useState("");
|
||||
const [form, setForm] = useState({
|
||||
@@ -38,6 +43,8 @@ export function CreatorCompetitorsPage() {
|
||||
const [minShares, setMinShares] = useState("");
|
||||
const [pending, setPending] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [preview, setPreview] = useState(null);
|
||||
const [previewConfirmed, setPreviewConfirmed] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [notice, setNotice] = useState(null);
|
||||
const [accountError, setAccountError] = useState(null);
|
||||
@@ -64,10 +71,15 @@ export function CreatorCompetitorsPage() {
|
||||
resource: "creator-competitors",
|
||||
filters: platform ? [{ field: "platform", value: platform }] : [],
|
||||
}),
|
||||
dataProvider.getList({ resource: "creator-works", filters }),
|
||||
dataProvider.getList({
|
||||
resource: "creator-works",
|
||||
filters,
|
||||
pagination: { currentPage: workPage, pageSize: 25 },
|
||||
}),
|
||||
]);
|
||||
setCompetitors(competitorResult.data);
|
||||
setWorks(workResult.data);
|
||||
setWorkPageInfo({ total: workResult.total, hasNext: workResult.hasNext });
|
||||
} catch (loadError) {
|
||||
setError(loadError);
|
||||
} finally {
|
||||
@@ -76,6 +88,9 @@ export function CreatorCompetitorsPage() {
|
||||
};
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [platform, minLikes, minComments, minShares, workPage]);
|
||||
useEffect(() => {
|
||||
setWorkPage(1);
|
||||
}, [platform, minLikes, minComments, minShares]);
|
||||
useEffect(() => {
|
||||
dataProvider
|
||||
@@ -87,8 +102,11 @@ export function CreatorCompetitorsPage() {
|
||||
.catch((loadError) => setAccountError(loadError));
|
||||
}, [dataProvider]);
|
||||
|
||||
const change = (field) => (event) =>
|
||||
const change = (field) => (event) => {
|
||||
setForm((value) => ({ ...value, [field]: event.target.value }));
|
||||
setPreview(null);
|
||||
setPreviewConfirmed(false);
|
||||
};
|
||||
const parseHomepage = () => {
|
||||
try {
|
||||
const parsed = new URL(form.homepage_url);
|
||||
@@ -108,10 +126,13 @@ export function CreatorCompetitorsPage() {
|
||||
"链接不是受支持的平台主页格式,请人工填写平台返回的稳定标识",
|
||||
);
|
||||
}
|
||||
setForm((value) => ({ ...value, platform_account_key: candidate }));
|
||||
const next = { ...form, platform_account_key: candidate };
|
||||
setForm(next);
|
||||
setPreview(next);
|
||||
setPreviewConfirmed(false);
|
||||
setNotice({
|
||||
variant: "info",
|
||||
text: `已解析候选标识 ${candidate},请核对平台返回值后再提交。`,
|
||||
text: `已解析候选标识 ${candidate},请核对平台返回值后确认。`,
|
||||
});
|
||||
} catch (parseError) {
|
||||
setNotice({
|
||||
@@ -122,6 +143,10 @@ export function CreatorCompetitorsPage() {
|
||||
};
|
||||
const create = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!preview || !previewConfirmed) {
|
||||
setNotice({ variant: "warning", text: "请先解析并确认导入预览。" });
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
@@ -136,6 +161,8 @@ export function CreatorCompetitorsPage() {
|
||||
nickname: "",
|
||||
homepage_url: "",
|
||||
}));
|
||||
setPreview(null);
|
||||
setPreviewConfirmed(false);
|
||||
setNotice({ variant: "success", text: "竞品账号已加入监测。" });
|
||||
} catch (createError) {
|
||||
setNotice({
|
||||
@@ -281,6 +308,30 @@ export function CreatorCompetitorsPage() {
|
||||
setMaterialPending(false);
|
||||
}
|
||||
};
|
||||
const generateRewrite = async () => {
|
||||
if (!material) return;
|
||||
setMaterialPending(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const result = await dataProvider.creatorAction(
|
||||
`/creator/works/${encodeURIComponent(material.work_id)}/material/rewrite/generate`,
|
||||
);
|
||||
setMaterial(result);
|
||||
setRewriteTitle(result.generated_title || "");
|
||||
setRewriteScript(result.generated_script || "");
|
||||
setNotice({
|
||||
variant: "success",
|
||||
text: "仿写草稿已生成,请人工复核后保存。",
|
||||
});
|
||||
} catch (actionError) {
|
||||
setNotice({
|
||||
variant: "destructive",
|
||||
text: conflictMessage(actionError, "仿写草稿生成失败"),
|
||||
});
|
||||
} finally {
|
||||
setMaterialPending(false);
|
||||
}
|
||||
};
|
||||
const saveRewrite = async () => {
|
||||
if (!material) return;
|
||||
setMaterialPending(true);
|
||||
@@ -365,10 +416,33 @@ export function CreatorCompetitorsPage() {
|
||||
<Button type="button" variant="outline" onClick={parseHomepage}>
|
||||
解析链接预览
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" busy={busy}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
busy={busy}
|
||||
disabled={!previewConfirmed}
|
||||
>
|
||||
加入监测
|
||||
</Button>
|
||||
</div>
|
||||
{preview ? (
|
||||
<div className="rounded-md border border-hairline bg-[#f7f8fa] p-3 text-sm">
|
||||
<p className="font-medium">导入预览</p>
|
||||
<p className="mt-1 text-muted">
|
||||
{preview.platform === "douyin" ? "抖音" : "小红书"} ·{" "}
|
||||
{preview.platform_account_key} · {preview.homepage_url}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
className="mt-3"
|
||||
size="sm"
|
||||
variant={previewConfirmed ? "outline" : "primary"}
|
||||
onClick={() => setPreviewConfirmed((value) => !value)}
|
||||
>
|
||||
{previewConfirmed ? "已确认预览" : "确认预览内容"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<p className="text-xs text-muted">
|
||||
主页链接仅用于人工核对;请把平台返回的稳定标识填入上方,不根据昵称自动猜测。
|
||||
</p>
|
||||
@@ -546,6 +620,29 @@ export function CreatorCompetitorsPage() {
|
||||
</table>
|
||||
</div>
|
||||
</PageState>
|
||||
<div className="mt-3 flex items-center justify-between text-xs text-muted">
|
||||
<span>
|
||||
当前页 {works.length} 条 · 共 {workPageInfo.total} 条
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setWorkPage((page) => Math.max(1, page - 1))}
|
||||
disabled={workPage === 1 || pending}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setWorkPage((page) => page + 1)}
|
||||
disabled={!workPageInfo.hasNext || pending}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{material ? (
|
||||
<div className="mt-5 space-y-4 border-t border-hairline pt-5">
|
||||
<div>
|
||||
@@ -617,25 +714,20 @@ export function CreatorCompetitorsPage() {
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
{!material.rewrite_confirmed_at ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={confirmRewrite}
|
||||
busy={materialPending}
|
||||
disabled={
|
||||
material.download_status !== "succeeded" ||
|
||||
["not_started", "running", "failed"].includes(
|
||||
material.audio_status,
|
||||
) ||
|
||||
["not_started", "running", "failed"].includes(
|
||||
material.transcription_status,
|
||||
)
|
||||
}
|
||||
>
|
||||
确认仿写要求
|
||||
</Button>
|
||||
) : (
|
||||
{material.rewrite_confirmed_at ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={generateRewrite}
|
||||
busy={materialPending}
|
||||
>
|
||||
生成仿写草稿
|
||||
</Button>
|
||||
<span className="text-xs text-muted">
|
||||
生成结果不会自动发布,保存前必须人工复核。
|
||||
</span>
|
||||
</div>
|
||||
<Field id="rewrite-title" label="可编辑标题" required>
|
||||
<Input
|
||||
id="rewrite-title"
|
||||
@@ -662,6 +754,23 @@ export function CreatorCompetitorsPage() {
|
||||
保存仿写结果
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={confirmRewrite}
|
||||
busy={materialPending}
|
||||
disabled={
|
||||
material.download_status !== "succeeded" ||
|
||||
["not_started", "running", "failed"].includes(
|
||||
material.audio_status,
|
||||
) ||
|
||||
["not_started", "running", "failed"].includes(
|
||||
material.transcription_status,
|
||||
)
|
||||
}
|
||||
>
|
||||
确认仿写要求
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -180,6 +180,29 @@ describe("creator pages", () => {
|
||||
expect(await screen.findByText("暂无符合条件的作品。")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("requires preview confirmation before importing a competitor", async () => {
|
||||
const dataProvider = provider();
|
||||
renderPage(<CreatorCompetitorsPage />, dataProvider);
|
||||
fireEvent.change(screen.getByLabelText("主页 URL", { exact: false }), {
|
||||
target: { value: "https://www.douyin.com/user/sec-b" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "解析链接预览" }));
|
||||
expect(await screen.findByText("导入预览")).toBeTruthy();
|
||||
expect(dataProvider.create).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "确认预览内容" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "加入监测" }));
|
||||
await waitFor(() => expect(dataProvider.create).toHaveBeenCalled());
|
||||
expect(dataProvider.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resource: "creator-competitors",
|
||||
variables: expect.objectContaining({
|
||||
platform: "douyin",
|
||||
platform_account_key: "sec-b",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps account password out of the returned profile and exposes big-account action", async () => {
|
||||
const dataProvider = provider();
|
||||
renderPage(<CreatorAccountsPage />, dataProvider);
|
||||
@@ -230,6 +253,40 @@ describe("creator pages", () => {
|
||||
expect(screen.getByRole("option", { name: "小红书账号" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the private-message business update connection state", async () => {
|
||||
const creatorSubscribe = vi.fn(() => new Promise(() => {}));
|
||||
const conversation = {
|
||||
id: "conversation-a",
|
||||
account_id: profile.id,
|
||||
platform: profile.platform,
|
||||
peer_uid: "peer-a",
|
||||
peer_name: "客户",
|
||||
};
|
||||
const dataProvider = provider({
|
||||
creatorSubscribe,
|
||||
getList: vi.fn(({ resource }) =>
|
||||
Promise.resolve({
|
||||
data:
|
||||
resource === "creator-accounts"
|
||||
? [profile]
|
||||
: resource === "creator-conversations"
|
||||
? [conversation]
|
||||
: [],
|
||||
total: resource === "creator-conversations" ? 1 : 0,
|
||||
}),
|
||||
),
|
||||
});
|
||||
renderPage(<CreatorWorkbenchPage />, dataProvider);
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "私信" }));
|
||||
expect(await screen.findByText("后台更新连接中")).toBeTruthy();
|
||||
expect(creatorSubscribe).toHaveBeenCalledWith(
|
||||
"/creator/updates",
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("isolates private messages by account and confirms one durable send", async () => {
|
||||
const conversation = {
|
||||
id: "conversation-a",
|
||||
|
||||
@@ -21,6 +21,7 @@ const tabs = [
|
||||
["comments", "评论"],
|
||||
["leads", "线索"],
|
||||
["rules", "规则"],
|
||||
["events", "事件监听"],
|
||||
["dms", "私信"],
|
||||
["operations", "操作记录"],
|
||||
];
|
||||
@@ -71,19 +72,58 @@ function operationStatus(state, reason) {
|
||||
return `${labels[state] || state || "未知状态"}${reason ? `:${reason}` : ""}`;
|
||||
}
|
||||
|
||||
function PageNav({ page, total, count, hasNext, onPageChange }) {
|
||||
return (
|
||||
<div className="mt-3 flex items-center justify-between text-xs text-muted">
|
||||
<span>
|
||||
当前页 {count} 条 · 共 {total} 条
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={!hasNext}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreatorWorkbenchPage() {
|
||||
const dataProvider = useDataProvider()("default");
|
||||
const [tab, setTab] = useState("comments");
|
||||
const [data, setData] = useState([]);
|
||||
const [dataTab, setDataTab] = useState("");
|
||||
const [listPage, setListPage] = useState(1);
|
||||
const [listPageInfo, setListPageInfo] = useState({
|
||||
total: 0,
|
||||
hasNext: false,
|
||||
});
|
||||
const [rules, setRules] = useState([]);
|
||||
const [accounts, setAccounts] = useState([]);
|
||||
const [conversations, setConversations] = useState([]);
|
||||
const [conversationAccountID, setConversationAccountID] = useState("");
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [messagePage, setMessagePage] = useState(1);
|
||||
const [messagePageInfo, setMessagePageInfo] = useState({
|
||||
total: 0,
|
||||
hasNext: false,
|
||||
});
|
||||
const [messagePending, setMessagePending] = useState(false);
|
||||
const [messageError, setMessageError] = useState(null);
|
||||
const [messageRetry, setMessageRetry] = useState(0);
|
||||
const [updateStatus, setUpdateStatus] = useState("disconnected");
|
||||
const [conversationID, setConversationID] = useState("");
|
||||
const [dmAccountID, setDmAccountID] = useState("");
|
||||
const [dmText, setDmText] = useState("");
|
||||
@@ -92,6 +132,7 @@ export function CreatorWorkbenchPage() {
|
||||
const [error, setError] = useState(null);
|
||||
const [notice, setNotice] = useState(null);
|
||||
const [analyzing, setAnalyzing] = useState("");
|
||||
const [selectedCommentIDs, setSelectedCommentIDs] = useState([]);
|
||||
const [ruleForm, setRuleForm] = useState({
|
||||
name: "",
|
||||
source_type: "all",
|
||||
@@ -125,11 +166,15 @@ export function CreatorWorkbenchPage() {
|
||||
try {
|
||||
if (tab === "comments") {
|
||||
const [comments, ruleList] = await Promise.all([
|
||||
dataProvider.getList({ resource: "creator-comments" }),
|
||||
dataProvider.getList({
|
||||
resource: "creator-comments",
|
||||
pagination: { currentPage: listPage, pageSize: 25 },
|
||||
}),
|
||||
dataProvider.getList({ resource: "creator-rules" }),
|
||||
]);
|
||||
if (sequence !== loadSequence.current) return;
|
||||
setData(comments.data);
|
||||
setListPageInfo({ total: comments.total, hasNext: comments.hasNext });
|
||||
setDataTab("comments");
|
||||
setRules(ruleList.data);
|
||||
} else if (tab === "leads") {
|
||||
@@ -139,6 +184,15 @@ export function CreatorWorkbenchPage() {
|
||||
if (sequence !== loadSequence.current) return;
|
||||
setData(result.data);
|
||||
setDataTab("leads");
|
||||
} else if (tab === "events") {
|
||||
const result = await dataProvider.getList({
|
||||
resource: "creator-events",
|
||||
pagination: { currentPage: listPage, pageSize: 25 },
|
||||
});
|
||||
if (sequence !== loadSequence.current) return;
|
||||
setData(result.data);
|
||||
setListPageInfo({ total: result.total, hasNext: result.hasNext });
|
||||
setDataTab("events");
|
||||
} else if (tab === "rules") {
|
||||
const result = await dataProvider.getList({
|
||||
resource: "creator-rules",
|
||||
@@ -199,12 +253,14 @@ export function CreatorWorkbenchPage() {
|
||||
}, [dataProvider]);
|
||||
useEffect(() => {
|
||||
if (tab !== "dms" || dmAccountID) load();
|
||||
}, [tab, dmAccountID]);
|
||||
}, [tab, dmAccountID, listPage]);
|
||||
const switchTab = (nextTab) => {
|
||||
if (nextTab === tab) return;
|
||||
loadSequence.current += 1;
|
||||
setData([]);
|
||||
setDataTab("");
|
||||
setListPage(1);
|
||||
setListPageInfo({ total: 0, hasNext: false });
|
||||
setError(null);
|
||||
setTab(nextTab);
|
||||
};
|
||||
@@ -221,10 +277,18 @@ export function CreatorWorkbenchPage() {
|
||||
setMessageError(null);
|
||||
dataProvider
|
||||
.creatorGet(
|
||||
`/creator/conversations/${encodeURIComponent(conversationID)}/messages`,
|
||||
`/creator/conversations/${encodeURIComponent(conversationID)}/messages?page=${messagePage}&page_size=25`,
|
||||
)
|
||||
.then((result) => {
|
||||
if (sequence === messageSequence.current) setMessages(result);
|
||||
if (sequence !== messageSequence.current) return;
|
||||
const items = Array.isArray(result) ? result : result.data || [];
|
||||
setMessages(items);
|
||||
setMessagePageInfo({
|
||||
total: Array.isArray(result)
|
||||
? items.length
|
||||
: (result.total ?? items.length),
|
||||
hasNext: Array.isArray(result) ? false : Boolean(result.has_next),
|
||||
});
|
||||
})
|
||||
.catch((loadError) => {
|
||||
if (sequence === messageSequence.current) setMessageError(loadError);
|
||||
@@ -235,19 +299,51 @@ export function CreatorWorkbenchPage() {
|
||||
return () => {
|
||||
if (sequence === messageSequence.current) setMessagePending(false);
|
||||
};
|
||||
}, [conversationID, tab, dataProvider, messageRetry]);
|
||||
}, [conversationID, tab, dataProvider, messageRetry, messagePage]);
|
||||
useEffect(() => {
|
||||
if (reply.target_comment_id) {
|
||||
writeDraft(replyDraftKey(reply.target_comment_id), reply);
|
||||
}
|
||||
}, [reply]);
|
||||
useEffect(() => {
|
||||
if (tab !== "dms" || !conversationID) return undefined;
|
||||
const timer = window.setInterval(() => {
|
||||
setMessageRetry((value) => value + 1);
|
||||
}, 10000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [tab, conversationID]);
|
||||
if (tab !== "dms" || typeof dataProvider.creatorSubscribe !== "function") {
|
||||
setUpdateStatus("disconnected");
|
||||
return undefined;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
let stopped = false;
|
||||
let retryTimer;
|
||||
const connect = () => {
|
||||
if (stopped) return;
|
||||
setUpdateStatus("connecting");
|
||||
dataProvider
|
||||
.creatorSubscribe(
|
||||
"/creator/updates",
|
||||
() => {
|
||||
load();
|
||||
setMessageRetry((value) => value + 1);
|
||||
},
|
||||
controller.signal,
|
||||
(status) => setUpdateStatus(status),
|
||||
)
|
||||
.then(() => {
|
||||
if (!stopped) retryTimer = window.setTimeout(connect, 5000);
|
||||
})
|
||||
.catch((streamError) => {
|
||||
if (stopped || streamError.name === "AbortError") return;
|
||||
setUpdateStatus("disconnected");
|
||||
setMessageError(streamError);
|
||||
retryTimer = window.setTimeout(connect, 5000);
|
||||
});
|
||||
};
|
||||
connect();
|
||||
return () => {
|
||||
stopped = true;
|
||||
controller.abort();
|
||||
window.clearTimeout(retryTimer);
|
||||
setUpdateStatus("disconnected");
|
||||
};
|
||||
}, [tab, dmAccountID, dataProvider]);
|
||||
useEffect(() => {
|
||||
if (!dmAccountID || !conversationID) return;
|
||||
const draft = readDraft(dmDraftKey(dmAccountID, conversationID));
|
||||
@@ -263,6 +359,37 @@ export function CreatorWorkbenchPage() {
|
||||
}
|
||||
}, [dmAccountID, conversationID, dmOperationKey, dmText]);
|
||||
|
||||
const analyzeSelected = async (ruleID) => {
|
||||
if (!ruleID || !selectedCommentIDs.length) return;
|
||||
setAnalyzing(`batch:${ruleID}`);
|
||||
setNotice(null);
|
||||
try {
|
||||
const response = await dataProvider.creatorCreate(
|
||||
"/creator/comments/analyze",
|
||||
{
|
||||
comment_ids: selectedCommentIDs,
|
||||
rule_id: ruleID,
|
||||
},
|
||||
);
|
||||
const failed = (response.items || []).filter((item) => item.error).length;
|
||||
setNotice({
|
||||
variant: failed ? "warning" : "success",
|
||||
text: `已分析 ${selectedCommentIDs.length - failed} 条评论${failed ? `,${failed} 条失败` : ""}。`,
|
||||
});
|
||||
setSelectedCommentIDs([]);
|
||||
} catch (actionError) {
|
||||
setNotice({
|
||||
variant: "warning",
|
||||
text: conflictMessage(
|
||||
actionError,
|
||||
"批量 AI 分析不可用,未生成虚假结果",
|
||||
),
|
||||
});
|
||||
} finally {
|
||||
setAnalyzing("");
|
||||
}
|
||||
};
|
||||
|
||||
const analyze = async (commentID, ruleID) => {
|
||||
setAnalyzing(`${commentID}:${ruleID}`);
|
||||
setNotice(null);
|
||||
@@ -418,12 +545,16 @@ export function CreatorWorkbenchPage() {
|
||||
setConversationAccountID("");
|
||||
setConversations([]);
|
||||
setConversationID("");
|
||||
setMessagePage(1);
|
||||
setMessagePageInfo({ total: 0, hasNext: false });
|
||||
setDmText("");
|
||||
setDmOperationKey("");
|
||||
};
|
||||
const switchConversation = (nextID) => {
|
||||
if (nextID === conversationID || !canSwitchDM()) return;
|
||||
setConversationID(nextID);
|
||||
setMessagePage(1);
|
||||
setMessagePageInfo({ total: 0, hasNext: false });
|
||||
setDmText("");
|
||||
setDmOperationKey("");
|
||||
};
|
||||
@@ -553,12 +684,44 @@ export function CreatorWorkbenchPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<Select
|
||||
id="batch-comment-rule"
|
||||
value=""
|
||||
onChange={(event) => analyzeSelected(event.target.value)}
|
||||
options={rules.map((rule) => ({
|
||||
value: rule.id,
|
||||
label: rule.name,
|
||||
}))}
|
||||
placeholder={rules.length ? "选择规则批量分析" : "先创建规则"}
|
||||
disabled={
|
||||
!rules.length || !selectedCommentIDs.length || Boolean(analyzing)
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-muted">
|
||||
已选 {selectedCommentIDs.length} 条;批量失败会逐条保留原因。
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{visibleData.map((comment) => (
|
||||
<Card key={comment.id}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<label className="mb-2 flex items-center gap-2 text-xs text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCommentIDs.includes(comment.id)}
|
||||
onChange={(event) =>
|
||||
setSelectedCommentIDs((current) =>
|
||||
event.target.checked
|
||||
? [...new Set([...current, comment.id])]
|
||||
: current.filter((id) => id !== comment.id),
|
||||
)
|
||||
}
|
||||
/>
|
||||
加入批量分析
|
||||
</label>
|
||||
<p className="font-medium">
|
||||
{comment.author_name || "未知用户"}{" "}
|
||||
<span className="ml-2 text-xs text-muted">
|
||||
@@ -601,6 +764,13 @@ export function CreatorWorkbenchPage() {
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<PageNav
|
||||
page={listPage}
|
||||
total={listPageInfo.total}
|
||||
count={visibleData.length}
|
||||
hasNext={listPageInfo.hasNext}
|
||||
onPageChange={setListPage}
|
||||
/>
|
||||
</PageState>
|
||||
) : tab === "leads" ? (
|
||||
<PageState
|
||||
@@ -638,6 +808,73 @@ export function CreatorWorkbenchPage() {
|
||||
))}
|
||||
</div>
|
||||
</PageState>
|
||||
) : tab === "events" ? (
|
||||
<PageState
|
||||
pending={pending}
|
||||
error={error}
|
||||
empty={!visibleData.length}
|
||||
emptyText="暂无监听事件。"
|
||||
onRetry={load}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{visibleData.map((event) => (
|
||||
<Card key={event.id}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{event.event_type} ·{" "}
|
||||
{event.interactor_uid || "UID 不可用"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
作品:{event.work_id || "—"} · 事件键:{event.event_key}
|
||||
</p>
|
||||
{event.message_text ? (
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm">
|
||||
{event.message_text}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<StatusPill
|
||||
tone={
|
||||
event.baseline || event.state === "blocked"
|
||||
? "warning"
|
||||
: event.state === "succeeded"
|
||||
? "success"
|
||||
: event.state === "uncertain"
|
||||
? "warning"
|
||||
: "neutral"
|
||||
}
|
||||
label={
|
||||
event.baseline
|
||||
? `基线/不触发:${event.reason || "需重新建立边界"}`
|
||||
: event.state || "未知"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted">
|
||||
平台时间:
|
||||
{event.platform_event_at
|
||||
? dateTime(event.platform_event_at)
|
||||
: "不可用"}{" "}
|
||||
· 网关接收:
|
||||
{event.gateway_received_at
|
||||
? dateTime(event.gateway_received_at)
|
||||
: "不可用"}{" "}
|
||||
· 控制面接收:{dateTime(event.received_at)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<PageNav
|
||||
page={listPage}
|
||||
total={listPageInfo.total}
|
||||
count={visibleData.length}
|
||||
hasNext={listPageInfo.hasNext}
|
||||
onPageChange={setListPage}
|
||||
/>
|
||||
</PageState>
|
||||
) : tab === "rules" ? (
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(260px,0.8fr)_minmax(0,1.5fr)]">
|
||||
<Card>
|
||||
@@ -878,8 +1115,26 @@ export function CreatorWorkbenchPage() {
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold">消息记录</h2>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-base font-semibold">消息记录</h2>
|
||||
<StatusPill
|
||||
tone={
|
||||
updateStatus === "connected"
|
||||
? "success"
|
||||
: updateStatus === "connecting"
|
||||
? "warning"
|
||||
: "danger"
|
||||
}
|
||||
label={
|
||||
updateStatus === "connected"
|
||||
? "后台更新已连接"
|
||||
: updateStatus === "connecting"
|
||||
? "后台更新连接中"
|
||||
: "后台更新已断开"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -912,6 +1167,13 @@ export function CreatorWorkbenchPage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<PageNav
|
||||
page={messagePage}
|
||||
total={messagePageInfo.total}
|
||||
count={messages.length}
|
||||
hasNext={messagePageInfo.hasNext}
|
||||
onPageChange={setMessagePage}
|
||||
/>
|
||||
</PageState>
|
||||
<div className="mt-5 border-t border-hairline pt-4">
|
||||
<Field id="dm-text" label="人工私信文案" required>
|
||||
|
||||
+64
-24
@@ -1,29 +1,40 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Alert, Button, Field, Input } from './lib/ui.jsx'
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { Alert, Button, Field, Input } from "./lib/ui.jsx";
|
||||
|
||||
// 开发阶段:本地保存 Basic 凭证,仅在请求层自动附带。开发代理(vite)不注入 Authorization。
|
||||
export function LoginPage() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
async function submit(event) {
|
||||
event.preventDefault()
|
||||
if (!username.trim() || !password) return
|
||||
setBusy(true); setError('')
|
||||
event.preventDefault();
|
||||
if (!username.trim() || !password) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(() => controller.abort(), 15000);
|
||||
try {
|
||||
const response = await fetch('/api/gateways', { headers: { Authorization: `Basic ${btoa(`${username}:${password}`)}` } })
|
||||
if (response.status === 401) throw new Error('用户名或密码不正确')
|
||||
if (!response.ok) throw new Error(`认证服务不可用(${response.status})`)
|
||||
localStorage.setItem('creatorhub.auth', `${username}:${password}`)
|
||||
navigate('/accounts')
|
||||
const response = await fetch("/api/gateways", {
|
||||
headers: { Authorization: `Basic ${btoa(`${username}:${password}`)}` },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (response.status === 401) throw new Error("用户名或密码不正确");
|
||||
if (!response.ok) throw new Error(`认证服务不可用(${response.status})`);
|
||||
localStorage.setItem("creatorhub.auth", `${username}:${password}`);
|
||||
navigate("/accounts");
|
||||
} catch (reason) {
|
||||
setError(reason.message)
|
||||
setError(
|
||||
reason.name === "AbortError"
|
||||
? "认证请求超时,请检查控制面连接"
|
||||
: reason.message || "登录失败",
|
||||
);
|
||||
} finally {
|
||||
setBusy(false)
|
||||
window.clearTimeout(timeout);
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,20 +42,49 @@ export function LoginPage() {
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#f7f8fa] px-4">
|
||||
<div className="w-full max-w-sm rounded-lg border border-hairline bg-white p-8 shadow-sm">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<i className="ri-stack-line text-3xl text-primary" aria-hidden="true" />
|
||||
<i
|
||||
className="ri-stack-line text-3xl text-primary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h1 className="text-xl font-bold">CreatorHub</h1>
|
||||
</div>
|
||||
{error ? <Alert variant="destructive" className="mb-4">{error}</Alert> : null}
|
||||
{error ? (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
<form onSubmit={submit} className="space-y-4" noValidate>
|
||||
<Field id="login-username" label="用户名" required>
|
||||
<Input id="login-username" required autoComplete="username" value={username} onChange={event => setUsername(event.target.value)} />
|
||||
<Input
|
||||
id="login-username"
|
||||
required
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field id="login-password" label="密码" required>
|
||||
<Input id="login-password" type="password" required autoComplete="current-password" value={password} onChange={event => setPassword(event.target.value)} />
|
||||
<Input
|
||||
id="login-password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Button variant="primary" type="submit" className="w-full" busy={busy} busyText="登录中…" disabled={!username.trim() || !password}>登录</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
className="w-full"
|
||||
busy={busy}
|
||||
busyText="登录中…"
|
||||
disabled={!username.trim() || !password}
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
+260
-115
@@ -1,179 +1,324 @@
|
||||
// 401 = 未认证:抹去凭证并引导到登录页。开发代理下 401 由后端直接返回。
|
||||
const unauthorized = () => {
|
||||
localStorage.removeItem('creatorhub.auth')
|
||||
if (!location.hash.startsWith('#/login')) location.hash = '#/login'
|
||||
return Object.assign(new Error('认证已过期,请重新登录'), { status: 401 })
|
||||
}
|
||||
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
|
||||
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 })
|
||||
response = await fetch(`/api${path}`, { ...options, headers });
|
||||
} catch (networkError) {
|
||||
throw Object.assign(new Error(`网络请求失败:${networkError.message}`), { status: 0 })
|
||||
throw Object.assign(new Error(`网络请求失败:${networkError.message}`), {
|
||||
status: 0,
|
||||
});
|
||||
}
|
||||
if (response.status === 401) throw unauthorized()
|
||||
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 })
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw Object.assign(
|
||||
new Error(body.error || `请求失败 (${response.status})`),
|
||||
{ status: response.status, body },
|
||||
);
|
||||
}
|
||||
return response.status === 204 ? null : response.json()
|
||||
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' },
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
});
|
||||
|
||||
const unsupported = (resource, operation) => Promise.reject(new Error(`${resource} 不支持 ${operation}`))
|
||||
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-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',
|
||||
}
|
||||
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-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 })
|
||||
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')
|
||||
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 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 query = new URLSearchParams(filterKeys(resource).flatMap(field => valueOf(field) !== undefined ? [[field, valueOf(field)]] : []))
|
||||
if (resource === 'audit') {
|
||||
query.set('page', pagination.currentPage || pagination.page || 1)
|
||||
query.set('page_size', pagination.pageSize || pagination.perPage || 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.prototype.hasOwnProperty.call(records, "has_next")
|
||||
) {
|
||||
result.hasNext = Boolean(records.has_next);
|
||||
}
|
||||
const records = await request(`${path}${query.size ? `?${query}` : ''}`)
|
||||
const data = Array.isArray(records) ? records : records.data
|
||||
return { data: data.map((record, index) => withID(record, index)), total: records.total ?? data.length }
|
||||
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) }
|
||||
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) }
|
||||
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')
|
||||
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 } }
|
||||
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 || resource !== 'browser-images') return unsupported(resource, 'deleteOne')
|
||||
await request(`${path}/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||
return { data: { id } }
|
||||
const path = resourcePaths[resource];
|
||||
if (!path || resource !== "browser-images")
|
||||
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'),
|
||||
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
|
||||
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] })
|
||||
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' })
|
||||
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 }))
|
||||
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,
|
||||
}))
|
||||
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 }))
|
||||
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)
|
||||
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 (action !== 'check' && action !== 'disable') throw new Error(`未知网络出口操作: ${action}`)
|
||||
return request(`/network-exits/${encodeURIComponent(id)}/${action}`, { method: 'POST' })
|
||||
if (action !== "check" && action !== "disable")
|
||||
throw new Error(`未知网络出口操作: ${action}`);
|
||||
return request(`/network-exits/${encodeURIComponent(id)}/${action}`, {
|
||||
method: "POST",
|
||||
});
|
||||
},
|
||||
creatorRequest(path, options) {
|
||||
return request(path, options)
|
||||
return request(path, options);
|
||||
},
|
||||
creatorAction(path, data) {
|
||||
return request(path, data === undefined ? { method: 'POST' } : jsonOptions('POST', data))
|
||||
return request(
|
||||
path,
|
||||
data === undefined ? { method: "POST" } : jsonOptions("POST", data),
|
||||
);
|
||||
},
|
||||
creatorCreate(path, data) {
|
||||
return request(path, jsonOptions('POST', data))
|
||||
return request(path, jsonOptions("POST", data));
|
||||
},
|
||||
creatorGet(path) {
|
||||
return request(path)
|
||||
return request(path);
|
||||
},
|
||||
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 = "";
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
|
||||
let boundary;
|
||||
while ((boundary = buffer.indexOf("\\n\\n")) >= 0) {
|
||||
const frame = buffer.slice(0, boundary);
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
if (frame.split(/\\r?\\n/).some((line) => line.startsWith("data:")))
|
||||
onMessage(frame);
|
||||
}
|
||||
if (done) return;
|
||||
}
|
||||
},
|
||||
creatorUpdate(path, data) {
|
||||
return request(path, jsonOptions('PUT', 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] || []
|
||||
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] || []
|
||||
);
|
||||
}
|
||||
|
||||
+5
-7
@@ -254,14 +254,12 @@ export function TagInput({
|
||||
}) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const commit = (raw) => {
|
||||
const tag = raw.trim();
|
||||
const tags = raw
|
||||
.split(/[,,]/)
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean);
|
||||
setDraft("");
|
||||
if (tag)
|
||||
onChange(
|
||||
value
|
||||
.concat(tag)
|
||||
.filter((item, index, all) => all.indexOf(item) === index),
|
||||
);
|
||||
if (tags.length) onChange(Array.from(new Set(value.concat(tags))));
|
||||
};
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === "Enter" || event.key === ",") {
|
||||
|
||||
+62
-35
@@ -1,40 +1,42 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { HashRouter, Navigate, Route, Routes, useLocation } from 'react-router'
|
||||
import { Refine } from '@refinedev/core'
|
||||
import { dataProvider } from './dataProvider'
|
||||
import { CreatorHubLayout } from './Layout'
|
||||
import { LoginPage } from './LoginPage'
|
||||
import { AccountDetail, AccountList } from './AccountsPage'
|
||||
import { AuditPage } from './AuditPage'
|
||||
import { BrowserCreatePage, BrowserDetail, BrowserList } from './BrowsersPage'
|
||||
import { BrowserImageList } from './BrowserImagesPage'
|
||||
import { DraftPage } from './DraftPage'
|
||||
import { GatewayList } from './GatewaysPage'
|
||||
import { NetworkExitDetail, NetworkExitList } from './NetworkExitsPage'
|
||||
import { TaskDetail, TaskList } from './TasksPage'
|
||||
import { AttemptDetail } from './TracePages'
|
||||
import { CreatorAccountsPage } from './CreatorAccountsPage'
|
||||
import { CreatorCompetitorsPage } from './CreatorCompetitorsPage'
|
||||
import { CreatorSettingsPage } from './CreatorSettingsPage'
|
||||
import { CreatorWorkbenchPage } from './CreatorWorkbenchPage'
|
||||
import 'remixicon/fonts/remixicon.css'
|
||||
import './styles.css'
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { HashRouter, Navigate, Route, Routes, useLocation } from "react-router";
|
||||
import { Refine } from "@refinedev/core";
|
||||
import { dataProvider } from "./dataProvider";
|
||||
import { CreatorHubLayout } from "./Layout";
|
||||
import { LoginPage } from "./LoginPage";
|
||||
import { AccountDetail, AccountList } from "./AccountsPage";
|
||||
import { AuditPage } from "./AuditPage";
|
||||
import { BrowserCreatePage, BrowserDetail, BrowserList } from "./BrowsersPage";
|
||||
import { BrowserImageList } from "./BrowserImagesPage";
|
||||
import { DraftPage } from "./DraftPage";
|
||||
import { GatewayList } from "./GatewaysPage";
|
||||
import { NetworkExitDetail, NetworkExitList } from "./NetworkExitsPage";
|
||||
import { TaskDetail, TaskList } from "./TasksPage";
|
||||
import { AttemptDetail } from "./TracePages";
|
||||
import { CreatorAccountsPage } from "./CreatorAccountsPage";
|
||||
import { CreatorCompetitorsPage } from "./CreatorCompetitorsPage";
|
||||
import { CreatorSettingsPage } from "./CreatorSettingsPage";
|
||||
import { CreatorWorkbenchPage } from "./CreatorWorkbenchPage";
|
||||
import "remixicon/fonts/remixicon.css";
|
||||
import "./styles.css";
|
||||
|
||||
// 开发阶段认证仅由页面层掌握:401 会清除凭证并跳转登录;localStorage 为空时直接展示登录页。
|
||||
// 不用 useState:HashRouter 的 pushState/replaceState 不触发 hashchange,状态会在导航后过期;
|
||||
// 改为每次渲染直接读 localStorage,登录后 navigate 引发重渲染即可拿到新状态。
|
||||
const authed = () => localStorage.getItem('creatorhub.auth') !== null
|
||||
const authed = () => localStorage.getItem("creatorhub.auth") !== null;
|
||||
|
||||
function AuthGate({ children }) {
|
||||
const { pathname } = useLocation()
|
||||
const loggedIn = authed()
|
||||
if (!loggedIn && pathname !== '/login') return <Navigate to="/login" replace />
|
||||
if (loggedIn && pathname === '/login') return <Navigate to="/accounts" replace />
|
||||
return children
|
||||
const { pathname } = useLocation();
|
||||
const loggedIn = authed();
|
||||
if (!loggedIn && pathname !== "/login")
|
||||
return <Navigate to="/login" replace />;
|
||||
if (loggedIn && pathname === "/login")
|
||||
return <Navigate to="/accounts" replace />;
|
||||
return children;
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
createRoot(document.getElementById("root")).render(
|
||||
<StrictMode>
|
||||
<HashRouter>
|
||||
<Refine dataProvider={dataProvider} options={{ syncWithLocation: false }}>
|
||||
@@ -45,15 +47,40 @@ createRoot(document.getElementById('root')).render(
|
||||
<Route path="/" element={<Navigate to="/accounts" replace />} />
|
||||
<Route path="/accounts" element={<AccountList />} />
|
||||
<Route path="/accounts/:id" element={<AccountDetail />} />
|
||||
<Route path="/creator/accounts" element={<CreatorAccountsPage />} />
|
||||
<Route path="/creator/competitors" element={<CreatorCompetitorsPage />} />
|
||||
<Route path="/creator/workbench" element={<CreatorWorkbenchPage />} />
|
||||
<Route path="/creator/settings" element={<CreatorSettingsPage />} />
|
||||
<Route
|
||||
path="/creator/accounts"
|
||||
element={<CreatorAccountsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/creator/competitors"
|
||||
element={<CreatorCompetitorsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/creator/competitors/:id"
|
||||
element={<CreatorCompetitorsPage />}
|
||||
/>
|
||||
<Route path="/competitors" element={<CreatorCompetitorsPage />} />
|
||||
<Route
|
||||
path="/competitors/:id"
|
||||
element={<CreatorCompetitorsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/creator/workbench"
|
||||
element={<CreatorWorkbenchPage />}
|
||||
/>
|
||||
<Route path="/workbench" element={<CreatorWorkbenchPage />} />
|
||||
<Route
|
||||
path="/creator/settings"
|
||||
element={<CreatorSettingsPage />}
|
||||
/>
|
||||
<Route path="/tasks" element={<TaskList />} />
|
||||
<Route path="/tasks/:id" element={<TaskDetail />} />
|
||||
<Route path="/audit" element={<AuditPage />} />
|
||||
<Route path="/network-exits" element={<NetworkExitList />} />
|
||||
<Route path="/network-exits/:id" element={<NetworkExitDetail />} />
|
||||
<Route
|
||||
path="/network-exits/:id"
|
||||
element={<NetworkExitDetail />}
|
||||
/>
|
||||
<Route path="/browsers" element={<BrowserList />} />
|
||||
<Route path="/browsers/new" element={<BrowserCreatePage />} />
|
||||
<Route path="/browsers/:id" element={<BrowserDetail />} />
|
||||
@@ -67,4 +94,4 @@ createRoot(document.getElementById('root')).render(
|
||||
</Refine>
|
||||
</HashRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user