feat: integrate creator hub douyin workflows

This commit is contained in:
2026-09-14 19:22:07 +08:00
parent 025fe62c37
commit 44a28954cf
42 changed files with 2850 additions and 880 deletions
+9
View File
@@ -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}
+84 -7
View File
@@ -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">
+132 -23
View File
@@ -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}
+57
View File
@@ -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",
+275 -13
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 为空时直接展示登录页。
// 不用 useStateHashRouter 的 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>,
)
);