feat: add douyin tracking digest flows
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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;
|
||||
|
||||
11
web/storyforge-web-v4/assets/favicon.svg
Normal file
11
web/storyforge-web-v4/assets/favicon.svg
Normal 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 |
BIN
web/storyforge-web-v4/favicon.ico
Normal file
BIN
web/storyforge-web-v4/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user