7 Commits

Author SHA1 Message Date
kris
dab444a83c feat: add reviews and integration health controls 2026-03-22 13:34:41 +08:00
kris
ed5bcaef84 style: optimize mobile discovery and production flows 2026-03-22 12:46:03 +08:00
kris
7500d02730 style: refine responsive topbar and auth sheet 2026-03-22 12:22:17 +08:00
kris
37709d37b7 style: make storyforge web v4 responsive 2026-03-22 12:15:11 +08:00
kris
9ed5f24364 feat: add douyin tracking digest flows 2026-03-22 12:11:15 +08:00
kris
031ba04d4e feat: streamline benchmark intake flows 2026-03-22 11:53:14 +08:00
kris
32dea8e3a6 feat: extend benchmark and job action flows 2026-03-22 11:45:18 +08:00
9 changed files with 1977 additions and 156 deletions

View File

@@ -211,6 +211,30 @@ class Database:
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS publish_reviews (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
project_id TEXT,
source_job_id TEXT,
assistant_id TEXT,
title TEXT NOT NULL,
platform TEXT NOT NULL DEFAULT 'douyin',
content_type TEXT NOT NULL DEFAULT 'video',
publish_url TEXT NOT NULL DEFAULT '',
published_at TEXT NOT NULL DEFAULT '',
metrics_json TEXT NOT NULL DEFAULT '{}',
verdict TEXT NOT NULL DEFAULT '',
highlights TEXT NOT NULL DEFAULT '',
next_actions TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE,
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE SET NULL,
FOREIGN KEY(source_job_id) REFERENCES jobs(id) ON DELETE SET NULL,
FOREIGN KEY(assistant_id) REFERENCES assistants(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS job_events (
id TEXT PRIMARY KEY,
job_id TEXT NOT NULL,

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

@@ -1,17 +1,20 @@
from __future__ import annotations
import asyncio
import httpx
import json
import os
import re
import secrets
import shutil
import socket
import subprocess
import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from urllib.parse import urljoin, urlparse
from fastapi import Body, Depends, FastAPI, File, Form, Header, HTTPException, Query, UploadFile
from fastapi.middleware.cors import CORSMiddleware
@@ -251,6 +254,36 @@ class AiVideoJobRequest(BaseModel):
duration: int = 5
class ReviewCreateRequest(BaseModel):
project_id: str = ""
source_job_id: str = ""
assistant_id: str = ""
title: str = ""
platform: str = "douyin"
content_type: str = "video"
publish_url: str = ""
published_at: str = ""
metrics: dict[str, Any] = Field(default_factory=dict)
verdict: str = ""
highlights: str = ""
next_actions: str = ""
notes: str = ""
class ReviewUpdateRequest(BaseModel):
title: str | None = None
platform: str | None = None
content_type: str | None = None
publish_url: str | None = None
published_at: str | None = None
metrics: dict[str, Any] | None = None
verdict: str | None = None
highlights: str | None = None
next_actions: str | None = None
notes: str | None = None
assistant_id: str | None = None
class InternalStepRequest(BaseModel):
job_id: str = ""
jobId: str = ""
@@ -521,6 +554,41 @@ def assistant_payload(row: dict[str, Any]) -> dict[str, Any]:
}
def review_payload(row: dict[str, Any]) -> dict[str, Any]:
metrics = parse_json_object(row.get("metrics_json") or "{}")
source_job = None
assistant = None
if row.get("source_job_id"):
source_job_row = db.fetch_one("SELECT * FROM jobs WHERE id = ?", (row["source_job_id"],))
if source_job_row:
source_job = job_payload(source_job_row)
if row.get("assistant_id"):
assistant_row = db.fetch_one("SELECT * FROM assistants WHERE id = ?", (row["assistant_id"],))
if assistant_row:
assistant = assistant_payload(assistant_row)
return {
"id": row["id"],
"user_id": row["user_id"],
"project_id": row.get("project_id", ""),
"source_job_id": row.get("source_job_id", ""),
"assistant_id": row.get("assistant_id", ""),
"title": row.get("title", ""),
"platform": row.get("platform", "douyin"),
"content_type": row.get("content_type", "video"),
"publish_url": row.get("publish_url", ""),
"published_at": row.get("published_at", ""),
"metrics": metrics,
"verdict": row.get("verdict", ""),
"highlights": row.get("highlights", ""),
"next_actions": row.get("next_actions", ""),
"notes": row.get("notes", ""),
"source_job": source_job,
"assistant": assistant,
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
def document_payload(row: dict[str, Any]) -> dict[str, Any]:
analysis_map = parse_json_object(row.get("analysis_json") or "{}")
source_artifacts = parse_json_object(row.get("source_artifact_json") or "{}")
@@ -1353,6 +1421,44 @@ async def process_job(job_id: str) -> None:
update_job_state(job_id, status="failed", error=str(exc))
def probe_tcp(url: str, timeout: float = 3.0) -> dict[str, Any]:
if not url:
return {"configured": False, "reachable": False, "status_code": 0, "error": "not_configured", "url": ""}
parsed = urlparse(url)
host = parsed.hostname
port = parsed.port or (443 if parsed.scheme == "https" else 80)
if not host:
return {"configured": True, "reachable": False, "status_code": 0, "error": "invalid_url", "url": url}
sock = socket.socket()
sock.settimeout(timeout)
try:
sock.connect((host, port))
return {"configured": True, "reachable": True, "status_code": 0, "error": "", "url": url}
except Exception as exc: # pragma: no cover - operational probe
return {"configured": True, "reachable": False, "status_code": 0, "error": str(exc), "url": url}
finally:
sock.close()
def probe_http(url: str, path: str = "", timeout: float = 3.0) -> dict[str, Any]:
tcp = probe_tcp(url, timeout=timeout)
target_url = urljoin(url if url.endswith("/") else f"{url}/", path.lstrip("/")) if url else ""
if not tcp["configured"] or not tcp["reachable"]:
if target_url:
tcp["url"] = target_url
return tcp
try:
response = httpx.get(target_url or url, timeout=timeout, follow_redirects=True)
tcp["status_code"] = response.status_code
tcp["reachable"] = response.status_code < 500
tcp["error"] = "" if response.status_code < 500 else f"http_{response.status_code}"
except Exception as exc: # pragma: no cover - operational probe
tcp["reachable"] = False
tcp["error"] = str(exc)
tcp["url"] = target_url or url
return tcp
@app.on_event("startup")
def on_startup() -> None:
db.init_schema()
@@ -1374,6 +1480,29 @@ def healthz() -> dict[str, Any]:
}
@app.get("/v2/integrations/health")
def integrations_health(account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]:
_ = account
return {
"cutvideo": {
"base_url": CUTVIDEO_BASE_URL,
**probe_http(CUTVIDEO_BASE_URL, "/api/bootstrap"),
},
"huobao": {
"base_url": HUOBAO_BASE_URL,
**probe_http(HUOBAO_BASE_URL, "/health"),
},
"n8n": {
"base_url": N8N_BASE_URL,
**probe_http(N8N_BASE_URL, "/healthz"),
},
"asr": {
"base_url": ASR_HTTP_BASE_URL,
**probe_tcp(ASR_HTTP_BASE_URL),
},
}
def seed_defaults() -> None:
if not db.fetch_one("SELECT id FROM model_profiles WHERE is_default = 1 LIMIT 1"):
profile_id = make_id("model")
@@ -1746,6 +1875,107 @@ def list_knowledge_documents(knowledge_base_id: str, account: dict[str, Any] = D
return [document_payload(row) for row in rows]
@app.get("/v2/reviews")
def list_reviews(
project_id: str | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
account: dict[str, Any] = Depends(require_approved),
) -> list[dict[str, Any]]:
clauses = ["user_id = ?"]
params: list[Any] = [account["id"]]
if project_id is not None:
normalized_project = project_id.strip()
if normalized_project:
clauses.append("project_id = ?")
params.append(normalized_project)
else:
clauses.append("(project_id IS NULL OR project_id = '')")
sql = f"SELECT * FROM publish_reviews WHERE {' AND '.join(clauses)} ORDER BY COALESCE(NULLIF(published_at, ''), created_at) DESC, created_at DESC LIMIT ?"
params.append(limit)
return [review_payload(row) for row in db.fetch_all(sql, tuple(params))]
@app.post("/v2/reviews")
def create_review(request: ReviewCreateRequest, account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]:
source_job = None
if request.source_job_id.strip():
source_job = load_owned_job(request.source_job_id.strip(), account["id"])
requested_project_id = request.project_id.strip() or (source_job.get("project_id", "") if source_job else "")
project = resolve_target_project(account["id"], requested_project_id or None, username=account["username"])
assistant = resolve_target_assistant(account["id"], request.assistant_id or None, project["id"])
review_id = make_id("review")
title = request.title.strip() or (source_job.get("title", "") if source_job else "")
if not title:
title = f"{project['name']} 复盘"
timestamp = utc_now()
db.execute(
"""
INSERT INTO publish_reviews (
id, user_id, project_id, source_job_id, assistant_id, title, platform, content_type,
publish_url, published_at, metrics_json, verdict, highlights, next_actions, notes, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
review_id,
account["id"],
project["id"],
source_job["id"] if source_job else None,
(assistant or {}).get("id") or None,
title,
request.platform or "douyin",
request.content_type or "video",
request.publish_url.strip(),
request.published_at.strip(),
json.dumps(request.metrics, ensure_ascii=False),
request.verdict.strip(),
request.highlights.strip(),
request.next_actions.strip(),
request.notes.strip(),
timestamp,
timestamp,
),
)
row = db.fetch_one("SELECT * FROM publish_reviews WHERE id = ?", (review_id,))
return review_payload(row)
@app.patch("/v2/reviews/{review_id}")
def update_review(review_id: str, request: ReviewUpdateRequest, account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]:
current = load_owned_review(review_id, account["id"])
assistant_id = current.get("assistant_id") or None
if request.assistant_id is not None:
assistant = resolve_target_assistant(account["id"], request.assistant_id or None, current.get("project_id", ""))
assistant_id = (assistant or {}).get("id") or None
db.execute(
"""
UPDATE publish_reviews
SET title = ?, platform = ?, content_type = ?, publish_url = ?, published_at = ?,
metrics_json = ?, verdict = ?, highlights = ?, next_actions = ?, notes = ?,
assistant_id = ?, updated_at = ?
WHERE id = ? AND user_id = ?
""",
(
request.title if request.title is not None else current.get("title", ""),
request.platform if request.platform is not None else current.get("platform", "douyin"),
request.content_type if request.content_type is not None else current.get("content_type", "video"),
request.publish_url if request.publish_url is not None else current.get("publish_url", ""),
request.published_at if request.published_at is not None else current.get("published_at", ""),
json.dumps(request.metrics if request.metrics is not None else parse_json_object(current.get("metrics_json") or "{}"), ensure_ascii=False),
request.verdict if request.verdict is not None else current.get("verdict", ""),
request.highlights if request.highlights is not None else current.get("highlights", ""),
request.next_actions if request.next_actions is not None else current.get("next_actions", ""),
request.notes if request.notes is not None else current.get("notes", ""),
assistant_id,
utc_now(),
review_id,
account["id"],
),
)
row = db.fetch_one("SELECT * FROM publish_reviews WHERE id = ?", (review_id,))
return review_payload(row)
@app.get("/v2/explore/jobs")
def list_jobs(
parent_job_id: str | None = Query(default=None),
@@ -2178,6 +2408,13 @@ def load_owned_content_source(source_id: str, account_id: str) -> dict[str, Any]
return row
def load_owned_review(review_id: str, account_id: str) -> dict[str, Any]:
row = db.fetch_one("SELECT * FROM publish_reviews WHERE id = ? AND user_id = ?", (review_id, account_id))
if not row:
raise HTTPException(status_code=404, detail="Review not found")
return row
def load_internal_job(job_id: str) -> dict[str, Any]:
row = db.fetch_one("SELECT * FROM jobs WHERE id = ?", (job_id,))
if not row:

View File

@@ -33,12 +33,15 @@
- 抖音对标账号 `/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`
## 当前已接入的真实动作
- 新建项目
- 导入主页并触发内容源同步
- 把当前对标账号直接导入到当前项目,并绑定 Agent 触发同步
- 导入作品链接并触发分析
- 导入文本素材并触发分析
- 上传本地视频并触发分析
@@ -46,7 +49,12 @@
- 对当前 Douyin 对标账号重跑分析
- 批量分析高分作品
- 查找相似对标账号
- 查看任务详情、事件和 artifacts/result
- 从相似候选一键保存对标关系
- 把当前对标账号加入跟踪,并绑定 Agent
- 按上次打开后生成跟踪日报与借鉴点摘要
- 查看任务详情、事件、子任务和 artifacts/result
- 从任务详情直接衔接 AI 视频 / 实拍剪辑 / 文案生成
- 在生产中心 / 发布与复盘常驻最近一次任务详情摘要
- 使用 Agent 生成文案
- 创建 AI 视频任务
- 创建实拍剪辑任务
@@ -71,6 +79,8 @@ python3 -m http.server 3918
## 后续建议
- 继续补动作型接口,例如导入、绑定 Agent、触发分析与生产
- 把对标导入后的 Agent 绑定和知识库入库反馈做得更完整
- 把跟踪日报从 Douyin 扩到多平台统一模型
- 把全局搜索和页内搜索合并成统一搜索体验
-`生产中心 / 发布与复盘` 接入更完整的任务与成片对象
- 不要把这套页面重新塞回 `scripts/douyin-browser-capture/control_panel.mjs`

File diff suppressed because it is too large Load Diff

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

View File

@@ -38,6 +38,7 @@ body {
radial-gradient(circle at top left, rgba(129, 180, 255, 0.18), transparent 28%),
linear-gradient(180deg, #f8fbff 0%, #eef4fb 100%);
color: var(--text);
overflow-x: hidden;
}
a {
@@ -55,6 +56,7 @@ select {
display: grid;
grid-template-columns: 272px minmax(0, 1fr);
min-height: 100vh;
width: 100%;
}
.sidebar {
@@ -65,6 +67,7 @@ select {
position: sticky;
top: 0;
height: 100vh;
overflow: auto;
}
.brand {
@@ -186,6 +189,7 @@ select {
.content {
padding: 18px 22px 26px;
min-width: 0;
}
.topbar {
@@ -206,6 +210,17 @@ select {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.topbar-left {
flex: 1 1 auto;
}
.topbar-right {
flex: 1 1 auto;
justify-content: flex-end;
flex-wrap: wrap;
}
.workspace-switch,
@@ -229,6 +244,8 @@ select {
.workspace-switch span {
font-size: 12px;
color: var(--muted);
display: block;
line-height: 1.4;
}
.search {
@@ -236,6 +253,7 @@ select {
align-items: center;
gap: 10px;
min-width: 340px;
max-width: 100%;
padding: 12px 14px;
color: var(--muted);
}
@@ -273,6 +291,7 @@ select {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.auth-status {
@@ -308,6 +327,11 @@ select {
padding: 22px;
}
.auth-form {
display: grid;
gap: 0;
}
.action-modal-backdrop {
position: fixed;
inset: 0;
@@ -482,6 +506,7 @@ select {
.layout-grid {
display: grid;
gap: 18px;
min-width: 0;
}
.grid-4 {
@@ -510,6 +535,7 @@ select {
border-radius: var(--radius-xl);
box-shadow: var(--shadow-soft);
overflow: hidden;
min-width: 0;
}
.panel.pad {
@@ -572,6 +598,7 @@ select {
border-radius: 18px;
border: 1px solid var(--line);
background: linear-gradient(180deg, #fff 0%, #f9fbff 100%);
min-width: 0;
}
.task-item,
@@ -653,6 +680,50 @@ select {
cursor: pointer;
}
.mobile-only {
display: none;
}
.desktop-only {
display: initial;
}
.mobile-account-list {
padding: 14px;
border-top: 1px solid var(--line);
background: linear-gradient(180deg, #fbfdff 0%, #f4f9ff 100%);
}
.account-select-card {
width: 100%;
display: grid;
gap: 10px;
text-align: left;
padding: 14px;
border-radius: 18px;
border: 1px solid var(--line);
background: linear-gradient(180deg, #fff 0%, #f7fbff 100%);
color: var(--text);
cursor: pointer;
margin-bottom: 10px;
}
.account-select-card:last-child {
margin-bottom: 0;
}
.account-select-card.is-active {
border-color: rgba(79, 143, 238, 0.24);
background: linear-gradient(180deg, #f8fbff 0%, #eef6ff 100%);
box-shadow: inset 0 0 0 1px rgba(79, 143, 238, 0.08);
}
.compact-summary-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.sheet-html {
border: 1px solid var(--line);
border-radius: 16px;
@@ -684,6 +755,7 @@ select {
.table-wrap {
overflow: auto;
-webkit-overflow-scrolling: touch;
}
table {
@@ -978,6 +1050,522 @@ tbody tr:hover {
font-size: 13px;
}
@media (max-width: 1400px) {
.grid-5 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.grid-split {
grid-template-columns: 240px minmax(0, 1fr) 280px;
}
}
@media (max-width: 1180px) {
.app-shell {
grid-template-columns: minmax(0, 1fr);
}
.sidebar {
position: relative;
top: auto;
height: auto;
border-right: none;
border-bottom: 1px solid rgba(201, 220, 239, 0.75);
padding: 18px 18px 14px;
}
.brand {
padding-bottom: 14px;
}
.nav-group {
margin-top: 10px;
}
.nav-group + .nav-group {
margin-top: 14px;
}
.nav-title {
padding: 0 4px 8px;
}
.nav-group .nav-item {
display: inline-flex;
width: auto;
margin: 0 8px 8px 0;
}
.sidebar-foot {
margin-top: 12px;
}
.content {
padding: 16px 18px 24px;
}
.topbar {
flex-direction: column;
align-items: stretch;
}
.topbar-left,
.topbar-right {
flex-wrap: wrap;
width: 100%;
}
.workspace-switch,
.search {
width: 100%;
min-width: 0;
}
.screen-head {
align-items: flex-start;
flex-direction: column;
}
.screen-head p {
max-width: none;
}
.grid-main,
.grid-split,
.two-col,
.three-col {
grid-template-columns: minmax(0, 1fr);
}
.grid-4 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid-5 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.detail-grid {
grid-template-columns: minmax(0, 1fr);
}
.toolbar {
flex-direction: column;
align-items: stretch;
}
.toolbar-stack {
min-width: 0;
}
table {
min-width: 760px;
}
}
@media (max-width: 760px) {
.mobile-only {
display: block;
}
.desktop-only {
display: none !important;
}
.sidebar {
padding: 14px 14px 12px;
}
.brand {
gap: 10px;
padding: 4px 4px 12px;
}
.brand-mark {
width: 38px;
height: 38px;
border-radius: 12px;
}
.brand h1 {
font-size: 17px;
}
.brand p {
font-size: 11px;
}
.nav-group .nav-item {
width: calc(50% - 8px);
justify-content: flex-start;
margin-right: 8px;
}
.sidebar-foot {
padding: 12px;
}
.content {
padding: 12px 12px 22px;
}
.topbar {
padding: 14px;
border-radius: 18px;
}
.topbar-left .chip-row,
.topbar-right {
width: 100%;
}
.topbar-left .chip-row {
flex-wrap: nowrap;
overflow-x: auto;
padding-bottom: 2px;
scrollbar-width: none;
}
.topbar-left .chip-row::-webkit-scrollbar {
display: none;
}
.top-pill {
padding: 7px 10px;
}
.auth-inline {
width: 100%;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.auth-status {
max-width: none;
width: 100%;
grid-column: 1 / -1;
}
.auth-modal-backdrop,
.action-modal-backdrop {
padding: 12px;
align-items: end;
}
.auth-modal,
.action-modal {
width: 100%;
max-height: min(90vh, 100%);
border-radius: 20px;
padding: 18px;
}
.auth-head {
flex-direction: column;
align-items: flex-start;
}
.auth-actions {
flex-direction: column-reverse;
}
.auth-actions .btn {
width: 100%;
justify-content: center;
}
.screen {
margin-top: 14px;
}
.screen-head {
margin-bottom: 14px;
}
.screen-head h2 {
font-size: 24px;
}
.screen-head p {
font-size: 12px;
}
.action-row {
width: 100%;
}
.action-row .btn {
flex: 1 1 calc(50% - 10px);
min-width: 0;
}
.grid-4,
.grid-5,
.mini-grid,
.calendar {
grid-template-columns: minmax(0, 1fr);
}
.stat-card strong {
font-size: 24px;
}
.stat-foot {
gap: 8px;
flex-direction: column;
align-items: flex-start;
}
.panel.pad,
.hero-card,
.task-item,
.queue-card,
.review-card,
.sheet-html,
.insight-card,
.playbook-item {
padding: 14px;
}
.task-meta,
.entity-meta,
.row-meta,
.chip-row,
.kpi-inline,
.timeline {
gap: 6px;
}
.toolbar {
padding: 14px;
}
.table-wrap .account-table {
display: none;
}
.search-inline {
width: 100%;
min-width: 0;
}
.filters {
gap: 8px;
}
.filter {
min-width: calc(50% - 4px);
flex: 1 1 calc(50% - 4px);
}
.mobile-account-list {
padding: 12px;
}
.account-select-card {
padding: 12px;
gap: 8px;
}
.account-select-card .kpi-inline {
justify-content: space-between;
}
.account-select-card .task-meta .tag {
flex: 1 1 calc(50% - 4px);
justify-content: center;
text-align: center;
}
.entity-cell {
align-items: flex-start;
}
.avatar-lg {
width: 40px;
height: 40px;
border-radius: 13px;
}
.bar-row {
grid-template-columns: minmax(0, 1fr);
gap: 6px;
}
.kpi-inline {
gap: 8px;
row-gap: 6px;
}
table {
min-width: 680px;
}
}
@media (max-width: 560px) {
.sidebar {
padding: 12px;
}
.brand {
padding: 2px 2px 10px;
}
.nav-group {
display: flex;
align-items: center;
gap: 8px;
overflow-x: auto;
padding-bottom: 4px;
margin-top: 8px;
scrollbar-width: none;
}
.nav-group::-webkit-scrollbar {
display: none;
}
.nav-title {
padding: 0 4px 0 2px;
flex: 0 0 auto;
white-space: nowrap;
}
.nav-group .nav-item {
width: auto;
margin-right: 0;
flex: 0 0 auto;
white-space: nowrap;
padding: 9px 11px;
}
.nav-item .icon {
width: 24px;
height: 24px;
border-radius: 8px;
font-size: 12px;
}
.sidebar-foot {
display: none;
}
.topbar-right {
gap: 8px;
align-items: stretch;
}
.search {
padding: 10px 12px;
}
.action-row .btn,
.auth-actions .btn {
width: 100%;
}
.workspace-switch {
padding: 10px 12px;
}
.workspace-switch strong {
font-size: 12px;
}
.workspace-switch span {
font-size: 11px;
}
.topbar-left .chip-row {
gap: 6px;
}
.topbar-left .chip,
.top-pill {
font-size: 11px;
}
.topbar-right > :not(.avatar) {
width: 100%;
}
.topbar-right .search {
order: 2;
}
.topbar-right .auth-inline {
order: 1;
}
.topbar-right .top-pill {
width: auto;
}
.topbar-right .avatar {
order: 4;
align-self: flex-end;
}
.auth-inline .btn {
width: 100%;
}
.hero-card .chip-row {
flex-wrap: nowrap;
overflow-x: auto;
padding-bottom: 2px;
scrollbar-width: none;
}
.hero-card .chip-row::-webkit-scrollbar {
display: none;
}
.compact-summary-row .tag {
width: calc(50% - 4px);
text-align: center;
}
.task-item.compact,
.review-card.compact {
padding: 12px;
}
.task-item.compact h4,
.review-card.compact h4 {
font-size: 14px;
line-height: 1.4;
}
.task-item.compact p,
.review-card.compact p {
font-size: 11px;
}
.filter {
min-width: 100%;
flex-basis: 100%;
}
.panel-head {
align-items: flex-start;
flex-direction: column;
}
.hero-card h3 {
font-size: 17px;
}
.screen-head h2 {
font-size: 22px;
}
table {
min-width: 620px;
}
}
.slot {
margin-top: 8px;
padding: 8px 10px;
@@ -995,39 +1583,3 @@ tbody tr:hover {
font-size: 12px;
text-align: right;
}
@media (max-width: 1320px) {
.grid-main,
.grid-split,
.grid-5,
.grid-4,
.grid-3,
.three-col,
.two-col {
grid-template-columns: 1fr;
}
.calendar {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 1080px) {
.app-shell {
grid-template-columns: 1fr;
}
.sidebar {
position: relative;
height: auto;
}
.topbar {
flex-direction: column;
align-items: stretch;
}
.topbar-left,
.topbar-right {
flex-wrap: wrap;
}
.search {
min-width: 0;
}
}

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>