feat: generalize web workbench platform routing
This commit is contained in:
@@ -17,6 +17,7 @@ const appState = {
|
||||
selectedVideos: { items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 },
|
||||
documents: [],
|
||||
discoveryQuery: "",
|
||||
currentPlatform: localStorage.getItem(STORAGE_KEY + ":currentPlatform") || "",
|
||||
selectedProjectId: "",
|
||||
selectedAssistantId: "",
|
||||
lastSeenAt: Number(localStorage.getItem(STORAGE_KEY + ":lastSeenAt") || Date.now()),
|
||||
@@ -43,6 +44,52 @@ const ACTIVE_PLATFORMS = [
|
||||
{ value: "wechat_video", label: "微信视频号" }
|
||||
];
|
||||
const ACTIVE_PLATFORM_CHIPS = ["全平台", "抖音", "小红书", "B站", "快手", "视频号"];
|
||||
const PLATFORM_REGISTRY = {
|
||||
douyin: {
|
||||
label: "抖音",
|
||||
shortLabel: "抖音",
|
||||
workbenchReady: true,
|
||||
routes: {
|
||||
accounts: "/v2/douyin/accounts",
|
||||
workspace: (accountId) => `/v2/douyin/accounts/${encodeURIComponent(accountId)}/workspace`,
|
||||
videos: (accountId) => `/v2/douyin/accounts/${encodeURIComponent(accountId)}/videos?limit=80`,
|
||||
analyzeAccount: (accountId) => `/v2/douyin/accounts/${encodeURIComponent(accountId)}/analysis`,
|
||||
analyzeTopVideos: (accountId) => `/v2/douyin/accounts/${encodeURIComponent(accountId)}/videos/analyze-top`,
|
||||
similarSearches: "/v2/douyin/similar-searches",
|
||||
similarSearchDetail: (searchId) => `/v2/douyin/similar-searches/${encodeURIComponent(searchId)}`,
|
||||
benchmarkLinks: (accountId) => `/v2/douyin/accounts/${encodeURIComponent(accountId)}/benchmark-links`,
|
||||
trackingAccounts: "/v2/douyin/tracking/accounts",
|
||||
trackingDigest: "/v2/douyin/tracking/digest",
|
||||
trackingRefresh: "/v2/douyin/tracking/refresh",
|
||||
trackingCursor: "/v2/douyin/tracking/cursor",
|
||||
trackingAccountRefresh: (trackedAccountId) => `/v2/douyin/tracking/accounts/${encodeURIComponent(trackedAccountId)}/refresh`
|
||||
}
|
||||
},
|
||||
xiaohongshu: {
|
||||
label: "小红书",
|
||||
shortLabel: "小红书",
|
||||
workbenchReady: false,
|
||||
pendingText: "小红书工作台待接入"
|
||||
},
|
||||
bilibili: {
|
||||
label: "哔哩哔哩",
|
||||
shortLabel: "B站",
|
||||
workbenchReady: false,
|
||||
pendingText: "B站工作台待接入"
|
||||
},
|
||||
kuaishou: {
|
||||
label: "快手",
|
||||
shortLabel: "快手",
|
||||
workbenchReady: false,
|
||||
pendingText: "快手工作台待接入"
|
||||
},
|
||||
wechat_video: {
|
||||
label: "微信视频号",
|
||||
shortLabel: "视频号",
|
||||
workbenchReady: false,
|
||||
pendingText: "视频号工作台待接入"
|
||||
}
|
||||
};
|
||||
const INTEGRATION_META = {
|
||||
local_model: {
|
||||
label: "本机模型",
|
||||
@@ -107,6 +154,90 @@ function platformLabel(value) {
|
||||
return matched?.label || String(value || "抖音");
|
||||
}
|
||||
|
||||
function getPlatformMeta(value) {
|
||||
return PLATFORM_REGISTRY[normalizePlatformValue(value, "")] || null;
|
||||
}
|
||||
|
||||
function getPlatformShortLabel(value) {
|
||||
return getPlatformMeta(value)?.shortLabel || platformLabel(value);
|
||||
}
|
||||
|
||||
function isWorkbenchPlatform(value) {
|
||||
return Boolean(getPlatformMeta(value)?.workbenchReady);
|
||||
}
|
||||
|
||||
function getWorkbenchRoute(platform, key, ...args) {
|
||||
const routes = getPlatformMeta(platform)?.routes;
|
||||
if (!routes) return "";
|
||||
const route = routes[key];
|
||||
if (typeof route === "function") return route(...args);
|
||||
return route || "";
|
||||
}
|
||||
|
||||
function setCurrentPlatform(value) {
|
||||
const normalized = normalizePlatformValue(value, "");
|
||||
appState.currentPlatform = normalized;
|
||||
if (normalized) {
|
||||
localStorage.setItem(STORAGE_KEY + ":currentPlatform", normalized);
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_KEY + ":currentPlatform");
|
||||
}
|
||||
}
|
||||
|
||||
function getAccountPlatform(account) {
|
||||
return normalizePlatformValue(
|
||||
account?.platform
|
||||
|| account?.source_platform
|
||||
|| account?.metadata?.platform
|
||||
|| "",
|
||||
"douyin"
|
||||
);
|
||||
}
|
||||
|
||||
function getAccountHandle(account) {
|
||||
return String(
|
||||
account?.handle
|
||||
|| account?.douyin_id
|
||||
|| account?.xhs_id
|
||||
|| account?.bilibili_uid
|
||||
|| account?.kuaishou_id
|
||||
|| account?.wechat_video_id
|
||||
|| account?.uid
|
||||
|| account?.username
|
||||
|| ""
|
||||
).trim();
|
||||
}
|
||||
|
||||
function getAccountProfileUrl(account) {
|
||||
return String(account?.profile_url || account?.source_url || account?.homepage_url || "").trim();
|
||||
}
|
||||
|
||||
function getAccountName(account) {
|
||||
return String(account?.nickname || getAccountHandle(account) || "未命名账号").trim();
|
||||
}
|
||||
|
||||
function getAccountSubtitle(account) {
|
||||
return getAccountHandle(account) || getAccountProfileUrl(account) || platformLabel(getAccountPlatform(account));
|
||||
}
|
||||
|
||||
function getPendingWorkbenchReason(platform) {
|
||||
const meta = getPlatformMeta(platform);
|
||||
return meta?.pendingText || `${platformLabel(platform)}工作台待接入`;
|
||||
}
|
||||
|
||||
function getPreferredPlatform() {
|
||||
const selectedAccountPlatform = getAccountPlatform(getSelectedAccount());
|
||||
if (selectedAccountPlatform && isWorkbenchPlatform(selectedAccountPlatform)) return selectedAccountPlatform;
|
||||
const current = normalizePlatformValue(appState.currentPlatform, "");
|
||||
if (current && isWorkbenchPlatform(current)) return current;
|
||||
const sourcePlatform = normalizePlatformValue(
|
||||
safeArray(appState.contentSources).find((item) => isWorkbenchPlatform(item.platform))?.platform || "",
|
||||
""
|
||||
);
|
||||
if (sourcePlatform) return sourcePlatform;
|
||||
return "douyin";
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
@@ -574,6 +705,7 @@ async function logoutSession() {
|
||||
appState.contentSources = [];
|
||||
appState.accounts = [];
|
||||
appState.selectedAccountId = "";
|
||||
appState.currentPlatform = "";
|
||||
appState.selectedAssistantId = "";
|
||||
appState.selectedWorkspace = null;
|
||||
appState.selectedVideos = { items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 };
|
||||
@@ -587,6 +719,7 @@ async function logoutSession() {
|
||||
appState.lastGeneratedCopy = null;
|
||||
appState.lastSimilaritySearch = null;
|
||||
appState.lastJobDetail = null;
|
||||
localStorage.removeItem(STORAGE_KEY + ":currentPlatform");
|
||||
renderAll();
|
||||
}
|
||||
|
||||
@@ -601,14 +734,23 @@ async function loadKnowledgeDocuments(knowledgeBases) {
|
||||
return groups.flat().slice(0, 12);
|
||||
}
|
||||
|
||||
async function loadDouyinAccount(accountId) {
|
||||
async function loadPlatformAccount(platform, accountId) {
|
||||
if (!accountId) return;
|
||||
const normalizedPlatform = normalizePlatformValue(platform, getPreferredPlatform());
|
||||
appState.selectedAccountId = accountId;
|
||||
const supportsAccountVideos = backendSupports("/v2/douyin/accounts/{account_id}/videos");
|
||||
setCurrentPlatform(normalizedPlatform);
|
||||
const workspacePath = getWorkbenchRoute(normalizedPlatform, "workspace", accountId);
|
||||
if (!workspacePath) {
|
||||
appState.selectedWorkspace = null;
|
||||
appState.selectedVideos = { items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 };
|
||||
return;
|
||||
}
|
||||
const videosPath = getWorkbenchRoute(normalizedPlatform, "videos", accountId);
|
||||
const supportsAccountVideos = videosPath && backendSupports(`/v2/${normalizedPlatform}/accounts/{account_id}/videos`);
|
||||
const [workspace, videos] = await Promise.all([
|
||||
storyforgeFetch(`/v2/douyin/accounts/${encodeURIComponent(accountId)}/workspace`),
|
||||
storyforgeFetch(workspacePath),
|
||||
supportsAccountVideos
|
||||
? storyforgeFetch(`/v2/douyin/accounts/${encodeURIComponent(accountId)}/videos?limit=80`).catch(() => ({
|
||||
? storyforgeFetch(videosPath).catch(() => ({
|
||||
items: [],
|
||||
meta: {},
|
||||
top_scored_video_ids: [],
|
||||
@@ -651,16 +793,20 @@ async function bootstrap() {
|
||||
return;
|
||||
}
|
||||
appState.backendCapabilities = await loadBackendCapabilities(appState.session.backendUrl).catch(() => null);
|
||||
const supportsTracking = backendSupports("/v2/douyin/tracking/accounts");
|
||||
const supportsTrackingDigest = backendSupports("/v2/douyin/tracking/digest");
|
||||
const preferredPlatform = getPreferredPlatform();
|
||||
setCurrentPlatform(preferredPlatform);
|
||||
const accountListPath = getWorkbenchRoute(preferredPlatform, "accounts");
|
||||
const trackingAccountsPath = getWorkbenchRoute(preferredPlatform, "trackingAccounts");
|
||||
const trackingDigestPath = getWorkbenchRoute(preferredPlatform, "trackingDigest");
|
||||
const supportsTrackingDigest = trackingDigestPath && backendSupports(trackingDigestPath);
|
||||
const supportsReviews = backendSupports("/v2/reviews");
|
||||
const supportsIntegrationHealth = backendSupports("/v2/integrations/health");
|
||||
const supportsLocalModels = backendSupports("/v2/integrations/local-models");
|
||||
const [dashboard, contentSources, accounts, trackingAccountsPayload, reviews, integrationHealth, localModelCatalog] = await Promise.all([
|
||||
storyforgeFetch("/v2/me/dashboard"),
|
||||
storyforgeFetch("/v2/content-sources").catch(() => []),
|
||||
storyforgeFetch("/v2/douyin/accounts").catch(() => []),
|
||||
supportsTracking ? storyforgeFetch("/v2/douyin/tracking/accounts").catch(() => ({ items: [], cursor_last_seen_at: "" })) : Promise.resolve({ items: [], cursor_last_seen_at: "" }),
|
||||
accountListPath ? storyforgeFetch(accountListPath).catch(() => []) : Promise.resolve([]),
|
||||
trackingAccountsPath ? storyforgeFetch(trackingAccountsPath).catch(() => ({ items: [], cursor_last_seen_at: "" })) : Promise.resolve({ items: [], cursor_last_seen_at: "" }),
|
||||
supportsReviews ? storyforgeFetch("/v2/reviews").catch(() => []) : Promise.resolve([]),
|
||||
supportsIntegrationHealth ? storyforgeFetch("/v2/integrations/health").catch(() => null) : Promise.resolve(null),
|
||||
supportsLocalModels ? storyforgeFetch("/v2/integrations/local-models").catch(() => null) : Promise.resolve(null)
|
||||
@@ -670,8 +816,8 @@ async function bootstrap() {
|
||||
setLastSeenAt(trackingCursorLastSeenAt);
|
||||
}
|
||||
const trackingSince = trackingCursorLastSeenAt || getTrackingSinceIso();
|
||||
const trackingDigest = supportsTrackingDigest
|
||||
? await storyforgeFetch(`/v2/douyin/tracking/digest?since=${encodeURIComponent(trackingSince)}&limit=24`).catch(() => ({
|
||||
const trackingDigest = trackingDigestPath
|
||||
? await storyforgeFetch(`${trackingDigestPath}?since=${encodeURIComponent(trackingSince)}&limit=24`).catch(() => ({
|
||||
items: [],
|
||||
tracked_accounts: [],
|
||||
cursor_last_seen_at: trackingCursorLastSeenAt
|
||||
@@ -696,7 +842,8 @@ async function bootstrap() {
|
||||
const selectedAccountExists = appState.accounts.some((item) => item.id === appState.selectedAccountId);
|
||||
const nextAccountId = selectedAccountExists ? appState.selectedAccountId : appState.accounts[0]?.id || "";
|
||||
if (nextAccountId) {
|
||||
await loadDouyinAccount(nextAccountId);
|
||||
const nextAccount = appState.accounts.find((item) => item.id === nextAccountId) || null;
|
||||
await loadPlatformAccount(getAccountPlatform(nextAccount), nextAccountId);
|
||||
} else {
|
||||
appState.selectedAccountId = "";
|
||||
appState.selectedWorkspace = null;
|
||||
@@ -714,13 +861,15 @@ async function bootstrap() {
|
||||
}
|
||||
|
||||
async function markTrackingDigestRead() {
|
||||
if (!backendSupports("/v2/douyin/tracking/cursor")) {
|
||||
const platform = getPreferredPlatform();
|
||||
const trackingCursorPath = getWorkbenchRoute(platform, "trackingCursor");
|
||||
if (!trackingCursorPath || !backendSupports(trackingCursorPath)) {
|
||||
rememberAction("当前后端暂不支持", "这套 live collector 还没有接入跟踪已读游标。", "orange");
|
||||
renderAll();
|
||||
return;
|
||||
}
|
||||
const nextSeenAt = new Date().toISOString();
|
||||
await storyforgeFetch("/v2/douyin/tracking/cursor", {
|
||||
await storyforgeFetch(trackingCursorPath, {
|
||||
method: "POST",
|
||||
body: { last_seen_at: nextSeenAt }
|
||||
});
|
||||
@@ -728,14 +877,16 @@ async function markTrackingDigestRead() {
|
||||
}
|
||||
|
||||
async function refreshTrackingAccountsAction() {
|
||||
if (!backendSupports("/v2/douyin/tracking/refresh")) {
|
||||
const platform = getPreferredPlatform();
|
||||
const trackingRefreshPath = getWorkbenchRoute(platform, "trackingRefresh");
|
||||
if (!trackingRefreshPath || !backendSupports(trackingRefreshPath)) {
|
||||
rememberAction("当前后端暂不支持", "这套 live collector 还没有接入批量跟踪同步。", "orange");
|
||||
renderAll();
|
||||
return;
|
||||
}
|
||||
setBusy(true, "正在同步跟踪账号...");
|
||||
try {
|
||||
const payload = await storyforgeFetch("/v2/douyin/tracking/refresh", {
|
||||
const payload = await storyforgeFetch(trackingRefreshPath, {
|
||||
method: "POST"
|
||||
});
|
||||
rememberAction(
|
||||
@@ -754,14 +905,16 @@ async function refreshTrackedAccountAction(trackedAccountId) {
|
||||
if (!trackedAccountId) {
|
||||
throw new Error("trackedAccountId is required");
|
||||
}
|
||||
if (!backendSupports("/v2/douyin/tracking/accounts/{tracked_account_id}/refresh")) {
|
||||
const platform = getPreferredPlatform();
|
||||
const trackingRefreshPath = getWorkbenchRoute(platform, "trackingAccountRefresh", trackedAccountId);
|
||||
if (!trackingRefreshPath || !backendSupports(`/v2/${platform}/tracking/accounts/{tracked_account_id}/refresh`)) {
|
||||
rememberAction("当前后端暂不支持", "这套 live collector 还没有接入单账号跟踪同步。", "orange");
|
||||
renderAll();
|
||||
return;
|
||||
}
|
||||
setBusy(true, "正在同步该跟踪账号...");
|
||||
try {
|
||||
const payload = await storyforgeFetch(`/v2/douyin/tracking/accounts/${encodeURIComponent(trackedAccountId)}/refresh`, {
|
||||
const payload = await storyforgeFetch(trackingRefreshPath, {
|
||||
method: "POST"
|
||||
});
|
||||
const success = payload.success !== false;
|
||||
@@ -849,17 +1002,21 @@ function getReviewById(reviewId) {
|
||||
|
||||
function getContentSourcesForAccount(account) {
|
||||
if (!account) return [];
|
||||
const profileUrl = String(account.profile_url || "").trim();
|
||||
const douyinId = String(account.douyin_id || "").trim();
|
||||
const nickname = String(account.nickname || "").trim();
|
||||
const platform = getAccountPlatform(account);
|
||||
const profileUrl = getAccountProfileUrl(account);
|
||||
const handle = getAccountHandle(account);
|
||||
const nickname = getAccountName(account);
|
||||
return safeArray(appState.contentSources).filter((source) => {
|
||||
const sourceUrl = String(source.source_url || "").trim();
|
||||
const handle = String(source.handle || "").trim();
|
||||
const sourceHandle = String(source.handle || "").trim();
|
||||
const title = String(source.title || "").trim();
|
||||
const sourcePlatform = normalizePlatformValue(source.platform || "", platform);
|
||||
return (
|
||||
(profileUrl && sourceUrl === profileUrl) ||
|
||||
(douyinId && handle === douyinId) ||
|
||||
(nickname && title.includes(nickname))
|
||||
sourcePlatform === platform && (
|
||||
(profileUrl && sourceUrl === profileUrl) ||
|
||||
(handle && sourceHandle === handle) ||
|
||||
(nickname && title.includes(nickname))
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1195,6 +1352,9 @@ function markSavedCandidate(candidate, links) {
|
||||
|
||||
async function saveCandidateAsBenchmark(candidateIndex, relationType = "benchmark") {
|
||||
const account = requireSelectedAccountRow();
|
||||
const platform = getAccountPlatform(account);
|
||||
const benchmarkPath = getWorkbenchRoute(platform, "benchmarkLinks", account.id);
|
||||
if (!benchmarkPath) throw new Error(getPendingWorkbenchReason(platform));
|
||||
const candidate = safeArray(appState.lastSimilaritySearch?.candidates)[Number(candidateIndex)];
|
||||
if (!candidate) throw new Error("当前候选不存在,请先重新查相似");
|
||||
const payload = {
|
||||
@@ -1207,7 +1367,7 @@ async function saveCandidateAsBenchmark(candidateIndex, relationType = "benchmar
|
||||
if (!payload.target_account_ids.length && !payload.target_profile_urls.length) {
|
||||
throw new Error("当前候选没有可保存的账号或主页链接");
|
||||
}
|
||||
const result = await storyforgeFetch(`/v2/douyin/accounts/${encodeURIComponent(account.id)}/benchmark-links`, {
|
||||
const result = await storyforgeFetch(benchmarkPath, {
|
||||
method: "POST",
|
||||
body: payload
|
||||
});
|
||||
@@ -1411,10 +1571,10 @@ function renderDashboardScreen() {
|
||||
${accounts.slice(0, 3).map((account) => `
|
||||
<div class="entity-card pad">
|
||||
<div class="entity-cell">
|
||||
<div class="avatar-lg">${escapeHtml(initials(account.nickname || account.douyin_id))}</div>
|
||||
<div class="avatar-lg">${escapeHtml(initials(getAccountName(account)))}</div>
|
||||
<div>
|
||||
<div class="cell-title">${escapeHtml(account.nickname || "未命名账号")}</div>
|
||||
<div class="cell-desc">${escapeHtml(account.signature || account.profile_url || "已同步抖音账号")}</div>
|
||||
<div class="cell-title">${escapeHtml(getAccountName(account))}</div>
|
||||
<div class="cell-desc">${escapeHtml(account.signature || getAccountProfileUrl(account) || `已同步${platformLabel(getAccountPlatform(account))}账号`)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="entity-meta">
|
||||
@@ -1536,17 +1696,20 @@ function renderProjectsScreen() {
|
||||
|
||||
function renderDiscoveryScreen() {
|
||||
if (!appState.dashboard) {
|
||||
return screenShell("找对标", "连接后端后才能加载真实对标账号。", `${button("连接后端", "open-auth", "primary")}`, renderEmptyState("对标库未加载", "登录后这里会直接显示抖音账号列表和详情。"));
|
||||
return screenShell("找对标", "连接后端后才能加载真实对标账号。", `${button("连接后端", "open-auth", "primary")}`, renderEmptyState("对标库未加载", "登录后这里会显示当前平台的账号列表和详情。"));
|
||||
}
|
||||
const query = appState.discoveryQuery.toLowerCase();
|
||||
const accounts = safeArray(appState.accounts).filter((account) => {
|
||||
if (!query) return true;
|
||||
return [account.nickname, account.signature, account.profile_url, account.douyin_id, ...safeArray(account.tags), ...safeArray(account.keywords)]
|
||||
return [getAccountName(account), account.signature, getAccountProfileUrl(account), getAccountHandle(account), ...safeArray(account.tags), ...safeArray(account.keywords)]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(query);
|
||||
});
|
||||
const selected = getSelectedAccount();
|
||||
const currentPlatform = getAccountPlatform(selected) || getPreferredPlatform();
|
||||
const currentPlatformLabel = getPlatformShortLabel(currentPlatform);
|
||||
const workbenchReason = !isWorkbenchPlatform(currentPlatform) ? getPendingWorkbenchReason(currentPlatform) : "";
|
||||
const reports = safeArray(appState.selectedWorkspace?.recent_reports);
|
||||
const linkedAccounts = safeArray(appState.selectedWorkspace?.linked_accounts);
|
||||
const videos = safeArray(appState.selectedVideos?.items);
|
||||
@@ -1560,18 +1723,20 @@ function renderDiscoveryScreen() {
|
||||
const tracked = selected?.id ? isTrackedAccount(selected.id) : false;
|
||||
return screenShell(
|
||||
"找对标",
|
||||
"这里已经接入真实抖音账号列表和单账号详情。",
|
||||
`${button("导入主页", "open-import-homepage")} ${button("导入当前对标", "open-import-selected-account")} ${button(tracked ? "已在跟踪" : "加入跟踪", "open-track-selected-account")} ${button("账号分析", "analyze-selected-account")} ${button("高分分析", "analyze-top-videos")} ${button("查相似", "open-similar-search")} ${button("存对标", "open-benchmark-link", "primary")}`,
|
||||
isWorkbenchPlatform(currentPlatform)
|
||||
? `这里已经接入真实${currentPlatformLabel}账号列表和单账号详情。`
|
||||
: `${workbenchReason}。当前仍可导入内容源、绑定 Agent 和沉淀复盘。`,
|
||||
`${button("导入主页", "open-import-homepage")} ${button("导入当前对标", "open-import-selected-account")} ${button(tracked ? "已在跟踪" : "加入跟踪", "open-track-selected-account", "secondary", { disabledReason: workbenchReason || "" })} ${button("账号分析", "analyze-selected-account", "secondary", { disabledReason: workbenchReason || "" })} ${button("高分分析", "analyze-top-videos", "secondary", { disabledReason: workbenchReason || "" })} ${button("查相似", "open-similar-search", "secondary", { disabledReason: workbenchReason || "" })} ${button("存对标", "open-benchmark-link", "primary", { disabledReason: workbenchReason || "" })}`,
|
||||
`
|
||||
<div class="panel">
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-stack">
|
||||
<label class="search search-inline">
|
||||
<span>⌕</span>
|
||||
<input data-action="discovery-query" value="${escapeHtml(appState.discoveryQuery)}" placeholder="搜账号名、主页链接、关键词" />
|
||||
<input data-action="discovery-query" value="${escapeHtml(appState.discoveryQuery)}" placeholder="搜账号名、账号标识、主页链接、关键词" />
|
||||
</label>
|
||||
<div class="filters">
|
||||
<div class="filter">平台:抖音</div>
|
||||
<div class="filter">平台:${escapeHtml(currentPlatformLabel)}</div>
|
||||
<div class="filter">账号数:${escapeHtml(formatNumber(accounts.length))}</div>
|
||||
<div class="filter">报告:${escapeHtml(formatNumber(reports.length))}</div>
|
||||
<div class="filter">作品:${escapeHtml(formatNumber(effectiveVideos.length))}</div>
|
||||
@@ -1592,10 +1757,10 @@ function renderDiscoveryScreen() {
|
||||
return `
|
||||
<button class="account-select-card ${active ? "is-active" : ""}" type="button" data-action="select-account" data-account-id="${escapeHtml(account.id)}">
|
||||
<div class="entity-cell">
|
||||
<div class="avatar-lg">${escapeHtml(initials(account.nickname || account.douyin_id))}</div>
|
||||
<div class="avatar-lg">${escapeHtml(initials(getAccountName(account)))}</div>
|
||||
<div>
|
||||
<div class="cell-title">${escapeHtml(account.nickname || "未命名账号")}</div>
|
||||
<div class="cell-desc">${escapeHtml(account.douyin_id || "未填抖音号")}</div>
|
||||
<div class="cell-title">${escapeHtml(getAccountName(account))}</div>
|
||||
<div class="cell-desc">${escapeHtml(getAccountSubtitle(account) || "未填账号标识")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kpi-inline">
|
||||
@@ -1609,7 +1774,7 @@ function renderDiscoveryScreen() {
|
||||
</div>
|
||||
</button>
|
||||
`;
|
||||
}).join("") || `<div class="empty-state">当前没有抖音账号数据。</div>`}
|
||||
}).join("") || `<div class="empty-state">当前平台没有账号数据。</div>`}
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="account-table">
|
||||
@@ -1628,10 +1793,10 @@ function renderDiscoveryScreen() {
|
||||
<tr>
|
||||
<td>
|
||||
<div class="entity-cell">
|
||||
<div class="avatar-lg">${escapeHtml(initials(account.nickname || account.douyin_id))}</div>
|
||||
<div class="avatar-lg">${escapeHtml(initials(getAccountName(account)))}</div>
|
||||
<div>
|
||||
<div class="cell-title">${escapeHtml(account.nickname || "未命名账号")}</div>
|
||||
<div class="cell-desc">${escapeHtml(account.douyin_id || account.profile_url || "-")}</div>
|
||||
<div class="cell-title">${escapeHtml(getAccountName(account))}</div>
|
||||
<div class="cell-desc">${escapeHtml(getAccountSubtitle(account) || "-")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -1641,20 +1806,20 @@ function renderDiscoveryScreen() {
|
||||
<td>${escapeHtml(formatNumber(account.video_summary?.avg_like))}</td>
|
||||
<td><div class="row-meta"><span class="tag" data-action="select-account" data-account-id="${escapeHtml(account.id)}">查看</span></div></td>
|
||||
</tr>
|
||||
`).join("") || `<tr><td colspan="6">当前没有抖音账号数据。</td></tr>`}
|
||||
`).join("") || `<tr><td colspan="6">当前平台没有账号数据。</td></tr>`}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="layout-grid grid-main" style="padding:18px; border-top:1px solid var(--line);">
|
||||
<div class="side-stack">
|
||||
<div class="panel pad" style="box-shadow:none;" id="selected-account-anchor">
|
||||
<div class="panel-head"><div><h3>当前选中对标</h3><div class="panel-subtitle">直接来自工作台接口</div></div><span class="tag blue">${escapeHtml(selected?.nickname || "未选中")}</span></div>
|
||||
<div class="panel-head"><div><h3>当前选中对标</h3><div class="panel-subtitle">直接来自当前平台工作台</div></div><span class="tag blue">${escapeHtml(getAccountName(selected) || "未选中")}</span></div>
|
||||
<div class="hero-card" style="padding:18px;">
|
||||
<div class="entity-cell">
|
||||
<div class="avatar-lg">${escapeHtml(initials(selected?.nickname || "SF"))}</div>
|
||||
<div class="avatar-lg">${escapeHtml(initials(getAccountName(selected) || "SF"))}</div>
|
||||
<div>
|
||||
<h3>${escapeHtml(selected?.nickname || "还没有选中账号")}</h3>
|
||||
<p>${escapeHtml(selected?.profile_url || selected?.signature || "左侧点一个账号,这里会展示详情。")}</p>
|
||||
<h3>${escapeHtml(getAccountName(selected) || "还没有选中账号")}</h3>
|
||||
<p>${escapeHtml(getAccountProfileUrl(selected) || selected?.signature || "左侧点一个账号,这里会展示详情。")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-only compact-summary-row" style="margin-top:14px;">
|
||||
@@ -1686,13 +1851,14 @@ function renderDiscoveryScreen() {
|
||||
` : `<div class="task-item"><h4>还没有选中账号</h4><p>先从左侧列表选一个对标账号,再决定是否导入到当前项目。</p></div>`}
|
||||
</div>
|
||||
<div class="three-col">
|
||||
<div class="insight-card">
|
||||
<h4>账号画像</h4>
|
||||
<ul>
|
||||
<li>${escapeHtml(selected?.signature || "暂无签名")}</li>
|
||||
<li>${escapeHtml("标签:" + safeArray(selected?.tags).slice(0, 4).join(" / ") || "暂无标签")}</li>
|
||||
<li>${escapeHtml("同步状态:" + (selected?.sync_status || "-"))}</li>
|
||||
</ul>
|
||||
<div class="insight-card">
|
||||
<h4>账号画像</h4>
|
||||
<ul>
|
||||
<li>${escapeHtml(selected?.signature || "暂无签名")}</li>
|
||||
<li>${escapeHtml("平台:" + currentPlatformLabel)}</li>
|
||||
<li>${escapeHtml("标签:" + safeArray(selected?.tags).slice(0, 4).join(" / ") || "暂无标签")}</li>
|
||||
<li>${escapeHtml("同步状态:" + (selected?.sync_status || "-"))}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="insight-card">
|
||||
<h4>高分作品</h4>
|
||||
@@ -1774,12 +1940,14 @@ function renderTrackingScreen() {
|
||||
if (!appState.dashboard) {
|
||||
return screenShell("跟踪账号", "登录后才能生成真实日报。", `${button("连接后端", "open-auth", "primary")}`, renderEmptyState("日报未加载", "当前还没有可用的对标账号数据。"));
|
||||
}
|
||||
if (!backendSupports("/v2/douyin/tracking/accounts")) {
|
||||
const currentPlatform = getPreferredPlatform();
|
||||
const trackingAccountsPath = getWorkbenchRoute(currentPlatform, "trackingAccounts");
|
||||
if (!trackingAccountsPath || !backendSupports(trackingAccountsPath)) {
|
||||
return screenShell(
|
||||
"跟踪账号",
|
||||
"当前 live collector 还没有接入跟踪日报接口。",
|
||||
`${getPendingWorkbenchReason(currentPlatform)}。`,
|
||||
`${button("跳到找对标", "goto-discovery", "primary")}`,
|
||||
renderEmptyState("跟踪能力暂未接入", "这套后端还缺 /v2/douyin/tracking/*,等 live collector 同步后这里会自动切成真实日报。")
|
||||
renderEmptyState("跟踪能力暂未接入", `这套后端还没有接入 ${platformLabel(currentPlatform)} 跟踪接口,等 live collector 同步后这里会自动切成真实日报。`)
|
||||
);
|
||||
}
|
||||
const trackedAccounts = safeArray(appState.trackingAccounts);
|
||||
@@ -1787,7 +1955,7 @@ function renderTrackingScreen() {
|
||||
const cursorLabel = appState.lastSeenAt ? formatDateTime(appState.lastSeenAt) : "尚未记录";
|
||||
return screenShell(
|
||||
"跟踪账号",
|
||||
"这里已经接上真实跟踪对象和按上次打开后的更新日报。",
|
||||
`这里已经接上真实${getPlatformShortLabel(currentPlatform)}跟踪对象和按上次打开后的更新日报。`,
|
||||
`${button("同步全部", "refresh-tracking")} ${button("标记已读", "mark-tracking-read")} ${button("跳到找对标", "goto-discovery", "primary")}`,
|
||||
`
|
||||
<div class="hero-card">
|
||||
@@ -2475,6 +2643,7 @@ function openImportHomepageAction() {
|
||||
|
||||
function openImportSelectedAccountAction() {
|
||||
const account = requireSelectedAccountRow();
|
||||
const platform = getAccountPlatform(account);
|
||||
const project = requireSelectedProject();
|
||||
const assistants = getAssistantOptions(project.id);
|
||||
const currentSources = getCurrentProjectSourcesForAccount(account, project.id);
|
||||
@@ -2488,10 +2657,10 @@ function openImportSelectedAccountAction() {
|
||||
submitLabel: currentSource ? "继续同步" : "导入并同步",
|
||||
fields: [
|
||||
{ name: "projectId", label: "归属项目", type: "select", value: project.id, options: getProjectOptions() },
|
||||
{ name: "platform", label: "平台", type: "select", value: normalizePlatformValue(currentSource?.platform || "douyin"), options: getPlatformOptions() },
|
||||
{ name: "title", label: "内容源标题", value: currentSource?.title || `${account.nickname || account.douyin_id || "对标账号"} 对标主页` },
|
||||
{ name: "handle", label: "账号标识", value: currentSource?.handle || account.douyin_id || "" },
|
||||
{ name: "sourceUrl", label: "主页链接", type: "url", value: currentSource?.source_url || account.profile_url || "", placeholder: "https://..." },
|
||||
{ name: "platform", label: "平台", type: "select", value: normalizePlatformValue(currentSource?.platform || platform), options: getPlatformOptions() },
|
||||
{ name: "title", label: "内容源标题", value: currentSource?.title || `${getAccountName(account)} 对标主页` },
|
||||
{ name: "handle", label: "账号标识", value: currentSource?.handle || getAccountHandle(account) || "" },
|
||||
{ name: "sourceUrl", label: "主页链接", type: "url", value: currentSource?.source_url || getAccountProfileUrl(account) || "", placeholder: "https://..." },
|
||||
{ name: "assistantId", label: "绑定 Agent", type: "select", value: getSelectedAssistant()?.id || assistants[0]?.value || "", options: [{ value: "", label: "暂不绑定" }, ...assistants] },
|
||||
{ name: "maxItems", label: "最多同步作品数", type: "number", value: Number(currentSource?.metadata?.max_items || 6), min: 1, max: 20 },
|
||||
{ name: "skipExisting", label: "跳过已存在作品", type: "checkbox", value: true },
|
||||
@@ -2511,7 +2680,7 @@ function openImportSelectedAccountAction() {
|
||||
platform,
|
||||
handle: values.handle || "",
|
||||
source_url: values.sourceUrl.trim(),
|
||||
title: values.title || values.handle || account.nickname || "对标主页",
|
||||
title: values.title || values.handle || getAccountName(account) || "对标主页",
|
||||
metadata: {
|
||||
imported_from_account_id: account.id,
|
||||
imported_from_workspace: "discovery"
|
||||
@@ -2526,15 +2695,15 @@ function openImportSelectedAccountAction() {
|
||||
assistant_id: values.assistantId || "",
|
||||
content_source_id: source.id,
|
||||
platform,
|
||||
handle: values.handle || account.douyin_id || "",
|
||||
handle: values.handle || getAccountHandle(account) || "",
|
||||
source_url: values.sourceUrl.trim(),
|
||||
title: values.title || account.nickname || values.handle || "对标主页",
|
||||
title: values.title || getAccountName(account) || values.handle || "对标主页",
|
||||
max_items: Number(values.maxItems || 6),
|
||||
skip_existing: Boolean(values.skipExisting),
|
||||
auto_trigger_analysis: Boolean(values.autoAnalyze)
|
||||
}
|
||||
});
|
||||
rememberAction("对标已接入项目", `已把「${account.nickname || account.douyin_id || "当前对标"}」接入项目,并创建同步任务 ${job.title || job.id}。`, "green", { source, job });
|
||||
rememberAction("对标已接入项目", `已把「${getAccountName(account) || "当前对标"}」接入项目,并创建同步任务 ${job.title || job.id}。`, "green", { source, job });
|
||||
await bootstrap();
|
||||
}
|
||||
});
|
||||
@@ -2542,6 +2711,13 @@ function openImportSelectedAccountAction() {
|
||||
|
||||
function openTrackSelectedAccountAction() {
|
||||
const account = requireSelectedAccountRow();
|
||||
const platform = getAccountPlatform(account);
|
||||
const trackingAccountsPath = getWorkbenchRoute(platform, "trackingAccounts");
|
||||
if (!trackingAccountsPath) {
|
||||
rememberAction("当前平台待接入", getPendingWorkbenchReason(platform), "orange");
|
||||
renderAll();
|
||||
return;
|
||||
}
|
||||
const project = requireSelectedProject();
|
||||
const assistants = getAssistantOptions(project.id);
|
||||
const trackedItem = safeArray(appState.trackingAccounts).find((item) => item.tracked_account_id === account.id);
|
||||
@@ -2552,12 +2728,12 @@ function openTrackSelectedAccountAction() {
|
||||
: "把当前对标账号加入每日跟踪,后续自动生成更新日报。",
|
||||
submitLabel: trackedItem ? "保存跟踪" : "开始跟踪",
|
||||
fields: [
|
||||
{ name: "accountName", label: "账号", type: "html", html: `<div class="sheet-html"><strong>${escapeHtml(account.nickname || account.douyin_id || "未命名账号")}</strong><p>${escapeHtml(account.profile_url || account.signature || "")}</p></div>` },
|
||||
{ name: "accountName", label: "账号", type: "html", html: `<div class="sheet-html"><strong>${escapeHtml(getAccountName(account) || "未命名账号")}</strong><p>${escapeHtml(getAccountProfileUrl(account) || account.signature || "")}</p></div>` },
|
||||
{ name: "assistantId", label: "负责 Agent", type: "select", value: trackedItem?.assistant_id || getSelectedAssistant()?.id || assistants[0]?.value || "", options: [{ value: "", label: "先不绑定" }, ...assistants] },
|
||||
{ name: "note", label: "跟踪备注", value: trackedItem?.note || "", placeholder: "例如:重点观察开头结构、成交句式和更新频率" }
|
||||
],
|
||||
onSubmit: async (values) => {
|
||||
await storyforgeFetch("/v2/douyin/tracking/accounts", {
|
||||
await storyforgeFetch(trackingAccountsPath, {
|
||||
method: "POST",
|
||||
body: {
|
||||
tracked_account_id: account.id,
|
||||
@@ -2565,7 +2741,7 @@ function openTrackSelectedAccountAction() {
|
||||
note: values.note || ""
|
||||
}
|
||||
});
|
||||
rememberAction(trackedItem ? "跟踪已更新" : "已加入跟踪", `账号「${account.nickname || account.douyin_id || "当前对标"}」现在会进入更新日报。`, "green");
|
||||
rememberAction(trackedItem ? "跟踪已更新" : "已加入跟踪", `账号「${getAccountName(account) || "当前对标"}」现在会进入更新日报。`, "green");
|
||||
await bootstrap();
|
||||
}
|
||||
});
|
||||
@@ -2748,6 +2924,13 @@ function openEditAssistantAction(assistantId = "") {
|
||||
|
||||
function openAnalyzeSelectedAccountAction() {
|
||||
const account = requireSelectedAccountRow();
|
||||
const platform = getAccountPlatform(account);
|
||||
const analyzePath = getWorkbenchRoute(platform, "analyzeAccount", account.id);
|
||||
if (!analyzePath) {
|
||||
rememberAction("当前平台待接入", getPendingWorkbenchReason(platform), "orange");
|
||||
renderAll();
|
||||
return;
|
||||
}
|
||||
openActionModal({
|
||||
title: "分析当前对标账号",
|
||||
description: "从商业化和内容运营角度重跑一次账号分析。",
|
||||
@@ -2759,7 +2942,7 @@ function openAnalyzeSelectedAccountAction() {
|
||||
{ name: "topVideoCount", label: "高分作品分析数", type: "number", value: 4, min: 1, max: 10 }
|
||||
],
|
||||
onSubmit: async (values) => {
|
||||
const result = await storyforgeFetch(`/v2/douyin/accounts/${encodeURIComponent(account.id)}/analysis`, {
|
||||
const result = await storyforgeFetch(analyzePath, {
|
||||
method: "POST",
|
||||
body: {
|
||||
model_profile_ids: [],
|
||||
@@ -2775,7 +2958,7 @@ function openAnalyzeSelectedAccountAction() {
|
||||
});
|
||||
const summary = result?.suggestions?.[0]?.parsed_json?.executive_summary || result?.suggestions?.[0]?.suggestion_text || "已生成新的账号分析。";
|
||||
rememberAction("对标账号分析完成", brief(summary, 120), "green", result);
|
||||
await loadDouyinAccount(account.id);
|
||||
await loadPlatformAccount(platform, account.id);
|
||||
renderAll();
|
||||
}
|
||||
});
|
||||
@@ -2783,7 +2966,9 @@ function openAnalyzeSelectedAccountAction() {
|
||||
|
||||
function openAnalyzeTopVideosAction() {
|
||||
const account = requireSelectedAccountRow();
|
||||
if (!backendSupports("/v2/douyin/accounts/{account_id}/videos/analyze-top")) {
|
||||
const platform = getAccountPlatform(account);
|
||||
const analyzePath = getWorkbenchRoute(platform, "analyzeTopVideos", account.id);
|
||||
if (!analyzePath || !backendSupports(`/v2/${platform}/accounts/{account_id}/videos/analyze-top`)) {
|
||||
rememberAction("当前后端暂不支持", "这套 live collector 还没有接入高分作品批量分析。", "orange");
|
||||
renderAll();
|
||||
return;
|
||||
@@ -2797,7 +2982,7 @@ function openAnalyzeTopVideosAction() {
|
||||
{ name: "minScore", label: "最低分阈值", type: "number", value: 45, min: 0, max: 100 }
|
||||
],
|
||||
onSubmit: async (values) => {
|
||||
const result = await storyforgeFetch(`/v2/douyin/accounts/${encodeURIComponent(account.id)}/videos/analyze-top`, {
|
||||
const result = await storyforgeFetch(analyzePath, {
|
||||
method: "POST",
|
||||
body: {
|
||||
model_profile_id: "",
|
||||
@@ -2807,7 +2992,7 @@ function openAnalyzeTopVideosAction() {
|
||||
}
|
||||
});
|
||||
rememberAction("高分作品分析完成", `已补分析 ${formatNumber(result.analyzed_count)} 条高分作品。`, "green", result);
|
||||
await loadDouyinAccount(account.id);
|
||||
await loadPlatformAccount(platform, account.id);
|
||||
renderAll();
|
||||
}
|
||||
});
|
||||
@@ -2815,6 +3000,13 @@ function openAnalyzeTopVideosAction() {
|
||||
|
||||
function openSimilaritySearchAction() {
|
||||
const account = requireSelectedAccountRow();
|
||||
const platform = getAccountPlatform(account);
|
||||
const createPath = getWorkbenchRoute(platform, "similarSearches");
|
||||
if (!createPath) {
|
||||
rememberAction("当前平台待接入", getPendingWorkbenchReason(platform), "orange");
|
||||
renderAll();
|
||||
return;
|
||||
}
|
||||
openActionModal({
|
||||
title: "查相似账号",
|
||||
description: "让 Agent 基于当前账号画像找更多可借鉴对象。",
|
||||
@@ -2824,7 +3016,7 @@ function openSimilaritySearchAction() {
|
||||
{ name: "extraRequirements", label: "额外要求", type: "textarea", rows: 4, placeholder: "例如:优先找创业成交类、口播结构强的账号" }
|
||||
],
|
||||
onSubmit: async (values) => {
|
||||
const created = await storyforgeFetch("/v2/douyin/similar-searches", {
|
||||
const created = await storyforgeFetch(createPath, {
|
||||
method: "POST",
|
||||
body: {
|
||||
source_account_id: account.id,
|
||||
@@ -2837,12 +3029,13 @@ function openSimilaritySearchAction() {
|
||||
}
|
||||
});
|
||||
const searchId = created.id || created.search_id;
|
||||
const detailPath = searchId ? getWorkbenchRoute(platform, "similarSearchDetail", searchId) : "";
|
||||
const detail = searchId
|
||||
? await storyforgeFetch(`/v2/douyin/similar-searches/${encodeURIComponent(searchId)}`)
|
||||
? await storyforgeFetch(detailPath)
|
||||
: created;
|
||||
appState.lastSimilaritySearch = detail;
|
||||
rememberAction("相似账号已生成", `已生成 ${formatNumber(safeArray(detail.candidates).length)} 个候选账号。`, "green", detail);
|
||||
await loadDouyinAccount(account.id);
|
||||
await loadPlatformAccount(platform, account.id);
|
||||
renderAll();
|
||||
}
|
||||
});
|
||||
@@ -2850,9 +3043,16 @@ function openSimilaritySearchAction() {
|
||||
|
||||
function openBenchmarkLinkAction(defaults = {}) {
|
||||
const account = requireSelectedAccountRow();
|
||||
const platform = getAccountPlatform(account);
|
||||
const benchmarkPath = getWorkbenchRoute(platform, "benchmarkLinks", account.id);
|
||||
if (!benchmarkPath) {
|
||||
rememberAction("当前平台待接入", getPendingWorkbenchReason(platform), "orange");
|
||||
renderAll();
|
||||
return;
|
||||
}
|
||||
const options = safeArray(appState.accounts)
|
||||
.filter((item) => item.id !== account.id)
|
||||
.map((item) => ({ value: item.id, label: item.nickname || item.douyin_id || item.id }));
|
||||
.map((item) => ({ value: item.id, label: getAccountName(item) || item.id }));
|
||||
const candidate = typeof defaults.candidateIndex === "number"
|
||||
? safeArray(appState.lastSimilaritySearch?.candidates)[defaults.candidateIndex] || null
|
||||
: null;
|
||||
@@ -2872,7 +3072,7 @@ function openBenchmarkLinkAction(defaults = {}) {
|
||||
],
|
||||
onSubmit: async (values) => {
|
||||
if (!values.targetAccountId && !values.targetProfileUrl?.trim()) throw new Error("请先选择一个目标账号或填写主页链接");
|
||||
const result = await storyforgeFetch(`/v2/douyin/accounts/${encodeURIComponent(account.id)}/benchmark-links`, {
|
||||
const result = await storyforgeFetch(benchmarkPath, {
|
||||
method: "POST",
|
||||
body: {
|
||||
target_account_ids: values.targetAccountId ? [values.targetAccountId] : [],
|
||||
@@ -3409,7 +3609,8 @@ document.addEventListener("click", async (event) => {
|
||||
if (!accountId) return;
|
||||
setBusy(true, "正在加载对标详情...");
|
||||
try {
|
||||
await loadDouyinAccount(accountId);
|
||||
const account = safeArray(appState.accounts).find((item) => item.id === accountId) || null;
|
||||
await loadPlatformAccount(getAccountPlatform(account), accountId);
|
||||
renderAll();
|
||||
} catch (error) {
|
||||
alert("加载对标详情失败: " + error.message);
|
||||
|
||||
Reference in New Issue
Block a user