Compare commits
7 Commits
codex/stor
...
codex/stor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dab444a83c | ||
|
|
ed5bcaef84 | ||
|
|
7500d02730 | ||
|
|
37709d37b7 | ||
|
|
9ed5f24364 | ||
|
|
031ba04d4e | ||
|
|
32dea8e3a6 |
@@ -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,166 @@ def register_douyin_routes(app: Any, legacy: Any) -> None:
|
||||
})
|
||||
return payloads
|
||||
|
||||
def _load_owned_assistant(assistant_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
if not str(assistant_id or "").strip():
|
||||
return None
|
||||
row = legacy.db.fetch_one(
|
||||
"SELECT * FROM assistants WHERE id = ? AND user_id = ?",
|
||||
(assistant_id, user_id)
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Assistant not found")
|
||||
return row
|
||||
|
||||
def _get_tracking_cursor(user_id: str) -> dict[str, Any] | None:
|
||||
return legacy.db.fetch_one(
|
||||
"SELECT * FROM douyin_tracking_cursors WHERE user_id = ?",
|
||||
(user_id,)
|
||||
)
|
||||
|
||||
def _set_tracking_cursor(user_id: str, last_seen_at: str) -> dict[str, Any]:
|
||||
existing = _get_tracking_cursor(user_id)
|
||||
timestamp = _first_non_empty(last_seen_at, now())
|
||||
updated_at = now()
|
||||
if existing:
|
||||
legacy.db.execute(
|
||||
"UPDATE douyin_tracking_cursors SET last_seen_at = ?, updated_at = ? WHERE user_id = ?",
|
||||
(timestamp, updated_at, user_id)
|
||||
)
|
||||
else:
|
||||
legacy.db.execute(
|
||||
"INSERT INTO douyin_tracking_cursors (user_id, last_seen_at, updated_at) VALUES (?, ?, ?)",
|
||||
(user_id, timestamp, updated_at)
|
||||
)
|
||||
return legacy.db.fetch_one("SELECT * FROM douyin_tracking_cursors WHERE user_id = ?", (user_id,))
|
||||
|
||||
def _list_tracked_accounts(user_id: str) -> list[dict[str, Any]]:
|
||||
rows = legacy.db.fetch_all(
|
||||
"""
|
||||
SELECT track.*,
|
||||
assistant.name AS assistant_name
|
||||
FROM douyin_tracked_accounts track
|
||||
LEFT JOIN assistants assistant ON assistant.id = track.assistant_id
|
||||
WHERE track.user_id = ?
|
||||
ORDER BY track.updated_at DESC
|
||||
""",
|
||||
(user_id,)
|
||||
)
|
||||
payloads: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
account_row = _require_owned_account(row["tracked_account_id"], user_id)
|
||||
account_payload = _build_account_payload(account_row, include_recent_videos=6)
|
||||
payloads.append({
|
||||
"id": row["id"],
|
||||
"tracked_account_id": row["tracked_account_id"],
|
||||
"assistant_id": row.get("assistant_id", "") or "",
|
||||
"assistant_name": row.get("assistant_name", "") or "",
|
||||
"note": row.get("note", "") or "",
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
"account": account_payload
|
||||
})
|
||||
return payloads
|
||||
|
||||
def _extract_tracking_borrowing_points(video: dict[str, Any]) -> list[str]:
|
||||
latest_analysis = (video.get("latest_analysis") or {}).get("parsed_json") or {}
|
||||
candidates: list[str] = []
|
||||
|
||||
def _collect(value: Any) -> None:
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
if isinstance(item, str) and item.strip():
|
||||
candidates.append(item.strip())
|
||||
elif isinstance(item, dict):
|
||||
for inner in item.values():
|
||||
if isinstance(inner, str) and inner.strip():
|
||||
candidates.append(inner.strip())
|
||||
elif isinstance(value, str) and value.strip():
|
||||
candidates.append(value.strip())
|
||||
|
||||
for key in ("winning_patterns", "replicate_plan", "hook_patterns", "content_engine", "offer_directions", "next_actions"):
|
||||
_collect(latest_analysis.get(key))
|
||||
|
||||
score = video.get("score", {}) or {}
|
||||
stats = video.get("stats", {}) or {}
|
||||
if float(score.get("hook_score") or 0) >= 70:
|
||||
candidates.append("开头抓人,适合借前三秒强结论或反常识开场。")
|
||||
if float(score.get("commercial_score") or 0) >= 65:
|
||||
candidates.append("转化信号较强,可拆成交句式和行动指令。")
|
||||
if float(score.get("performance_score") or 0) >= 70:
|
||||
candidates.append("整体表现高,值得提炼成可复用栏目模板。")
|
||||
if float(stats.get("comment") or 0) >= 100:
|
||||
candidates.append("评论互动明显,适合提炼争议点或提问句。")
|
||||
if str(video.get("content_type") or "") == "image_text":
|
||||
candidates.append("图文结构清晰,可借分段标题和卡片式表达。")
|
||||
|
||||
deduped: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in candidates:
|
||||
normalized = _compact_text(item, 80)
|
||||
if not normalized or normalized in seen:
|
||||
continue
|
||||
seen.add(normalized)
|
||||
deduped.append(normalized)
|
||||
return deduped[:4]
|
||||
|
||||
def _build_tracking_digest_item(tracked_item: dict[str, Any], video: dict[str, Any]) -> dict[str, Any]:
|
||||
latest_analysis = video.get("latest_analysis") or {}
|
||||
summary = (
|
||||
(latest_analysis.get("parsed_json") or {}).get("executive_summary")
|
||||
or latest_analysis.get("summary_text")
|
||||
or latest_analysis.get("suggestion_text")
|
||||
or video.get("description")
|
||||
or video.get("title")
|
||||
or "暂无摘要"
|
||||
)
|
||||
borrowing_points = _extract_tracking_borrowing_points(video)
|
||||
return {
|
||||
"tracking_id": tracked_item["id"],
|
||||
"tracked_account_id": tracked_item["tracked_account_id"],
|
||||
"assistant_id": tracked_item["assistant_id"],
|
||||
"assistant_name": tracked_item["assistant_name"],
|
||||
"account": tracked_item["account"],
|
||||
"video": video,
|
||||
"summary": _compact_text(summary, 160),
|
||||
"borrowing_points": borrowing_points,
|
||||
"is_high_value": float((video.get("score") or {}).get("performance_score") or 0) >= 70 or bool(borrowing_points),
|
||||
}
|
||||
|
||||
def _build_tracking_digest(user_id: str, since_value: str = "", limit: int = 24) -> dict[str, Any]:
|
||||
tracked_accounts = _list_tracked_accounts(user_id)
|
||||
cursor = _get_tracking_cursor(user_id)
|
||||
since_dt = _parse_iso_datetime(since_value) if since_value else None
|
||||
if since_dt is None and cursor:
|
||||
since_dt = _parse_iso_datetime(cursor.get("last_seen_at"))
|
||||
if since_dt is None:
|
||||
since_dt = (datetime.now(timezone.utc) - timedelta(days=3)).replace(microsecond=0)
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
for tracked in tracked_accounts:
|
||||
account_row = _require_owned_account(tracked["tracked_account_id"], user_id)
|
||||
workspace = _build_video_workspace_payload(account_row, limit=36)
|
||||
for video in workspace.get("items", []):
|
||||
published_at = _parse_iso_datetime(video.get("published_at"))
|
||||
if published_at is None or published_at <= since_dt:
|
||||
continue
|
||||
items.append(_build_tracking_digest_item(tracked, video))
|
||||
|
||||
items.sort(
|
||||
key=lambda item: (
|
||||
_parse_iso_datetime(item["video"].get("published_at")) or datetime.fromtimestamp(0, tz=timezone.utc),
|
||||
float((item["video"].get("score") or {}).get("performance_score") or 0)
|
||||
),
|
||||
reverse=True
|
||||
)
|
||||
return {
|
||||
"generated_at": now(),
|
||||
"since": since_dt.isoformat(),
|
||||
"cursor_last_seen_at": (cursor or {}).get("last_seen_at", ""),
|
||||
"tracked_accounts": tracked_accounts,
|
||||
"items": items[: max(1, min(limit, 100))]
|
||||
}
|
||||
|
||||
def _normalize_report_text(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
@@ -3133,3 +3327,69 @@ def register_douyin_routes(app: Any, legacy: Any) -> None:
|
||||
"relation_ids": linked_ids,
|
||||
"links": _list_linked_accounts(account_row)
|
||||
}
|
||||
|
||||
@app.get("/v2/douyin/tracking/accounts")
|
||||
def list_douyin_tracked_accounts(
|
||||
account: dict[str, Any] = Depends(legacy.require_approved)
|
||||
) -> dict[str, Any]:
|
||||
cursor = _get_tracking_cursor(account["id"])
|
||||
return {
|
||||
"cursor_last_seen_at": (cursor or {}).get("last_seen_at", ""),
|
||||
"items": _list_tracked_accounts(account["id"])
|
||||
}
|
||||
|
||||
@app.post("/v2/douyin/tracking/accounts")
|
||||
def create_douyin_tracked_account(
|
||||
request: DouyinTrackedAccountRequest,
|
||||
account: dict[str, Any] = Depends(legacy.require_approved)
|
||||
) -> dict[str, Any]:
|
||||
tracked_account = _require_owned_account(request.tracked_account_id, account["id"])
|
||||
assistant = _load_owned_assistant(request.assistant_id, account["id"])
|
||||
existing = legacy.db.fetch_one(
|
||||
"SELECT * FROM douyin_tracked_accounts WHERE user_id = ? AND tracked_account_id = ?",
|
||||
(account["id"], tracked_account["id"])
|
||||
)
|
||||
updated_at = now()
|
||||
if existing:
|
||||
legacy.db.execute(
|
||||
"""
|
||||
UPDATE douyin_tracked_accounts
|
||||
SET assistant_id = ?, note = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
((assistant or {}).get("id"), request.note.strip(), updated_at, existing["id"])
|
||||
)
|
||||
else:
|
||||
legacy.db.execute(
|
||||
"""
|
||||
INSERT INTO douyin_tracked_accounts (
|
||||
id, user_id, tracked_account_id, assistant_id, note, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(make_id("dytrack"), account["id"], tracked_account["id"], (assistant or {}).get("id"), request.note.strip(), updated_at, updated_at)
|
||||
)
|
||||
return {
|
||||
"tracked_account_id": tracked_account["id"],
|
||||
"assistant_id": (assistant or {}).get("id", ""),
|
||||
"items": _list_tracked_accounts(account["id"])
|
||||
}
|
||||
|
||||
@app.post("/v2/douyin/tracking/cursor")
|
||||
def update_douyin_tracking_cursor(
|
||||
request: DouyinTrackingCursorRequest,
|
||||
account: dict[str, Any] = Depends(legacy.require_approved)
|
||||
) -> dict[str, Any]:
|
||||
cursor = _set_tracking_cursor(account["id"], request.last_seen_at)
|
||||
return {
|
||||
"user_id": account["id"],
|
||||
"last_seen_at": cursor["last_seen_at"],
|
||||
"updated_at": cursor["updated_at"]
|
||||
}
|
||||
|
||||
@app.get("/v2/douyin/tracking/digest")
|
||||
def get_douyin_tracking_digest(
|
||||
since: str | None = None,
|
||||
limit: int = 24,
|
||||
account: dict[str, Any] = Depends(legacy.require_approved)
|
||||
) -> dict[str, Any]:
|
||||
return _build_tracking_digest(account["id"], since_value=(since or "").strip(), limit=limit)
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
from fastapi import Body, Depends, FastAPI, File, Form, Header, HTTPException, Query, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -251,6 +254,36 @@ class AiVideoJobRequest(BaseModel):
|
||||
duration: int = 5
|
||||
|
||||
|
||||
class ReviewCreateRequest(BaseModel):
|
||||
project_id: str = ""
|
||||
source_job_id: str = ""
|
||||
assistant_id: str = ""
|
||||
title: str = ""
|
||||
platform: str = "douyin"
|
||||
content_type: str = "video"
|
||||
publish_url: str = ""
|
||||
published_at: str = ""
|
||||
metrics: dict[str, Any] = Field(default_factory=dict)
|
||||
verdict: str = ""
|
||||
highlights: str = ""
|
||||
next_actions: str = ""
|
||||
notes: str = ""
|
||||
|
||||
|
||||
class ReviewUpdateRequest(BaseModel):
|
||||
title: str | None = None
|
||||
platform: str | None = None
|
||||
content_type: str | None = None
|
||||
publish_url: str | None = None
|
||||
published_at: str | None = None
|
||||
metrics: dict[str, Any] | None = None
|
||||
verdict: str | None = None
|
||||
highlights: str | None = None
|
||||
next_actions: str | None = None
|
||||
notes: str | None = None
|
||||
assistant_id: str | None = None
|
||||
|
||||
|
||||
class InternalStepRequest(BaseModel):
|
||||
job_id: str = ""
|
||||
jobId: str = ""
|
||||
@@ -521,6 +554,41 @@ def assistant_payload(row: dict[str, Any]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def review_payload(row: dict[str, Any]) -> dict[str, Any]:
|
||||
metrics = parse_json_object(row.get("metrics_json") or "{}")
|
||||
source_job = None
|
||||
assistant = None
|
||||
if row.get("source_job_id"):
|
||||
source_job_row = db.fetch_one("SELECT * FROM jobs WHERE id = ?", (row["source_job_id"],))
|
||||
if source_job_row:
|
||||
source_job = job_payload(source_job_row)
|
||||
if row.get("assistant_id"):
|
||||
assistant_row = db.fetch_one("SELECT * FROM assistants WHERE id = ?", (row["assistant_id"],))
|
||||
if assistant_row:
|
||||
assistant = assistant_payload(assistant_row)
|
||||
return {
|
||||
"id": row["id"],
|
||||
"user_id": row["user_id"],
|
||||
"project_id": row.get("project_id", ""),
|
||||
"source_job_id": row.get("source_job_id", ""),
|
||||
"assistant_id": row.get("assistant_id", ""),
|
||||
"title": row.get("title", ""),
|
||||
"platform": row.get("platform", "douyin"),
|
||||
"content_type": row.get("content_type", "video"),
|
||||
"publish_url": row.get("publish_url", ""),
|
||||
"published_at": row.get("published_at", ""),
|
||||
"metrics": metrics,
|
||||
"verdict": row.get("verdict", ""),
|
||||
"highlights": row.get("highlights", ""),
|
||||
"next_actions": row.get("next_actions", ""),
|
||||
"notes": row.get("notes", ""),
|
||||
"source_job": source_job,
|
||||
"assistant": assistant,
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
|
||||
|
||||
def document_payload(row: dict[str, Any]) -> dict[str, Any]:
|
||||
analysis_map = parse_json_object(row.get("analysis_json") or "{}")
|
||||
source_artifacts = parse_json_object(row.get("source_artifact_json") or "{}")
|
||||
@@ -1353,6 +1421,44 @@ async def process_job(job_id: str) -> None:
|
||||
update_job_state(job_id, status="failed", error=str(exc))
|
||||
|
||||
|
||||
def probe_tcp(url: str, timeout: float = 3.0) -> dict[str, Any]:
|
||||
if not url:
|
||||
return {"configured": False, "reachable": False, "status_code": 0, "error": "not_configured", "url": ""}
|
||||
parsed = urlparse(url)
|
||||
host = parsed.hostname
|
||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||
if not host:
|
||||
return {"configured": True, "reachable": False, "status_code": 0, "error": "invalid_url", "url": url}
|
||||
sock = socket.socket()
|
||||
sock.settimeout(timeout)
|
||||
try:
|
||||
sock.connect((host, port))
|
||||
return {"configured": True, "reachable": True, "status_code": 0, "error": "", "url": url}
|
||||
except Exception as exc: # pragma: no cover - operational probe
|
||||
return {"configured": True, "reachable": False, "status_code": 0, "error": str(exc), "url": url}
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def probe_http(url: str, path: str = "", timeout: float = 3.0) -> dict[str, Any]:
|
||||
tcp = probe_tcp(url, timeout=timeout)
|
||||
target_url = urljoin(url if url.endswith("/") else f"{url}/", path.lstrip("/")) if url else ""
|
||||
if not tcp["configured"] or not tcp["reachable"]:
|
||||
if target_url:
|
||||
tcp["url"] = target_url
|
||||
return tcp
|
||||
try:
|
||||
response = httpx.get(target_url or url, timeout=timeout, follow_redirects=True)
|
||||
tcp["status_code"] = response.status_code
|
||||
tcp["reachable"] = response.status_code < 500
|
||||
tcp["error"] = "" if response.status_code < 500 else f"http_{response.status_code}"
|
||||
except Exception as exc: # pragma: no cover - operational probe
|
||||
tcp["reachable"] = False
|
||||
tcp["error"] = str(exc)
|
||||
tcp["url"] = target_url or url
|
||||
return tcp
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup() -> None:
|
||||
db.init_schema()
|
||||
@@ -1374,6 +1480,29 @@ def healthz() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
@app.get("/v2/integrations/health")
|
||||
def integrations_health(account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]:
|
||||
_ = account
|
||||
return {
|
||||
"cutvideo": {
|
||||
"base_url": CUTVIDEO_BASE_URL,
|
||||
**probe_http(CUTVIDEO_BASE_URL, "/api/bootstrap"),
|
||||
},
|
||||
"huobao": {
|
||||
"base_url": HUOBAO_BASE_URL,
|
||||
**probe_http(HUOBAO_BASE_URL, "/health"),
|
||||
},
|
||||
"n8n": {
|
||||
"base_url": N8N_BASE_URL,
|
||||
**probe_http(N8N_BASE_URL, "/healthz"),
|
||||
},
|
||||
"asr": {
|
||||
"base_url": ASR_HTTP_BASE_URL,
|
||||
**probe_tcp(ASR_HTTP_BASE_URL),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def seed_defaults() -> None:
|
||||
if not db.fetch_one("SELECT id FROM model_profiles WHERE is_default = 1 LIMIT 1"):
|
||||
profile_id = make_id("model")
|
||||
@@ -1746,6 +1875,107 @@ def list_knowledge_documents(knowledge_base_id: str, account: dict[str, Any] = D
|
||||
return [document_payload(row) for row in rows]
|
||||
|
||||
|
||||
@app.get("/v2/reviews")
|
||||
def list_reviews(
|
||||
project_id: str | None = Query(default=None),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
account: dict[str, Any] = Depends(require_approved),
|
||||
) -> list[dict[str, Any]]:
|
||||
clauses = ["user_id = ?"]
|
||||
params: list[Any] = [account["id"]]
|
||||
if project_id is not None:
|
||||
normalized_project = project_id.strip()
|
||||
if normalized_project:
|
||||
clauses.append("project_id = ?")
|
||||
params.append(normalized_project)
|
||||
else:
|
||||
clauses.append("(project_id IS NULL OR project_id = '')")
|
||||
sql = f"SELECT * FROM publish_reviews WHERE {' AND '.join(clauses)} ORDER BY COALESCE(NULLIF(published_at, ''), created_at) DESC, created_at DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
return [review_payload(row) for row in db.fetch_all(sql, tuple(params))]
|
||||
|
||||
|
||||
@app.post("/v2/reviews")
|
||||
def create_review(request: ReviewCreateRequest, account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]:
|
||||
source_job = None
|
||||
if request.source_job_id.strip():
|
||||
source_job = load_owned_job(request.source_job_id.strip(), account["id"])
|
||||
requested_project_id = request.project_id.strip() or (source_job.get("project_id", "") if source_job else "")
|
||||
project = resolve_target_project(account["id"], requested_project_id or None, username=account["username"])
|
||||
assistant = resolve_target_assistant(account["id"], request.assistant_id or None, project["id"])
|
||||
review_id = make_id("review")
|
||||
title = request.title.strip() or (source_job.get("title", "") if source_job else "")
|
||||
if not title:
|
||||
title = f"{project['name']} 复盘"
|
||||
timestamp = utc_now()
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO publish_reviews (
|
||||
id, user_id, project_id, source_job_id, assistant_id, title, platform, content_type,
|
||||
publish_url, published_at, metrics_json, verdict, highlights, next_actions, notes, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
review_id,
|
||||
account["id"],
|
||||
project["id"],
|
||||
source_job["id"] if source_job else None,
|
||||
(assistant or {}).get("id") or None,
|
||||
title,
|
||||
request.platform or "douyin",
|
||||
request.content_type or "video",
|
||||
request.publish_url.strip(),
|
||||
request.published_at.strip(),
|
||||
json.dumps(request.metrics, ensure_ascii=False),
|
||||
request.verdict.strip(),
|
||||
request.highlights.strip(),
|
||||
request.next_actions.strip(),
|
||||
request.notes.strip(),
|
||||
timestamp,
|
||||
timestamp,
|
||||
),
|
||||
)
|
||||
row = db.fetch_one("SELECT * FROM publish_reviews WHERE id = ?", (review_id,))
|
||||
return review_payload(row)
|
||||
|
||||
|
||||
@app.patch("/v2/reviews/{review_id}")
|
||||
def update_review(review_id: str, request: ReviewUpdateRequest, account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]:
|
||||
current = load_owned_review(review_id, account["id"])
|
||||
assistant_id = current.get("assistant_id") or None
|
||||
if request.assistant_id is not None:
|
||||
assistant = resolve_target_assistant(account["id"], request.assistant_id or None, current.get("project_id", ""))
|
||||
assistant_id = (assistant or {}).get("id") or None
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE publish_reviews
|
||||
SET title = ?, platform = ?, content_type = ?, publish_url = ?, published_at = ?,
|
||||
metrics_json = ?, verdict = ?, highlights = ?, next_actions = ?, notes = ?,
|
||||
assistant_id = ?, updated_at = ?
|
||||
WHERE id = ? AND user_id = ?
|
||||
""",
|
||||
(
|
||||
request.title if request.title is not None else current.get("title", ""),
|
||||
request.platform if request.platform is not None else current.get("platform", "douyin"),
|
||||
request.content_type if request.content_type is not None else current.get("content_type", "video"),
|
||||
request.publish_url if request.publish_url is not None else current.get("publish_url", ""),
|
||||
request.published_at if request.published_at is not None else current.get("published_at", ""),
|
||||
json.dumps(request.metrics if request.metrics is not None else parse_json_object(current.get("metrics_json") or "{}"), ensure_ascii=False),
|
||||
request.verdict if request.verdict is not None else current.get("verdict", ""),
|
||||
request.highlights if request.highlights is not None else current.get("highlights", ""),
|
||||
request.next_actions if request.next_actions is not None else current.get("next_actions", ""),
|
||||
request.notes if request.notes is not None else current.get("notes", ""),
|
||||
assistant_id,
|
||||
utc_now(),
|
||||
review_id,
|
||||
account["id"],
|
||||
),
|
||||
)
|
||||
row = db.fetch_one("SELECT * FROM publish_reviews WHERE id = ?", (review_id,))
|
||||
return review_payload(row)
|
||||
|
||||
|
||||
@app.get("/v2/explore/jobs")
|
||||
def list_jobs(
|
||||
parent_job_id: str | None = Query(default=None),
|
||||
@@ -2178,6 +2408,13 @@ def load_owned_content_source(source_id: str, account_id: str) -> dict[str, Any]
|
||||
return row
|
||||
|
||||
|
||||
def load_owned_review(review_id: str, account_id: str) -> dict[str, Any]:
|
||||
row = db.fetch_one("SELECT * FROM publish_reviews WHERE id = ? AND user_id = ?", (review_id, account_id))
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Review not found")
|
||||
return row
|
||||
|
||||
|
||||
def load_internal_job(job_id: str) -> dict[str, Any]:
|
||||
row = db.fetch_one("SELECT * FROM jobs WHERE id = ?", (job_id,))
|
||||
if not row:
|
||||
|
||||
@@ -33,12 +33,15 @@
|
||||
- 抖音对标账号 `/v2/douyin/accounts`
|
||||
- 单账号工作台 `/v2/douyin/accounts/{id}/workspace`
|
||||
- 单账号作品列表 `/v2/douyin/accounts/{id}/videos`
|
||||
- 跟踪账号 `/v2/douyin/tracking/accounts`
|
||||
- 跟踪日报 `/v2/douyin/tracking/digest`
|
||||
- 最近知识库文档 `/v2/knowledge-bases/{id}/documents`
|
||||
|
||||
## 当前已接入的真实动作
|
||||
|
||||
- 新建项目
|
||||
- 导入主页并触发内容源同步
|
||||
- 把当前对标账号直接导入到当前项目,并绑定 Agent 触发同步
|
||||
- 导入作品链接并触发分析
|
||||
- 导入文本素材并触发分析
|
||||
- 上传本地视频并触发分析
|
||||
@@ -46,7 +49,12 @@
|
||||
- 对当前 Douyin 对标账号重跑分析
|
||||
- 批量分析高分作品
|
||||
- 查找相似对标账号
|
||||
- 查看任务详情、事件和 artifacts/result
|
||||
- 从相似候选一键保存对标关系
|
||||
- 把当前对标账号加入跟踪,并绑定 Agent
|
||||
- 按上次打开后生成跟踪日报与借鉴点摘要
|
||||
- 查看任务详情、事件、子任务和 artifacts/result
|
||||
- 从任务详情直接衔接 AI 视频 / 实拍剪辑 / 文案生成
|
||||
- 在生产中心 / 发布与复盘常驻最近一次任务详情摘要
|
||||
- 使用 Agent 生成文案
|
||||
- 创建 AI 视频任务
|
||||
- 创建实拍剪辑任务
|
||||
@@ -71,6 +79,8 @@ python3 -m http.server 3918
|
||||
## 后续建议
|
||||
|
||||
- 继续补动作型接口,例如导入、绑定 Agent、触发分析与生产
|
||||
- 把对标导入后的 Agent 绑定和知识库入库反馈做得更完整
|
||||
- 把跟踪日报从 Douyin 扩到多平台统一模型
|
||||
- 把全局搜索和页内搜索合并成统一搜索体验
|
||||
- 为 `生产中心 / 发布与复盘` 接入更完整的任务与成片对象
|
||||
- 不要把这套页面重新塞回 `scripts/douyin-browser-capture/control_panel.mjs`
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
11
web/storyforge-web-v4/assets/favicon.svg
Normal file
11
web/storyforge-web-v4/assets/favicon.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="sfg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#dff4ff"/>
|
||||
<stop offset="100%" stop-color="#86cfff"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="4" y="4" width="56" height="56" rx="16" fill="url(#sfg)"/>
|
||||
<path d="M19 23.5c0-2.5 2-4.5 4.5-4.5h17c1.8 0 3.2 1.4 3.2 3.2 0 1.6-1.1 3-2.7 3.2l-10.8 1.6c-1 .1-1.7 1-1.7 2 0 1.1.9 2 2 2h8.2c3.5 0 6.3 2.8 6.3 6.3 0 4.2-3.4 7.7-7.7 7.7H21.2v-5.8h16.2c1 0 1.8-.8 1.8-1.8s-.8-1.8-1.8-1.8h-8.5c-4 0-7.3-3.3-7.3-7.3 0-3.6 2.6-6.7 6.2-7.2l9-1.3H19v-5.5Z" fill="#0f172a"/>
|
||||
<path d="M18 44.8h9.1l-4.6-7.6H18v7.6Z" fill="#0f172a" opacity=".85"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 720 B |
@@ -38,6 +38,7 @@ body {
|
||||
radial-gradient(circle at top left, rgba(129, 180, 255, 0.18), transparent 28%),
|
||||
linear-gradient(180deg, #f8fbff 0%, #eef4fb 100%);
|
||||
color: var(--text);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
a {
|
||||
@@ -55,6 +56,7 @@ select {
|
||||
display: grid;
|
||||
grid-template-columns: 272px minmax(0, 1fr);
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
@@ -65,6 +67,7 @@ select {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.brand {
|
||||
@@ -186,6 +189,7 @@ select {
|
||||
|
||||
.content {
|
||||
padding: 18px 22px 26px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
@@ -206,6 +210,17 @@ select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar-left {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.topbar-right {
|
||||
flex: 1 1 auto;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.workspace-switch,
|
||||
@@ -229,6 +244,8 @@ select {
|
||||
.workspace-switch span {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
display: block;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.search {
|
||||
@@ -236,6 +253,7 @@ select {
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 340px;
|
||||
max-width: 100%;
|
||||
padding: 12px 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
@@ -273,6 +291,7 @@ select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.auth-status {
|
||||
@@ -308,6 +327,11 @@ select {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.action-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -482,6 +506,7 @@ select {
|
||||
.layout-grid {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.grid-4 {
|
||||
@@ -510,6 +535,7 @@ select {
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-soft);
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel.pad {
|
||||
@@ -572,6 +598,7 @@ select {
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, #fff 0%, #f9fbff 100%);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-item,
|
||||
@@ -653,6 +680,50 @@ select {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.desktop-only {
|
||||
display: initial;
|
||||
}
|
||||
|
||||
.mobile-account-list {
|
||||
padding: 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, #fbfdff 0%, #f4f9ff 100%);
|
||||
}
|
||||
|
||||
.account-select-card {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
text-align: left;
|
||||
padding: 14px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, #fff 0%, #f7fbff 100%);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.account-select-card:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.account-select-card.is-active {
|
||||
border-color: rgba(79, 143, 238, 0.24);
|
||||
background: linear-gradient(180deg, #f8fbff 0%, #eef6ff 100%);
|
||||
box-shadow: inset 0 0 0 1px rgba(79, 143, 238, 0.08);
|
||||
}
|
||||
|
||||
.compact-summary-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sheet-html {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
@@ -684,6 +755,7 @@ select {
|
||||
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
table {
|
||||
@@ -978,6 +1050,522 @@ tbody tr:hover {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
.grid-5 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.grid-split {
|
||||
grid-template-columns: 240px minmax(0, 1fr) 280px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.app-shell {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: relative;
|
||||
top: auto;
|
||||
height: auto;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid rgba(201, 220, 239, 0.75);
|
||||
padding: 18px 18px 14px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.nav-group + .nav-group {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
padding: 0 4px 8px;
|
||||
}
|
||||
|
||||
.nav-group .nav-item {
|
||||
display: inline-flex;
|
||||
width: auto;
|
||||
margin: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
.sidebar-foot {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 16px 18px 24px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.topbar-left,
|
||||
.topbar-right {
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.workspace-switch,
|
||||
.search {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.screen-head {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.screen-head p {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.grid-main,
|
||||
.grid-split,
|
||||
.two-col,
|
||||
.three-col {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.grid-4 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.grid-5 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.toolbar-stack {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
table {
|
||||
min-width: 760px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.mobile-only {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.desktop-only {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
padding: 14px 14px 12px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
gap: 10px;
|
||||
padding: 4px 4px 12px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.brand h1 {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.brand p {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.nav-group .nav-item {
|
||||
width: calc(50% - 8px);
|
||||
justify-content: flex-start;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.sidebar-foot {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 12px 12px 22px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
padding: 14px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.topbar-left .chip-row,
|
||||
.topbar-right {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.topbar-left .chip-row {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.topbar-left .chip-row::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.top-pill {
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.auth-inline {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.auth-status {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.auth-modal-backdrop,
|
||||
.action-modal-backdrop {
|
||||
padding: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.auth-modal,
|
||||
.action-modal {
|
||||
width: 100%;
|
||||
max-height: min(90vh, 100%);
|
||||
border-radius: 20px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.auth-head {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.auth-actions {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.auth-actions .btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.screen {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.screen-head {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.screen-head h2 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.screen-head p {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.action-row .btn {
|
||||
flex: 1 1 calc(50% - 10px);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.grid-4,
|
||||
.grid-5,
|
||||
.mini-grid,
|
||||
.calendar {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.stat-card strong {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.stat-foot {
|
||||
gap: 8px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.panel.pad,
|
||||
.hero-card,
|
||||
.task-item,
|
||||
.queue-card,
|
||||
.review-card,
|
||||
.sheet-html,
|
||||
.insight-card,
|
||||
.playbook-item {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.task-meta,
|
||||
.entity-meta,
|
||||
.row-meta,
|
||||
.chip-row,
|
||||
.kpi-inline,
|
||||
.timeline {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.table-wrap .account-table {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search-inline {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filters {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter {
|
||||
min-width: calc(50% - 4px);
|
||||
flex: 1 1 calc(50% - 4px);
|
||||
}
|
||||
|
||||
.mobile-account-list {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.account-select-card {
|
||||
padding: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.account-select-card .kpi-inline {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.account-select-card .task-meta .tag {
|
||||
flex: 1 1 calc(50% - 4px);
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.entity-cell {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.avatar-lg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 13px;
|
||||
}
|
||||
|
||||
.bar-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.kpi-inline {
|
||||
gap: 8px;
|
||||
row-gap: 6px;
|
||||
}
|
||||
|
||||
table {
|
||||
min-width: 680px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.sidebar {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
padding: 2px 2px 10px;
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
margin-top: 8px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.nav-group::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
padding: 0 4px 0 2px;
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-group .nav-item {
|
||||
width: auto;
|
||||
margin-right: 0;
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
padding: 9px 11px;
|
||||
}
|
||||
|
||||
.nav-item .icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sidebar-foot {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topbar-right {
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.search {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.action-row .btn,
|
||||
.auth-actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.workspace-switch {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.workspace-switch strong {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.workspace-switch span {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.topbar-left .chip-row {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.topbar-left .chip,
|
||||
.top-pill {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.topbar-right > :not(.avatar) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.topbar-right .search {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.topbar-right .auth-inline {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.topbar-right .top-pill {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.topbar-right .avatar {
|
||||
order: 4;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.auth-inline .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hero-card .chip-row {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.hero-card .chip-row::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.compact-summary-row .tag {
|
||||
width: calc(50% - 4px);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.task-item.compact,
|
||||
.review-card.compact {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.task-item.compact h4,
|
||||
.review-card.compact h4 {
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.task-item.compact p,
|
||||
.review-card.compact p {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.filter {
|
||||
min-width: 100%;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hero-card h3 {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.screen-head h2 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
table {
|
||||
min-width: 620px;
|
||||
}
|
||||
}
|
||||
|
||||
.slot {
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
@@ -995,39 +1583,3 @@ tbody tr:hover {
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@media (max-width: 1320px) {
|
||||
.grid-main,
|
||||
.grid-split,
|
||||
.grid-5,
|
||||
.grid-4,
|
||||
.grid-3,
|
||||
.three-col,
|
||||
.two-col {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.calendar {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sidebar {
|
||||
position: relative;
|
||||
height: auto;
|
||||
}
|
||||
.topbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.topbar-left,
|
||||
.topbar-right {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.search {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
BIN
web/storyforge-web-v4/favicon.ico
Normal file
BIN
web/storyforge-web-v4/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -4,6 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>StoryForge Web V4 Prototype</title>
|
||||
<link rel="icon" href="./assets/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="stylesheet" href="./assets/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user