Compare commits
21 Commits
codex/n8n-
...
codex/stor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea6a855890 | ||
|
|
042188f954 | ||
|
|
c657db9b38 | ||
|
|
652f0c9f79 | ||
|
|
dab444a83c | ||
|
|
ed5bcaef84 | ||
|
|
7500d02730 | ||
|
|
37709d37b7 | ||
|
|
9ed5f24364 | ||
|
|
031ba04d4e | ||
|
|
32dea8e3a6 | ||
|
|
4106347b67 | ||
|
|
b75c9e275b | ||
|
|
540be80719 | ||
|
|
fe07a5f212 | ||
|
|
35c97ffe4d | ||
|
|
1851625a53 | ||
|
|
66db9e8687 | ||
|
|
98592168b7 | ||
|
|
e771919e4a | ||
|
|
6899ebba60 |
@@ -33,3 +33,7 @@ WEBHOOK_URL=http://127.0.0.1:5670/
|
||||
GENERIC_TIMEZONE=Asia/Shanghai
|
||||
TZ=Asia/Shanghai
|
||||
CLIPROXY_IMAGE=storyforge/cli-proxy-api:patched
|
||||
CLIPROXY_MANAGEMENT_SECRET=storyforge-local-management
|
||||
CLIPROXY_DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
# Optional but recommended for local model gateway recovery.
|
||||
# DASHSCOPE_API_KEY=
|
||||
|
||||
16
README.md
@@ -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
|
||||
@@ -99,6 +107,14 @@ cp .env.example .env
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
如果要让本机模型网关 `cli-proxy-api` 自动提供 `GLM-5`,建议在启动前确保本机环境里存在:
|
||||
|
||||
```bash
|
||||
export DASHSCOPE_API_KEY=your_dashscope_key
|
||||
```
|
||||
|
||||
或者把它写进本地 `.env`。`./scripts/start_business.sh` 会自动生成 `data/cliproxyapi/config.yaml` 并把 `glm-5 -> GLM-5` 映射到本机网关。
|
||||
|
||||
如果 `collector` 跑在 Docker 里,建议保留:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,96 @@ 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
|
||||
|
||||
|
||||
def local_model_public_base_url() -> str:
|
||||
if not LOCAL_OPENAI_BASE_URL:
|
||||
return ""
|
||||
parsed = urlparse(LOCAL_OPENAI_BASE_URL)
|
||||
scheme = parsed.scheme or "http"
|
||||
host = parsed.hostname or "127.0.0.1"
|
||||
if host in {"host.docker.internal", "localhost"}:
|
||||
host = "127.0.0.1"
|
||||
port = parsed.port
|
||||
root = f"{scheme}://{host}"
|
||||
if port:
|
||||
root = f"{root}:{port}"
|
||||
return root
|
||||
|
||||
|
||||
def fetch_local_model_catalog(timeout: float = 8.0) -> dict[str, Any]:
|
||||
detail = probe_http(LOCAL_OPENAI_BASE_URL, "/models", timeout=timeout)
|
||||
public_base_url = local_model_public_base_url()
|
||||
management_url = f"{public_base_url}/management.html" if public_base_url else ""
|
||||
payload = {
|
||||
"configured": detail.get("configured", False),
|
||||
"reachable": detail.get("reachable", False),
|
||||
"base_url": LOCAL_OPENAI_BASE_URL,
|
||||
"public_base_url": public_base_url,
|
||||
"management_url": management_url,
|
||||
"default_model": LOCAL_OPENAI_MODEL,
|
||||
"models": [],
|
||||
"status_code": detail.get("status_code", 0),
|
||||
"error": detail.get("error", ""),
|
||||
"url": detail.get("url", ""),
|
||||
}
|
||||
if not detail.get("configured") or not detail.get("reachable"):
|
||||
return payload
|
||||
try:
|
||||
response = httpx.get(urljoin(LOCAL_OPENAI_BASE_URL if LOCAL_OPENAI_BASE_URL.endswith("/") else f"{LOCAL_OPENAI_BASE_URL}/", "models"), timeout=timeout)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
payload["models"] = [
|
||||
{
|
||||
"id": item.get("id", ""),
|
||||
"owned_by": item.get("owned_by", ""),
|
||||
"created": item.get("created", 0),
|
||||
}
|
||||
for item in (data.get("data") or [])
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
except Exception as exc: # pragma: no cover - operational probe
|
||||
payload["reachable"] = False
|
||||
payload["error"] = str(exc)
|
||||
return payload
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup() -> None:
|
||||
db.init_schema()
|
||||
@@ -1374,6 +1532,50 @@ def healthz() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
@app.get("/v2/integrations/health")
|
||||
def integrations_health(account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]:
|
||||
_ = account
|
||||
cutvideo_bootstrap = probe_http(CUTVIDEO_BASE_URL, "/api/bootstrap", timeout=5.0)
|
||||
cutvideo_uploads = probe_http(CUTVIDEO_BASE_URL, "/api/uploads", timeout=5.0)
|
||||
cutvideo_supports_uploads = bool(
|
||||
cutvideo_uploads.get("configured")
|
||||
and cutvideo_uploads.get("reachable")
|
||||
and int(cutvideo_uploads.get("status_code") or 0) != 404
|
||||
)
|
||||
return {
|
||||
"local_model": {
|
||||
"base_url": LOCAL_OPENAI_BASE_URL,
|
||||
**probe_http(LOCAL_OPENAI_BASE_URL, "/models"),
|
||||
},
|
||||
"cutvideo": {
|
||||
"base_url": CUTVIDEO_BASE_URL,
|
||||
**cutvideo_bootstrap,
|
||||
"supports_uploads": cutvideo_supports_uploads,
|
||||
"upload_status_code": int(cutvideo_uploads.get("status_code") or 0),
|
||||
"upload_error": cutvideo_uploads.get("error", ""),
|
||||
"upload_url": cutvideo_uploads.get("url", ""),
|
||||
},
|
||||
"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),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.get("/v2/integrations/local-models")
|
||||
def integrations_local_models(account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]:
|
||||
_ = account
|
||||
return fetch_local_model_catalog()
|
||||
|
||||
|
||||
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 +1948,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 +2481,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:
|
||||
|
||||
@@ -65,6 +65,14 @@ services:
|
||||
image: ${CLIPROXY_IMAGE:-storyforge/cli-proxy-api:patched}
|
||||
container_name: storyforge-cliproxyapi
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- ./CLIProxyAPI
|
||||
- -config
|
||||
- /CLIProxyAPI/config.yaml
|
||||
volumes:
|
||||
- ./data/cliproxyapi/config.yaml:/CLIProxyAPI/config.yaml:ro
|
||||
- ./data/cliproxyapi/auths:/root/.cli-proxy-api
|
||||
- ./data/cliproxyapi/logs:/CLIProxyAPI/logs
|
||||
ports:
|
||||
- "8317:8317"
|
||||
- "8085:8085"
|
||||
|
||||
555
docs/PRODUCT_LOGIC_NEW_MEDIA_OPERATING_SYSTEM_2026-03-22.md
Normal 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 驱动、覆盖多平台调研、导入分析、内容生产和复盘的新媒体运营中台。**
|
||||
68
output/ui/new-media-ops-reference-2026-03-22/README.md
Normal 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 放在用户主视角,创作者更关心“账号、作品、选题、生产、复盘”
|
||||
136
output/ui/new-media-ops-reference-2026-03-22/index.html
Normal 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>
|
||||
|
After Width: | Height: | Size: 596 KiB |
|
After Width: | Height: | Size: 937 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 112 KiB |
@@ -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` 保持一致
|
||||
- 当前重点是验证信息层级、导航、操作入口和视觉节奏
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
1916
output/ui/storyforge-web-v4-html-prototype-2026-03-22/index.html
Normal file
|
After Width: | Height: | Size: 488 KiB |
|
After Width: | Height: | Size: 276 KiB |
|
After Width: | Height: | Size: 354 KiB |
|
After Width: | Height: | Size: 363 KiB |
|
After Width: | Height: | Size: 659 KiB |
|
After Width: | Height: | Size: 577 KiB |
|
After Width: | Height: | Size: 482 KiB |
|
After Width: | Height: | Size: 452 KiB |
|
After Width: | Height: | Size: 587 KiB |
|
After Width: | Height: | Size: 272 KiB |
|
After Width: | Height: | Size: 442 KiB |
|
After Width: | Height: | Size: 486 KiB |
|
After Width: | Height: | Size: 302 KiB |
73
scripts/render_cliproxy_config.sh
Executable file
@@ -0,0 +1,73 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
if [ -f "$ROOT/.env" ]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
. "$ROOT/.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
DATA_DIR="$ROOT/data/cliproxyapi"
|
||||
CONFIG_PATH="$DATA_DIR/config.yaml"
|
||||
mkdir -p "$DATA_DIR/auths" "$DATA_DIR/logs"
|
||||
|
||||
: "${CLIPROXY_MANAGEMENT_SECRET:=storyforge-local-management}"
|
||||
: "${CLIPROXY_DASHSCOPE_BASE_URL:=https://dashscope.aliyuncs.com/compatible-mode/v1}"
|
||||
|
||||
python3 - <<'PY' "$CONFIG_PATH" "$CLIPROXY_MANAGEMENT_SECRET" "$CLIPROXY_DASHSCOPE_BASE_URL"
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
config_path = Path(sys.argv[1])
|
||||
management_secret = sys.argv[2]
|
||||
base_url = sys.argv[3]
|
||||
dashscope_api_key = os.environ.get("DASHSCOPE_API_KEY", "").strip()
|
||||
|
||||
lines = [
|
||||
'host: ""',
|
||||
'port: 8317',
|
||||
'tls:',
|
||||
' enable: false',
|
||||
' cert: ""',
|
||||
' key: ""',
|
||||
'remote-management:',
|
||||
' allow-remote: false',
|
||||
f' secret-key: "{management_secret}"',
|
||||
' disable-control-panel: false',
|
||||
'auth-dir: "/root/.cli-proxy-api"',
|
||||
'debug: false',
|
||||
'logging-to-file: true',
|
||||
'logs-max-total-size-mb: 200',
|
||||
'usage-statistics-enabled: true',
|
||||
'request-retry: 2',
|
||||
]
|
||||
|
||||
if dashscope_api_key:
|
||||
lines.extend(
|
||||
[
|
||||
'openai-compatibility:',
|
||||
' - name: "dashscope"',
|
||||
f' base-url: "{base_url}"',
|
||||
' api-key-entries:',
|
||||
f' - api-key: "{dashscope_api_key}"',
|
||||
' models:',
|
||||
' - name: "glm-5"',
|
||||
' alias: "GLM-5"',
|
||||
' - name: "glm-5"',
|
||||
' alias: "glm-5"',
|
||||
' - name: "qwen3.5-plus"',
|
||||
' alias: "qwen3.5-plus"',
|
||||
]
|
||||
)
|
||||
|
||||
config_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
if dashscope_api_key:
|
||||
print(f"rendered cliproxy config with DashScope upstream -> {config_path}")
|
||||
else:
|
||||
print(f"rendered cliproxy config without upstream credentials -> {config_path}")
|
||||
PY
|
||||
@@ -5,7 +5,14 @@ ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
|
||||
COMPOSE_FILE="$ROOT/docker-compose.yml"
|
||||
|
||||
cd "$ROOT"
|
||||
docker compose -f "$COMPOSE_FILE" up -d --build collector n8n
|
||||
"$ROOT/scripts/render_cliproxy_config.sh"
|
||||
|
||||
OWNER="$(docker inspect storyforge-cliproxyapi --format '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}' 2>/dev/null || true)"
|
||||
if [ -n "$OWNER" ] && [ "$OWNER" != "$ROOT" ]; then
|
||||
docker rm -f storyforge-cliproxyapi >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
docker compose -f "$COMPOSE_FILE" up -d --build collector n8n cli-proxy-api
|
||||
|
||||
python3 - <<'PY'
|
||||
import time
|
||||
@@ -14,6 +21,7 @@ import urllib.request
|
||||
checks = [
|
||||
("collector", "http://127.0.0.1:8081/healthz"),
|
||||
("n8n", "http://127.0.0.1:5670/healthz"),
|
||||
("cli-proxy-api", "http://127.0.0.1:8317/v1/models"),
|
||||
]
|
||||
|
||||
deadline = time.time() + 45
|
||||
@@ -37,3 +45,4 @@ PY
|
||||
echo "business started"
|
||||
echo "collector: http://127.0.0.1:8081/healthz"
|
||||
echo "n8n: http://127.0.0.1:5670/healthz"
|
||||
echo "cli-proxy-api: http://127.0.0.1:8317/v1/models"
|
||||
|
||||
@@ -13,6 +13,7 @@ import urllib.request
|
||||
for name, url in [
|
||||
("collector", "http://127.0.0.1:8081/healthz"),
|
||||
("n8n", "http://127.0.0.1:5670/healthz"),
|
||||
("cli-proxy-api", "http://127.0.0.1:8317/v1/models"),
|
||||
]:
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=5) as resp:
|
||||
|
||||
95
web/storyforge-web-v4/README.md
Normal 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`
|
||||
- 抖音采集控制台仍作为独立工具存在,这里才是正式业务应用壳
|
||||
3309
web/storyforge-web-v4/assets/app.js
Normal file
11
web/storyforge-web-v4/assets/favicon.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="sfg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#dff4ff"/>
|
||||
<stop offset="100%" stop-color="#86cfff"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="4" y="4" width="56" height="56" rx="16" fill="url(#sfg)"/>
|
||||
<path d="M19 23.5c0-2.5 2-4.5 4.5-4.5h17c1.8 0 3.2 1.4 3.2 3.2 0 1.6-1.1 3-2.7 3.2l-10.8 1.6c-1 .1-1.7 1-1.7 2 0 1.1.9 2 2 2h8.2c3.5 0 6.3 2.8 6.3 6.3 0 4.2-3.4 7.7-7.7 7.7H21.2v-5.8h16.2c1 0 1.8-.8 1.8-1.8s-.8-1.8-1.8-1.8h-8.5c-4 0-7.3-3.3-7.3-7.3 0-3.6 2.6-6.7 6.2-7.2l9-1.3H19v-5.5Z" fill="#0f172a"/>
|
||||
<path d="M18 44.8h9.1l-4.6-7.6H18v7.6Z" fill="#0f172a" opacity=".85"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 720 B |
1782
web/storyforge-web-v4/assets/styles.css
Normal file
BIN
web/storyforge-web-v4/favicon.ico
Normal file
|
After Width: | Height: | Size: 13 KiB |