feat: add douyin tracking digest flows

This commit is contained in:
kris
2026-03-22 12:11:15 +08:00
parent 031ba04d4e
commit 9ed5f24364
6 changed files with 405 additions and 33 deletions

View File

@@ -5,7 +5,7 @@ import json
import math
import re
from collections import Counter
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from html import unescape
from typing import Any, Iterable
from urllib.parse import quote, unquote
@@ -84,6 +84,16 @@ class DouyinBenchmarkLinkRequest(BaseModel):
search_id: str = ""
class DouyinTrackedAccountRequest(BaseModel):
tracked_account_id: str = ""
assistant_id: str = ""
note: str = ""
class DouyinTrackingCursorRequest(BaseModel):
last_seen_at: str = ""
def _safe_json_dumps(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
@@ -1033,6 +1043,30 @@ def register_douyin_routes(app: Any, legacy: Any) -> None:
CREATE INDEX IF NOT EXISTS idx_douyin_account_relations_source
ON douyin_account_relations(source_account_id, created_at DESC);
CREATE TABLE IF NOT EXISTS douyin_tracked_accounts (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
tracked_account_id TEXT NOT NULL,
assistant_id TEXT,
note TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(user_id, tracked_account_id),
FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE,
FOREIGN KEY(tracked_account_id) REFERENCES douyin_accounts(id) ON DELETE CASCADE,
FOREIGN KEY(assistant_id) REFERENCES assistants(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_douyin_tracked_accounts_user_updated
ON douyin_tracked_accounts(user_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS douyin_tracking_cursors (
user_id TEXT PRIMARY KEY,
last_seen_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE
);
"""
with legacy.db.session() as conn:
conn.executescript(schema)
@@ -1691,6 +1725,166 @@ def register_douyin_routes(app: Any, legacy: Any) -> None:
})
return payloads
def _load_owned_assistant(assistant_id: str, user_id: str) -> dict[str, Any] | None:
if not str(assistant_id or "").strip():
return None
row = legacy.db.fetch_one(
"SELECT * FROM assistants WHERE id = ? AND user_id = ?",
(assistant_id, user_id)
)
if not row:
raise HTTPException(status_code=404, detail="Assistant not found")
return row
def _get_tracking_cursor(user_id: str) -> dict[str, Any] | None:
return legacy.db.fetch_one(
"SELECT * FROM douyin_tracking_cursors WHERE user_id = ?",
(user_id,)
)
def _set_tracking_cursor(user_id: str, last_seen_at: str) -> dict[str, Any]:
existing = _get_tracking_cursor(user_id)
timestamp = _first_non_empty(last_seen_at, now())
updated_at = now()
if existing:
legacy.db.execute(
"UPDATE douyin_tracking_cursors SET last_seen_at = ?, updated_at = ? WHERE user_id = ?",
(timestamp, updated_at, user_id)
)
else:
legacy.db.execute(
"INSERT INTO douyin_tracking_cursors (user_id, last_seen_at, updated_at) VALUES (?, ?, ?)",
(user_id, timestamp, updated_at)
)
return legacy.db.fetch_one("SELECT * FROM douyin_tracking_cursors WHERE user_id = ?", (user_id,))
def _list_tracked_accounts(user_id: str) -> list[dict[str, Any]]:
rows = legacy.db.fetch_all(
"""
SELECT track.*,
assistant.name AS assistant_name
FROM douyin_tracked_accounts track
LEFT JOIN assistants assistant ON assistant.id = track.assistant_id
WHERE track.user_id = ?
ORDER BY track.updated_at DESC
""",
(user_id,)
)
payloads: list[dict[str, Any]] = []
for row in rows:
account_row = _require_owned_account(row["tracked_account_id"], user_id)
account_payload = _build_account_payload(account_row, include_recent_videos=6)
payloads.append({
"id": row["id"],
"tracked_account_id": row["tracked_account_id"],
"assistant_id": row.get("assistant_id", "") or "",
"assistant_name": row.get("assistant_name", "") or "",
"note": row.get("note", "") or "",
"created_at": row["created_at"],
"updated_at": row["updated_at"],
"account": account_payload
})
return payloads
def _extract_tracking_borrowing_points(video: dict[str, Any]) -> list[str]:
latest_analysis = (video.get("latest_analysis") or {}).get("parsed_json") or {}
candidates: list[str] = []
def _collect(value: Any) -> None:
if isinstance(value, list):
for item in value:
if isinstance(item, str) and item.strip():
candidates.append(item.strip())
elif isinstance(item, dict):
for inner in item.values():
if isinstance(inner, str) and inner.strip():
candidates.append(inner.strip())
elif isinstance(value, str) and value.strip():
candidates.append(value.strip())
for key in ("winning_patterns", "replicate_plan", "hook_patterns", "content_engine", "offer_directions", "next_actions"):
_collect(latest_analysis.get(key))
score = video.get("score", {}) or {}
stats = video.get("stats", {}) or {}
if float(score.get("hook_score") or 0) >= 70:
candidates.append("开头抓人,适合借前三秒强结论或反常识开场。")
if float(score.get("commercial_score") or 0) >= 65:
candidates.append("转化信号较强,可拆成交句式和行动指令。")
if float(score.get("performance_score") or 0) >= 70:
candidates.append("整体表现高,值得提炼成可复用栏目模板。")
if float(stats.get("comment") or 0) >= 100:
candidates.append("评论互动明显,适合提炼争议点或提问句。")
if str(video.get("content_type") or "") == "image_text":
candidates.append("图文结构清晰,可借分段标题和卡片式表达。")
deduped: list[str] = []
seen: set[str] = set()
for item in candidates:
normalized = _compact_text(item, 80)
if not normalized or normalized in seen:
continue
seen.add(normalized)
deduped.append(normalized)
return deduped[:4]
def _build_tracking_digest_item(tracked_item: dict[str, Any], video: dict[str, Any]) -> dict[str, Any]:
latest_analysis = video.get("latest_analysis") or {}
summary = (
(latest_analysis.get("parsed_json") or {}).get("executive_summary")
or latest_analysis.get("summary_text")
or latest_analysis.get("suggestion_text")
or video.get("description")
or video.get("title")
or "暂无摘要"
)
borrowing_points = _extract_tracking_borrowing_points(video)
return {
"tracking_id": tracked_item["id"],
"tracked_account_id": tracked_item["tracked_account_id"],
"assistant_id": tracked_item["assistant_id"],
"assistant_name": tracked_item["assistant_name"],
"account": tracked_item["account"],
"video": video,
"summary": _compact_text(summary, 160),
"borrowing_points": borrowing_points,
"is_high_value": float((video.get("score") or {}).get("performance_score") or 0) >= 70 or bool(borrowing_points),
}
def _build_tracking_digest(user_id: str, since_value: str = "", limit: int = 24) -> dict[str, Any]:
tracked_accounts = _list_tracked_accounts(user_id)
cursor = _get_tracking_cursor(user_id)
since_dt = _parse_iso_datetime(since_value) if since_value else None
if since_dt is None and cursor:
since_dt = _parse_iso_datetime(cursor.get("last_seen_at"))
if since_dt is None:
since_dt = (datetime.now(timezone.utc) - timedelta(days=3)).replace(microsecond=0)
items: list[dict[str, Any]] = []
for tracked in tracked_accounts:
account_row = _require_owned_account(tracked["tracked_account_id"], user_id)
workspace = _build_video_workspace_payload(account_row, limit=36)
for video in workspace.get("items", []):
published_at = _parse_iso_datetime(video.get("published_at"))
if published_at is None or published_at <= since_dt:
continue
items.append(_build_tracking_digest_item(tracked, video))
items.sort(
key=lambda item: (
_parse_iso_datetime(item["video"].get("published_at")) or datetime.fromtimestamp(0, tz=timezone.utc),
float((item["video"].get("score") or {}).get("performance_score") or 0)
),
reverse=True
)
return {
"generated_at": now(),
"since": since_dt.isoformat(),
"cursor_last_seen_at": (cursor or {}).get("last_seen_at", ""),
"tracked_accounts": tracked_accounts,
"items": items[: max(1, min(limit, 100))]
}
def _normalize_report_text(value: Any) -> str:
text = str(value or "").strip()
if not text:
@@ -3133,3 +3327,69 @@ def register_douyin_routes(app: Any, legacy: Any) -> None:
"relation_ids": linked_ids,
"links": _list_linked_accounts(account_row)
}
@app.get("/v2/douyin/tracking/accounts")
def list_douyin_tracked_accounts(
account: dict[str, Any] = Depends(legacy.require_approved)
) -> dict[str, Any]:
cursor = _get_tracking_cursor(account["id"])
return {
"cursor_last_seen_at": (cursor or {}).get("last_seen_at", ""),
"items": _list_tracked_accounts(account["id"])
}
@app.post("/v2/douyin/tracking/accounts")
def create_douyin_tracked_account(
request: DouyinTrackedAccountRequest,
account: dict[str, Any] = Depends(legacy.require_approved)
) -> dict[str, Any]:
tracked_account = _require_owned_account(request.tracked_account_id, account["id"])
assistant = _load_owned_assistant(request.assistant_id, account["id"])
existing = legacy.db.fetch_one(
"SELECT * FROM douyin_tracked_accounts WHERE user_id = ? AND tracked_account_id = ?",
(account["id"], tracked_account["id"])
)
updated_at = now()
if existing:
legacy.db.execute(
"""
UPDATE douyin_tracked_accounts
SET assistant_id = ?, note = ?, updated_at = ?
WHERE id = ?
""",
((assistant or {}).get("id"), request.note.strip(), updated_at, existing["id"])
)
else:
legacy.db.execute(
"""
INSERT INTO douyin_tracked_accounts (
id, user_id, tracked_account_id, assistant_id, note, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(make_id("dytrack"), account["id"], tracked_account["id"], (assistant or {}).get("id"), request.note.strip(), updated_at, updated_at)
)
return {
"tracked_account_id": tracked_account["id"],
"assistant_id": (assistant or {}).get("id", ""),
"items": _list_tracked_accounts(account["id"])
}
@app.post("/v2/douyin/tracking/cursor")
def update_douyin_tracking_cursor(
request: DouyinTrackingCursorRequest,
account: dict[str, Any] = Depends(legacy.require_approved)
) -> dict[str, Any]:
cursor = _set_tracking_cursor(account["id"], request.last_seen_at)
return {
"user_id": account["id"],
"last_seen_at": cursor["last_seen_at"],
"updated_at": cursor["updated_at"]
}
@app.get("/v2/douyin/tracking/digest")
def get_douyin_tracking_digest(
since: str | None = None,
limit: int = 24,
account: dict[str, Any] = Depends(legacy.require_approved)
) -> dict[str, Any]:
return _build_tracking_digest(account["id"], since_value=(since or "").strip(), limit=limit)

View File

@@ -33,6 +33,8 @@
- 抖音对标账号 `/v2/douyin/accounts`
- 单账号工作台 `/v2/douyin/accounts/{id}/workspace`
- 单账号作品列表 `/v2/douyin/accounts/{id}/videos`
- 跟踪账号 `/v2/douyin/tracking/accounts`
- 跟踪日报 `/v2/douyin/tracking/digest`
- 最近知识库文档 `/v2/knowledge-bases/{id}/documents`
## 当前已接入的真实动作
@@ -48,6 +50,8 @@
- 批量分析高分作品
- 查找相似对标账号
- 从相似候选一键保存对标关系
- 把当前对标账号加入跟踪,并绑定 Agent
- 按上次打开后生成跟踪日报与借鉴点摘要
- 查看任务详情、事件、子任务和 artifacts/result
- 从任务详情直接衔接 AI 视频 / 实拍剪辑 / 文案生成
- 在生产中心 / 发布与复盘常驻最近一次任务详情摘要
@@ -76,6 +80,7 @@ python3 -m http.server 3918
- 继续补动作型接口,例如导入、绑定 Agent、触发分析与生产
- 把对标导入后的 Agent 绑定和知识库入库反馈做得更完整
- 把跟踪日报从 Douyin 扩到多平台统一模型
- 把全局搜索和页内搜索合并成统一搜索体验
-`生产中心 / 发布与复盘` 接入更完整的任务与成片对象
- 不要把这套页面重新塞回 `scripts/douyin-browser-capture/control_panel.mjs`

View File

@@ -20,6 +20,8 @@ const appState = {
selectedProjectId: "",
selectedAssistantId: "",
lastSeenAt: Number(localStorage.getItem(STORAGE_KEY + ":lastSeenAt") || Date.now()),
trackingAccounts: [],
trackingDigest: null,
busy: false,
message: "",
lastAction: null,
@@ -118,9 +120,15 @@ function persistSession(session) {
}
}
function setLastSeenAt(value) {
const date = value instanceof Date ? value : new Date(value);
const time = Number.isFinite(date.getTime()) ? date.getTime() : Date.now();
appState.lastSeenAt = time;
localStorage.setItem(STORAGE_KEY + ":lastSeenAt", String(time));
}
function markSeenNow() {
appState.lastSeenAt = Date.now();
localStorage.setItem(STORAGE_KEY + ":lastSeenAt", String(appState.lastSeenAt));
setLastSeenAt(Date.now());
}
function setBusy(next, message = "") {
@@ -477,6 +485,8 @@ async function logoutSession() {
appState.selectedWorkspace = null;
appState.selectedVideos = { items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 };
appState.documents = [];
appState.trackingAccounts = [];
appState.trackingDigest = null;
appState.lastAction = null;
appState.lastGeneratedCopy = null;
appState.lastSimilaritySearch = null;
@@ -512,6 +522,12 @@ async function loadDouyinAccount(accountId) {
appState.selectedVideos = videos;
}
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) {
@@ -529,14 +545,27 @@ async function bootstrap() {
renderAll();
return;
}
const [dashboard, contentSources, accounts] = await Promise.all([
const [dashboard, contentSources, accounts, trackingAccountsPayload] = await Promise.all([
storyforgeFetch("/v2/me/dashboard"),
storyforgeFetch("/v2/content-sources").catch(() => []),
storyforgeFetch("/v2/douyin/accounts").catch(() => [])
storyforgeFetch("/v2/douyin/accounts").catch(() => []),
storyforgeFetch("/v2/douyin/tracking/accounts").catch(() => ({ items: [], cursor_last_seen_at: "" }))
]);
const trackingCursorLastSeenAt = trackingAccountsPayload?.cursor_last_seen_at || "";
if (trackingCursorLastSeenAt) {
setLastSeenAt(trackingCursorLastSeenAt);
}
const trackingSince = trackingCursorLastSeenAt || getTrackingSinceIso();
const trackingDigest = await storyforgeFetch(`/v2/douyin/tracking/digest?since=${encodeURIComponent(trackingSince)}&limit=24`).catch(() => ({
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.documents = await loadKnowledgeDocuments(dashboard.knowledge_bases);
appState.selectedProjectId = appState.selectedProjectId || dashboard.projects?.[0]?.id || "";
const selectedAssistantExists = safeArray(dashboard.assistants).some((item) => item.id === appState.selectedAssistantId);
@@ -550,7 +579,12 @@ async function bootstrap() {
appState.selectedWorkspace = null;
appState.selectedVideos = { items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 };
}
markSeenNow();
const nextSeenAt = new Date().toISOString();
storyforgeFetch("/v2/douyin/tracking/cursor", {
method: "POST",
body: { last_seen_at: nextSeenAt }
}).catch(() => null);
setLastSeenAt(nextSeenAt);
} catch (error) {
appState.message = error.message;
if (String(error.message || "").includes("401") || String(error.message || "").includes("Not authenticated")) {
@@ -635,6 +669,14 @@ function getCurrentProjectSourcesForAccount(account, projectId) {
return getContentSourcesForAccount(account).filter((source) => source.project_id === projectId);
}
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 getSelectedAccount() {
return appState.selectedWorkspace?.account
|| appState.accounts.find((item) => item.id === appState.selectedAccountId)
@@ -852,15 +894,15 @@ function renderDashboardScreen() {
const jobs = safeArray(dashboard.recent_jobs);
const assistants = safeArray(dashboard.assistants);
const accounts = safeArray(appState.accounts);
const trackedAccounts = safeArray(appState.trackingAccounts);
const digestItems = getTrackingDigestItems(3);
const actions = [];
if (!projects.length) actions.push("先新建一个项目");
if (!assistants.length) actions.push("先创建第一个 Agent");
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("继续补高分对标并安排生产");
const digestItems = accounts
.flatMap((account) => safeArray(account.video_summary?.videos).slice(0, 1).map((video) => ({ account, video })))
.slice(0, 3);
return screenShell(
"项目总台",
"先看项目状态、待办动作和高价值对标。",
@@ -869,7 +911,7 @@ function renderDashboardScreen() {
<div class="layout-grid grid-5">
<div class="stat-card"><small>活跃项目</small><strong>${escapeHtml(formatNumber(projects.length))}</strong><div class="stat-foot"><span>项目总数</span><span class="positive">${escapeHtml(formatNumber(projects.filter((item) => item.description).length))} 个有说明</span></div></div>
<div class="stat-card"><small>导入内容</small><strong>${escapeHtml(formatNumber(appState.contentSources.length))}</strong><div class="stat-foot"><span>主页 / 作品 / 本地素材</span><span class="positive">${escapeHtml(formatNumber(appState.contentSources.filter((item) => item.source_kind === "creator_account").length))} 个主页</span></div></div>
<div class="stat-card"><small>跟踪账号</small><strong>${escapeHtml(formatNumber(accounts.length))}</strong><div class="stat-foot"><span>可生成日报</span><span class="positive">${escapeHtml(formatNumber(digestItems.length))} 条新摘要</span></div></div>
<div class="stat-card"><small>跟踪账号</small><strong>${escapeHtml(formatNumber(trackedAccounts.length))}</strong><div class="stat-foot"><span>可生成日报</span><span class="positive">${escapeHtml(formatNumber(digestItems.length))} 条新摘要</span></div></div>
<div class="stat-card"><small>Agent</small><strong>${escapeHtml(formatNumber(assistants.length))}</strong><div class="stat-foot"><span>已创建</span><span class="warn">${escapeHtml(formatNumber(assistants.filter((item) => !(item.model_profile_id || "")).length))} 个待补模型</span></div></div>
<div class="stat-card"><small>生产任务</small><strong>${escapeHtml(formatNumber(jobs.length))}</strong><div class="stat-foot"><span>最近 20 条</span><span class="positive">${escapeHtml(formatNumber(jobs.filter((item) => item.status === "completed").length))} 条已完成</span></div></div>
</div>
@@ -934,13 +976,17 @@ function renderDashboardScreen() {
<div class="panel pad">
<div class="panel-head"><div><h3>跟踪摘要</h3><div class="panel-subtitle">按最近同步的账号作品生成</div></div><span class="tag blue">${escapeHtml(daysSince(appState.lastSeenAt))} 天汇总</span></div>
<div class="list">
${digestItems.map(({ account, video }) => `
${digestItems.map((item) => `
<div class="task-item">
<h4>${escapeHtml(account.nickname || "未命名账号")} · ${escapeHtml(video.title || video.description || "最新作品")}</h4>
<p>最近发布时间 ${escapeHtml(formatDateTime(video.published_at))}适合继续交给 Agent 做借鉴点标注</p>
<div class="task-meta"><span class="tag">抖音</span><span class="tag green"></span></div>
<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 green">${escapeHtml(item.is_high_value ? "高价值" : "可学习")}</span>
${item.assistant_name ? `<span class="tag">${escapeHtml(item.assistant_name)}</span>` : ""}
</div>
</div>
`).join("") || `<div class="task-item"><h4>还没有日报</h4><p></p></div>`}
`).join("") || `<div class="task-item"><h4>还没有日报</h4><p>先把重点账号加入跟踪,日报才会开始累积。</p></div>`}
</div>
</div>
<div class="panel pad">
@@ -1044,10 +1090,11 @@ function renderDiscoveryScreen() {
const similarCandidates = safeArray(appState.lastSimilaritySearch?.candidates).slice(0, 5);
const selectedProject = getSelectedProject();
const importedSources = getCurrentProjectSourcesForAccount(selected, selectedProject?.id || "");
const tracked = selected?.id ? isTrackedAccount(selected.id) : false;
return screenShell(
"找对标",
"这里已经接入真实抖音账号列表和单账号详情。",
`${button("导入主页", "open-import-homepage")} ${button("导入当前对标", "open-import-selected-account")} ${button("账号分析", "analyze-selected-account")} ${button("高分分析", "analyze-top-videos")} ${button("查相似", "open-similar-search")} ${button("存对标", "open-benchmark-link", "primary")}`,
`${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")}`,
`
<div class="panel">
<div class="toolbar">
@@ -1135,6 +1182,7 @@ function renderDiscoveryScreen() {
<span class="tag">${escapeHtml(selectedProject?.name || "未选项目")}</span>
<span class="tag">${escapeHtml(getSelectedAssistant()?.name || "未选 Agent")}</span>
<span class="tag clickable-tag" data-action="open-import-selected-account">${importedSources.length ? "继续同步" : "导入当前对标"}</span>
<span class="tag ${tracked ? "green" : "clickable-tag"}" ${tracked ? "" : `data-action="open-track-selected-account"`}>${escapeHtml(tracked ? "已在跟踪" : "加入跟踪")}</span>
</div>
</div>
` : `<div class="task-item"><h4>还没有选中账号</h4><p>先从左侧列表选一个对标账号,再决定是否导入到当前项目。</p></div>`}
@@ -1228,18 +1276,16 @@ function renderTrackingScreen() {
if (!appState.dashboard) {
return screenShell("跟踪账号", "登录后才能生成真实日报。", `${button("连接后端", "open-auth", "primary")}`, renderEmptyState("日报未加载", "当前还没有可用的对标账号数据。"));
}
const accounts = safeArray(appState.accounts);
const digestItems = accounts.flatMap((account) =>
safeArray(account.video_summary?.videos).slice(0, 1).map((video) => ({ account, video }))
).slice(0, 6);
const trackedAccounts = safeArray(appState.trackingAccounts);
const digestItems = getTrackingDigestItems(12);
return screenShell(
"跟踪账号",
"当前先按上次打开后生成一份轻量日报摘要。",
"这里已经接上真实跟踪对象和按上次打开后的更新日报。",
`${button("刷新日报", "refresh-data")} ${button("跳到找对标", "goto-discovery", "primary")}`,
`
<div class="hero-card">
<h3>日报逻辑</h3>
<p>按上次打开后汇总上次打开距今 ${escapeHtml(daysSince(appState.lastSeenAt))} 本次摘要优先展示有作品更新的账号</p>
<p>按上次打开后汇总上次打开距今 ${escapeHtml(daysSince(appState.lastSeenAt))} 本次优先展示有更新且值得借鉴的内容</p>
<div class="chip-row" style="margin-top:14px;">
<span class="chip active">按上次打开汇总</span>
<span class="chip">Agent 标借鉴点</span>
@@ -1249,15 +1295,19 @@ function renderTrackingScreen() {
<div class="layout-grid grid-main" style="margin-top:18px;">
<div class="side-stack">
<div class="panel pad">
<div class="panel-head"><div><h3>跟踪列表</h3><div class="panel-subtitle"></div></div><span class="tag">${escapeHtml(formatNumber(accounts.length))} </span></div>
<div class="panel-head"><div><h3>跟踪列表</h3><div class="panel-subtitle"> Agent</div></div><span class="tag">${escapeHtml(formatNumber(trackedAccounts.length))} </span></div>
<div class="list">
${accounts.map((account) => `
${trackedAccounts.map((item) => `
<div class="task-item">
<h4>${escapeHtml(account.nickname || "未命名账号")}</h4>
<p>最近作品 ${escapeHtml(formatNumber(account.video_summary?.count))} 条 · 平均播放 ${escapeHtml(formatNumber(account.video_summary?.avg_play))}</p>
<div class="task-meta"><span class="tag green">已同步</span><span class="tag">${escapeHtml(account.sync_status || "synced")}</span></div>
<h4>${escapeHtml(item.account?.nickname || "未命名账号")}</h4>
<p>最近作品 ${escapeHtml(formatNumber(item.account?.video_summary?.count))} 条 · 平均播放 ${escapeHtml(formatNumber(item.account?.video_summary?.avg_play))}</p>
<div class="task-meta">
<span class="tag green">已跟踪</span>
<span class="tag">${escapeHtml(item.assistant_name || "未绑 Agent")}</span>
<span class="tag clickable-tag" data-action="select-account" data-account-id="${escapeHtml(item.tracked_account_id)}">看详情</span>
</div>
</div>
`).join("") || `<div class="task-item"><h4>暂无跟踪账号</h4><p>先去找对标导入一个主页。</p></div>`}
`).join("") || `<div class="task-item"><h4>暂无跟踪账号</h4><p>先去找对标把重点账号加入跟踪。</p></div>`}
</div>
</div>
</div>
@@ -1265,13 +1315,23 @@ function renderTrackingScreen() {
<div class="panel pad">
<div class="panel-head"><div><h3>更新日报</h3><div class="panel-subtitle"></div></div><span class="tag blue">${escapeHtml(formatNumber(digestItems.length))} </span></div>
<div class="list">
${digestItems.map(({ account, video }) => `
${digestItems.map((item) => `
<div class="review-card">
<h4>${escapeHtml(account.nickname || "账号")} · ${escapeHtml(video.title || video.description || "最新作品")}</h4>
<p>发布时间 ${escapeHtml(formatDateTime(video.published_at))},建议交给当前项目 Agent 继续判断借鉴点。</p>
<div class="task-meta"><span class="tag">抖音</span><span class="tag green">可学习</span></div>
<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 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>` : ""}
</div>
${safeArray(item.borrowing_points).length ? `
<div class="task-meta">
${safeArray(item.borrowing_points).slice(0, 3).map((point) => `<span class="tag blue">${escapeHtml(point)}</span>`).join("")}
</div>
` : ""}
</div>
`).join("") || `<div class="review-card"><h4>暂无日报</h4><p>先同步一个账号,日报才会开始累积。</p></div>`}
`).join("") || `<div class="review-card"><h4>暂无日报</h4><p>先把账号加入跟踪,并等待新作品更新。</p></div>`}
</div>
</div>
</div>
@@ -1829,6 +1889,37 @@ function openImportSelectedAccountAction() {
});
}
function openTrackSelectedAccountAction() {
const account = requireSelectedAccountRow();
const project = requireSelectedProject();
const assistants = getAssistantOptions(project.id);
const trackedItem = safeArray(appState.trackingAccounts).find((item) => item.tracked_account_id === account.id);
openActionModal({
title: trackedItem ? "更新跟踪账号" : "加入跟踪",
description: trackedItem
? "这个账号已经在跟踪中,可以切换负责 Agent 或补充备注。"
: "把当前对标账号加入每日跟踪,后续自动生成更新日报。",
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: "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", {
method: "POST",
body: {
tracked_account_id: account.id,
assistant_id: values.assistantId || "",
note: values.note || ""
}
});
rememberAction(trackedItem ? "跟踪已更新" : "已加入跟踪", `账号「${account.nickname || account.douyin_id || "当前对标"}」现在会进入更新日报。`, "green");
await bootstrap();
}
});
}
function openImportVideoLinkAction() {
const project = requireSelectedProject();
const assistants = getAssistantOptions(project.id);
@@ -2396,6 +2487,10 @@ document.addEventListener("click", async (event) => {
openImportSelectedAccountAction();
return;
}
if (name === "open-track-selected-account") {
openTrackSelectedAccountAction();
return;
}
if (name === "open-import-video-link") {
openImportVideoLinkAction();
return;

View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="sfg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#dff4ff"/>
<stop offset="100%" stop-color="#86cfff"/>
</linearGradient>
</defs>
<rect x="4" y="4" width="56" height="56" rx="16" fill="url(#sfg)"/>
<path d="M19 23.5c0-2.5 2-4.5 4.5-4.5h17c1.8 0 3.2 1.4 3.2 3.2 0 1.6-1.1 3-2.7 3.2l-10.8 1.6c-1 .1-1.7 1-1.7 2 0 1.1.9 2 2 2h8.2c3.5 0 6.3 2.8 6.3 6.3 0 4.2-3.4 7.7-7.7 7.7H21.2v-5.8h16.2c1 0 1.8-.8 1.8-1.8s-.8-1.8-1.8-1.8h-8.5c-4 0-7.3-3.3-7.3-7.3 0-3.6 2.6-6.7 6.2-7.2l9-1.3H19v-5.5Z" fill="#0f172a"/>
<path d="M18 44.8h9.1l-4.6-7.6H18v7.6Z" fill="#0f172a" opacity=".85"/>
</svg>

After

Width:  |  Height:  |  Size: 720 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -4,6 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>StoryForge Web V4 Prototype</title>
<link rel="icon" href="./assets/favicon.svg" type="image/svg+xml" />
<link rel="stylesheet" href="./assets/styles.css" />
</head>
<body>