19 Commits

Author SHA1 Message Date
kris
c657db9b38 feat: surface local model health in web ui 2026-03-22 14:18:38 +08:00
kris
652f0c9f79 feat: extend web tracking and integration controls 2026-03-22 14:13:10 +08:00
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
kris
4106347b67 feat: add job details and benchmark actions to web v4 2026-03-22 11:27:02 +08:00
kris
b75c9e275b feat: add storyforge web v4 action workflows 2026-03-22 11:22:10 +08:00
kris
540be80719 feat: connect storyforge web v4 to live workspace data 2026-03-22 11:10:21 +08:00
kris
fe07a5f212 feat: implement storyforge mobile v4 shell 2026-03-22 10:39:53 +08:00
kris
35c97ffe4d feat: add storyforge mobile v4 html prototype 2026-03-22 10:33:03 +08:00
kris
1851625a53 style: refine storyforge ops ui visual rhythm 2026-03-22 08:39:39 +08:00
kris
66db9e8687 style: unify storyforge ops ui actions 2026-03-22 08:12:07 +08:00
kris
98592168b7 style: simplify storyforge ops ui copy 2026-03-22 08:08:04 +08:00
kris
e771919e4a feat: refine storyforge ops ui information architecture 2026-03-22 08:03:02 +08:00
kris
6899ebba60 feat: add storyforge ops ui prototype and tracking digest 2026-03-22 07:38:49 +08:00
41 changed files with 12631 additions and 326 deletions

View File

@@ -12,6 +12,14 @@ StoryForge 现在拆成独立项目目录,和 `AI-glasses` 分开维护。
- `data/collector/`SQLite、任务文件、下载产物
- `docs/`:审计、实施计划、联调说明、当前 MVP 状态
## 产品手册
- [新媒体运营中台产品逻辑手册](./docs/PRODUCT_LOGIC_NEW_MEDIA_OPERATING_SYSTEM_2026-03-22.md)
- [新媒体运营平台 UI 参考包](./output/ui/new-media-ops-reference-2026-03-22/README.md)
- [Web V4 UI 原型](./output/ui/storyforge-web-v4-html-prototype-2026-03-22/README.md)
- [Web V4 前端骨架](./web/storyforge-web-v4/README.md)
- [Mobile V4 UI 原型](./output/ui/storyforge-mobile-v4-html-prototype-2026-03-22/README.md)
## Android
```bash

View File

@@ -15,7 +15,9 @@ import kotlinx.coroutines.launch
import retrofit2.HttpException
enum class StoryForgeTab {
Explore,
Overview,
Benchmark,
Agent,
Production,
Mine
}
@@ -53,7 +55,7 @@ data class StoryForgeUiState(
val originalHost: String = "",
val isAuthenticated: Boolean = false,
val isApproved: Boolean = false,
val currentTab: StoryForgeTab = StoryForgeTab.Explore,
val currentTab: StoryForgeTab = StoryForgeTab.Overview,
val busy: Boolean = false,
val generateBusy: Boolean = false,
val statusMessage: String = "准备连接 StoryForge",
@@ -847,7 +849,7 @@ class StoryForgeViewModel(application: Application) : AndroidViewModel(applicati
_state.value = state.value.copy(
latestJob = job,
latestJobId = job.id,
currentTab = StoryForgeTab.Explore
currentTab = StoryForgeTab.Benchmark
)
refreshWorkspace()
startJobPolling(job.id)

View File

@@ -13,51 +13,82 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
private val LightColors = lightColorScheme(
primary = Color(0xFF0E4B43),
secondary = Color(0xFF9C6427),
tertiary = Color(0xFF2A5B8A),
background = Color(0xFFF7F3EC),
surface = Color(0xFFFFFCF8),
primary = Color(0xFF4E89F5),
secondary = Color(0xFF87AEEB),
tertiary = Color(0xFF17283A),
background = Color(0xFFF2F7FF),
surface = Color(0xFFFFFFFF),
surfaceVariant = Color(0xFFEAF2FF),
onPrimary = Color.White,
onSecondary = Color.White,
onBackground = Color(0xFF1A1713),
onSurface = Color(0xFF1A1713)
onBackground = Color(0xFF152332),
onSurface = Color(0xFF152332),
outline = Color(0xFFC9D8EA)
)
private val DarkColors = darkColorScheme(
primary = Color(0xFF7FD6C7),
secondary = Color(0xFFFFC27A),
tertiary = Color(0xFF98C7FF),
background = Color(0xFF101714),
surface = Color(0xFF18211D),
onPrimary = Color(0xFF062D29),
onSecondary = Color(0xFF4B2B00),
onBackground = Color(0xFFF0E8DB),
onSurface = Color(0xFFF0E8DB)
primary = Color(0xFF8CB7FF),
secondary = Color(0xFF7EA5DE),
tertiary = Color(0xFFE6EEF9),
background = Color(0xFF101823),
surface = Color(0xFF162131),
surfaceVariant = Color(0xFF1D2B3D),
onPrimary = Color(0xFF0C1B30),
onSecondary = Color(0xFF0C1B30),
onBackground = Color(0xFFEAF1FB),
onSurface = Color(0xFFEAF1FB),
outline = Color(0xFF35506F)
)
private val AppTypography = Typography(
headlineLarge = TextStyle(
fontFamily = FontFamily.Serif,
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Bold,
fontSize = 34.sp,
lineHeight = 40.sp
fontSize = 30.sp,
lineHeight = 36.sp
),
headlineMedium = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Bold,
fontSize = 26.sp,
lineHeight = 32.sp
),
headlineSmall = TextStyle(
fontFamily = FontFamily.Serif,
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
lineHeight = 28.sp
),
titleLarge = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
lineHeight = 26.sp
),
bodyLarge = TextStyle(
fontFamily = FontFamily.SansSerif,
fontSize = 16.sp,
lineHeight = 24.sp
),
bodyMedium = TextStyle(
fontFamily = FontFamily.SansSerif,
fontSize = 14.sp,
lineHeight = 21.sp
),
bodySmall = TextStyle(
fontFamily = FontFamily.SansSerif,
fontSize = 12.sp,
lineHeight = 18.sp
),
labelLarge = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Medium,
fontSize = 14.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Medium,
fontSize = 11.sp
)
)

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,193 @@ 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))]
}
async def _refresh_tracked_account_workspace(
owner: dict[str, Any],
tracked_account_id: str,
discovery_note: str = "tracking_refresh"
) -> dict[str, Any]:
account_row = _require_owned_account(tracked_account_id, owner["id"])
profile_url = _first_non_empty(
account_row.get("canonical_profile_url"),
account_row.get("profile_url")
)
if not profile_url:
raise HTTPException(status_code=400, detail="Tracked account has no profile_url to refresh")
request = DouyinAccountSyncRequest(
profile_url=profile_url,
compact_response=True,
discovery_note=discovery_note
)
public_data = await _collect_public_profile(profile_url, None)
creator_data = {"pages": [], "errors": []}
return await run_in_threadpool(
_finalize_sync_workspace,
owner,
request,
public_data,
creator_data
)
def _normalize_report_text(value: Any) -> str:
text = str(value or "").strip()
if not text:
@@ -3133,3 +3354,134 @@ 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/accounts/{tracked_account_id}/refresh")
async def refresh_douyin_tracked_account(
tracked_account_id: str,
account: dict[str, Any] = Depends(legacy.require_approved)
) -> dict[str, Any]:
account_row = _require_owned_account(tracked_account_id, account["id"])
account_payload = _build_account_payload(account_row, include_recent_videos=6)
try:
refreshed = await _refresh_tracked_account_workspace(account, tracked_account_id)
return {
"success": True,
"tracked_account_id": tracked_account_id,
"account": refreshed.get("account", {}),
"sync_errors": refreshed.get("sync_errors", []),
"public_video_count": refreshed.get("public_video_count", 0),
"creator_page_count": refreshed.get("creator_page_count", 0)
}
except HTTPException as exc:
detail = exc.detail if isinstance(exc.detail, dict) else {"message": str(exc.detail)}
return {
"success": False,
"tracked_account_id": tracked_account_id,
"account": account_payload,
"message": detail.get("message") or str(exc.detail),
"detail": detail,
"sync_errors": detail.get("public_errors", []) + detail.get("creator_errors", [])
}
@app.post("/v2/douyin/tracking/refresh")
async def refresh_all_douyin_tracked_accounts(
account: dict[str, Any] = Depends(legacy.require_approved)
) -> dict[str, Any]:
tracked_accounts = _list_tracked_accounts(account["id"])
items: list[dict[str, Any]] = []
errors: list[dict[str, Any]] = []
for tracked in tracked_accounts:
try:
refreshed = await _refresh_tracked_account_workspace(account, tracked["tracked_account_id"])
items.append({
"tracking_id": tracked["id"],
"tracked_account_id": tracked["tracked_account_id"],
"nickname": (refreshed.get("account") or {}).get("nickname", ""),
"sync_errors": refreshed.get("sync_errors", []),
"public_video_count": refreshed.get("public_video_count", 0)
})
except HTTPException as exc:
errors.append({
"tracking_id": tracked["id"],
"tracked_account_id": tracked["tracked_account_id"],
"message": str(exc.detail)
})
except Exception as exc:
errors.append({
"tracking_id": tracked["id"],
"tracked_account_id": tracked["tracked_account_id"],
"message": str(exc)
})
return {
"tracked_count": len(tracked_accounts),
"refreshed": len(items),
"failed": len(errors),
"items": items,
"errors": errors
}
@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,33 @@ 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 {
"local_model": {
"base_url": LOCAL_OPENAI_BASE_URL,
**probe_http(LOCAL_OPENAI_BASE_URL, "/models"),
},
"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 +1879,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 +2412,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

@@ -0,0 +1,555 @@
# StoryForge 产品逻辑重构手册
日期2026-03-22
## 1. 目标重定义
StoryForge 不应再被定义成“AI 内容工具集合”。
更准确的定位应是:
**一个以“项目”为入口、以 Agent 为执行中枢、面向多平台账号经营的新媒体运营与生产中台。**
覆盖的平台至少包括:
- 小红书
- 抖音
- 快手
- 微信视频号
- YouTube
- 哔哩哔哩
新的核心能力不是“直接生成一条内容”,而是先完成:
1. 用户先建项目,明确这是已绑定账号项目还是预调研项目
2. 项目创建后先创建 Agent
3. Agent 完成账号画像、多平台市场调研和导入分析
4. 持续跟踪重点创作者的更新并自动汇总日报
5. 再把分析结果转成内容生产链与复盘闭环
## 2. 为什么要调整
之前的系统更偏:
- 任务中心
- Agent 中心
- Pipeline 中心
这对研发是友好的,但对创作者不够自然。
创作者真正的心智顺序是:
1. 我先要建一个项目
2. 这个项目是运营自己的账号,还是先做市场调研
3. 我应该先创建哪个 Agent
4. 这个 Agent 要服务哪些平台、靠什么变现
5. 参考作品和主页怎么导入,谁来分析
6. 哪条内容该走文案、封面、实拍剪辑还是 AI 视频
7. 产生的额度和成本怎么管
8. 发完之后效果如何
因此 StoryForge 的主对象必须重构。
## 3. 新的主对象模型
### 3.1 项目 Project
项目是 StoryForge 的第一层入口,分为两类:
- `bound_account_project`:已绑定账号项目,适合直接围绕自己的账号运营
- `pre_research_project`:预调研项目,适合先做市场和账号研究,再决定后续是否绑定账号
项目创建后,不直接进入生产,而是先进入 Agent 创建流程。
### 3.2 工作区 Workspace
代表一个团队、品牌、创作者个人,或者一个客户项目集合。
### 3.3 平台账号 Platform Account
按平台保存账号实体,必须带平台字段:
- `xiaohongshu`
- `douyin`
- `kuaishou`
- `wechat_video`
- `youtube`
- `bilibili`
账号类型分两类:
- `reference_account`:参考账号 / 精品账号 / 对标账号
- `owned_account`:自己在运营的账号
### 3.4 Agent
Agent 是项目内的执行中枢,不是用户直接操作内容的替代品。
创建 Agent 时必须定义:
- 账号类型
- 变现方式
- 目标平台
- 默认主大模型
- 可选对比模型
目标平台必须支持多选,至少包括:
- 小红书
- 抖音
- 快手
- 微信视频号
- YouTube
- 哔哩哔哩
Agent 创建完成后,默认先做多平台市场调研,再进入账号导入、分析、生产和复盘。
### 3.5 多平台市场调研
这是 Agent 创建后的第一步工作,不是可选项。
调研输出建议包含:
- 平台机会判断
- 账号类型差异
- 内容形态偏好
- 变现方式匹配度
- 竞争密度
- 适合先做的平台建议
### 3.6 账号画像 Account Insight
对一个账号的阶段性总结,不是单次报告。
建议固定结构:
- 账号定位
- 栏目结构
- 内容支柱
- 爆款规律
- 商业化机会
- 风险与短板
- 下阶段动作建议
### 3.7 作品 Content Item
所有作品统一抽象,不管来源于哪个平台,都沉淀到生产中心里的“作品与成片”区域。
作品需要统一字段:
- 标题
- 平台
- 作者
- 发布时间
- 内容类型:视频 / 图文 / 长视频 / Shorts
- 互动指标:播放、点赞、评论、收藏、转发
- 平台原链接
- 标准化热度分
- 标准化商业价值分
- 标准化可复刻分
### 3.8 跟踪账号 Tracking Account
这是区别于“一次性导入”的持续性对象。
用户可以手动把某些参考账号加入跟踪列表,系统随后持续监控:
- 是否有新作品发布
- 自上次打开后新增了哪些内容
- 哪些新内容值得借鉴
- 应该送给哪个 Agent 做进一步学习
跟踪账号需要绑定:
- 平台
- 账号主页
- 所属项目
- 关联 Agent
- 是否开启自动日报
### 3.9 更新日报 Update Digest
日报不是固定按自然日生成,而是按“自用户上次打开后”或“自上次已读后”的更新窗口动态汇总。
例如:
- 用户 1 天没打开,则生成 1 天更新汇总
- 用户 5 天没打开,则自动生成 5 天汇总
日报内容应包含:
- 跟踪账号新增内容
- 作品摘要
- Agent 标注的借鉴点
- 风险点
- 建议动作
- 一键加入学习集 / Playbook / 生产中心作品区
### 3.10 内容打法 Playbook
从精品账号和高分作品中总结出的可学习方法论。
例如:
- 开头钩子模板
- 文案结构模板
- 镜头节奏模板
- 情绪驱动模板
- 选题组合模板
### 3.11 生产任务 Production Task
生产任务不是平台发现逻辑,而是执行逻辑。
统一分为:
- 文案生成任务
- 封面生成任务
- 实拍剪辑任务
- AI 视频任务
- 发布准备任务
- 复盘任务
### 3.12 发布复盘 Publish Review
真正的闭环在发布后。
复盘必须沉淀:
- 作品最终版本
- 发布时间
- 实际平台链接
- 实际数据表现
- 是否达到目标
- 下一步建议
## 4. 核心业务闭环
StoryForge 的闭环应该改成下面 8 步:
### 第 1 步:创建项目
用户先建项目,项目分两类:
- 已绑定账号项目:直接围绕自己的账号运营
- 预调研项目:先研究市场和参考账号,再决定是否进入绑定账号运营
### 第 2 步:创建 Agent
项目创建后先创建 Agent并在创建时定义
- 账号类型
- 变现方式
- 目标平台
- 默认主大模型
- 可选对比模型
### 第 3 步:多平台市场调研
Agent 创建后先做多平台市场调研,为项目判断优先平台和内容方向。
### 第 4 步:导入参考作品或主页
参考作品 / 参考主页导入时必须支持:
- 手动绑定 Agent
- 自动关联 Agent
导入后的分析不由用户手工处理,而由 Agent 负责完成。
### 第 5 步:跟踪重点账号并生成更新日报
用户可以把重点参考账号加入“跟踪账号”列表。
系统应在账号更新后自动:
- 抓取最新作品
- 汇总自上次打开后的新增内容
- 由关联 Agent 标注借鉴点
- 生成日报供用户进入系统后优先查看
### 第 6 步:沉淀账号画像与内容打法
Agent 将调研和导入分析结果转成结构化资产:
- 账号画像
- 内容打法
- Playbook
- 选题池
### 第 7 步:进入生产链
生产链统一分流为:
- 文案
- 封面生成
- 实拍剪辑
- AI 视频
### 第 8 步:发布与复盘
发布后把真实反馈写回系统,更新:
- 项目策略
- 账号策略
- 选题池
- Playbook
- Agent 学习集
## 5. 页面与信息架构
## 5.1 Web 端一级导航
建议固定为:
- 运营总台
- 我的项目
- Agent
- 找对标
- 跟踪账号
- 自运营账号
- Playbook
- 生产中心
- 发布与复盘
- 自动流程
- 设置
## 5.2 运营总台
首页不应该先展示工具,而应该先展示业务动作:
- 今日待办
- 待创建的项目
- 待创建的 Agent
- 新发现的高价值账号
- 新发现的高价值作品
- 本周重点选题
- 待生产任务
- 待复盘任务
- 平台异常提醒
## 5.3 找对标页
这个页面应借鉴 `飞瓜 / 千瓜` 的榜单和筛选思路,但它不只是“发现页”,还要承接对标账号的页内详情。
核心结构:
- 页内搜索
- 顶部平台切换
- 赛道筛选
- 榜单类型切换
- 排序切换
- 列表区
- 页内详情区或展开态
- 快速加入项目 / 绑定 Agent
补充要求:
- 全局搜索保留,但找对标页必须有页内搜索
- 页内搜索支持账号名、主页链接、作品链接、关键词
- “变现方式”不应只保留单一选项,至少支持不限、知识付费、广告合作、带货转化、私域咨询
## 5.4 跟踪账号页
这是一个高价值的持续运营页面,必须进入一级导航。
核心结构:
- 跟踪账号列表
- 最近更新时间
- 关联 Agent
- 更新日报
- 借鉴点标注
- 一键加入学习集 / Playbook / 生产中心作品区
逻辑要求:
- 跟踪账号由用户手动添加
- 系统自动监控更新
- 日报按“上次打开后”汇总,而不是死板按自然日切分
- 如果用户多天未登录,则进入平台后看到的是多天汇总日报
## 5.5 找对标页内详情态
对标账号的详情不要再拆成独立一级页面,而应在 `找对标` 页面里用页内展开、右侧详情区或抽屉承接。
建议包含:
- 总览
- 高分作品
- 账号画像
- 内容打法
- 相似账号
- 已学习 Agent
## 5.6 我的项目
“我的项目”是新的主入口,建议展示:
- 项目类型
- 绑定状态
- 已创建 Agent
- 调研状态
- 导入状态
- 生产进度
- 复盘状态
项目详情里要能直接进入 Agent 创建和 Agent 管理。
## 5.7 自运营账号工作区
比参考账号多两块:
- 生产计划
- 发布复盘
## 5.8 生产中心里的作品与成片
作品不再单独拆成一级页,而是并入生产中心里的“作品与成片”区域。
这个区域必须支持:
- 平台筛选
- 类型筛选
- 时间筛选
- AI 分数排序
- 互动热度排序
- 商业价值排序
- 可复刻排序
每条内容下面必须同时展示:
- 基础数据
- AI 摘要
- 可借鉴点
- 风险点
- 一键加入 Playbook / 选题池 / Agent 学习集
## 5.9 Playbook 页
这是 StoryForge 未来的核心资产层。
Playbook 不能只是文本。
应结构化为:
- 适用平台
- 适用赛道
- 适用人群
- 钩子模板
- 结构模板
- 表达模板
- 商业承接方式
- 不适用场景
## 5.10 Agent 工作台
Agent 页面不要做成技术配置页。
应分为:
- 学习源
- 能力标签
- 当前任务
- 输出风格
- 产出记录
- 账号类型
- 变现方式
- 目标平台
- 默认主大模型
- 可选对比模型
高级 Prompt 和模型切换才进入高级设置。
## 5.11 生产中心
生产中心统一承接所有内容生产,不要再拆成分散入口。
主分流:
- 文案
- 封面生成
- 实拍剪辑
- AI 视频
同时要内置“作品与成片”区域,让用户在生产页面里直接查看:
- 当前在产内容
- 已沉淀的高分内容
- 待审核成片
- 已发布后待复盘内容
## 5.12 发布与复盘
这个模块是现在最缺的。
建议结构:
- 待发布
- 已发布
- 7 日复盘
- 30 日复盘
- 继续做 / 停止做 / 升级做
## 6. 产品规则补充
### 6.1 参考作品和主页导入
导入参考作品或主页时,必须支持两种方式:
- 手动绑定到某个 Agent
- 系统自动关联到推荐 Agent
无论哪种方式,后续的导入分析都由 Agent 负责,不再依赖用户手工整理。
### 6.2 跟踪账号与日报
跟踪账号是长期行为,不是一次性导入。
规则建议:
- 用户手动把账号加入跟踪列表
- 系统监控是否有新增作品
- 新增作品按“上次打开后”自动汇总
- 由用户创建的 Agent 分析借鉴点
- 用户打开平台后优先看到这组日报
- 高价值更新可一键送入学习集 / Playbook / 生产中心作品区
### 6.3 API key 管理
API key 统一后台托管,用户不直接管理密钥。
产品侧只展示:
- 当前可用模型
- 模型能力说明
- 额度消耗情况
- 是否支持对比模型
### 6.4 积分 / 额度体系
新增积分 / 额度体系,先按三类额度表达:
- 文案额度
- 封面额度
- 视频额度
额度用于控制生成、渲染和调用成本,不要求用户感知底层 API key。
## 7. 对当前 StoryForge 的直接调整建议
### 7.1 产品抽象调整
从:
- Workspace
- Job
- Pipeline
改成:
- 项目
- Agent
- 账号
- 作品
- Playbook
- 生产
- 复盘
### 7.2 Douyin Workbench 调整
当前 Douyin Workbench 是一个阶段性工具页。
下一步要升级成通用的 `Platform Account Workspace`
也就是:
- 不再只服务抖音
- 抖音先做出来,但模型上必须对齐未来多平台
### 7.3 Agent 展示方式调整
Agent 必须保留,并成为项目执行主中枢,但不应替代项目作为一级入口。
一级主视角应该是:
- 项目
- Agent
- 账号
- 作品
- Playbook
- 生产
- 复盘
### 7.4 API Key 管理调整
这一项直接沿用 6.3 的规则,产品落地时只需要把“可用模型、能力说明、额度消耗、对比模型支持情况”放到前台,不把密钥暴露给用户。
### 7.5 额度体系调整
这一项直接沿用 6.4 的规则,产品层面只暴露三类额度:
- 文案额度
- 封面额度
- 视频额度
额度用于控制生成、渲染和调用成本,不要求用户感知底层 API key。
## 8. 当前优先级建议
### P0
- 定义新的项目对象模型
- 定义多平台账号模型
- 重做 Web 信息架构
- 把“项目 -> Agent -> 调研 -> 导入分析 -> 生产 -> 复盘”的闭环做清楚
- 打通 API key 后台托管
- 打通文案 / 封面 / 视频三类额度
### P1
- 打通 Playbook
- 打通发布与复盘
- 把 Douyin Workbench 升级成多平台工作区框架
- 打通参考作品 / 主页导入时的手动绑定与自动关联 Agent
- 打通 Agent 的多平台市场调研
### P2
- 团队协作
- 审批流
- 批量投放与品牌协作
## 9. 最终一句话
StoryForge 的下一阶段不应该再做成“AI 工具后台”。
它应该做成:
**一个以项目为入口、由 Agent 驱动、覆盖多平台调研、导入分析、内容生产和复盘的新媒体运营中台。**

View File

@@ -0,0 +1,68 @@
# 新媒体运营平台 UI 参考包
这份参考包只保留更贴近 `StoryForge` 业务逻辑的页面,不再以通用 AI 后台为主。
目标场景:
- 发现各平台精品账号
- 分析账号和作品
- 沉淀可学习的内容方法论
- 创建 Agent 学习这些方法论
- 反推到自己的选题、生产、发布与复盘
预览入口:
- [图片墙预览](./index.html)
## 当前收录
### 千瓜数据
来源:
- `https://www.qian-gua.com/rank/fans/1/7/20250112/0.html`
- `https://www.qian-gua.com/Home/AllPrice`
适合借鉴:
- 小红书达人榜单页
- 多维筛选条件
- 榜单 -> 详情 -> 收藏/监控 的路径
- 平台能力地图
文件:
- [01-qiangua-rank-page.png](./raw/01-qiangua-rank-page.png)
- [02-qiangua-plan-and-modules.png](./raw/02-qiangua-plan-and-modules.png)
### 飞瓜数据
来源:
- `https://dy.feigua.cn/help/detail/9/447.html`
适合借鉴:
- 抖音运营后台一级导航
- 账号发现与涨粉榜
- 数据监测工作台
- 品牌投放 / 竞品投放
文件:
- [03-feigua-menu-structure.png](./raw/03-feigua-menu-structure.png)
- [04-feigua-account-discovery.png](./raw/04-feigua-account-discovery.png)
- [05-feigua-monitoring-workbench.png](./raw/05-feigua-monitoring-workbench.png)
- [06-feigua-brand-delivery.png](./raw/06-feigua-brand-delivery.png)
## 我对这批参考的判断
最适合 StoryForge 的,不是照搬它们某一家的界面,而是组合借法:
1.`飞瓜数据` 借后台主壳与一级导航分组
2.`千瓜数据` 借榜单、达人筛选、监测和内容资产视角
3. StoryForge 自己再把平台差异抽象成统一对象:
- 平台账号
- 参考账号
- 作品
- 账号洞察
- 内容打法
- Agent
- 生产任务
- 发布复盘
## 不建议直接照搬的点
- 不要照搬纯投放平台风格,那会过度偏品牌投放
- 不要把账号分析页做成单纯长报告
- 不要把 Agent 放在用户主视角,创作者更关心“账号、作品、选题、生产、复盘”

View File

@@ -0,0 +1,136 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>StoryForge 新媒体运营参考 UI</title>
<style>
:root {
--bg: #f4f8fd;
--panel: #fff;
--line: #dbe7f2;
--text: #152131;
--muted: #62758d;
--blue: #8fc2ff;
--blue-deep: #3f7fe7;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
color: var(--text);
background: linear-gradient(180deg, #f7fbff 0%, #eef4fb 100%);
}
.wrap { max-width: 1440px; margin: 0 auto; padding: 28px 22px 56px; }
.hero {
background: rgba(255,255,255,.86);
border: 1px solid rgba(143,194,255,.35);
border-radius: 24px;
box-shadow: 0 22px 48px rgba(80, 113, 145, .12);
padding: 28px;
}
h1 { margin: 0 0 10px; font-size: 30px; }
.hero p { margin: 0; line-height: 1.8; color: var(--muted); max-width: 980px; }
.hero ul { margin: 12px 0 0; padding-left: 18px; color: var(--muted); line-height: 1.9; }
.section { margin-top: 28px; }
.section h2 { margin: 0 0 12px; font-size: 22px; }
.section p { margin: 0 0 16px; color: var(--muted); }
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; }
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 18px;
overflow: hidden;
box-shadow: 0 10px 24px rgba(44, 72, 102, .08);
}
.card img { width: 100%; display: block; background: #eff5fc; }
.meta { padding: 16px 18px 18px; }
.meta h3 { margin: 0 0 8px; font-size: 18px; }
.meta .source { margin: 0 0 8px; color: var(--blue-deep); font-size: 13px; }
.meta .use { margin: 0; color: var(--muted); line-height: 1.8; font-size: 14px; }
@media (max-width: 960px) {
.grid { grid-template-columns: 1fr; }
.wrap { padding: 18px 14px 40px; }
h1 { font-size: 24px; }
}
</style>
</head>
<body>
<div class="wrap">
<div class="hero">
<h1>StoryForge 新媒体运营参考 UI</h1>
<p>
这批参考不是通用 AI 后台,而是更贴近“抖音 / 小红书 / B站 / YouTube 多平台账号运营”的产品形态。
重点观察的是:账号发现、精品账号分析、内容监控、竞品跟踪、品牌投放、内容复盘。
</p>
<ul>
<li>飞瓜:适合借抖音后台主壳、一级导航、监控与投放模块</li>
<li>千瓜:适合借小红书榜单、达人筛选、竞品监测与内容资产视角</li>
<li>StoryForge 自己再抽象成统一的跨平台工作区,而不是按平台裂成四套后台</li>
</ul>
</div>
<div class="section">
<h2>千瓜数据</h2>
<p>更适合作为“小红书方向的榜单发现、达人筛选、行业观察、内容洞察”的参考。</p>
<div class="grid">
<div class="card">
<img src="./raw/01-qiangua-rank-page.png" alt="千瓜达人榜单页" />
<div class="meta">
<h3>达人榜单页</h3>
<p class="source">来源qian-gua.com</p>
<p class="use">适合借榜单页的筛选区、排行表格、达人卡摘要信息。可转译为 StoryForge 的“精品账号发现”。</p>
</div>
</div>
<div class="card">
<img src="./raw/02-qiangua-plan-and-modules.png" alt="千瓜模块与能力地图" />
<div class="meta">
<h3>模块与能力地图</h3>
<p class="source">来源qian-gua.com</p>
<p class="use">这张不是最终界面参考,而是产品能力地图参考,适合帮助我们定义菜单层级和模块边界。</p>
</div>
</div>
</div>
</div>
<div class="section">
<h2>飞瓜数据</h2>
<p>更适合作为“抖音方向的后台主壳、账号发现、监控工作台、品牌投放”的参考。</p>
<div class="grid">
<div class="card">
<img src="./raw/03-feigua-menu-structure.png" alt="飞瓜菜单结构图" />
<div class="meta">
<h3>一级菜单结构</h3>
<p class="source">来源dy.feigua.cn/help</p>
<p class="use">适合借一级导航分组方式。StoryForge 可转译成运营总台、账号发现、内容库、Agent、生产、发布复盘、设置。</p>
</div>
</div>
<div class="card">
<img src="./raw/04-feigua-account-discovery.png" alt="飞瓜账号发现页" />
<div class="meta">
<h3>账号发现页</h3>
<p class="source">来源dy.feigua.cn/help</p>
<p class="use">适合借涨粉榜、行业筛选、账号列表和“详情”入口,用于 StoryForge 的精品账号发现和对标账号收录。</p>
</div>
</div>
<div class="card">
<img src="./raw/05-feigua-monitoring-workbench.png" alt="飞瓜监测工作台" />
<div class="meta">
<h3>数据监测工作台</h3>
<p class="source">来源dy.feigua.cn/help</p>
<p class="use">适合借监控任务页和持续追踪视角,可转译为 StoryForge 的“账号跟踪 / 作品跟踪 / 竞品跟踪”。</p>
</div>
</div>
<div class="card">
<img src="./raw/06-feigua-brand-delivery.png" alt="飞瓜品牌投放页" />
<div class="meta">
<h3>品牌投放与竞品页</h3>
<p class="source">来源dy.feigua.cn/help</p>
<p class="use">适合借品牌搜索、筛选和表格摘要区。StoryForge 可转译为“品牌案例库 / 商业化机会 / 竞品投放观察”。</p>
</div>
</div>
</div>
</div>
</div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 937 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

View File

@@ -0,0 +1,30 @@
# StoryForge Mobile V4 HTML Prototype
这是一套只做界面、不接真实功能的移动端高保真原型,用来配合当前 `Web V4` 的产品逻辑评审。
## 入口
- 预览文件:`index.html`
## 页面结构
1. `登录与工作区`
2. `总览`
3. `找对标`
4. `跟踪日报`
5. `Agent`
6. `生产中心`
7. `我的`
## 设计口径
- 产品定位:多平台新媒体运营助手
- 主题色:淡蓝、白、黑、灰
- 主对象项目、对标、Agent、生产、复盘
- 导航方式:底部 5 栏,适合 Android / iOS 通用使用
## 说明
- 这是静态 HTML 原型,不依赖本地服务
- 命名和业务逻辑与 `Web V4` 保持一致
- 当前重点是验证信息层级、导航、操作入口和视觉节奏

View File

@@ -0,0 +1,785 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>StoryForge Mobile V4 Prototype</title>
<style>
:root {
--bg: #edf6ff;
--bg-soft: #f7fbff;
--panel: #ffffff;
--panel-soft: #f5f9ff;
--line: #dbe7f3;
--line-strong: #cddded;
--text: #152332;
--muted: #64788e;
--blue-50: #f3f8ff;
--blue-100: #e6f1ff;
--blue-500: #70a8ff;
--blue-600: #4d8ff0;
--blue-700: #356fd0;
--green: #23a873;
--orange: #f2a64a;
--red: #df6e6e;
--shadow: 0 22px 56px rgba(21, 35, 50, 0.1);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(113, 171, 255, 0.22), transparent 26%),
linear-gradient(180deg, #f8fbff 0%, var(--bg) 100%);
}
main {
max-width: 1900px;
margin: 0 auto;
padding: 28px;
}
.hero {
margin-bottom: 20px;
}
.hero h1 {
margin: 0 0 8px;
font-size: 34px;
}
.hero p {
margin: 0;
max-width: 920px;
color: var(--muted);
line-height: 1.6;
}
.board {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 24px;
}
.meta-card {
background: rgba(255,255,255,0.82);
border: 1px solid var(--line);
border-radius: 26px;
padding: 18px;
box-shadow: var(--shadow);
}
.meta-card h2 {
margin: 0 0 6px;
font-size: 20px;
}
.meta-card p {
margin: 0 0 16px;
color: var(--muted);
font-size: 13px;
line-height: 1.5;
}
.phone {
width: 100%;
max-width: 430px;
aspect-ratio: 430 / 932;
margin: 0 auto;
background: linear-gradient(180deg, #ffffff 0%, #f7fbff 100%);
border: 1px solid var(--line-strong);
border-radius: 34px;
box-shadow: 0 26px 60px rgba(21, 35, 50, 0.14);
overflow: hidden;
position: relative;
}
.status {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 20px 10px;
font-size: 13px;
font-weight: 600;
color: var(--muted);
}
.screen {
display: flex;
flex-direction: column;
height: calc(100% - 40px);
padding: 0 16px 16px;
gap: 12px;
}
.screen.login {
justify-content: space-between;
padding-top: 10px;
}
.brand-box,
.card,
.sheet {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 24px;
box-shadow: 0 10px 26px rgba(21,35,50,0.06);
}
.brand-box {
padding: 24px;
background: linear-gradient(145deg, #eef6ff 0%, #ffffff 78%);
}
.logo {
width: 58px;
height: 58px;
border-radius: 18px;
display: grid;
place-items: center;
background: linear-gradient(145deg, var(--blue-500), var(--blue-600));
color: #fff;
font-weight: 800;
font-size: 24px;
margin-bottom: 16px;
}
.brand-box h3,
.card h3,
.sheet h3 {
margin: 0 0 6px;
font-size: 18px;
}
.sub {
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.form {
display: grid;
gap: 12px;
}
.field {
padding: 14px 16px;
border-radius: 18px;
border: 1px solid var(--line);
background: #fff;
}
.field label {
display: block;
font-size: 11px;
color: var(--muted);
margin-bottom: 6px;
}
.field div {
font-size: 15px;
font-weight: 600;
}
.actions,
.chips,
.stats,
.tabs,
.kv {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 16px;
padding: 12px 14px;
font-size: 13px;
font-weight: 700;
border: 1px solid var(--line);
background: #fff;
color: var(--text);
}
.btn.primary {
background: linear-gradient(180deg, var(--blue-500), var(--blue-600));
color: #fff;
border-color: rgba(77,143,240,0.18);
}
.btn.ghost {
background: var(--blue-50);
color: var(--blue-700);
border-color: rgba(77,143,240,0.16);
}
.chip,
.pill {
display: inline-flex;
align-items: center;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid var(--line);
background: var(--blue-50);
color: var(--muted);
font-size: 11px;
line-height: 1;
}
.chip.active,
.pill.active {
background: var(--blue-100);
color: var(--blue-700);
border-color: rgba(77,143,240,0.16);
font-weight: 700;
}
.metric {
flex: 1 1 0;
min-width: 92px;
padding: 12px;
border-radius: 18px;
background: linear-gradient(180deg, #ffffff 0%, #f4f9ff 100%);
border: 1px solid var(--line);
}
.metric small {
display: block;
color: var(--muted);
font-size: 11px;
margin-bottom: 6px;
}
.metric strong {
font-size: 18px;
}
.card {
padding: 16px;
display: grid;
gap: 10px;
}
.card.tight {
gap: 8px;
}
.stack {
display: grid;
gap: 10px;
}
.row {
display: flex;
gap: 10px;
align-items: start;
}
.avatar {
width: 42px;
height: 42px;
border-radius: 14px;
background: linear-gradient(145deg, #c9e0ff, #92bcff);
display: grid;
place-items: center;
color: #fff;
font-weight: 800;
flex: 0 0 auto;
}
.title {
font-size: 16px;
font-weight: 700;
line-height: 1.35;
}
.caption {
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.progress {
height: 8px;
border-radius: 999px;
background: #edf3fa;
overflow: hidden;
}
.progress > span {
display: block;
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #8cc2ff, #4f8fee);
}
.list-item {
padding: 14px;
border-radius: 20px;
border: 1px solid var(--line);
background: linear-gradient(180deg, #fff 0%, #f8fbff 100%);
display: grid;
gap: 8px;
}
.bottom-nav {
margin-top: auto;
padding: 10px;
border-radius: 24px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.96);
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
box-shadow: 0 12px 30px rgba(21,35,50,0.08);
}
.nav-tab {
text-align: center;
padding: 10px 0;
border-radius: 18px;
font-size: 11px;
color: var(--muted);
font-weight: 700;
}
.nav-tab.active {
background: linear-gradient(180deg, var(--blue-500), var(--blue-600));
color: #fff;
}
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.sheet {
padding: 14px;
background: linear-gradient(180deg, #f7fbff 0%, #ffffff 100%);
}
@media (max-width: 1480px) {
.board { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 980px) {
main { padding: 18px; }
.board { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main>
<section class="hero">
<h1>StoryForge Mobile V4</h1>
<p>这版移动端不照搬 Web而是保留高频动作。主逻辑仍然是“我的项目 -> 找对标 -> 跟踪日报 -> Agent -> 生产”,把重配置留在 Web把快决策、快查看、快推进留给手机。</p>
</section>
<section class="board">
<article class="meta-card">
<h2>01 登录与工作区</h2>
<p>先进入工作区再同步项目、Agent 和今日任务。</p>
<div class="phone">
<div class="status"><span>9:41</span><span>5G 100%</span></div>
<div class="screen login">
<div class="brand-box">
<div class="logo">SF</div>
<h3>StoryForge</h3>
<div class="sub">创作者移动工作台</div>
</div>
<div class="form">
<div class="field">
<label>工作区</label>
<div>星流内容组</div>
</div>
<div class="field">
<label>手机号</label>
<div style="color:#9aa9b8;font-weight:500;">请输入登录手机号</div>
</div>
<div class="field">
<label>密码</label>
<div>••••••••</div>
</div>
<div class="chips">
<span class="chip active">短信登录</span>
<span class="chip">Token</span>
<span class="chip">高级登录</span>
</div>
<div class="actions">
<span class="btn primary" style="width:100%;">进入工作台</span>
</div>
</div>
<div class="sub">登录后自动同步你的项目、Agent、生产队列和跟踪日报。</div>
</div>
</div>
</article>
<article class="meta-card">
<h2>02 总览</h2>
<p>先看今天该推进什么,再跳转到对应动作。</p>
<div class="phone">
<div class="status"><span>9:41</span><span>Wi-Fi 93%</span></div>
<div class="screen">
<div class="brand-box">
<div class="title">早上好,林闻</div>
<div class="caption">星流内容组 · 今天有 3 个动作值得先做</div>
<div class="stats" style="margin-top:12px;">
<div class="metric"><small>项目</small><strong>5</strong></div>
<div class="metric"><small>更新</small><strong>12</strong></div>
<div class="metric"><small>待调研</small><strong>4</strong></div>
</div>
</div>
<div class="card">
<div class="section-head">
<h3>今日重点</h3>
<span class="pill active">4 项</span>
</div>
<div class="list-item">
<div class="title">先为“副业增长实验室”建项目</div>
<div class="caption">先开项目,再决定要不要绑定账号。</div>
</div>
<div class="list-item">
<div class="title">补齐“教育切片助手”的平台和变现</div>
<div class="caption">补完后再跑首轮调研。</div>
</div>
</div>
<div class="split">
<div class="card tight">
<h3>高分提醒</h3>
<div class="caption">《副业失败的 3 个坑》适合转系列。</div>
</div>
<div class="card tight">
<h3>跟踪日报</h3>
<div class="caption">5 天内 7 条更新值得学。</div>
</div>
</div>
<div class="card">
<h3>本周进度</h3>
<div class="progress"><span style="width:68%;"></span></div>
<div class="caption">已完成 8 / 12 个内容动作</div>
<div class="chips">
<span class="chip active">同步账号</span>
<span class="chip">找对标</span>
<span class="chip">去生产</span>
<span class="chip">看复盘</span>
</div>
</div>
<div class="bottom-nav">
<div class="nav-tab active">总览</div>
<div class="nav-tab">对标</div>
<div class="nav-tab">Agent</div>
<div class="nav-tab">生产</div>
<div class="nav-tab">我的</div>
</div>
</div>
</div>
</article>
<article class="meta-card">
<h2>03 找对标</h2>
<p>在手机上做快速筛选、快速收藏、快速导入。</p>
<div class="phone">
<div class="status"><span>9:41</span><span>5G 91%</span></div>
<div class="screen">
<div class="card">
<div class="title">找对标</div>
<div class="caption">搜账号、主页链接、作品链接。</div>
<div class="field" style="padding:12px 14px;">
<div style="font-size:13px;color:#91a2b3;font-weight:500;">搜账号、主页链接、作品链接</div>
</div>
<div class="chips">
<span class="chip active">抖音</span>
<span class="chip">小红书</span>
<span class="chip">B站</span>
<span class="chip">YouTube</span>
</div>
<div class="chips">
<span class="chip active">涨粉</span>
<span class="chip">互动</span>
<span class="chip">商业价值</span>
</div>
</div>
<div class="list-item">
<div class="row">
<div class="avatar"></div>
<div>
<div class="title">阿元创业手记</div>
<div class="caption">抖音 · 创业成长 · AI 可学习度 93</div>
</div>
</div>
<div class="chips">
<span class="chip active">查看</span>
<span class="chip">导入</span>
<span class="chip">绑 Agent</span>
</div>
</div>
<div class="sheet">
<div class="section-head">
<h3>当前选中对标</h3>
<span class="pill active">阿元创业手记</span>
</div>
<div class="split">
<div class="metric"><small>可学习度</small><strong>93</strong></div>
<div class="metric"><small>商业价值</small><strong>88</strong></div>
</div>
<div class="stack" style="margin-top:10px;">
<div class="caption">画像:反常识切入、案例推进强、结尾动作明确。</div>
<div class="caption">高分内容:《副业失败的 3 个坑》适合提炼成系列。</div>
</div>
<div class="actions" style="margin-top:12px;">
<span class="btn ghost" style="flex:1 1 0;">导入项目</span>
<span class="btn primary" style="flex:1 1 0;">创建 Agent</span>
</div>
</div>
<div class="bottom-nav">
<div class="nav-tab">总览</div>
<div class="nav-tab active">对标</div>
<div class="nav-tab">Agent</div>
<div class="nav-tab">生产</div>
<div class="nav-tab">我的</div>
</div>
</div>
</div>
</article>
<article class="meta-card">
<h2>04 跟踪日报</h2>
<p>手机端优先看“上次打开后”的更新,不必再翻后台。</p>
<div class="phone">
<div class="status"><span>9:41</span><span>5G 89%</span></div>
<div class="screen">
<div class="card">
<div class="section-head">
<h3>跟踪日报</h3>
<span class="pill active">5 天汇总</span>
</div>
<div class="caption">自上次打开后,共有 5 个账号更新Agent 标了 7 条借鉴点。</div>
<div class="chips">
<span class="chip active">刷新</span>
<span class="chip">看全部</span>
<span class="chip">新增跟踪</span>
</div>
</div>
<div class="list-item">
<div class="title">秋芝2046 · 新增 3 条作品</div>
<div class="caption">教育切片助手判断:其中 2 条适合转 30 秒口播。</div>
<div class="chips">
<span class="chip active">有借鉴点</span>
<span class="chip">入学习集</span>
</div>
</div>
<div class="list-item">
<div class="title">晨风老师 · 新增 2 条图文</div>
<div class="caption">更适合补小红书搜索承接模板。</div>
<div class="chips">
<span class="chip active">适合图文线</span>
<span class="chip">加 Playbook</span>
</div>
</div>
<div class="card tight">
<h3>今日建议</h3>
<div class="caption">先把 3 条高价值更新送入 Agent 学习,再决定是否转生产。</div>
</div>
<div class="bottom-nav">
<div class="nav-tab active">总览</div>
<div class="nav-tab">对标</div>
<div class="nav-tab">Agent</div>
<div class="nav-tab">生产</div>
<div class="nav-tab">我的</div>
</div>
</div>
</div>
</article>
<article class="meta-card">
<h2>05 Agent</h2>
<p>手机上更适合快速建 Agent、看学习源、看首轮调研结果。</p>
<div class="phone">
<div class="status"><span>9:41</span><span>Wi-Fi 90%</span></div>
<div class="screen">
<div class="card">
<div class="section-head">
<h3>Agent</h3>
<span class="pill active">待调研 4</span>
</div>
<div class="caption">先定项目、平台、变现和主模型,再跑首轮调研。</div>
<div class="chips">
<span class="chip active">抖音</span>
<span class="chip active">小红书</span>
<span class="chip">视频号</span>
</div>
<div class="chips">
<span class="chip active">知识付费</span>
<span class="chip">广告合作</span>
<span class="chip">私域咨询</span>
</div>
<div class="actions">
<span class="btn ghost" style="flex:1 1 0;">跑调研</span>
<span class="btn primary" style="flex:1 1 0;">创建 Agent</span>
</div>
</div>
<div class="list-item">
<div class="title">选题助手 · 教育切片</div>
<div class="caption">学习源:高信任图文 + 强观点短视频</div>
<div class="chips">
<span class="chip active">主模型:通义</span>
<span class="chip">已调研</span>
<span class="chip">最近产出 6 条</span>
</div>
</div>
<div class="list-item">
<div class="title">导入分析 Agent</div>
<div class="caption">负责解析主页、单条作品和本地视频,自动给出绑定建议。</div>
<div class="chips">
<span class="chip active">自动归类</span>
<span class="chip">支持复核</span>
</div>
</div>
<div class="bottom-nav">
<div class="nav-tab">总览</div>
<div class="nav-tab">对标</div>
<div class="nav-tab active">Agent</div>
<div class="nav-tab">生产</div>
<div class="nav-tab">我的</div>
</div>
</div>
</div>
</article>
<article class="meta-card">
<h2>06 生产中心</h2>
<p>移动端重点看队列、看卡点、看成片,不做重配置。</p>
<div class="phone">
<div class="status"><span>9:41</span><span>5G 87%</span></div>
<div class="screen">
<div class="card">
<div class="section-head">
<h3>生产中心</h3>
<span class="pill active">在产 6</span>
</div>
<div class="chips">
<span class="chip active">文案</span>
<span class="chip">封面</span>
<span class="chip">实拍</span>
<span class="chip">AI 视频</span>
</div>
</div>
<div class="list-item">
<div class="title">《副业避坑》封面生成</div>
<div class="caption">阿里 / 火山 / 通用图像 · 当前卡在选图</div>
<div class="progress"><span style="width:72%;"></span></div>
<div class="chips">
<span class="chip active">补封面</span>
<span class="chip">看样片</span>
</div>
</div>
<div class="list-item">
<div class="title">作品与成片</div>
<div class="caption">从生产结果反看当前最值得继续推进的内容。</div>
<div class="chips">
<span class="chip active">高分</span>
<span class="chip">最新</span>
<span class="chip">看成片</span>
</div>
</div>
<div class="sheet">
<h3>当前选中内容</h3>
<div class="caption">《副业失败的 3 个真实坑》适合继续生成脚本,封面先做 3 个模型对比。</div>
<div class="actions" style="margin-top:12px;">
<span class="btn ghost" style="flex:1 1 0;">补封面</span>
<span class="btn primary" style="flex:1 1 0;">继续做</span>
</div>
</div>
<div class="bottom-nav">
<div class="nav-tab">总览</div>
<div class="nav-tab">对标</div>
<div class="nav-tab">Agent</div>
<div class="nav-tab active">生产</div>
<div class="nav-tab">我的</div>
</div>
</div>
</div>
</article>
<article class="meta-card">
<h2>07 我的</h2>
<p>工作区、自动流程、额度和通知都放在这里。</p>
<div class="phone">
<div class="status"><span>9:41</span><span>5G 94%</span></div>
<div class="screen">
<div class="brand-box">
<div class="title">我的</div>
<div class="caption">星流内容组 · 林闻 · 杭州工作区</div>
<div class="chips" style="margin-top:12px;">
<span class="chip active">流程 7/8</span>
<span class="chip">额度正常</span>
<span class="chip">2 台设备在线</span>
</div>
</div>
<div class="list-item">
<div class="title">自动流程</div>
<div class="caption">账号同步、跟踪日报、失败补跑、异常提醒。</div>
</div>
<div class="list-item">
<div class="title">额度</div>
<div class="caption">文案 / 封面 / 视频三类额度分开看。</div>
</div>
<div class="list-item">
<div class="title">通知与同步</div>
<div class="caption">日报提醒、任务结果、设备同步都在这里。</div>
</div>
<div class="actions">
<span class="btn ghost" style="flex:1 1 0;">看额度</span>
<span class="btn primary" style="flex:1 1 0;">看流程</span>
</div>
<div class="bottom-nav">
<div class="nav-tab">总览</div>
<div class="nav-tab">对标</div>
<div class="nav-tab">Agent</div>
<div class="nav-tab">生产</div>
<div class="nav-tab active">我的</div>
</div>
</div>
</div>
</article>
</section>
</main>
</body>
</html>

View File

@@ -0,0 +1,43 @@
# StoryForge Web V4 HTML Prototype
这是一个只做界面、不接业务功能的静态 Web 原型。
入口:
- [index.html](./index.html)
包含页面:
- 项目总台
- 我的项目
- 找对标
- 跟踪账号
- 我的账号
- Agent
- 生产中心
- 发布与复盘
- 自动流程
- 额度
说明:
- 这版不依赖后端接口
- 主要用于确认新的产品逻辑、信息架构和布局方向
- 主业务流已调整为:项目 -> Agent -> 首轮调研 -> 导入绑定 -> 生产 -> 发布复盘
- 新增:跟踪账号 -> 自动汇总更新 -> Agent 标注借鉴点 -> 日报回看
- 导入分析以 Agent 为中心,不再以规则判断为主
- `找对标` 已经把列表和详情收在一个页面里,不再单独拆 `参考详情`
- `作品库` 已并入 `生产中心` 的“作品与成片”区域
- 模型凭证默认后台托管,用户界面只表达模型选择与额度消耗
- 主题色以淡蓝、白、黑、灰为主
新增预览:
- [05-intake.png](./previews/05-intake.png)
- [06-agent.png](./previews/06-agent.png)
- [07-production.png](./previews/07-production.png)
- [08-credits.png](./previews/08-credits.png)
- [09-dashboard-lite.png](./previews/09-dashboard-lite.png)
- [10-find-reference-lite.png](./previews/10-find-reference-lite.png)
- [11-reference-detail-lite.png](./previews/11-reference-detail-lite.png)
- [12-tracking-digest.png](./previews/12-tracking-digest.png)
- [13-find-reference-search.png](./previews/13-find-reference-search.png)
备份:
- [pre-simplify backup](/Users/kris/code/StoryForge-gitea/output/ui/backups/storyforge-web-v4-html-prototype-2026-03-22-pre-simplify-2026-03-22)

View File

@@ -0,0 +1,24 @@
const navButtons = document.querySelectorAll("[data-screen-target]");
const screens = document.querySelectorAll("[data-screen]");
function activateScreen(id) {
navButtons.forEach((button) => {
const active = button.dataset.screenTarget === id;
button.classList.toggle("is-active", active);
});
screens.forEach((screen) => {
screen.classList.toggle("is-active", screen.dataset.screen === id);
});
}
navButtons.forEach((button) => {
button.addEventListener("click", () => {
const next = button.dataset.screenTarget;
activateScreen(next);
window.location.hash = next;
});
});
const initial = window.location.hash.replace("#", "") || "dashboard";
activateScreen(initial);

View File

@@ -0,0 +1,865 @@
:root {
--bg: #f4f8fd;
--bg-soft: #eef4fb;
--panel: #ffffff;
--panel-soft: #f7fbff;
--line: #d9e5f2;
--line-strong: #c8d8ea;
--text: #182433;
--muted: #66788f;
--blue-50: #f3f8ff;
--blue-100: #e8f1ff;
--blue-200: #d9e8ff;
--blue-300: #c4ddff;
--blue-500: #6aa4ff;
--blue-600: #4f8fee;
--blue-700: #3977d8;
--green: #2db584;
--orange: #f29a38;
--red: #e46767;
--shadow: 0 18px 40px rgba(67, 93, 125, 0.12);
--shadow-soft: 0 10px 24px rgba(67, 93, 125, 0.08);
--radius-xl: 24px;
--radius-lg: 18px;
--radius-md: 14px;
--radius-sm: 10px;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
background:
radial-gradient(circle at top left, rgba(129, 180, 255, 0.18), transparent 28%),
linear-gradient(180deg, #f8fbff 0%, #eef4fb 100%);
color: var(--text);
}
a {
color: inherit;
text-decoration: none;
}
button,
input,
select {
font: inherit;
}
.app-shell {
display: grid;
grid-template-columns: 272px minmax(0, 1fr);
min-height: 100vh;
}
.sidebar {
background: rgba(255, 255, 255, 0.82);
border-right: 1px solid rgba(201, 220, 239, 0.75);
backdrop-filter: blur(14px);
padding: 22px 18px 18px;
position: sticky;
top: 0;
height: 100vh;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 10px 20px;
}
.brand-mark {
width: 42px;
height: 42px;
border-radius: 14px;
background: linear-gradient(145deg, #b9d7ff 0%, #6ea8ff 100%);
display: grid;
place-items: center;
color: white;
font-weight: 700;
letter-spacing: 0.04em;
}
.brand h1 {
margin: 0;
font-size: 18px;
}
.brand p {
margin: 4px 0 0;
font-size: 12px;
color: var(--muted);
}
.nav-group {
margin-top: 14px;
}
.nav-title {
padding: 0 10px 8px;
color: var(--muted);
font-size: 12px;
letter-spacing: 0.04em;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
border: none;
background: transparent;
border-radius: 14px;
padding: 11px 12px;
color: var(--text);
cursor: pointer;
text-align: left;
transition: 0.18s ease;
}
.nav-item:hover {
background: rgba(106, 164, 255, 0.08);
}
.nav-item.is-active {
background: linear-gradient(180deg, #edf5ff 0%, #e6f0ff 100%);
box-shadow: inset 0 0 0 1px rgba(106, 164, 255, 0.22);
color: var(--blue-700);
}
.nav-item .icon {
width: 28px;
height: 28px;
border-radius: 10px;
background: var(--blue-50);
display: grid;
place-items: center;
font-size: 14px;
}
.sidebar-foot {
margin-top: 22px;
padding: 14px;
border-radius: 18px;
background: linear-gradient(180deg, #f7fbff 0%, #eef5ff 100%);
border: 1px solid var(--line);
}
.sidebar-foot h3 {
margin: 0 0 8px;
font-size: 14px;
}
.sidebar-foot p {
margin: 0 0 10px;
color: var(--muted);
line-height: 1.55;
font-size: 12px;
}
.chip-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip {
padding: 6px 10px;
border-radius: 999px;
background: var(--blue-50);
border: 1px solid var(--line);
color: var(--muted);
font-size: 12px;
}
.chip.active {
background: var(--blue-100);
color: var(--blue-700);
border-color: rgba(79, 143, 238, 0.22);
}
.content {
padding: 18px 22px 26px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 16px 18px;
border-radius: 22px;
background: rgba(255, 255, 255, 0.82);
backdrop-filter: blur(12px);
border: 1px solid rgba(201, 220, 239, 0.75);
box-shadow: var(--shadow-soft);
}
.topbar-left,
.topbar-right {
display: flex;
align-items: center;
gap: 12px;
}
.workspace-switch,
.search,
.mini-card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 14px;
}
.workspace-switch {
padding: 10px 14px;
min-width: 190px;
}
.workspace-switch strong {
display: block;
font-size: 13px;
}
.workspace-switch span {
font-size: 12px;
color: var(--muted);
}
.search {
display: flex;
align-items: center;
gap: 10px;
min-width: 340px;
padding: 12px 14px;
color: var(--muted);
}
.search input {
border: none;
outline: none;
background: transparent;
width: 100%;
color: var(--text);
}
.top-pill {
padding: 8px 12px;
border-radius: 999px;
background: var(--blue-50);
color: var(--muted);
border: 1px solid var(--line);
font-size: 12px;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 12px;
background: linear-gradient(145deg, #bedcff 0%, #82b8ff 100%);
display: grid;
place-items: center;
font-size: 13px;
color: white;
font-weight: 700;
}
.screen {
display: none;
margin-top: 18px;
}
.screen.is-active {
display: block;
}
.screen-head {
display: flex;
align-items: end;
justify-content: space-between;
gap: 18px;
margin-bottom: 18px;
}
.screen-head h2 {
margin: 0 0 6px;
font-size: 28px;
}
.screen-head p {
margin: 0;
color: var(--muted);
font-size: 13px;
line-height: 1.45;
max-width: 560px;
}
.action-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn {
border: none;
border-radius: 12px;
padding: 10px 13px;
font-size: 13px;
font-weight: 600;
line-height: 1;
cursor: pointer;
transition: 0.18s ease;
}
.btn-primary {
background: linear-gradient(180deg, var(--blue-500) 0%, var(--blue-600) 100%);
color: white;
box-shadow: 0 8px 18px rgba(79, 143, 238, 0.22);
}
.btn-secondary {
background: white;
color: var(--text);
border: 1px solid var(--line);
}
.btn:hover {
transform: translateY(-1px);
}
.layout-grid {
display: grid;
gap: 18px;
}
.grid-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.grid-5 {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.grid-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.grid-main {
grid-template-columns: minmax(0, 1.45fr) minmax(0, 1fr);
}
.grid-split {
grid-template-columns: 280px minmax(0, 1fr) 310px;
}
.panel {
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(201, 220, 239, 0.9);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-soft);
overflow: hidden;
}
.panel.pad {
padding: 17px;
}
.panel h3,
.panel h4 {
margin: 0;
}
.panel-subtitle {
margin-top: 6px;
color: var(--muted);
font-size: 11px;
line-height: 1.4;
}
.stat-card {
padding: 18px;
border-radius: 20px;
background: linear-gradient(180deg, #fbfdff 0%, #f3f8ff 100%);
border: 1px solid rgba(201, 220, 239, 0.9);
box-shadow: var(--shadow);
}
.stat-card small {
color: var(--muted);
}
.stat-card strong {
display: block;
margin-top: 10px;
font-size: 28px;
}
.stat-foot {
margin-top: 10px;
display: flex;
align-items: center;
justify-content: space-between;
color: var(--muted);
font-size: 12px;
}
.positive { color: var(--green); }
.warn { color: var(--orange); }
.negative { color: var(--red); }
.list {
display: grid;
gap: 10px;
}
.task-item,
.entity-card,
.topic-card,
.review-card,
.queue-card {
border-radius: 18px;
border: 1px solid var(--line);
background: linear-gradient(180deg, #fff 0%, #f9fbff 100%);
}
.task-item,
.queue-card,
.review-card {
padding: 15px;
}
.task-item h4,
.entity-card h4,
.topic-card h4,
.queue-card h4,
.review-card h4 {
margin: 0 0 6px;
font-size: 15px;
}
.task-item p,
.entity-card p,
.topic-card p,
.queue-card p,
.review-card p {
margin: 0;
color: var(--muted);
line-height: 1.4;
font-size: 11px;
}
.task-meta,
.entity-meta,
.row-meta {
display: flex;
flex-wrap: wrap;
gap: 7px;
margin-top: 10px;
}
.tag {
padding: 5px 9px;
border-radius: 999px;
background: #f6f9fe;
border: 1px solid var(--line);
color: var(--muted);
font-size: 11px;
line-height: 1.1;
}
.row-meta .tag {
background: var(--blue-50);
border-color: rgba(106, 164, 255, 0.18);
color: var(--blue-700);
font-weight: 600;
}
.tag.blue {
background: var(--blue-100);
color: var(--blue-700);
}
.tag.green {
background: rgba(45, 181, 132, 0.1);
border-color: rgba(45, 181, 132, 0.18);
color: #1b8b61;
}
.tag.orange {
background: rgba(242, 154, 56, 0.1);
border-color: rgba(242, 154, 56, 0.18);
color: #b76d16;
}
.tag.red {
background: rgba(228, 103, 103, 0.1);
border-color: rgba(228, 103, 103, 0.18);
color: #b24c4c;
}
.two-col {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px;
}
.three-col {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.table-wrap {
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 920px;
}
th,
td {
padding: 12px 12px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
}
thead th {
background: #f8fbff;
color: var(--muted);
font-weight: 600;
font-size: 12px;
letter-spacing: 0.02em;
}
tbody tr:hover {
background: rgba(106, 164, 255, 0.055);
}
.entity-cell {
display: flex;
gap: 12px;
align-items: start;
}
.avatar-lg {
width: 46px;
height: 46px;
border-radius: 15px;
background: linear-gradient(145deg, #c9e2ff 0%, #8bbcff 100%);
display: grid;
place-items: center;
color: white;
font-weight: 700;
}
.cell-title {
font-weight: 600;
margin-bottom: 4px;
}
.cell-desc {
font-size: 12px;
color: var(--muted);
line-height: 1.5;
}
.kpi-inline {
display: flex;
flex-wrap: wrap;
gap: 14px;
color: var(--muted);
font-size: 12px;
}
.metric {
font-weight: 600;
color: var(--text);
}
.toolbar {
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 16px;
border-bottom: 1px solid var(--line);
background: linear-gradient(180deg, #fbfdff 0%, #f4f9ff 100%);
}
.toolbar-stack {
display: grid;
gap: 10px;
min-width: min(760px, 100%);
}
.search-inline {
min-width: 320px;
width: min(720px, 100%);
padding: 10px 12px;
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.filter {
min-width: 132px;
padding: 10px 11px;
border-radius: 12px;
border: 1px solid var(--line);
background: white;
color: var(--muted);
font-size: 12px;
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 14px;
margin-bottom: 12px;
}
.side-stack {
display: grid;
gap: 16px;
}
.insight-card {
padding: 15px;
border-radius: 18px;
border: 1px solid var(--line);
background: linear-gradient(180deg, #fff 0%, #f6faff 100%);
}
.insight-card h4 {
margin: 0 0 8px;
font-size: 15px;
}
.insight-card ul {
margin: 0;
padding-left: 18px;
color: var(--muted);
line-height: 1.5;
font-size: 11px;
}
.tab-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin: 16px 0 18px;
}
.tab {
padding: 8px 12px;
border-radius: 999px;
border: 1px solid var(--line);
background: #fff;
color: var(--muted);
font-size: 13px;
}
.tab.active {
background: var(--blue-100);
color: var(--blue-700);
border-color: rgba(79, 143, 238, 0.2);
}
.hero-card {
padding: 20px;
border-radius: 24px;
background: linear-gradient(145deg, rgba(212, 230, 255, 0.85) 0%, rgba(245, 250, 255, 0.96) 72%);
border: 1px solid rgba(180, 210, 248, 0.85);
box-shadow: var(--shadow-soft);
}
.hero-card h3 {
margin: 0 0 8px;
font-size: 18px;
}
.hero-card p {
margin: 0;
color: var(--muted);
line-height: 1.45;
font-size: 13px;
}
.mini-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 16px;
}
.mini-card {
padding: 14px;
}
.mini-card strong {
display: block;
margin-top: 8px;
font-size: 17px;
}
.playbook-list {
display: grid;
gap: 12px;
}
.playbook-item {
padding: 14px;
border-radius: 16px;
border: 1px solid var(--line);
background: linear-gradient(180deg, #fff 0%, #f8fbff 100%);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.8);
}
.playbook-item.active {
border-color: rgba(79, 143, 238, 0.24);
background: linear-gradient(180deg, #f8fbff 0%, #eef6ff 100%);
}
.timeline {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.step {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 999px;
background: #fff;
border: 1px solid var(--line);
color: var(--muted);
font-size: 12px;
}
.step.done {
color: #167657;
border-color: rgba(45, 181, 132, 0.18);
background: rgba(45, 181, 132, 0.08);
}
.step.current {
color: var(--blue-700);
border-color: rgba(79, 143, 238, 0.2);
background: var(--blue-100);
}
.bar-chart {
display: grid;
gap: 10px;
}
.bar-row {
display: grid;
grid-template-columns: 108px minmax(0, 1fr) 48px;
gap: 10px;
align-items: center;
font-size: 13px;
color: var(--muted);
}
.bar-track {
height: 10px;
border-radius: 999px;
background: #eef3f8;
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #93c3ff 0%, #5c95ef 100%);
}
.calendar {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 10px;
}
.day {
min-height: 118px;
border: 1px solid var(--line);
border-radius: 16px;
padding: 12px;
background: linear-gradient(180deg, #fff 0%, #f9fbff 100%);
}
.day strong {
display: block;
margin-bottom: 8px;
font-size: 13px;
}
.slot {
margin-top: 8px;
padding: 8px 10px;
border-radius: 12px;
background: var(--blue-50);
border: 1px solid rgba(106, 164, 255, 0.16);
font-size: 12px;
color: var(--text);
line-height: 1.5;
}
.footer-note {
margin-top: 18px;
color: var(--muted);
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;
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 659 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 577 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

View File

@@ -0,0 +1,95 @@
# StoryForge Web V4
这是 `StoryForge` 当前面向正式前端实现的 Web 承载目录。
## 入口
- 页面:`index.html`
- 样式:`assets/styles.css`
- 页面交互:`assets/app.js`
## 当前定位
- 这不是最终生产版,但已经不是纯静态原型
- 目录已经从 `output/ui/` 原型区独立出来,并接上了第一层真实业务接口
- 当前保留的核心页面结构:
- 项目总台
- 我的项目
- 找对标
- 跟踪账号
- 自动流程
- Agent
- 生产中心
- 发布与复盘
- 额度
## 当前已接入的真实能力
- 后端登录与会话保持
- 工作区信息与 `/v2/me`
- 项目总台 `/v2/me/dashboard`
- 项目创建 `/v2/projects`
- 内容源列表 `/v2/content-sources`
- 抖音对标账号 `/v2/douyin/accounts`
- 单账号工作台 `/v2/douyin/accounts/{id}/workspace`
- 单账号作品列表 `/v2/douyin/accounts/{id}/videos`
- 跟踪账号 `/v2/douyin/tracking/accounts`
- 跟踪日报 `/v2/douyin/tracking/digest`
- 发布复盘 `/v2/reviews`
- 集成健康 `/v2/integrations/health`
- 最近知识库文档 `/v2/knowledge-bases/{id}/documents`
## 当前已接入的真实动作
- 新建项目
- 导入主页并触发内容源同步
- 把当前对标账号直接导入到当前项目,并绑定 Agent 触发同步
- 导入作品链接并触发分析
- 导入文本素材并触发分析
- 上传本地视频并触发分析
- 创建 Agent
- 对当前 Douyin 对标账号重跑分析
- 批量分析高分作品
- 查找相似对标账号
- 从相似候选一键保存对标关系
- 把当前对标账号加入跟踪,并绑定 Agent
- 单账号立即同步跟踪对象
- 批量同步全部跟踪对象
- 日报手动标记已读,不再在刷新页面时自动吞掉未读摘要
- 按上次打开后生成跟踪日报与借鉴点摘要
- 查看任务详情、事件、子任务和 artifacts/result
- 从任务详情直接衔接 AI 视频 / 实拍剪辑 / 文案生成
- 在生产中心 / 发布与复盘常驻最近一次任务详情摘要
- 在 Web 中直接创建和编辑复盘
- 在页面里直接看到 `本机模型 / cutvideo / huobao / n8n / ASR` 的真实健康状态
- 依赖不可达时,自动拦住 AI 视频 / 实拍剪辑动作并展示原因
- 使用 Agent 生成文案
- 创建 AI 视频任务
- 创建实拍剪辑任务
## 本地预览
推荐直接在目录内起一个临时静态服务:
```bash
cd /Users/kris/code/StoryForge-gitea/web/storyforge-web-v4
python3 -m http.server 3918
```
然后打开:
- [http://127.0.0.1:3918/index.html](http://127.0.0.1:3918/index.html)
首次进入需要手动连接后端,默认地址是:
- `http://127.0.0.1:8081`
## 后续建议
- 继续补多平台真实接入,而不只是一套 Douyin 工作流
- 把对标导入后的 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

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because it is too large Load Diff