feat: finish web workbench multi-platform baseline

This commit is contained in:
kris
2026-03-26 12:28:27 +08:00
parent 8d62da7e91
commit 160cece196
6 changed files with 456 additions and 125 deletions

View File

@@ -4,11 +4,13 @@ StoryForge 现在拆成独立项目目录,和 `AI-glasses` 分开维护。
仓库边界和维护约束见:[StoryForge 仓库边界说明](./docs/STORYFORGE_REPO_BOUNDARY_2026-03-26.md)。
拆分治理方案见:[StoryForge / AI Glasses 拆分评估方案](./docs/STORYFORGE_SPLIT_ASSESSMENT_2026-03-26.md)。
当前项目状态见:[StoryForge 当前项目状态](./docs/CURRENT_PROJECT_STATE_2026-03-26.md)。
`AI-glasses` 独立代码仓库已单独维护在 [krisolo/ai-glasses](https://git.hyzq.site/krisolo/ai-glasses)。
## 目录
- `collector-service/`FastAPI 后端负责用户体系、项目、Agent、任务、内容分析和对外能力接入
- `web/storyforge-web-v4/`:正式 Web 工作台,承接多平台运营、对标、跟踪、生产和复盘入口
- `n8n/`:工作流导出文件,作为流程编排中枢
- `docker-compose.yml`:本地 `collector + n8n + cli-proxy-api` 编排
- `Common/`:项目约束和架构说明
@@ -20,7 +22,7 @@ StoryForge 现在拆成独立项目目录,和 `AI-glasses` 分开维护。
- [新媒体运营中台产品逻辑手册](./docs/PRODUCT_LOGIC_NEW_MEDIA_OPERATING_SYSTEM_2026-03-22.md)
- [新媒体运营平台 UI 参考包](./output/ui/new-media-ops-reference-2026-03-22/README.md)
- [Web V4 UI 原型](./output/ui/storyforge-web-v4-html-prototype-2026-03-22/README.md)
- [Web V4 前端骨架](./web/storyforge-web-v4/README.md)(国内平台 UI 承载,当前工作台仅 `douyin` 完整实现
- [Web V4 前端骨架](./web/storyforge-web-v4/README.md)(国内平台 UI 承载,当前已接上 `douyin / xiaohongshu / bilibili / kuaishou / wechat_video` 统一工作台
- [Mobile V4 UI 原型](./output/ui/storyforge-mobile-v4-html-prototype-2026-03-22/README.md)(仅 UI 原型,不代表当前仓库承载 Android 工程)
## Douyin Browser Capture

View File

@@ -449,39 +449,61 @@ def register_domestic_platform_routes(app: Any, legacy: Any, *, platform: str, l
(user_id,),
)
def _tracking_digest_item(tracked_row: dict[str, Any], video: dict[str, Any]) -> dict[str, Any]:
latest_job = _latest_job_for_source(video["id"])
summary = (latest_job or {}).get("style_summary") or video.get("description") or "已发现更新内容"
assistant = None
def _tracked_account_payload(tracked_row: dict[str, Any]) -> dict[str, Any]:
assistant_name = ""
if tracked_row.get("assistant_id"):
assistant_row = legacy.db.fetch_one("SELECT * FROM assistants WHERE id = ?", (tracked_row["assistant_id"],))
if assistant_row:
assistant = legacy.assistant_payload(assistant_row)
borrowing_points = [point for point in [summary[:36], video.get("title", "")[:36]] if point]
assistant_name = legacy.assistant_payload(assistant_row).get("name", "")
account_row = _require_account(tracked_row["tracked_account_id"], tracked_row["user_id"])
return {
"tracking_id": tracked_row["id"],
"id": tracked_row["id"],
"platform": platform,
"tracked_account_id": tracked_row["tracked_account_id"],
"tracked_account_name": _account_payload(_require_account(tracked_row["tracked_account_id"], tracked_row["user_id"]))["nickname"],
"assistant_id": tracked_row.get("assistant_id", "") or "",
"assistant_name": (assistant or {}).get("name", ""),
"assistant_name": assistant_name,
"note": tracked_row.get("note", ""),
"created_at": tracked_row.get("created_at", ""),
"updated_at": tracked_row["updated_at"],
"account": _account_payload(account_row),
}
def _list_tracked_accounts(user_id: str) -> list[dict[str, Any]]:
rows = legacy.db.fetch_all(
f"SELECT * FROM {table_prefix}_tracked_accounts WHERE user_id = ? ORDER BY updated_at DESC",
(user_id,),
)
return [_tracked_account_payload(row) for row in rows]
def _tracking_digest_item(tracked_item: dict[str, Any], video: dict[str, Any]) -> dict[str, Any]:
latest_job = _latest_job_for_source(video["id"])
summary = (latest_job or {}).get("style_summary") or video.get("description") or "已发现更新内容"
borrowing_points = [point for point in [summary[:36], video.get("title", "")[:36]] if point]
performance_score = float(video.get("score", {}).get("performance_score") or 0)
return {
"tracking_id": tracked_item["id"],
"platform": platform,
"tracked_account_id": tracked_item["tracked_account_id"],
"tracked_account_name": tracked_item["account"]["nickname"],
"assistant_id": tracked_item.get("assistant_id", "") or "",
"assistant_name": tracked_item.get("assistant_name", ""),
"note": tracked_item.get("note", ""),
"account": tracked_item["account"],
"video": video,
"summary": summary,
"summary_text": summary,
"borrowing_points": borrowing_points[:3],
"is_high_value": performance_score >= 60,
"created_at": video.get("published_at") or now(),
}
def _tracking_digest(user_id: str, since_value: str = "", limit: int = 24) -> dict[str, Any]:
tracked_rows = legacy.db.fetch_all(
f"SELECT * FROM {table_prefix}_tracked_accounts WHERE user_id = ? ORDER BY updated_at DESC",
(user_id,),
)
tracked_items = _list_tracked_accounts(user_id)
cursor = _tracking_cursor(user_id)
threshold = (since_value or (cursor or {}).get("last_seen_at") or "").strip()
items: list[dict[str, Any]] = []
for tracked in tracked_rows:
account_row = _require_account(tracked["tracked_account_id"], user_id)
for video in _account_payload(account_row)["video_summary"]["videos"]:
for tracked in tracked_items:
for video in tracked["account"]["video_summary"]["videos"]:
published_at = str(video.get("published_at") or "")
if threshold and published_at and published_at <= threshold:
continue
@@ -489,16 +511,7 @@ def register_domestic_platform_routes(app: Any, legacy: Any, *, platform: str, l
items.sort(key=lambda item: item.get("created_at", ""), reverse=True)
return {
"items": items[:limit],
"tracked_accounts": [
{
"id": row["id"],
"tracked_account_id": row["tracked_account_id"],
"assistant_id": row.get("assistant_id", "") or "",
"note": row.get("note", ""),
"updated_at": row["updated_at"],
}
for row in tracked_rows
],
"tracked_accounts": tracked_items,
"cursor_last_seen_at": (cursor or {}).get("last_seen_at", ""),
}
@@ -791,22 +804,9 @@ def register_domestic_platform_routes(app: Any, legacy: Any, *, platform: str, l
@app.get(f"/v2/{platform}/tracking/accounts")
def list_platform_tracking_accounts(account: dict[str, Any] = Depends(legacy.require_approved)) -> dict[str, Any]:
rows = legacy.db.fetch_all(
f"SELECT * FROM {table_prefix}_tracked_accounts WHERE user_id = ? ORDER BY updated_at DESC",
(account["id"],),
)
cursor = _tracking_cursor(account["id"])
return {
"items": [
{
"id": row["id"],
"tracked_account_id": row["tracked_account_id"],
"assistant_id": row.get("assistant_id", "") or "",
"note": row.get("note", ""),
"updated_at": row["updated_at"],
}
for row in rows
],
"items": _list_tracked_accounts(account["id"]),
"cursor_last_seen_at": (cursor or {}).get("last_seen_at", ""),
}
@@ -842,13 +842,7 @@ def register_domestic_platform_routes(app: Any, legacy: Any, *, platform: str, l
),
)
row = legacy.db.fetch_one(f"SELECT * FROM {table_prefix}_tracked_accounts WHERE id = ?", (tracking_id,))
return {
"id": row["id"],
"tracked_account_id": row["tracked_account_id"],
"assistant_id": row.get("assistant_id", "") or "",
"note": row.get("note", ""),
"updated_at": row["updated_at"],
}
return _tracked_account_payload(row)
@app.post(f"/v2/{platform}/tracking/accounts/{{tracked_account_id}}/refresh")
async def refresh_platform_tracked_account(tracked_account_id: str, account: dict[str, Any] = Depends(legacy.require_approved)) -> dict[str, Any]:

View File

@@ -0,0 +1,76 @@
# StoryForge 当前项目状态
日期2026-03-26
本文档用于固定当前 `StoryForge-gitea` 的真实维护范围、主运行链和继续开发基线。
## 当前项目边界
- 当前仓库只维护 `StoryForge`
- `AI Glasses` 已拆回独立仓库维护,不再属于当前仓库主线。
- 当前仓库主维护目录:
- `collector-service/`
- `web/storyforge-web-v4/`
- `scripts/douyin-browser-capture/`
- `n8n/`
- `deploy/`
- `docs/`
## 当前产品主线
- `collector-service`FastAPI 主状态中心承接登录、项目、Agent、内容源、任务、平台工作台与内部执行接口。
- `web/storyforge-web-v4`:当前正式业务 Web 壳,面向日常运营工作台。
- `n8n`分析、内容源同步、AI 视频、实拍剪辑编排工作流。
- `scripts/douyin-browser-capture`:抖音真实浏览器辅助采集工具,作为反爬环境下的兜底采集入口。
## 当前已经接通的主要能力
- 多用户与审批体系。
- `project / assistant / knowledge base / job / content source` 主数据模型。
- 文本、视频链接、上传视频分析。
- `n8n` 工作流触发与任务编排。
- 本地 ASR、本机模型、Windows `cutvideo`、本机 `huobao-drama` 的后端接入。
- Web 工作台已经承接:
- 项目总台
- 对标导入
- 多平台账号工作台
- 跟踪账号与日报
- Agent 控制面
- 生产中心
- 复盘
- 额度与运维面板
## 当前支持的平台
- `douyin`
- `xiaohongshu`
- `bilibili`
- `kuaishou`
- `wechat_video`
说明:
- Web V4 当前已经按统一工作台模型接上以上平台的账号列表、单账号详情、作品列表、账号分析、高分作品分析、相似账号搜索、对标关系、跟踪账号与日报入口。
- 其中 `douyin` 仍然是采集与验证最完整的平台。
- 其余国内平台的工作台接口已由 `collector-service` 正式挂载,前端也已切成统一可用工作台;但真实平台采集质量仍取决于后续各平台专项验证。
## 当前仍受外部依赖限制的项
- 抖音 public 页直抓仍可能触发反爬挑战,需要真实浏览器登录或手工页面辅助采集。
- 小红书账号级内容源还需要补真实平台验证。
- `huobao-drama` fresh 生成仍依赖可用的外部图片 / 视频凭证;仓库代码已预留 env 覆盖能力,但没有新 key 时无法靠本仓库单独打通。
## 当前公网部署目标
- 公网入口:`https://storyforge.hyzq.net/`
- 云服务器 `nginx` 提供 HTTPS 入口。
- 云服务器本地 `storyforge-web-v4.service` 承接静态前端。
- 云服务器本地 `collector-service` 承接 `/v2/*``/openapi.json``/healthz``/downloads/*`
- `n8n / huobao / cutvideo / 本机模型 / ASR / 录制链路` 继续通过本机和局域网桥接提供。
## 后续开发建议基线
1. 继续按当前仓库边界维护,不再把 `AI Glasses` 代码重新叠进来。
2. Web 功能优先围绕多平台工作台、生产中心和租户控制面继续深化。
3. 需要真实平台验证的事项,单独作为联调任务推进,不再和仓库边界治理混在一起。
4. 公网环境出现异常时,先检查云服务器上的 `nginx / storyforge-web-v4.service / collector-service`,再检查本机桥接链。

View File

@@ -17,7 +17,9 @@
- `YouTube` 目前明确不在本轮范围内
- 已支持通过 `https://storyforge.hyzq.net/` 做公网访问
- 通用的项目、内容源、复盘、集成等流程可以正常使用
- 平台工作台和运行时数据目前只有 `douyin` 做到了完整实现,其余平台统一按 `待接入工作台` 处理,相关工作台动作也会被 capability gate 拦住
- 平台工作台已切到统一模型,当前支持在页面内切换 `douyin / xiaohongshu / bilibili / kuaishou / wechat_video`
- 账号列表、单账号详情、作品、高分分析、相似搜索、对标关系、跟踪对象和日报都已经按平台统一加载
- 当前前端会先读取 `/openapi.json` 能力集,再决定是否展示或调用对应平台动作,避免不同 live 版本直接刷 404
- 当前保留的核心页面结构:
- 项目总台
- 我的项目
@@ -43,6 +45,16 @@
- 单账号作品列表 `/v2/douyin/accounts/{id}/videos`
- 跟踪账号 `/v2/douyin/tracking/accounts`
- 跟踪日报 `/v2/douyin/tracking/digest`
- 国内平台统一工作台:
- `/v2/{platform}/accounts`
- `/v2/{platform}/accounts/{id}/workspace`
- `/v2/{platform}/accounts/{id}/videos`
- `/v2/{platform}/accounts/{id}/analysis`
- `/v2/{platform}/accounts/{id}/videos/analyze-top`
- `/v2/{platform}/similar-searches`
- `/v2/{platform}/accounts/{id}/benchmark-links`
- `/v2/{platform}/tracking/accounts`
- `/v2/{platform}/tracking/digest`
- 最近知识库文档 `/v2/knowledge-bases/{id}/documents`
## 当前已接入的真实动作
@@ -56,13 +68,14 @@
- 创建 Agent
- 选择当前 Agent
- 编辑 Agent 的名称、目标、系统提示词和主模型
- 对当前 Douyin 对标账号重跑分析
- 对当前选中对标账号重跑分析
- 批量分析高分作品
- 查找相似对标账号
- 从相似候选一键保存对标关系
- 把当前对标账号加入跟踪,并绑定 Agent
- 单账号立即同步跟踪对象
- 批量同步全部跟踪对象
- 以上账号工作台动作现在已按统一工作台模型覆盖到已接入的国内平台
- 日报手动标记已读,不再在刷新页面时自动吞掉未读摘要
- 按上次打开后生成跟踪日报与借鉴点摘要
- 查看任务详情、事件、子任务和 artifacts/result
@@ -80,7 +93,6 @@
- 最近写入 NAS 的缓存样本路径
- 会先识别后端是否具备 `tracking / reviews / integrations` 路由,再决定是否请求,避免不同版本 live collector 刷 404
- 依赖不可达时,自动拦住 AI 视频 / 实拍剪辑动作并展示原因
- 非 Douyin 平台的账号工作台动作会直接显示待接入原因,避免误触发半成品链路
- 使用 Agent 生成文案
- 创建 AI 视频任务
- 创建实拍剪辑任务

View File

@@ -23,6 +23,7 @@ const appState = {
selectedProjectId: "",
selectedAssistantId: "",
lastSeenAt: SESSION_STORE.getLastSeenAt(Date.now()),
trackingCursorMap: {},
trackingAccounts: [],
trackingDigest: null,
reviews: [],
@@ -66,7 +67,6 @@ const ACTIVE_PLATFORMS = [
{ value: "kuaishou", label: "快手" },
{ value: "wechat_video", label: "微信视频号" }
];
const ACTIVE_PLATFORM_CHIPS = ["全平台", "抖音", "小红书", "B站", "快手", "视频号"];
const makePlatformRoutes = StoryForgePlatformRuntime.makePlatformRoutes;
const PLATFORM_REGISTRY = {
@@ -79,29 +79,25 @@ const PLATFORM_REGISTRY = {
xiaohongshu: {
label: "小红书",
shortLabel: "小红书",
workbenchReady: false,
pendingText: "小红书工作台当前还没有完整接入,请先停留在导入和通用流程。",
workbenchReady: true,
routes: makePlatformRoutes("xiaohongshu")
},
bilibili: {
label: "哔哩哔哩",
shortLabel: "B站",
workbenchReady: false,
pendingText: "B站工作台当前还没有完整接入请先停留在导入和通用流程。",
workbenchReady: true,
routes: makePlatformRoutes("bilibili")
},
kuaishou: {
label: "快手",
shortLabel: "快手",
workbenchReady: false,
pendingText: "快手工作台当前还没有完整接入,请先停留在导入和通用流程。",
workbenchReady: true,
routes: makePlatformRoutes("kuaishou")
},
wechat_video: {
label: "微信视频号",
shortLabel: "视频号",
workbenchReady: false,
pendingText: "微信视频号工作台当前还没有完整接入,请先停留在导入和通用流程。",
workbenchReady: true,
routes: makePlatformRoutes("wechat_video")
}
};
@@ -352,6 +348,71 @@ function setLastSeenAt(value) {
appState.lastSeenAt = SESSION_STORE.setLastSeenAt(value);
}
function compareDateDesc(leftValue, rightValue) {
return new Date(rightValue || 0).getTime() - new Date(leftValue || 0).getTime();
}
function buildAssistantNameMap(items) {
return new Map(
safeArray(items)
.filter((item) => item?.id)
.map((item) => [item.id, item.name || ""])
);
}
function getTrackingCursorForPlatform(platform = getCurrentPlatformValue()) {
const normalizedPlatform = normalizePlatformValue(platform, "");
return appState.trackingCursorMap?.[normalizedPlatform] || "";
}
function getTrackingSinceIso(platform = getCurrentPlatformValue()) {
const cursor = getTrackingCursorForPlatform(platform) || appState.lastSeenAt || Date.now();
const date = new Date(cursor);
if (Number.isNaN(date.getTime())) return new Date(Date.now() - 86400000).toISOString();
return date.toISOString();
}
function enrichTrackingAccounts(items, accountIndex, assistantNameMap, fallbackPlatform) {
return safeArray(items)
.map((item) => {
const account = accountIndex.get(item.tracked_account_id) || item.account || null;
const normalizedPlatform = normalizePlatformValue(
item.platform || account?.platform || fallbackPlatform || "",
fallbackPlatform || "douyin"
);
return {
...item,
platform: normalizedPlatform,
assistant_name: item.assistant_name || assistantNameMap.get(item.assistant_id) || "",
account
};
})
.sort((left, right) => compareDateDesc(left.updated_at || left.created_at, right.updated_at || right.created_at));
}
function enrichTrackingDigestItems(items, accountIndex, fallbackPlatform) {
return safeArray(items)
.map((item) => {
const account = item.account || accountIndex.get(item.tracked_account_id) || null;
const platform = normalizePlatformValue(
item.platform || item.video?.platform || account?.platform || fallbackPlatform || "",
fallbackPlatform || "douyin"
);
const performanceScore = Number(item.video?.score?.performance_score || 0);
return {
...item,
platform,
account,
summary: item.summary || item.summary_text || item.video?.description || item.video?.title || "已发现更新内容",
is_high_value: typeof item.is_high_value === "boolean" ? item.is_high_value : performanceScore >= 60
};
})
.sort((left, right) => compareDateDesc(
left.video?.published_at || left.created_at,
right.video?.published_at || right.created_at
));
}
function markSeenNow() {
setLastSeenAt(Date.now());
}
@@ -1348,12 +1409,6 @@ async function loadPlatformAccount(platform, accountId, requestToken = 0) {
}
}
function getTrackingSinceIso() {
const date = new Date(appState.lastSeenAt || Date.now());
if (Number.isNaN(date.getTime())) return new Date(Date.now() - 86400000).toISOString();
return date.toISOString();
}
async function bootstrap() {
renderAll();
if (!appState.session) {
@@ -1367,17 +1422,19 @@ async function bootstrap() {
appState.dashboard = null;
appState.accounts = [];
appState.contentSources = [];
appState.trackingAccounts = [];
appState.trackingDigest = null;
appState.trackingCursorMap = {};
appState.documents = [];
renderAll();
return;
}
appState.backendCapabilities = await loadBackendCapabilities(appState.session.backendUrl).catch(() => null);
const preferredPlatform = getPreferredPlatform();
const dashboard = await storyforgeFetch("/v2/me/dashboard");
appState.dashboard = dashboard;
const runtimePlatforms = getRuntimePlatformValues();
const preferredPlatform = getCurrentPlatformValue();
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");
@@ -1385,11 +1442,43 @@ async function bootstrap() {
const supportsLiveRecorderSources = backendSupports("/v2/live-recorder/sources");
const supportsLiveRecorderStatus = backendSupports("/v2/live-recorder/status");
const supportsLiveRecorderFiles = backendSupports("/v2/live-recorder/files");
const [dashboard, contentSources, accounts, trackingAccountsPayload, reviews, integrationHealth, localModelCatalog, liveRecorderSourcesPayload, liveRecorderStatus, liveRecorderFilesPayload] = await Promise.all([
storyforgeFetch("/v2/me/dashboard"),
const [contentSources, platformPayloads, reviews, integrationHealth, localModelCatalog, liveRecorderSourcesPayload, liveRecorderStatus, liveRecorderFilesPayload] = await Promise.all([
storyforgeFetch("/v2/content-sources").catch(() => []),
accountListPath ? storyforgeFetch(accountListPath).catch(() => []) : Promise.resolve([]),
trackingAccountsPath ? storyforgeFetch(trackingAccountsPath).catch(() => ({ items: [], cursor_last_seen_at: "" })) : Promise.resolve({ items: [], cursor_last_seen_at: "" }),
Promise.all(runtimePlatforms.map(async (platform) => {
const accountListPath = getWorkbenchRoute(platform, "accounts");
const trackingAccountsPath = getWorkbenchRoute(platform, "trackingAccounts");
const trackingDigestPath = getWorkbenchRoute(platform, "trackingDigest");
const supportsAccounts = accountListPath && backendSupports(accountListPath);
const supportsTrackingAccounts = trackingAccountsPath && backendSupports(trackingAccountsPath);
const supportsTrackingDigest = trackingDigestPath && backendSupports(trackingDigestPath);
const accounts = supportsAccounts
? await storyforgeFetch(accountListPath).catch(() => [])
: [];
const trackingAccountsPayload = supportsTrackingAccounts
? await storyforgeFetch(trackingAccountsPath).catch(() => ({ items: [], cursor_last_seen_at: "" }))
: { items: [], cursor_last_seen_at: "" };
const trackingCursorLastSeenAt = trackingAccountsPayload?.cursor_last_seen_at || "";
const trackingDigest = supportsTrackingDigest
? await storyforgeFetch(`${trackingDigestPath}?since=${encodeURIComponent(trackingCursorLastSeenAt || getTrackingSinceIso(platform))}&limit=24`).catch(() => ({
items: [],
tracked_accounts: [],
cursor_last_seen_at: trackingCursorLastSeenAt
}))
: {
items: [],
tracked_accounts: [],
cursor_last_seen_at: trackingCursorLastSeenAt
};
return {
platform,
accounts: safeArray(accounts).map((item) => ({
...item,
platform: normalizePlatformValue(item?.platform || platform, platform)
})),
trackingAccountsPayload,
trackingDigest
};
})),
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),
@@ -1397,27 +1486,45 @@ async function bootstrap() {
supportsLiveRecorderStatus ? storyforgeFetch("/v2/live-recorder/status").catch(() => null) : Promise.resolve(null),
supportsLiveRecorderFiles ? storyforgeFetch("/v2/live-recorder/files?limit=16").catch(() => ({ items: [] })) : Promise.resolve({ items: [] })
]);
const trackingCursorLastSeenAt = trackingAccountsPayload?.cursor_last_seen_at || "";
if (trackingCursorLastSeenAt) {
setLastSeenAt(trackingCursorLastSeenAt);
const mergedAccounts = safeArray(platformPayloads)
.flatMap((entry) => safeArray(entry.accounts))
.sort((a, b) => {
const platformCompare = platformLabel(getAccountPlatform(a)).localeCompare(platformLabel(getAccountPlatform(b)), "zh-Hans-CN");
if (platformCompare !== 0) return platformCompare;
return getAccountName(a).localeCompare(getAccountName(b), "zh-Hans-CN");
});
const accountIndex = new Map(mergedAccounts.map((item) => [item.id, item]));
const assistantNameMap = buildAssistantNameMap(dashboard.assistants);
const trackingCursorMap = Object.fromEntries(
safeArray(platformPayloads)
.map((entry) => [entry.platform, entry.trackingAccountsPayload?.cursor_last_seen_at || ""])
.filter(([, value]) => value)
);
const currentCursor = trackingCursorMap[preferredPlatform] || pickLatestIso(Object.values(trackingCursorMap)) || "";
if (currentCursor) {
setLastSeenAt(currentCursor);
}
const trackingSince = trackingCursorLastSeenAt || getTrackingSinceIso();
const trackingDigest = trackingDigestPath
? await storyforgeFetch(`${trackingDigestPath}?since=${encodeURIComponent(trackingSince)}&limit=24`).catch(() => ({
items: [],
tracked_accounts: [],
cursor_last_seen_at: trackingCursorLastSeenAt
}))
: ({
items: [],
tracked_accounts: [],
cursor_last_seen_at: trackingCursorLastSeenAt
});
appState.dashboard = dashboard;
appState.contentSources = safeArray(contentSources);
appState.accounts = safeArray(accounts);
appState.trackingAccounts = safeArray(trackingAccountsPayload.items || trackingAccountsPayload);
appState.trackingDigest = trackingDigest;
appState.accounts = mergedAccounts;
appState.trackingCursorMap = trackingCursorMap;
appState.trackingAccounts = safeArray(platformPayloads).flatMap((entry) =>
enrichTrackingAccounts(
entry.trackingAccountsPayload?.items || entry.trackingAccountsPayload,
accountIndex,
assistantNameMap,
entry.platform
)
);
appState.trackingDigest = {
cursor_last_seen_at: currentCursor,
items: safeArray(platformPayloads).flatMap((entry) => enrichTrackingDigestItems(entry.trackingDigest?.items, accountIndex, entry.platform)),
tracked_accounts: safeArray(platformPayloads).flatMap((entry) =>
safeArray(entry.trackingDigest?.tracked_accounts).map((item) => ({
...item,
platform: normalizePlatformValue(item.platform || entry.platform, entry.platform)
}))
)
};
appState.reviews = safeArray(reviews);
appState.liveRecorderSources = safeArray(liveRecorderSourcesPayload?.items || liveRecorderSourcesPayload);
appState.liveRecorderStatus = liveRecorderStatus;
@@ -1439,8 +1546,9 @@ async function bootstrap() {
}
const selectedAssistantExists = safeArray(dashboard.assistants).some((item) => item.id === appState.selectedAssistantId);
appState.selectedAssistantId = selectedAssistantExists ? appState.selectedAssistantId : (dashboard.assistants?.[0]?.id || "");
const selectedAccountExists = appState.accounts.some((item) => item.id === appState.selectedAccountId);
const nextAccountId = selectedAccountExists ? appState.selectedAccountId : appState.accounts[0]?.id || "";
const platformAccounts = getAccountsForPlatform(preferredPlatform);
const selectedAccountExists = platformAccounts.some((item) => item.id === appState.selectedAccountId);
const nextAccountId = selectedAccountExists ? appState.selectedAccountId : platformAccounts[0]?.id || appState.accounts[0]?.id || "";
if (nextAccountId) {
const nextAccount = appState.accounts.find((item) => item.id === nextAccountId) || null;
await loadPlatformAccount(getAccountPlatform(nextAccount), nextAccountId);
@@ -1461,7 +1569,7 @@ async function bootstrap() {
}
async function markTrackingDigestRead() {
const platform = getPreferredPlatform();
const platform = getCurrentPlatformValue();
const trackingCursorPath = getWorkbenchRoute(platform, "trackingCursor");
if (!trackingCursorPath || !backendSupports(trackingCursorPath)) {
rememberAction("当前后端暂不支持", "这套 live collector 还没有接入跟踪已读游标。", "orange");
@@ -1473,11 +1581,24 @@ async function markTrackingDigestRead() {
method: "POST",
body: { last_seen_at: nextSeenAt }
});
appState.trackingCursorMap = {
...(appState.trackingCursorMap || {}),
[platform]: nextSeenAt
};
if (appState.trackingDigest) {
appState.trackingDigest = {
...appState.trackingDigest,
items: safeArray(appState.trackingDigest.items).filter((item) => item.platform !== platform),
cursor_last_seen_at: platform === getCurrentPlatformValue()
? nextSeenAt
: (appState.trackingDigest.cursor_last_seen_at || "")
};
}
setLastSeenAt(nextSeenAt);
}
async function refreshTrackingAccountsAction() {
const platform = getPreferredPlatform();
const platform = getCurrentPlatformValue();
const trackingRefreshPath = getWorkbenchRoute(platform, "trackingRefresh");
if (!trackingRefreshPath || !backendSupports(trackingRefreshPath)) {
rememberAction("当前后端暂不支持", "这套 live collector 还没有接入批量跟踪同步。", "orange");
@@ -1505,7 +1626,8 @@ async function refreshTrackedAccountAction(trackedAccountId) {
if (!trackedAccountId) {
throw new Error("trackedAccountId is required");
}
const platform = getPreferredPlatform();
const trackedItem = getTrackingAccounts().find((item) => item.tracked_account_id === trackedAccountId);
const platform = trackedItem?.platform || getCurrentPlatformValue();
const trackingRefreshPath = getWorkbenchRoute(platform, "trackingAccountRefresh", trackedAccountId);
if (!trackingRefreshPath || !backendSupports(`/v2/${platform}/tracking/accounts/{tracked_account_id}/refresh`)) {
rememberAction("当前后端暂不支持", "这套 live collector 还没有接入单账号跟踪同步。", "orange");
@@ -1642,12 +1764,102 @@ function getCurrentProjectSourcesForAccount(account, projectId) {
return getContentSourcesForAccount(account).filter((source) => source.project_id === projectId);
}
function getCurrentPlatformValue() {
const available = getRuntimePlatformValues();
const fallback = available[0] || "douyin";
const current = normalizePlatformValue(appState.currentPlatform, "");
if (current && available.includes(current)) return current;
return normalizePlatformValue(getPreferredPlatform(), fallback);
}
function getAccountsForPlatform(platform) {
const normalizedPlatform = normalizePlatformValue(platform, getCurrentPlatformValue());
return safeArray(appState.accounts).filter((item) => getAccountPlatform(item) === normalizedPlatform);
}
function parseIsoTime(value) {
const text = String(value || "").trim();
if (!text) return 0;
const timestamp = new Date(text).getTime();
return Number.isNaN(timestamp) ? 0 : timestamp;
}
function sortItemsByIsoDesc(items, fieldName) {
return items
.slice()
.sort((left, right) => parseIsoTime(right?.[fieldName]) - parseIsoTime(left?.[fieldName]));
}
function pickLatestIso(values) {
return values
.map((value) => String(value || "").trim())
.filter(Boolean)
.sort((left, right) => parseIsoTime(right) - parseIsoTime(left))[0] || "";
}
function createEmptyTrackingDigest(cursorLastSeenAt = "") {
return {
items: [],
tracked_accounts: [],
cursor_last_seen_at: cursorLastSeenAt
};
}
function getAssistantById(assistantId) {
if (!assistantId) return null;
return safeArray(appState.dashboard?.assistants).find((item) => item.id === assistantId) || null;
}
function getTrackedAccountDisplay(item) {
const account = item?.account
|| safeArray(appState.accounts).find((row) => row.id === item?.tracked_account_id)
|| null;
const assistant = getAssistantById(item?.assistant_id || "");
const platform = normalizePlatformValue(item?.platform || account?.platform || getCurrentPlatformValue(), getCurrentPlatformValue());
return {
...item,
account,
platform,
assistant_name: item?.assistant_name || assistant?.name || "",
note: item?.note || ""
};
}
function isTrackedAccount(accountId) {
return safeArray(appState.trackingAccounts).some((item) => item.tracked_account_id === accountId);
}
function getTrackingDigestItems(limit = 6) {
return safeArray(appState.trackingDigest?.items).slice(0, limit);
function getTrackingAccounts() {
return sortItemsByIsoDesc(
safeArray(appState.trackingAccounts).map((item) => getTrackedAccountDisplay(item)),
"updated_at"
);
}
function getTrackingDigestItems(limit = 6, options = {}) {
const targetPlatform = normalizePlatformValue(options.platform || "", "");
const fallbackPlatform = targetPlatform || getCurrentPlatformValue();
return safeArray(appState.trackingDigest?.items)
.map((item) => {
const tracked = getTrackedAccountDisplay(item);
const summary = item?.summary || item?.summary_text || item?.video?.description || item?.video?.title || item?.description || "";
const video = item?.video || {};
const isHighValue = item?.is_high_value != null
? Boolean(item.is_high_value)
: Number(video?.score?.performance_score || 0) >= 60;
return {
...item,
account: item?.account || tracked.account,
platform: tracked.platform || fallbackPlatform,
assistant_name: item?.assistant_name || tracked.assistant_name || "",
summary,
borrowing_points: safeArray(item?.borrowing_points),
is_high_value: isHighValue
};
})
.filter((item) => !targetPlatform || item.platform === targetPlatform)
.sort((left, right) => parseIsoTime(right?.created_at || right?.video?.published_at) - parseIsoTime(left?.created_at || left?.video?.published_at))
.slice(0, limit);
}
function getSelectedAccount() {
@@ -2655,6 +2867,18 @@ function renderEmptyState(title, description) {
return `<div class="panel pad"><div class="empty-state"><strong>${escapeHtml(title)}</strong><p>${escapeHtml(description)}</p></div></div>`;
}
function renderPlatformSwitchChips(currentPlatform) {
return getPlatformOptions().map((item) => `
<span
class="chip clickable-tag ${item.value === currentPlatform ? "active" : ""}"
data-action="select-platform"
data-platform="${escapeHtml(item.value)}"
>
${escapeHtml(getPlatformShortLabel(item.value))}
</span>
`).join("");
}
function renderDashboardScreen() {
if (!appState.session) {
return screenShell(
@@ -2677,12 +2901,12 @@ function renderDashboardScreen() {
const jobs = safeArray(dashboard.recent_jobs);
const assistants = safeArray(dashboard.assistants);
const accounts = safeArray(appState.accounts);
const trackedAccounts = safeArray(appState.trackingAccounts);
const trackedAccounts = getTrackingAccounts();
const digestItems = getTrackingDigestItems(3);
const actions = [];
if (!projects.length) actions.push("先新建一个项目");
if (!assistants.length) actions.push("先创建第一个 Agent");
if (!accounts.length) actions.push("先导入一个抖音主页或作品");
if (!accounts.length) actions.push("先导入一个平台主页或作品");
if (!trackedAccounts.length && accounts.length) actions.push("挑 1 个重点账号加入跟踪");
if (jobs.some((item) => item.status !== "completed")) actions.push("处理进行中的生产任务");
if (!actions.length) actions.push("继续补高分对标并安排生产");
@@ -2771,7 +2995,7 @@ function renderDashboardScreen() {
<h4>${escapeHtml(item.account?.nickname || "未命名账号")} · ${escapeHtml(item.video?.title || item.video?.description || "最新作品")}</h4>
<p>${escapeHtml(item.summary || `最近发布时间 ${formatDateTime(item.video?.published_at)},适合继续交给 Agent 做借鉴点标注。`)}</p>
<div class="task-meta">
<span class="tag">抖音</span>
<span class="tag">${escapeHtml(getPlatformShortLabel(item.platform || item.account?.platform || getCurrentPlatformValue()))}</span>
<span class="tag green">${escapeHtml(item.is_high_value ? "高价值" : "可学习")}</span>
${item.assistant_name ? `<span class="tag">${escapeHtml(item.assistant_name)}</span>` : ""}
</div>
@@ -2864,7 +3088,9 @@ function renderDiscoveryScreen() {
return screenShell("找对标", "连接后端后才能加载真实对标账号。", `${button("连接后端", "open-auth", "primary")}`, renderEmptyState("对标库未加载", "登录后这里会显示当前平台的账号列表和详情。"));
}
const query = appState.discoveryQuery.toLowerCase();
const accounts = safeArray(appState.accounts).filter((account) => {
const currentPlatform = getCurrentPlatformValue();
const currentPlatformLabel = getPlatformShortLabel(currentPlatform);
const accounts = getAccountsForPlatform(currentPlatform).filter((account) => {
if (!query) return true;
return [getAccountName(account), account.signature, getAccountProfileUrl(account), getAccountHandle(account), ...safeArray(account.tags), ...safeArray(account.keywords)]
.join(" ")
@@ -2872,9 +3098,9 @@ function renderDiscoveryScreen() {
.includes(query);
});
const selected = getSelectedAccount();
const currentPlatform = getAccountPlatform(selected) || getPreferredPlatform();
const currentPlatformLabel = getPlatformShortLabel(currentPlatform);
const workbenchReason = !isWorkbenchPlatform(currentPlatform) ? getPendingWorkbenchReason(currentPlatform) : "";
const selectedPlatform = getAccountPlatform(selected);
const effectivePlatform = selectedPlatform || currentPlatform;
const workbenchReason = !isWorkbenchPlatform(effectivePlatform) ? getPendingWorkbenchReason(effectivePlatform) : "";
const reports = safeArray(appState.selectedWorkspace?.recent_reports);
const linkedAccounts = safeArray(appState.selectedWorkspace?.linked_accounts);
const videos = safeArray(appState.selectedVideos?.items);
@@ -2909,10 +3135,7 @@ function renderDiscoveryScreen() {
</div>
<div class="side-stack">
<div class="chip-row">
<span class="chip active">真实接口</span>
<span class="chip">工作台详情</span>
<span class="chip">高分作品</span>
<span class="chip">绑定关系</span>
${renderPlatformSwitchChips(currentPlatform)}
</div>
</div>
</div>
@@ -3105,7 +3328,7 @@ function renderTrackingScreen() {
if (!appState.dashboard) {
return screenShell("跟踪账号", "登录后才能生成真实日报。", `${button("连接后端", "open-auth", "primary")}`, renderEmptyState("日报未加载", "当前还没有可用的对标账号数据。"));
}
const currentPlatform = getPreferredPlatform();
const currentPlatform = getCurrentPlatformValue();
const trackingAccountsPath = getWorkbenchRoute(currentPlatform, "trackingAccounts");
if (!trackingAccountsPath || !backendSupports(trackingAccountsPath)) {
return screenShell(
@@ -3115,9 +3338,10 @@ function renderTrackingScreen() {
renderEmptyState("跟踪能力暂未接入", `这套后端还没有接入 ${platformLabel(currentPlatform)} 跟踪接口 live collector 同步后这里会自动切成真实日报`)
);
}
const trackedAccounts = safeArray(appState.trackingAccounts);
const digestItems = getTrackingDigestItems(12);
const cursorLabel = appState.lastSeenAt ? formatDateTime(appState.lastSeenAt) : "尚未记录";
const trackedAccounts = getTrackingAccounts().filter((item) => item.platform === currentPlatform);
const digestItems = getTrackingDigestItems(12, { platform: currentPlatform });
const platformCursor = getTrackingCursorForPlatform(currentPlatform) || appState.lastSeenAt;
const cursorLabel = platformCursor ? formatDateTime(platformCursor) : "尚未记录";
return screenShell(
"跟踪账号",
`这里已经接上真实${getPlatformShortLabel(currentPlatform)}跟踪对象和按上次打开后的更新日报`,
@@ -3125,11 +3349,9 @@ function renderTrackingScreen() {
`
<div class="hero-card">
<h3>日报逻辑</h3>
<p>按上次打开后汇总上次打开距今 ${escapeHtml(daysSince(appState.lastSeenAt))} 本次优先展示有更新且值得借鉴的内容</p>
<p>按上次打开后汇总上次打开距今 ${escapeHtml(daysSince(platformCursor))} 本次优先展示有更新且值得借鉴的内容</p>
<div class="chip-row" style="margin-top:14px;">
<span class="chip active">按上次打开汇总</span>
<span class="chip">Agent 标借鉴点</span>
<span class="chip">高价值内容可进学习集</span>
${renderPlatformSwitchChips(currentPlatform)}
<span class="chip">上次已读 ${escapeHtml(cursorLabel)}</span>
</div>
</div>
@@ -3140,7 +3362,7 @@ function renderTrackingScreen() {
<div class="mobile-only compact-summary-row">
<span class="tag blue">跟踪 ${escapeHtml(formatNumber(trackedAccounts.length))}</span>
<span class="tag green">日报 ${escapeHtml(formatNumber(digestItems.length))}</span>
<span class="tag">${escapeHtml(daysSince(appState.lastSeenAt))} 天窗口</span>
<span class="tag">${escapeHtml(daysSince(platformCursor))} 天窗口</span>
</div>
<div class="list">
${trackedAccounts.map((item) => `
@@ -3167,7 +3389,7 @@ function renderTrackingScreen() {
<h4>${escapeHtml(item.account?.nickname || "账号")} · ${escapeHtml(item.video?.title || item.video?.description || "最新作品")}</h4>
<p>${escapeHtml(item.summary || `发布时间 ${formatDateTime(item.video?.published_at)},建议继续判断借鉴点。`)}</p>
<div class="task-meta">
<span class="tag">抖音</span>
<span class="tag">${escapeHtml(getPlatformShortLabel(item.platform || currentPlatform))}</span>
<span class="tag green">${escapeHtml(item.is_high_value ? "高价值" : "可学习")}</span>
${item.assistant_name ? `<span class="tag">${escapeHtml(item.assistant_name)}</span>` : ""}
${item.video?.share_url ? `<a class="tag" href="${escapeHtml(item.video.share_url)}" target="_blank" rel="noreferrer">打开作品</a>` : ""}
@@ -3618,7 +3840,15 @@ function renderTopbar() {
topPills[2].textContent = `任务 ${formatNumber(appState.dashboard?.recent_jobs?.length || 0)}`;
}
if (platforms) {
platforms.innerHTML = getPlatformChips().map((label, index) => `<span class="chip ${index === 0 ? "active" : ""}">${escapeHtml(label)}</span>`).join("");
const currentPlatform = getCurrentPlatformValue();
platforms.innerHTML = [
`<span class="chip">已接入平台</span>`,
...getPlatformOptions().map((item) => `
<span class="chip clickable-tag ${item.value === currentPlatform ? "active" : ""}" data-action="select-platform" data-platform="${escapeHtml(item.value)}">
${escapeHtml(getPlatformShortLabel(item.value))}
</span>
`)
].join("");
}
}
@@ -5652,6 +5882,21 @@ document.addEventListener("click", async (event) => {
renderAll();
return;
}
if (name === "select-platform") {
const nextPlatform = normalizePlatformValue(action.dataset.platform || "", "");
if (!nextPlatform || nextPlatform === getCurrentPlatformValue()) {
renderAll();
return;
}
setCurrentPlatform(nextPlatform);
setBusy(true, `正在切换到${platformLabel(nextPlatform)}...`);
try {
await bootstrap();
} finally {
setBusy(false, "");
}
return;
}
if (name === "select-account") {
const accountId = action.dataset.accountId;
if (!accountId) return;

View File

@@ -138,15 +138,17 @@
}
function getPreferredPlatform() {
const selectedAccountPlatform = getAccountPlatform(getSelectedAccount());
if (selectedAccountPlatform && isWorkbenchPlatform(selectedAccountPlatform)) return selectedAccountPlatform;
const current = normalizePlatformValue(appState.currentPlatform, "");
if (current && isWorkbenchPlatform(current)) return current;
const selectedAccountPlatform = getAccountPlatform(getSelectedAccount());
if (selectedAccountPlatform && isWorkbenchPlatform(selectedAccountPlatform)) return selectedAccountPlatform;
const sourcePlatform = normalizePlatformValue(
safeArray(appState.contentSources).find((item) => isWorkbenchPlatform(item.platform))?.platform || "",
""
);
if (sourcePlatform) return sourcePlatform;
const runtimePlatform = getRuntimePlatformValues().find((value) => isWorkbenchPlatform(value));
if (runtimePlatform) return runtimePlatform;
return "douyin";
}