2092 lines
89 KiB
JavaScript
2092 lines
89 KiB
JavaScript
#!/usr/bin/env node
|
||
|
||
import { spawn } from "node:child_process";
|
||
import fs from "node:fs/promises";
|
||
import http from "node:http";
|
||
import os from "node:os";
|
||
import path from "node:path";
|
||
import process from "node:process";
|
||
import { fileURLToPath } from "node:url";
|
||
|
||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||
const CAPTURE_SCRIPT = path.join(SCRIPT_DIR, "capture_and_sync.mjs");
|
||
const DEFAULT_PORT = Number.parseInt(process.env.PORT || "3618", 10);
|
||
const DEFAULT_BACKEND_URL = "http://127.0.0.1:8081";
|
||
const DEFAULT_OUTPUT_ROOT = "/Users/kris/code/StoryForge-gitea/output/playwright/douyin/control-panel";
|
||
const DEFAULT_STATE_DIR = path.join(os.homedir(), ".storyforge", "douyin-playwright");
|
||
const MAX_LOG_LINES = 240;
|
||
const MAX_RECENT_RUNS = 8;
|
||
|
||
const runs = new Map();
|
||
|
||
function nowIso() {
|
||
return new Date().toISOString();
|
||
}
|
||
|
||
function createRunId() {
|
||
return `run-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||
}
|
||
|
||
async function ensureDir(dir) {
|
||
await fs.mkdir(dir, { recursive: true });
|
||
}
|
||
|
||
function trimLogBuffer(logs) {
|
||
if (logs.length > MAX_LOG_LINES) {
|
||
logs.splice(0, logs.length - MAX_LOG_LINES);
|
||
}
|
||
}
|
||
|
||
function appendLog(run, source, chunk) {
|
||
const lines = String(chunk || "")
|
||
.split(/\r?\n/)
|
||
.map((line) => line.trimEnd())
|
||
.filter(Boolean);
|
||
for (const line of lines) {
|
||
run.logs.push(`[${new Date().toLocaleTimeString("zh-CN", { hour12: false })}] [${source}] ${line}`);
|
||
}
|
||
trimLogBuffer(run.logs);
|
||
}
|
||
|
||
async function readJsonBody(req) {
|
||
const chunks = [];
|
||
let size = 0;
|
||
for await (const chunk of req) {
|
||
size += chunk.length;
|
||
if (size > 512 * 1024) {
|
||
throw new Error("Request body too large");
|
||
}
|
||
chunks.push(chunk);
|
||
}
|
||
const raw = Buffer.concat(chunks).toString("utf8").trim();
|
||
return raw ? JSON.parse(raw) : {};
|
||
}
|
||
|
||
async function readJsonIfExists(filePath) {
|
||
try {
|
||
const raw = await fs.readFile(filePath, "utf8");
|
||
return JSON.parse(raw);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function findLatestCaptureDir(runBaseDir) {
|
||
try {
|
||
const entries = await fs.readdir(runBaseDir, { withFileTypes: true });
|
||
const dirs = [];
|
||
for (const entry of entries) {
|
||
if (!entry.isDirectory()) {
|
||
continue;
|
||
}
|
||
const fullPath = path.join(runBaseDir, entry.name);
|
||
const stat = await fs.stat(fullPath);
|
||
dirs.push({ fullPath, mtimeMs: stat.mtimeMs });
|
||
}
|
||
dirs.sort((left, right) => right.mtimeMs - left.mtimeMs);
|
||
return dirs[0]?.fullPath || "";
|
||
} catch {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
async function loadArtifacts(runBaseDir) {
|
||
const outputDir = await findLatestCaptureDir(runBaseDir);
|
||
if (!outputDir) {
|
||
return null;
|
||
}
|
||
const [summary, syncResponse, syncError, login] = await Promise.all([
|
||
readJsonIfExists(path.join(outputDir, "summary.json")),
|
||
readJsonIfExists(path.join(outputDir, "storyforge-sync-response.json")),
|
||
readJsonIfExists(path.join(outputDir, "storyforge-sync-error.json")),
|
||
readJsonIfExists(path.join(outputDir, "storyforge-login.json"))
|
||
]);
|
||
return {
|
||
outputDir,
|
||
summary,
|
||
syncResponse,
|
||
syncError,
|
||
login
|
||
};
|
||
}
|
||
|
||
async function refreshRunArtifacts(run) {
|
||
const artifacts = await loadArtifacts(run.runBaseDir);
|
||
if (!artifacts) {
|
||
return;
|
||
}
|
||
run.outputDir = artifacts.outputDir;
|
||
run.summary = artifacts.summary;
|
||
run.syncResponse = artifacts.syncResponse;
|
||
run.syncError = artifacts.syncError;
|
||
run.login = artifacts.login;
|
||
}
|
||
|
||
function serializeRun(run) {
|
||
if (!run) {
|
||
return null;
|
||
}
|
||
return {
|
||
id: run.id,
|
||
status: run.status,
|
||
profileUrl: run.profileUrl,
|
||
backendUrl: run.backendUrl,
|
||
syncEnabled: run.syncEnabled,
|
||
headless: run.headless,
|
||
startedAt: run.startedAt,
|
||
continuedAt: run.continuedAt || "",
|
||
finishedAt: run.finishedAt || "",
|
||
outputDir: run.outputDir || "",
|
||
exitCode: run.exitCode,
|
||
signal: run.signal || "",
|
||
summary: run.summary || null,
|
||
syncResponse: run.syncResponse || null,
|
||
syncError: run.syncError || null,
|
||
logs: run.logs.slice(-80)
|
||
};
|
||
}
|
||
|
||
function getActiveRun() {
|
||
return Array.from(runs.values()).find((run) => !["completed", "failed", "terminated"].includes(run.status)) || null;
|
||
}
|
||
|
||
function buildCaptureArgs(payload, runBaseDir, readyFile) {
|
||
const token = String(payload.token || payload.storyforgeToken || "").trim();
|
||
const username = String(payload.username || payload.storyforgeUsername || "").trim();
|
||
const password = String(payload.password || payload.storyforgePassword || "");
|
||
const parsedMaxVideos = Number.parseInt(String(payload.maxVideos ?? "4"), 10);
|
||
const parsedWaitMs = Number.parseInt(String(payload.waitMs ?? "4000"), 10);
|
||
const args = [
|
||
CAPTURE_SCRIPT,
|
||
"--profile-url",
|
||
String(payload.profileUrl || "").trim(),
|
||
"--backend-url",
|
||
String(payload.backendUrl || DEFAULT_BACKEND_URL).trim(),
|
||
"--output-dir",
|
||
runBaseDir,
|
||
"--state-dir",
|
||
String(payload.stateDir || DEFAULT_STATE_DIR).trim(),
|
||
"--max-videos",
|
||
String(Number.isFinite(parsedMaxVideos) ? Math.max(0, parsedMaxVideos) : 4),
|
||
"--wait-ms",
|
||
String(Number.isFinite(parsedWaitMs) ? Math.max(800, parsedWaitMs) : 4000),
|
||
"--ready-file",
|
||
readyFile
|
||
];
|
||
if (payload.note) {
|
||
args.push("--note", String(payload.note).trim());
|
||
}
|
||
if (payload.headless) {
|
||
args.push("--headless");
|
||
}
|
||
if (!payload.syncEnabled) {
|
||
args.push("--no-sync");
|
||
}
|
||
if (payload.skipCreatorCenter) {
|
||
args.push("--no-creator-center");
|
||
}
|
||
if (payload.allowCreatorCenterFallback) {
|
||
args.push("--allow-creator-center-fallback");
|
||
}
|
||
if (token) {
|
||
args.push("--storyforge-token", token);
|
||
} else if (payload.syncEnabled) {
|
||
args.push("--storyforge-username", username);
|
||
args.push("--storyforge-password", password);
|
||
}
|
||
return args;
|
||
}
|
||
|
||
async function startRun(payload) {
|
||
const profileUrl = String(payload.profileUrl || "").trim();
|
||
const token = String(payload.token || payload.storyforgeToken || "").trim();
|
||
const username = String(payload.username || payload.storyforgeUsername || "").trim();
|
||
const password = String(payload.password || payload.storyforgePassword || "");
|
||
if (!profileUrl) {
|
||
throw new Error("请先填写抖音主页链接");
|
||
}
|
||
const syncEnabled = payload.syncEnabled !== false;
|
||
if (syncEnabled && !token) {
|
||
if (!username || !password) {
|
||
throw new Error("导入 StoryForge 时需要账号密码,或者直接提供 Token");
|
||
}
|
||
}
|
||
if (getActiveRun()) {
|
||
throw new Error("当前已有进行中的采集任务,请先完成或等待结束");
|
||
}
|
||
|
||
const id = createRunId();
|
||
const runBaseDir = path.join(DEFAULT_OUTPUT_ROOT, id);
|
||
const readyFile = path.join(runBaseDir, "manual-ready.signal");
|
||
await ensureDir(runBaseDir);
|
||
|
||
const args = buildCaptureArgs(
|
||
{
|
||
...payload,
|
||
profileUrl,
|
||
syncEnabled
|
||
},
|
||
runBaseDir,
|
||
readyFile
|
||
);
|
||
|
||
const child = spawn(process.execPath, args, {
|
||
cwd: SCRIPT_DIR,
|
||
env: process.env,
|
||
stdio: ["ignore", "pipe", "pipe"]
|
||
});
|
||
|
||
const run = {
|
||
id,
|
||
status: "awaiting_continue",
|
||
profileUrl,
|
||
backendUrl: String(payload.backendUrl || DEFAULT_BACKEND_URL).trim(),
|
||
syncEnabled,
|
||
headless: Boolean(payload.headless),
|
||
startedAt: nowIso(),
|
||
continuedAt: "",
|
||
finishedAt: "",
|
||
runBaseDir,
|
||
readyFile,
|
||
child,
|
||
logs: [],
|
||
outputDir: "",
|
||
summary: null,
|
||
syncResponse: null,
|
||
syncError: null,
|
||
exitCode: null,
|
||
signal: ""
|
||
};
|
||
|
||
const visibleArgs = args.map((arg, index) => {
|
||
if (args[index - 1] === "--storyforge-password") {
|
||
return "******";
|
||
}
|
||
return arg;
|
||
});
|
||
appendLog(run, "system", `Started ${process.execPath} ${visibleArgs.join(" ")}`);
|
||
|
||
child.stdout.on("data", (chunk) => appendLog(run, "stdout", chunk));
|
||
child.stderr.on("data", (chunk) => appendLog(run, "stderr", chunk));
|
||
child.on("exit", async (code, signal) => {
|
||
run.exitCode = code;
|
||
run.signal = signal || "";
|
||
run.finishedAt = nowIso();
|
||
await refreshRunArtifacts(run);
|
||
run.status = signal ? "terminated" : code === 0 ? "completed" : "failed";
|
||
appendLog(run, "system", `Process exited with status ${run.status}${code !== null ? ` (${code})` : ""}`);
|
||
});
|
||
|
||
runs.set(id, run);
|
||
return run;
|
||
}
|
||
|
||
async function continueRun(runId) {
|
||
const run = runs.get(runId);
|
||
if (!run) {
|
||
throw new Error("采集任务不存在");
|
||
}
|
||
if (["completed", "failed", "terminated"].includes(run.status)) {
|
||
throw new Error("这个采集任务已经结束了");
|
||
}
|
||
await ensureDir(path.dirname(run.readyFile));
|
||
await fs.writeFile(run.readyFile, `${nowIso()}\n`, "utf8");
|
||
run.continuedAt = nowIso();
|
||
run.status = "capturing";
|
||
appendLog(run, "system", "Manual ready signal sent");
|
||
return run;
|
||
}
|
||
|
||
async function listRecentRuns() {
|
||
await ensureDir(DEFAULT_OUTPUT_ROOT);
|
||
const entries = await fs.readdir(DEFAULT_OUTPUT_ROOT, { withFileTypes: true });
|
||
const dirs = [];
|
||
for (const entry of entries) {
|
||
if (!entry.isDirectory()) {
|
||
continue;
|
||
}
|
||
const runBaseDir = path.join(DEFAULT_OUTPUT_ROOT, entry.name);
|
||
const stat = await fs.stat(runBaseDir);
|
||
dirs.push({ id: entry.name, runBaseDir, mtimeMs: stat.mtimeMs });
|
||
}
|
||
dirs.sort((left, right) => right.mtimeMs - left.mtimeMs);
|
||
|
||
const recent = [];
|
||
for (const item of dirs.slice(0, MAX_RECENT_RUNS)) {
|
||
const artifacts = await loadArtifacts(item.runBaseDir);
|
||
recent.push({
|
||
id: item.id,
|
||
outputDir: artifacts?.outputDir || "",
|
||
summary: artifacts?.summary || null,
|
||
syncResponse: artifacts?.syncResponse || null,
|
||
syncError: artifacts?.syncError || null
|
||
});
|
||
}
|
||
return recent;
|
||
}
|
||
|
||
function sendJson(res, statusCode, payload) {
|
||
const body = JSON.stringify(payload, null, 2);
|
||
res.writeHead(statusCode, {
|
||
"content-type": "application/json; charset=utf-8",
|
||
"cache-control": "no-store"
|
||
});
|
||
res.end(body);
|
||
}
|
||
|
||
function sendHtml(res, html) {
|
||
res.writeHead(200, {
|
||
"content-type": "text/html; charset=utf-8",
|
||
"cache-control": "no-store"
|
||
});
|
||
res.end(html);
|
||
}
|
||
|
||
function renderPage() {
|
||
return `<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>StoryForge Douyin Browser Assist</title>
|
||
<style>
|
||
:root {
|
||
color-scheme: light;
|
||
--bg: #f4efe6;
|
||
--ink: #16313d;
|
||
--muted: #577182;
|
||
--accent: #1f6e5f;
|
||
--accent-2: #b97524;
|
||
--card: rgba(255, 255, 255, 0.86);
|
||
--border: rgba(22, 49, 61, 0.12);
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0;
|
||
font-family: "PingFang SC", "Noto Sans SC", sans-serif;
|
||
background:
|
||
radial-gradient(circle at top left, rgba(31, 110, 95, 0.16), transparent 30%),
|
||
linear-gradient(135deg, #f6efe3, #eff7f4 55%, #fdf8ef);
|
||
color: var(--ink);
|
||
}
|
||
main {
|
||
max-width: 1080px;
|
||
margin: 0 auto;
|
||
padding: 32px 20px 48px;
|
||
}
|
||
h1, h2, h3 { margin: 0; }
|
||
.hero {
|
||
background: linear-gradient(135deg, #0b3c5d, #1f6e5f 58%, #b97524);
|
||
color: white;
|
||
border-radius: 28px;
|
||
padding: 28px;
|
||
box-shadow: 0 18px 42px rgba(11, 60, 93, 0.18);
|
||
}
|
||
.hero p {
|
||
margin: 12px 0 0;
|
||
max-width: 760px;
|
||
line-height: 1.6;
|
||
color: rgba(255, 255, 255, 0.88);
|
||
}
|
||
.grid {
|
||
display: grid;
|
||
grid-template-columns: 1.1fr 0.9fr;
|
||
gap: 18px;
|
||
margin-top: 20px;
|
||
}
|
||
.card {
|
||
background: var(--card);
|
||
border: 1px solid var(--border);
|
||
border-radius: 22px;
|
||
padding: 20px;
|
||
box-shadow: 0 12px 30px rgba(22, 49, 61, 0.08);
|
||
backdrop-filter: blur(8px);
|
||
}
|
||
.stack { display: grid; gap: 12px; }
|
||
label { display: grid; gap: 6px; font-size: 14px; color: var(--muted); }
|
||
input, textarea {
|
||
width: 100%;
|
||
border-radius: 14px;
|
||
border: 1px solid rgba(22, 49, 61, 0.12);
|
||
padding: 12px 14px;
|
||
font: inherit;
|
||
background: rgba(255, 255, 255, 0.96);
|
||
color: var(--ink);
|
||
}
|
||
textarea { min-height: 88px; resize: vertical; }
|
||
.field-note {
|
||
font-size: 12px;
|
||
color: rgba(74, 96, 107, 0.9);
|
||
line-height: 1.5;
|
||
}
|
||
.advanced-box {
|
||
border: 1px solid rgba(22, 49, 61, 0.12);
|
||
border-radius: 16px;
|
||
background: rgba(255, 255, 255, 0.66);
|
||
padding: 0 14px;
|
||
}
|
||
.advanced-box[open] {
|
||
padding-bottom: 14px;
|
||
}
|
||
.advanced-toggle {
|
||
list-style: none;
|
||
cursor: pointer;
|
||
padding: 14px 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
color: var(--ink);
|
||
font-weight: 600;
|
||
}
|
||
.advanced-toggle::-webkit-details-marker { display: none; }
|
||
.advanced-toggle::after {
|
||
content: "展开";
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
color: var(--muted);
|
||
}
|
||
.advanced-box[open] .advanced-toggle::after {
|
||
content: "收起";
|
||
}
|
||
.token-summary {
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
color: var(--muted);
|
||
}
|
||
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||
.checks {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 10px;
|
||
}
|
||
.check {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
border: 1px solid rgba(22, 49, 61, 0.1);
|
||
border-radius: 14px;
|
||
padding: 10px 12px;
|
||
background: rgba(255, 255, 255, 0.75);
|
||
color: var(--ink);
|
||
}
|
||
button {
|
||
border: 0;
|
||
border-radius: 999px;
|
||
padding: 12px 18px;
|
||
font: inherit;
|
||
cursor: pointer;
|
||
transition: transform 0.15s ease, opacity 0.15s ease;
|
||
}
|
||
button:hover { transform: translateY(-1px); }
|
||
.primary { background: var(--accent); color: white; }
|
||
.secondary { background: rgba(22, 49, 61, 0.08); color: var(--ink); }
|
||
.warning { background: var(--accent-2); color: white; }
|
||
.actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
||
.steps {
|
||
display: grid;
|
||
gap: 10px;
|
||
margin-top: 16px;
|
||
}
|
||
.step {
|
||
border-left: 3px solid var(--accent);
|
||
padding-left: 12px;
|
||
color: var(--muted);
|
||
line-height: 1.55;
|
||
}
|
||
.pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 6px 10px;
|
||
border-radius: 999px;
|
||
background: rgba(31, 110, 95, 0.1);
|
||
color: var(--accent);
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
.status-box {
|
||
display: grid;
|
||
gap: 12px;
|
||
margin-top: 14px;
|
||
}
|
||
.status-line {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
font-size: 14px;
|
||
color: var(--muted);
|
||
}
|
||
pre {
|
||
margin: 0;
|
||
padding: 14px;
|
||
border-radius: 18px;
|
||
background: #12222c;
|
||
color: #d7efe8;
|
||
min-height: 220px;
|
||
max-height: 380px;
|
||
overflow: auto;
|
||
font-size: 12px;
|
||
line-height: 1.55;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
.recent-list { display: grid; gap: 12px; }
|
||
.recent-item {
|
||
border: 1px solid rgba(22, 49, 61, 0.1);
|
||
border-radius: 16px;
|
||
padding: 14px;
|
||
background: rgba(255, 255, 255, 0.72);
|
||
}
|
||
.meta { color: var(--muted); font-size: 13px; line-height: 1.55; }
|
||
.path {
|
||
font-family: "SF Mono", ui-monospace, monospace;
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
word-break: break-all;
|
||
}
|
||
.hint {
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
line-height: 1.55;
|
||
}
|
||
.subpanel {
|
||
border: 1px solid rgba(22, 49, 61, 0.1);
|
||
border-radius: 18px;
|
||
padding: 16px;
|
||
background: rgba(255, 255, 255, 0.72);
|
||
}
|
||
.workbench-layout {
|
||
display: grid;
|
||
grid-template-columns: 320px 1fr;
|
||
gap: 18px;
|
||
margin-top: 18px;
|
||
}
|
||
.account-list,
|
||
.snapshot-list,
|
||
.similar-list {
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
.account-item,
|
||
.snapshot-item,
|
||
.similar-item,
|
||
.link-item,
|
||
.video-item,
|
||
.report-item {
|
||
border: 1px solid rgba(22, 49, 61, 0.1);
|
||
border-radius: 16px;
|
||
padding: 14px;
|
||
background: rgba(255, 255, 255, 0.8);
|
||
}
|
||
.account-item {
|
||
cursor: pointer;
|
||
transition: transform 0.15s ease, border-color 0.15s ease;
|
||
width: 100%;
|
||
text-align: left;
|
||
}
|
||
.account-item:hover {
|
||
transform: translateY(-1px);
|
||
border-color: rgba(31, 110, 95, 0.28);
|
||
}
|
||
.account-item.active,
|
||
.snapshot-item.active,
|
||
.similar-item.active {
|
||
border-color: rgba(31, 110, 95, 0.55);
|
||
background: rgba(31, 110, 95, 0.08);
|
||
}
|
||
.profile-hero {
|
||
display: grid;
|
||
grid-template-columns: 86px 1fr;
|
||
gap: 16px;
|
||
align-items: center;
|
||
}
|
||
.avatar {
|
||
width: 86px;
|
||
height: 86px;
|
||
border-radius: 22px;
|
||
object-fit: cover;
|
||
background: rgba(22, 49, 61, 0.08);
|
||
border: 1px solid rgba(22, 49, 61, 0.12);
|
||
}
|
||
.metric-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 12px;
|
||
}
|
||
.metric-card {
|
||
border: 1px solid rgba(22, 49, 61, 0.1);
|
||
border-radius: 16px;
|
||
padding: 14px;
|
||
background: rgba(255, 255, 255, 0.84);
|
||
}
|
||
.metric-label {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
margin-bottom: 6px;
|
||
}
|
||
.metric-value {
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
}
|
||
.chips {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
.chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 6px 10px;
|
||
border-radius: 999px;
|
||
background: rgba(22, 49, 61, 0.08);
|
||
color: var(--ink);
|
||
font-size: 12px;
|
||
}
|
||
.two-col {
|
||
display: grid;
|
||
grid-template-columns: 0.9fr 1.1fr;
|
||
gap: 16px;
|
||
}
|
||
.detail-box {
|
||
border: 1px solid rgba(22, 49, 61, 0.1);
|
||
border-radius: 16px;
|
||
padding: 14px;
|
||
background: rgba(255, 255, 255, 0.78);
|
||
min-height: 180px;
|
||
}
|
||
.inline-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.mono {
|
||
font-family: "SF Mono", ui-monospace, monospace;
|
||
font-size: 12px;
|
||
word-break: break-all;
|
||
}
|
||
select {
|
||
width: 100%;
|
||
border-radius: 14px;
|
||
border: 1px solid rgba(22, 49, 61, 0.12);
|
||
padding: 12px 14px;
|
||
font: inherit;
|
||
background: rgba(255, 255, 255, 0.96);
|
||
color: var(--ink);
|
||
}
|
||
.report-suggestion {
|
||
border-left: 3px solid rgba(31, 110, 95, 0.55);
|
||
padding-left: 12px;
|
||
margin-top: 12px;
|
||
white-space: pre-wrap;
|
||
line-height: 1.6;
|
||
}
|
||
.analysis-grid,
|
||
.video-grid {
|
||
display: grid;
|
||
gap: 12px;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
.summary-callout {
|
||
border-radius: 16px;
|
||
padding: 14px;
|
||
background: linear-gradient(135deg, rgba(31, 110, 95, 0.1), rgba(185, 117, 36, 0.08));
|
||
border: 1px solid rgba(31, 110, 95, 0.12);
|
||
line-height: 1.65;
|
||
}
|
||
.bullet-list {
|
||
margin: 0;
|
||
padding-left: 18px;
|
||
display: grid;
|
||
gap: 8px;
|
||
color: var(--ink);
|
||
line-height: 1.6;
|
||
}
|
||
.analysis-block {
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
.video-card {
|
||
border: 1px solid rgba(22, 49, 61, 0.1);
|
||
border-radius: 18px;
|
||
padding: 16px;
|
||
background: rgba(255, 255, 255, 0.85);
|
||
display: grid;
|
||
gap: 14px;
|
||
}
|
||
.video-layout {
|
||
display: grid;
|
||
grid-template-columns: 120px 1fr;
|
||
gap: 14px;
|
||
}
|
||
.cover-thumb {
|
||
width: 100%;
|
||
aspect-ratio: 3 / 4;
|
||
border-radius: 16px;
|
||
object-fit: cover;
|
||
background: rgba(22, 49, 61, 0.08);
|
||
border: 1px solid rgba(22, 49, 61, 0.1);
|
||
}
|
||
.toolbar-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 12px;
|
||
}
|
||
.score-badges {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
.score-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 6px 10px;
|
||
border-radius: 999px;
|
||
background: rgba(31, 110, 95, 0.08);
|
||
color: var(--ink);
|
||
font-size: 12px;
|
||
}
|
||
.score-badge strong {
|
||
color: var(--accent);
|
||
}
|
||
.link-row {
|
||
display: flex;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
}
|
||
.link-row a {
|
||
color: var(--accent);
|
||
text-decoration: none;
|
||
font-weight: 600;
|
||
}
|
||
.link-row a:hover {
|
||
text-decoration: underline;
|
||
}
|
||
details {
|
||
border-top: 1px solid rgba(22, 49, 61, 0.1);
|
||
padding-top: 10px;
|
||
}
|
||
details summary {
|
||
cursor: pointer;
|
||
color: var(--muted);
|
||
}
|
||
.empty-state {
|
||
color: var(--muted);
|
||
font-size: 14px;
|
||
line-height: 1.6;
|
||
}
|
||
.section-divider {
|
||
height: 1px;
|
||
background: rgba(22, 49, 61, 0.1);
|
||
margin: 4px 0;
|
||
}
|
||
@media (max-width: 900px) {
|
||
.grid, .row, .checks, .workbench-layout, .metric-grid, .two-col, .analysis-grid, .video-grid, .video-layout, .toolbar-grid { grid-template-columns: 1fr; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main>
|
||
<section class="hero">
|
||
<span class="pill">StoryForge / Douyin Browser Assist</span>
|
||
<h1 style="margin-top: 14px;">用网页点按钮,驱动真实浏览器采集抖音账号</h1>
|
||
<p>这不是无头绕反爬,而是一个可控的半自动流程。你点击“开始采集”后,脚本会打开真实 Chromium,会话沿用同一份登录态。你在浏览器里登录或过滑块后,回到这里点“已完成登录,继续采集”,系统就会继续抓取主页、creator-center,并按安全规则同步进 StoryForge。</p>
|
||
</section>
|
||
|
||
<div class="grid">
|
||
<section class="card stack">
|
||
<div>
|
||
<h2>开始新采集</h2>
|
||
<p class="hint">默认会导入 StoryForge;如果只是想先抓本地 bundle,可以勾选“仅采集不导入”。</p>
|
||
</div>
|
||
<form id="capture-form" class="stack">
|
||
<label>
|
||
抖音主页链接
|
||
<input id="profile-url" name="profileUrl" placeholder="https://www.douyin.com/user/..." autocomplete="url" required />
|
||
</label>
|
||
<div class="row">
|
||
<label>
|
||
StoryForge 地址
|
||
<input id="backend-url" name="backendUrl" value="${DEFAULT_BACKEND_URL}" autocomplete="url" />
|
||
</label>
|
||
<label>
|
||
备注
|
||
<input id="note" name="note" placeholder="例如:浏览器辅助采集" />
|
||
</label>
|
||
</div>
|
||
<div class="row">
|
||
<label>
|
||
StoryForge 用户名
|
||
<input id="username" name="username" placeholder="kris" autocomplete="username" />
|
||
</label>
|
||
<label>
|
||
StoryForge 密码
|
||
<input id="password" name="password" type="password" placeholder="用于自动导入时登录" autocomplete="current-password" />
|
||
</label>
|
||
</div>
|
||
<div class="row">
|
||
<label>
|
||
最大作品页抓取数
|
||
<input id="max-videos" name="maxVideos" type="number" min="0" max="10" value="4" />
|
||
</label>
|
||
<div class="field-note" style="display:flex;align-items:end;">
|
||
已登录工作台后会自动复用当前会话 Token。通常只需要填账号密码,不需要手动维护 Token。
|
||
</div>
|
||
</div>
|
||
<details class="advanced-box" id="token-details">
|
||
<summary class="advanced-toggle">
|
||
<span>高级认证</span>
|
||
<span class="token-summary" id="token-summary">未填写 Token</span>
|
||
</summary>
|
||
<div class="stack">
|
||
<label>
|
||
已有 Token(可选)
|
||
<input id="token" name="token" placeholder="Bearer token,可替代账号密码" autocomplete="off" />
|
||
</label>
|
||
<div class="field-note">只有在你已经拿到 Bearer Token,或者不希望页面保存密码时,再手动填写这里。</div>
|
||
</div>
|
||
</details>
|
||
<div class="checks">
|
||
<label class="check"><input id="sync-enabled" type="checkbox" checked /> 导入 StoryForge</label>
|
||
<label class="check"><input id="headless" type="checkbox" /> Headless</label>
|
||
<label class="check"><input id="skip-creator-center" type="checkbox" /> 跳过 creator-center</label>
|
||
<label class="check"><input id="allow-fallback" type="checkbox" /> 允许 creator-center 兜底</label>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="primary" type="submit">开始采集</button>
|
||
<button class="warning" id="continue-button" type="button" disabled>已完成登录,继续采集</button>
|
||
<button class="secondary" id="refresh-button" type="button">刷新状态</button>
|
||
</div>
|
||
</form>
|
||
<div class="steps">
|
||
<div class="step">1. 点击“开始采集”,脚本会在本机打开 Chromium。</div>
|
||
<div class="step">2. 在打开的浏览器里完成登录、滑块或验证码,并确认已进入目标主页。</div>
|
||
<div class="step">3. 回到这里点击“已完成登录,继续采集”。</div>
|
||
<div class="step">4. 等待脚本自动抓取、写出 <code>summary.json</code>,并可选同步到 StoryForge。</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2>当前任务</h2>
|
||
<div id="active-status" class="status-box">
|
||
<p class="hint">当前没有进行中的采集任务。</p>
|
||
</div>
|
||
<h3 style="margin-top: 18px;">实时日志</h3>
|
||
<pre id="logs">等待任务启动…</pre>
|
||
</section>
|
||
</div>
|
||
|
||
<section class="card" style="margin-top: 18px;">
|
||
<div style="display: flex; justify-content: space-between; gap: 12px; align-items: center;">
|
||
<div>
|
||
<h2>最近运行</h2>
|
||
<p class="hint">这里展示的是控制台模式启动过的采集任务。</p>
|
||
</div>
|
||
</div>
|
||
<div id="recent-runs" class="recent-list" style="margin-top: 14px;"></div>
|
||
</section>
|
||
|
||
<section class="card" style="margin-top: 18px;">
|
||
<div style="display: flex; justify-content: space-between; gap: 12px; align-items: center; flex-wrap: wrap;">
|
||
<div>
|
||
<h2>Douyin Workbench</h2>
|
||
<p class="hint">采集完成后的结构化数据、Agent 结论、快照与对标结果,都在这块工作台里查看。</p>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="secondary" id="workbench-login-button" type="button">登录并加载</button>
|
||
<button class="secondary" id="workbench-refresh-button" type="button">刷新工作台</button>
|
||
<button class="secondary" id="workbench-logout-button" type="button">清除会话</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="workbench-session" class="status-box" style="margin-top: 14px;">
|
||
<p class="hint">登录 StoryForge 后,这里会展示抖音账号列表和分析工作台。</p>
|
||
</div>
|
||
|
||
<div class="workbench-layout">
|
||
<section class="subpanel stack">
|
||
<div style="display:flex;justify-content:space-between;gap:12px;align-items:center;">
|
||
<h3>账号列表</h3>
|
||
<span class="pill" id="accounts-count-pill">0 个账号</span>
|
||
</div>
|
||
<input id="accounts-filter" placeholder="按昵称、抖音号、标签搜索" autocomplete="off" />
|
||
<div id="accounts-list" class="account-list">
|
||
<p class="empty-state">登录后将显示可用账号。</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="stack">
|
||
<div id="workspace-empty" class="subpanel">
|
||
<p class="empty-state">先登录并在左侧选择一个账号,或者从“最近运行”里直接打开同步成功的账号。</p>
|
||
</div>
|
||
|
||
<div id="workspace-content" class="stack" hidden>
|
||
<section class="subpanel stack">
|
||
<div style="display:flex;justify-content:space-between;gap:12px;align-items:center;flex-wrap:wrap;">
|
||
<h3>账号总览</h3>
|
||
<div class="inline-actions">
|
||
<button class="secondary" id="reload-selected-account-button" type="button">刷新当前账号</button>
|
||
</div>
|
||
</div>
|
||
<div id="account-overview"></div>
|
||
</section>
|
||
|
||
<section class="subpanel stack">
|
||
<div style="display:flex;justify-content:space-between;gap:12px;align-items:center;flex-wrap:wrap;">
|
||
<h3>Agent 结论</h3>
|
||
<div class="inline-actions">
|
||
<button class="primary" id="run-analysis-button" type="button">运行分析</button>
|
||
</div>
|
||
</div>
|
||
<div class="row">
|
||
<label>
|
||
分析重点
|
||
<textarea id="analysis-focus" placeholder="例如:请重点总结选题结构、钩子设计、镜头节奏和二创模板。"></textarea>
|
||
</label>
|
||
<div class="stack">
|
||
<label>
|
||
分析模型
|
||
<select id="analysis-model-select"></select>
|
||
</label>
|
||
<label>
|
||
最大视频数
|
||
<input id="analysis-max-videos" type="number" min="1" max="20" value="12" />
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div id="analysis-feedback" class="hint">这里会显示分析执行状态和最近报告。</div>
|
||
<div id="analysis-reports" class="stack"></div>
|
||
</section>
|
||
|
||
<section class="subpanel stack">
|
||
<div style="display:flex;justify-content:space-between;gap:12px;align-items:center;flex-wrap:wrap;">
|
||
<div>
|
||
<h3>作品工作台</h3>
|
||
<p class="hint" style="margin:6px 0 0;">这里会把高分作品和最新作品拆开看,并给高分作品自动补运营分析。</p>
|
||
</div>
|
||
<div class="inline-actions">
|
||
<button class="secondary" id="analyze-top-videos-button" type="button">自动分析高分作品</button>
|
||
</div>
|
||
</div>
|
||
<div class="toolbar-grid">
|
||
<label>
|
||
作品列表
|
||
<select id="videos-scope-select">
|
||
<option value="all">全部作品</option>
|
||
<option value="top">高分作品</option>
|
||
<option value="latest">最新作品</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
排序方式
|
||
<select id="videos-sort-select">
|
||
<option value="score">综合高分</option>
|
||
<option value="commercial">商业价值</option>
|
||
<option value="latest">最新发布时间</option>
|
||
<option value="play">播放量</option>
|
||
<option value="like">点赞量</option>
|
||
<option value="share">分享量</option>
|
||
<option value="comment">评论量</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
标签筛选
|
||
<input id="videos-tag-filter" placeholder="例如:创业 / 文案" autocomplete="off" />
|
||
</label>
|
||
<label>
|
||
关键词搜索
|
||
<input id="videos-query-filter" placeholder="按标题或描述搜索" autocomplete="off" />
|
||
</label>
|
||
</div>
|
||
<div id="videos-summary"></div>
|
||
<div id="videos-list" class="stack"></div>
|
||
</section>
|
||
|
||
<section class="subpanel stack">
|
||
<h3>快照与原始采集</h3>
|
||
<div id="snapshot-summary"></div>
|
||
<div class="two-col">
|
||
<div class="stack">
|
||
<h4 style="margin:0;">快照列表</h4>
|
||
<div id="snapshot-list" class="snapshot-list"></div>
|
||
</div>
|
||
<div class="stack">
|
||
<h4 style="margin:0;">快照详情</h4>
|
||
<div id="snapshot-detail" class="detail-box">
|
||
<p class="empty-state">选择左侧快照后,这里会展示字段摘要和原始内容。</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="subpanel stack">
|
||
<h3>对标与相似账号</h3>
|
||
<div id="linked-accounts" class="stack"></div>
|
||
<div class="section-divider"></div>
|
||
<div class="two-col">
|
||
<div class="stack">
|
||
<h4 style="margin:0;">最近相似搜索</h4>
|
||
<div id="similar-search-list" class="similar-list"></div>
|
||
</div>
|
||
<div class="stack">
|
||
<h4 style="margin:0;">相似搜索详情</h4>
|
||
<div id="similar-search-detail" class="detail-box">
|
||
<p class="empty-state">选择一次相似搜索后,这里会展示候选账号和推荐理由。</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
|
||
<script>
|
||
const activeStatusEl = document.getElementById("active-status");
|
||
const logsEl = document.getElementById("logs");
|
||
const recentRunsEl = document.getElementById("recent-runs");
|
||
const continueButton = document.getElementById("continue-button");
|
||
const refreshButton = document.getElementById("refresh-button");
|
||
const form = document.getElementById("capture-form");
|
||
const storageKey = "storyforge-douyin-control-panel";
|
||
const sessionStorageKey = "storyforge-douyin-workbench-session";
|
||
const workbenchSessionEl = document.getElementById("workbench-session");
|
||
const tokenDetailsEl = document.getElementById("token-details");
|
||
const tokenInputEl = document.getElementById("token");
|
||
const tokenSummaryEl = document.getElementById("token-summary");
|
||
const accountsCountPillEl = document.getElementById("accounts-count-pill");
|
||
const accountsListEl = document.getElementById("accounts-list");
|
||
const accountsFilterEl = document.getElementById("accounts-filter");
|
||
const workspaceEmptyEl = document.getElementById("workspace-empty");
|
||
const workspaceContentEl = document.getElementById("workspace-content");
|
||
const accountOverviewEl = document.getElementById("account-overview");
|
||
const analysisFeedbackEl = document.getElementById("analysis-feedback");
|
||
const analysisReportsEl = document.getElementById("analysis-reports");
|
||
const analysisFocusEl = document.getElementById("analysis-focus");
|
||
const analysisModelSelectEl = document.getElementById("analysis-model-select");
|
||
const analysisMaxVideosEl = document.getElementById("analysis-max-videos");
|
||
const analyzeTopVideosButton = document.getElementById("analyze-top-videos-button");
|
||
const videosScopeEl = document.getElementById("videos-scope-select");
|
||
const videosSortEl = document.getElementById("videos-sort-select");
|
||
const videosTagFilterEl = document.getElementById("videos-tag-filter");
|
||
const videosQueryFilterEl = document.getElementById("videos-query-filter");
|
||
const videosSummaryEl = document.getElementById("videos-summary");
|
||
const videosListEl = document.getElementById("videos-list");
|
||
const snapshotSummaryEl = document.getElementById("snapshot-summary");
|
||
const snapshotListEl = document.getElementById("snapshot-list");
|
||
const snapshotDetailEl = document.getElementById("snapshot-detail");
|
||
const linkedAccountsEl = document.getElementById("linked-accounts");
|
||
const similarSearchListEl = document.getElementById("similar-search-list");
|
||
const similarSearchDetailEl = document.getElementById("similar-search-detail");
|
||
const workbenchLoginButton = document.getElementById("workbench-login-button");
|
||
const workbenchRefreshButton = document.getElementById("workbench-refresh-button");
|
||
const workbenchLogoutButton = document.getElementById("workbench-logout-button");
|
||
const reloadSelectedAccountButton = document.getElementById("reload-selected-account-button");
|
||
const runAnalysisButton = document.getElementById("run-analysis-button");
|
||
let activeRunId = "";
|
||
const workbenchState = {
|
||
session: null,
|
||
accounts: [],
|
||
selectedAccountId: "",
|
||
selectedWorkspace: null,
|
||
videoItems: [],
|
||
videoMeta: null,
|
||
topScoredVideoIds: [],
|
||
latestVideoIds: [],
|
||
highScoreThreshold: 60,
|
||
snapshots: [],
|
||
selectedSnapshotId: "",
|
||
selectedSnapshotDetail: null,
|
||
similarSearchDetail: null,
|
||
loadingAccountId: "",
|
||
lastAnalysisMessage: ""
|
||
};
|
||
|
||
function escapeHtml(value) {
|
||
return String(value || "")
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">");
|
||
}
|
||
|
||
function escapeHtmlWithBreaks(value) {
|
||
return escapeHtml(value).replaceAll("\\n", "<br>");
|
||
}
|
||
|
||
function safeArray(value) {
|
||
return Array.isArray(value) ? value : [];
|
||
}
|
||
|
||
function formatNumber(value) {
|
||
const num = Number(value || 0);
|
||
if (!Number.isFinite(num)) {
|
||
return "-";
|
||
}
|
||
if (Math.abs(num) >= 10000) {
|
||
return (num / 10000).toFixed(1) + "w";
|
||
}
|
||
return num.toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
||
}
|
||
|
||
function formatDateTime(value) {
|
||
if (!value) {
|
||
return "-";
|
||
}
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) {
|
||
return String(value);
|
||
}
|
||
return date.toLocaleString("zh-CN", { hour12: false });
|
||
}
|
||
|
||
function formatPercent(value) {
|
||
const num = Number(value || 0);
|
||
if (!Number.isFinite(num)) {
|
||
return "-";
|
||
}
|
||
let text = (num * 100).toFixed(2);
|
||
if (text.endsWith(".00")) {
|
||
text = text.slice(0, -3);
|
||
}
|
||
return text + "%";
|
||
}
|
||
|
||
function normalizeBackendUrl(value) {
|
||
let normalized = String(value || "").trim();
|
||
while (normalized.endsWith("/")) {
|
||
normalized = normalized.slice(0, -1);
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
function getFormAuthPayload() {
|
||
const backendUrl = normalizeBackendUrl(document.getElementById("backend-url").value);
|
||
const username = document.getElementById("username").value.trim();
|
||
const password = document.getElementById("password").value;
|
||
const token = tokenInputEl.value.trim();
|
||
return {
|
||
backendUrl,
|
||
username,
|
||
password,
|
||
token
|
||
};
|
||
}
|
||
|
||
function updateTokenUi() {
|
||
const token = tokenInputEl.value.trim();
|
||
tokenSummaryEl.textContent = token ? "已填写 Token" : "未填写 Token";
|
||
if (!token && document.activeElement !== tokenInputEl) {
|
||
tokenDetailsEl.open = false;
|
||
}
|
||
}
|
||
|
||
function persistWorkbenchSession(session) {
|
||
workbenchState.session = session;
|
||
localStorage.setItem(sessionStorageKey, JSON.stringify(session));
|
||
renderWorkbenchSession();
|
||
}
|
||
|
||
function clearWorkbenchSession() {
|
||
workbenchState.session = null;
|
||
workbenchState.accounts = [];
|
||
workbenchState.selectedAccountId = "";
|
||
workbenchState.selectedWorkspace = null;
|
||
workbenchState.videoItems = [];
|
||
workbenchState.videoMeta = null;
|
||
workbenchState.topScoredVideoIds = [];
|
||
workbenchState.latestVideoIds = [];
|
||
workbenchState.highScoreThreshold = 60;
|
||
workbenchState.snapshots = [];
|
||
workbenchState.selectedSnapshotId = "";
|
||
workbenchState.selectedSnapshotDetail = null;
|
||
workbenchState.similarSearchDetail = null;
|
||
workbenchState.lastAnalysisMessage = "";
|
||
localStorage.removeItem(sessionStorageKey);
|
||
renderWorkbenchSession();
|
||
renderAccountList();
|
||
renderWorkspace();
|
||
}
|
||
|
||
function loadWorkbenchSession() {
|
||
try {
|
||
const saved = JSON.parse(localStorage.getItem(sessionStorageKey) || "null");
|
||
if (saved && saved.token && saved.backendUrl) {
|
||
workbenchState.session = saved;
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
async function storyforgeFetch(pathname, options = {}) {
|
||
const session = workbenchState.session;
|
||
const backendUrl = normalizeBackendUrl(options.backendUrl || session?.backendUrl || document.getElementById("backend-url").value);
|
||
const token = String(options.token || session?.token || "").trim();
|
||
const headers = {
|
||
"content-type": "application/json"
|
||
};
|
||
if (token) {
|
||
headers.Authorization = "Bearer " + token;
|
||
}
|
||
const response = await fetch(backendUrl + pathname, {
|
||
method: options.method || "GET",
|
||
headers,
|
||
body: options.body ? JSON.stringify(options.body) : undefined
|
||
});
|
||
const raw = await response.text();
|
||
let payload = null;
|
||
try {
|
||
payload = raw ? JSON.parse(raw) : null;
|
||
} catch {
|
||
payload = { raw };
|
||
}
|
||
if (!response.ok) {
|
||
const detail = payload?.detail;
|
||
const message =
|
||
(typeof detail === "string" && detail) ||
|
||
detail?.message ||
|
||
payload?.message ||
|
||
payload?.raw ||
|
||
("HTTP " + response.status);
|
||
throw new Error(message);
|
||
}
|
||
return payload;
|
||
}
|
||
|
||
function renderBulletList(items, emptyText) {
|
||
const safeItems = safeArray(items).filter(Boolean);
|
||
if (!safeItems.length) {
|
||
return '<p class="empty-state">' + escapeHtml(emptyText || "暂无内容。") + '</p>';
|
||
}
|
||
return '<ul class="bullet-list">' + safeItems.map((item) => '<li>' + escapeHtml(typeof item === "string" ? item : JSON.stringify(item)) + '</li>').join("") + '</ul>';
|
||
}
|
||
|
||
function renderObjectBulletList(items, renderFn, emptyText) {
|
||
const safeItems = safeArray(items);
|
||
if (!safeItems.length) {
|
||
return '<p class="empty-state">' + escapeHtml(emptyText || "暂无内容。") + '</p>';
|
||
}
|
||
return '<div class="stack">' + safeItems.map(renderFn).join("") + '</div>';
|
||
}
|
||
|
||
function getSortedVideos() {
|
||
const scope = videosScopeEl.value || "all";
|
||
const sortBy = videosSortEl.value || "score";
|
||
const query = videosQueryFilterEl.value.trim().toLowerCase();
|
||
const tag = videosTagFilterEl.value.trim().toLowerCase();
|
||
const topSet = new Set(safeArray(workbenchState.topScoredVideoIds));
|
||
const latestSet = new Set(safeArray(workbenchState.latestVideoIds));
|
||
let items = safeArray(workbenchState.videoItems).filter((video) => {
|
||
if (scope === "top" && !topSet.has(video.id)) {
|
||
return false;
|
||
}
|
||
if (scope === "latest" && !latestSet.has(video.id)) {
|
||
return false;
|
||
}
|
||
if (query) {
|
||
const haystack = [video.title, video.description, video.aweme_id, ...safeArray(video.tags)].join(" ").toLowerCase();
|
||
if (!haystack.includes(query)) {
|
||
return false;
|
||
}
|
||
}
|
||
if (tag) {
|
||
const tags = safeArray(video.tags).map((item) => String(item).toLowerCase());
|
||
if (!tags.some((item) => item.includes(tag))) {
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
});
|
||
const getValue = (video) => {
|
||
if (sortBy === "commercial") return Number(video.score?.commercial_score || 0);
|
||
if (sortBy === "latest") return new Date(video.published_at || 0).getTime();
|
||
if (sortBy === "play") return Number(video.stats?.play || 0);
|
||
if (sortBy === "like") return Number(video.stats?.like || 0);
|
||
if (sortBy === "share") return Number(video.stats?.share || 0);
|
||
if (sortBy === "comment") return Number(video.stats?.comment || 0);
|
||
return Number(video.score?.performance_score || 0);
|
||
};
|
||
items.sort((left, right) => getValue(right) - getValue(left));
|
||
return items;
|
||
}
|
||
|
||
function renderAccountSuggestion(suggestion) {
|
||
const parsed = suggestion.parsed_json || {};
|
||
if (!parsed.executive_summary) {
|
||
return [
|
||
'<div class="report-item">',
|
||
'<div style="display:flex;justify-content:space-between;gap:12px;align-items:flex-start;flex-wrap:wrap;">',
|
||
'<strong>' + escapeHtml(suggestion.model_label || "模型") + '</strong>',
|
||
'<span class="pill">' + escapeHtml(suggestion.status || "-") + '</span>',
|
||
'</div>',
|
||
'<div class="report-suggestion">' + escapeHtmlWithBreaks(suggestion.suggestion_text || "暂无结论") + '</div>',
|
||
'</div>'
|
||
].join("");
|
||
}
|
||
const positioning = parsed.commercial_positioning || {};
|
||
const engine = parsed.content_engine || {};
|
||
return [
|
||
'<div class="report-item">',
|
||
'<div style="display:flex;justify-content:space-between;gap:12px;align-items:flex-start;flex-wrap:wrap;">',
|
||
'<strong>' + escapeHtml(suggestion.model_label || "模型") + '</strong>',
|
||
'<span class="pill">' + escapeHtml(suggestion.status || "-") + '</span>',
|
||
'</div>',
|
||
'<div class="summary-callout" style="margin-top:12px;">' + escapeHtml(parsed.executive_summary || "暂无总结") + '</div>',
|
||
'<div class="analysis-grid" style="margin-top:12px;">',
|
||
'<div class="detail-box"><h4 style="margin:0 0 10px;">商业定位</h4><div class="meta">受众:' + escapeHtml(positioning.audience || "-") + '</div><div class="meta" style="margin-top:8px;">核心承诺:' + escapeHtml(positioning.core_promise || "-") + '</div><div class="meta" style="margin-top:8px;">商业化准备度:' + escapeHtml(formatNumber(positioning.monetization_readiness_score)) + '</div><div style="margin-top:12px;">' + renderBulletList(positioning.offer_directions, "暂无产品方向建议") + '</div></div>',
|
||
'<div class="detail-box"><h4 style="margin:0 0 10px;">内容引擎</h4><div class="meta">内容支柱</div>' + renderBulletList(engine.pillars, "暂无内容支柱") + '<div class="meta" style="margin-top:10px;">开头模式</div>' + renderBulletList(engine.hook_patterns, "暂无开头模式") + '</div>',
|
||
'</div>',
|
||
'<div class="analysis-grid" style="margin-top:12px;">',
|
||
'<div class="detail-box"><h4 style="margin:0 0 10px;">结构与 CTA</h4><div class="meta">结构模式</div>' + renderBulletList(engine.structure_patterns, "暂无结构结论") + '<div class="meta" style="margin-top:10px;">CTA 模式</div>' + renderBulletList(engine.cta_patterns, "暂无 CTA 建议") + '</div>',
|
||
'<div class="detail-box"><h4 style="margin:0 0 10px;">商业化与运营动作</h4><div class="meta">承接路径</div>' + renderBulletList(parsed.monetization_plan, "暂无商业化承接建议") + '<div class="meta" style="margin-top:10px;">30 天动作</div>' + renderBulletList(parsed.next_30_day_actions, "暂无 30 天动作建议") + '</div>',
|
||
'</div>',
|
||
'<div class="analysis-grid" style="margin-top:12px;">',
|
||
'<div class="detail-box"><h4 style="margin:0 0 10px;">高分作品规律</h4>' + renderObjectBulletList(parsed.winning_patterns, (item) => '<div class="link-item"><strong>' + escapeHtml(item.video_title || "高分作品") + '</strong><div class="meta" style="margin-top:6px;">' + escapeHtml(item.why || "-") + '</div><div class="meta" style="margin-top:6px;">复刻建议:' + escapeHtml(item.replication_angle || "-") + '</div></div>', "暂无高分作品分析") + '</div>',
|
||
'<div class="detail-box"><h4 style="margin:0 0 10px;">最近内容信号</h4>' + renderObjectBulletList(parsed.latest_signal, (item) => '<div class="link-item"><strong>' + escapeHtml(item.video_title || "最近作品") + '</strong><div class="meta" style="margin-top:6px;">' + escapeHtml(item.signal || "-") + '</div><div class="meta" style="margin-top:6px;">动作:' + escapeHtml(item.action || "-") + '</div></div>', "暂无最新作品信号") + '</div>',
|
||
'</div>',
|
||
'<div class="analysis-grid" style="margin-top:12px;">',
|
||
'<div class="detail-box"><h4 style="margin:0 0 10px;">对标与风险</h4><div class="meta">对标洞察</div>' + renderBulletList(parsed.benchmark_insights, "暂无对标洞察") + '<div class="meta" style="margin-top:10px;">风险观察</div>' + renderBulletList(parsed.risk_watchlist, "暂无风险提示") + '</div>',
|
||
'<div class="detail-box"><h4 style="margin:0 0 10px;">当前缺口</h4>' + renderBulletList(parsed.operational_gaps, "暂无明显缺口") + '</div>',
|
||
'</div>',
|
||
suggestion.suggestion_text ? '<details style="margin-top:12px;"><summary>查看模型原始输出</summary><div class="report-suggestion">' + escapeHtmlWithBreaks(suggestion.suggestion_text) + '</div></details>' : '',
|
||
'</div>'
|
||
].join("");
|
||
}
|
||
|
||
function renderVideoAnalysisCard(video) {
|
||
const analysis = video.latest_analysis || {};
|
||
const parsed = analysis.parsed_json || {};
|
||
const score = video.score || {};
|
||
const stats = video.stats || {};
|
||
return [
|
||
'<article class="video-card">',
|
||
'<div class="video-layout">',
|
||
video.cover_url ? '<img class="cover-thumb" src="' + escapeHtml(video.cover_url) + '" alt="cover" />' : '<div class="cover-thumb"></div>',
|
||
'<div class="stack">',
|
||
'<div style="display:flex;justify-content:space-between;gap:12px;align-items:flex-start;flex-wrap:wrap;">',
|
||
'<div><strong>' + escapeHtml(video.title || video.aweme_id || "未命名作品") + '</strong><div class="meta" style="margin-top:8px;">发布时间:' + escapeHtml(formatDateTime(video.published_at)) + '</div></div>',
|
||
'<span class="pill">' + escapeHtml(video.aweme_id || "-") + '</span>',
|
||
'</div>',
|
||
'<div class="score-badges">',
|
||
'<span class="score-badge"><strong>综合</strong>' + escapeHtml(formatNumber(score.performance_score)) + '</span>',
|
||
'<span class="score-badge"><strong>商业</strong>' + escapeHtml(formatNumber(score.commercial_score)) + '</span>',
|
||
'<span class="score-badge"><strong>播放</strong>' + escapeHtml(formatNumber(stats.play)) + '</span>',
|
||
'<span class="score-badge"><strong>点赞</strong>' + escapeHtml(formatNumber(stats.like)) + '</span>',
|
||
'<span class="score-badge"><strong>分享</strong>' + escapeHtml(formatNumber(stats.share)) + '</span>',
|
||
'<span class="score-badge"><strong>收藏率</strong>' + escapeHtml(formatPercent(score.collect_rate)) + '</span>',
|
||
'</div>',
|
||
safeArray(video.tags).length ? '<div class="chips">' + safeArray(video.tags).map((tag) => '<span class="chip">' + escapeHtml(tag) + '</span>').join("") + '</div>' : '',
|
||
'<div class="meta">互动率:' + escapeHtml(formatPercent(score.engagement_rate)) + ',评论率:' + escapeHtml(formatPercent(score.comment_rate)) + ',发布时间距今:' + escapeHtml(score.age_days == null ? "-" : score.age_days + " 天") + '</div>',
|
||
'<div class="chips">' + safeArray(score.signals).map((item) => '<span class="chip">' + escapeHtml(item) + '</span>').join("") + '</div>',
|
||
'<div class="link-row">' + (video.share_url ? '<a href="' + escapeHtml(video.share_url) + '" target="_blank" rel="noreferrer">打开作品</a>' : '') + '</div>',
|
||
'</div>',
|
||
'</div>',
|
||
parsed.headline_summary ? '<div class="analysis-block"><div class="summary-callout">' + escapeHtml(parsed.headline_summary || analysis.summary_text || "暂无分析结论") + '</div><div class="analysis-grid"><div class="detail-box"><h4 style="margin:0 0 10px;">为什么值得做</h4><div class="meta">商业判断:' + escapeHtml((parsed.commercial_angle || {}).judgement || "-") + '</div><div class="meta" style="margin-top:8px;">可承接方向</div>' + renderBulletList((parsed.commercial_angle || {}).suitable_for, "暂无承接方向") + '<div class="meta" style="margin-top:10px;">分项评分</div>' + renderBulletList(['钩子 ' + formatNumber((parsed.scores || {}).hook), '留存 ' + formatNumber((parsed.scores || {}).retention), '转化 ' + formatNumber((parsed.scores || {}).conversion), '商业 ' + formatNumber((parsed.scores || {}).commercial)], "暂无分项评分") + '</div><div class="detail-box"><h4 style="margin:0 0 10px;">复刻与运营动作</h4><div class="meta">复刻计划</div>' + renderBulletList(parsed.replication_plan, "暂无复刻计划") + '<div class="meta" style="margin-top:10px;">运营动作</div>' + renderBulletList(parsed.operator_actions, "暂无运营动作") + '</div></div><div class="analysis-grid"><div class="detail-box"><h4 style="margin:0 0 10px;">钩子与结构</h4><div class="meta">钩子拆解</div>' + renderBulletList(parsed.hook_breakdown, "暂无钩子拆解") + '<div class="meta" style="margin-top:10px;">结构拆解</div>' + renderBulletList(parsed.structure_breakdown, "暂无结构拆解") + '</div><div class="detail-box"><h4 style="margin:0 0 10px;">风险提醒</h4>' + renderBulletList(parsed.risk_notes, "暂无风险提醒") + '</div></div></div>' : '<div class="detail-box"><p class="empty-state">这条作品还没有自动分析。点击上面的“自动分析高分作品”即可补齐。</p></div>',
|
||
'</article>'
|
||
].join("");
|
||
}
|
||
|
||
function renderVideos() {
|
||
const items = getSortedVideos();
|
||
const meta = workbenchState.videoMeta || {};
|
||
videosSummaryEl.innerHTML = [
|
||
'<div class="metric-grid">',
|
||
'<div class="metric-card"><div class="metric-label">作品总数</div><div class="metric-value">' + escapeHtml(formatNumber(meta.total_count)) + '</div></div>',
|
||
'<div class="metric-card"><div class="metric-label">已分析作品</div><div class="metric-value">' + escapeHtml(formatNumber(meta.analyzed_count)) + '</div></div>',
|
||
'<div class="metric-card"><div class="metric-label">高分作品数</div><div class="metric-value">' + escapeHtml(formatNumber(meta.high_score_count)) + '</div></div>',
|
||
'<div class="metric-card"><div class="metric-label">当前显示</div><div class="metric-value">' + escapeHtml(formatNumber(items.length)) + '</div></div>',
|
||
'</div>',
|
||
'<p class="hint" style="margin-top:12px;">高分阈值:' + escapeHtml(formatNumber(workbenchState.highScoreThreshold)) + '。高分榜更适合找商业化样板,最新榜更适合看近期题材窗口。</p>'
|
||
].join("");
|
||
if (!items.length) {
|
||
videosListEl.innerHTML = '<p class="empty-state">当前筛选条件下没有作品。</p>';
|
||
return;
|
||
}
|
||
videosListEl.innerHTML = '<div class="video-grid">' + items.map(renderVideoAnalysisCard).join("") + '</div>';
|
||
}
|
||
|
||
function renderWorkbenchSession() {
|
||
const session = workbenchState.session;
|
||
if (!session) {
|
||
workbenchSessionEl.innerHTML = '<p class="hint">还没有 StoryForge 会话。你可以直接用上面的账号密码登录,然后加载抖音工作台。</p>';
|
||
return;
|
||
}
|
||
workbenchSessionEl.innerHTML = [
|
||
'<div class="status-line"><strong>当前账号</strong><span>' + escapeHtml(session.account?.display_name || session.account?.username || "未知账号") + '</span></div>',
|
||
'<div class="status-line"><strong>角色</strong><span>' + escapeHtml(session.account?.role || "-") + '</span></div>',
|
||
'<div class="status-line"><strong>后端地址</strong><span class="path">' + escapeHtml(session.backendUrl) + '</span></div>',
|
||
'<div class="status-line"><strong>认证状态</strong><span>Token 已缓存,可直接刷新工作台或复用到采集任务</span></div>'
|
||
].join("");
|
||
}
|
||
|
||
function renderAccountList() {
|
||
const filterText = accountsFilterEl.value.trim().toLowerCase();
|
||
const accounts = safeArray(workbenchState.accounts).filter((account) => {
|
||
if (!filterText) {
|
||
return true;
|
||
}
|
||
const haystack = [
|
||
account.nickname,
|
||
account.douyin_id,
|
||
account.signature,
|
||
...safeArray(account.tags),
|
||
...safeArray(account.keywords)
|
||
].join(" ").toLowerCase();
|
||
return haystack.includes(filterText);
|
||
});
|
||
accountsCountPillEl.textContent = accounts.length + " 个账号";
|
||
if (!accounts.length) {
|
||
accountsListEl.innerHTML = '<p class="empty-state">当前没有符合条件的账号。</p>';
|
||
return;
|
||
}
|
||
accountsListEl.innerHTML = accounts.map((account) => {
|
||
const selected = workbenchState.selectedAccountId === account.id;
|
||
return [
|
||
'<button type="button" class="account-item ' + (selected ? "active" : "") + '" data-account-id="' + escapeHtml(account.id) + '">',
|
||
'<div style="display:flex;justify-content:space-between;gap:12px;align-items:flex-start;">',
|
||
'<strong>' + escapeHtml(account.nickname || "未命名账号") + '</strong>',
|
||
'<span class="pill">' + escapeHtml(account.sync_status || "unknown") + '</span>',
|
||
'</div>',
|
||
'<p class="meta" style="margin:10px 0 0;">抖音号:' + escapeHtml(account.douyin_id || "-") + '</p>',
|
||
'<p class="meta" style="margin:6px 0 0;">最近视频:' + escapeHtml(account.video_summary?.count ?? "-") + ' 条</p>',
|
||
safeArray(account.tags).length ? '<div class="chips" style="margin-top:10px;">' + safeArray(account.tags).slice(0, 6).map((tag) => '<span class="chip">' + escapeHtml(tag) + '</span>').join("") + '</div>' : '',
|
||
'</button>'
|
||
].join("");
|
||
}).join("");
|
||
}
|
||
|
||
function renderSnapshotDetail() {
|
||
const detail = workbenchState.selectedSnapshotDetail;
|
||
if (!detail) {
|
||
snapshotDetailEl.innerHTML = '<p class="empty-state">选择左侧快照后,这里会展示字段摘要和原始内容。</p>';
|
||
return;
|
||
}
|
||
const fields = safeArray(detail.fields).slice(0, 40).map((field) => {
|
||
return '<div class="link-item"><div><strong>' + escapeHtml(field.field_path || "-") + '</strong></div><div class="meta" style="margin-top:6px;">类型:' + escapeHtml(field.field_type || "-") + '</div><div class="meta" style="margin-top:6px;">值:' + escapeHtml(String(field.field_value || "")).slice(0, 220) + '</div></div>';
|
||
}).join("");
|
||
snapshotDetailEl.innerHTML = [
|
||
'<div class="stack">',
|
||
'<div class="meta">快照类型:' + escapeHtml(detail.snapshot_type || "-") + '</div>',
|
||
'<div class="meta">来源:' + escapeHtml(detail.source_url || "-") + '</div>',
|
||
detail.summary ? '<pre>' + escapeHtml(JSON.stringify(detail.summary, null, 2)) + '</pre>' : '',
|
||
fields || '<p class="empty-state">这个快照当前没有可展示的字段。</p>',
|
||
'</div>'
|
||
].join("");
|
||
}
|
||
|
||
function renderSimilarSearchDetail() {
|
||
const detail = workbenchState.similarSearchDetail;
|
||
if (!detail) {
|
||
similarSearchDetailEl.innerHTML = '<p class="empty-state">选择一次相似搜索后,这里会展示候选账号和推荐理由。</p>';
|
||
return;
|
||
}
|
||
const candidates = safeArray(detail.candidates);
|
||
similarSearchDetailEl.innerHTML = [
|
||
'<div class="stack">',
|
||
'<div class="meta">关键词:' + escapeHtml(safeArray(detail.keywords).join(" / ")) + '</div>',
|
||
candidates.length ? candidates.map((candidate) => {
|
||
return [
|
||
'<div class="link-item">',
|
||
'<div style="display:flex;justify-content:space-between;gap:12px;align-items:flex-start;">',
|
||
'<strong>' + escapeHtml(candidate.candidate_nickname || candidate.candidate_profile_url || "候选账号") + '</strong>',
|
||
'<span class="pill">综合 ' + escapeHtml(formatNumber(candidate.agent_score || candidate.heuristic_score)) + '</span>',
|
||
'</div>',
|
||
'<div class="meta" style="margin-top:8px;">' + escapeHtml(candidate.candidate_profile_url || "-") + '</div>',
|
||
'<div class="meta" style="margin-top:8px;">' + escapeHtml(candidate.rationale_text || "暂无理由说明") + '</div>',
|
||
'</div>'
|
||
].join("");
|
||
}).join("") : '<p class="empty-state">这次搜索还没有候选账号。</p>',
|
||
'</div>'
|
||
].join("");
|
||
}
|
||
|
||
function renderWorkspace() {
|
||
const workspace = workbenchState.selectedWorkspace;
|
||
if (!workspace || !workspace.account) {
|
||
workspaceEmptyEl.hidden = false;
|
||
workspaceContentEl.hidden = true;
|
||
accountOverviewEl.innerHTML = "";
|
||
analysisReportsEl.innerHTML = "";
|
||
snapshotSummaryEl.innerHTML = "";
|
||
snapshotListEl.innerHTML = "";
|
||
linkedAccountsEl.innerHTML = "";
|
||
similarSearchListEl.innerHTML = "";
|
||
videosSummaryEl.innerHTML = "";
|
||
videosListEl.innerHTML = "";
|
||
renderSnapshotDetail();
|
||
renderSimilarSearchDetail();
|
||
return;
|
||
}
|
||
|
||
const account = workspace.account;
|
||
const videos = safeArray(account.video_summary?.videos);
|
||
const reports = safeArray(workspace.recent_reports);
|
||
const linkedAccounts = safeArray(workspace.linked_accounts);
|
||
const similarSearches = safeArray(workspace.recent_similarity_searches);
|
||
const models = safeArray(workspace.available_model_profiles);
|
||
const syncErrors = safeArray(workspace.sync_errors);
|
||
workspaceEmptyEl.hidden = true;
|
||
workspaceContentEl.hidden = false;
|
||
|
||
if (!analysisFocusEl.value.trim() && reports[0]?.focus_text) {
|
||
analysisFocusEl.value = reports[0].focus_text;
|
||
}
|
||
|
||
const selectedModelId = analysisModelSelectEl.value;
|
||
analysisModelSelectEl.innerHTML = models.length ? models.map((model) => {
|
||
const selected = selectedModelId ? selectedModelId === model.id : Boolean(model.is_default);
|
||
return '<option value="' + escapeHtml(model.id) + '"' + (selected ? " selected" : "") + '>' + escapeHtml(model.name + " / " + model.model_name) + '</option>';
|
||
}).join("") : '<option value="">暂无可用模型</option>';
|
||
|
||
accountOverviewEl.innerHTML = [
|
||
'<div class="profile-hero">',
|
||
account.avatar_url ? '<img class="avatar" src="' + escapeHtml(account.avatar_url) + '" alt="avatar" />' : '<div class="avatar"></div>',
|
||
'<div class="stack">',
|
||
'<div style="display:flex;justify-content:space-between;gap:12px;align-items:flex-start;flex-wrap:wrap;">',
|
||
'<div><h3 style="margin:0;">' + escapeHtml(account.nickname || "未命名账号") + '</h3><div class="meta" style="margin-top:8px;">抖音号:' + escapeHtml(account.douyin_id || "-") + '</div></div>',
|
||
'<span class="pill">' + escapeHtml(account.sync_status || "unknown") + '</span>',
|
||
'</div>',
|
||
'<div class="meta">' + escapeHtmlWithBreaks(account.signature || "暂无签名") + '</div>',
|
||
'<div class="path">' + escapeHtml(account.profile_url || "-") + '</div>',
|
||
'</div>',
|
||
'</div>',
|
||
'<div class="metric-grid" style="margin-top: 16px;">',
|
||
'<div class="metric-card"><div class="metric-label">作品数</div><div class="metric-value">' + escapeHtml(formatNumber(account.video_summary?.count)) + '</div></div>',
|
||
'<div class="metric-card"><div class="metric-label">平均播放</div><div class="metric-value">' + escapeHtml(formatNumber(account.video_summary?.avg_play)) + '</div></div>',
|
||
'<div class="metric-card"><div class="metric-label">平均点赞</div><div class="metric-value">' + escapeHtml(formatNumber(account.video_summary?.avg_like)) + '</div></div>',
|
||
'<div class="metric-card"><div class="metric-label">平均分享</div><div class="metric-value">' + escapeHtml(formatNumber(account.video_summary?.avg_share)) + '</div></div>',
|
||
'</div>',
|
||
safeArray(account.tags).length ? '<div class="chips" style="margin-top:16px;">' + safeArray(account.tags).slice(0, 18).map((tag) => '<span class="chip">' + escapeHtml(tag) + '</span>').join("") + '</div>' : '',
|
||
syncErrors.length ? '<div class="link-item" style="margin-top:16px;"><strong>同步提示</strong><div class="meta" style="margin-top:8px;">' + escapeHtml(syncErrors.join(" / ")) + '</div></div>' : '',
|
||
videos.length ? '<div class="stack" style="margin-top:16px;"><h4 style="margin:0;">最近作品</h4>' + videos.slice(0, 8).map((video) => '<div class="video-item"><strong>' + escapeHtml(video.title || video.description || video.aweme_id) + '</strong><div class="meta" style="margin-top:8px;">发布时间:' + escapeHtml(formatDateTime(video.published_at)) + '</div><div class="meta" style="margin-top:6px;">播放 ' + escapeHtml(formatNumber(video.stats?.play)) + ' / 点赞 ' + escapeHtml(formatNumber(video.stats?.like)) + ' / 评论 ' + escapeHtml(formatNumber(video.stats?.comment)) + '</div></div>').join("") + '</div>' : ''
|
||
].join("");
|
||
|
||
analysisReportsEl.innerHTML = reports.length ? reports.map((report) => {
|
||
return [
|
||
'<div class="report-item">',
|
||
'<div style="display:flex;justify-content:space-between;gap:12px;align-items:flex-start;flex-wrap:wrap;">',
|
||
'<strong>' + escapeHtml(report.focus_text || "默认分析") + '</strong>',
|
||
'<span class="pill">' + escapeHtml(formatDateTime(report.created_at)) + '</span>',
|
||
'</div>',
|
||
Number(report.duplicate_count || 1) > 1 ? '<div class="meta" style="margin-top:8px;">已折叠 ' + escapeHtml(String(Number(report.duplicate_count) - 1)) + ' 条同主题历史</div>' : '',
|
||
safeArray(report.suggestions).length ? safeArray(report.suggestions).map(renderAccountSuggestion).join("") : '<p class="empty-state" style="margin-top:10px;">这份报告还没有 suggestion。</p>',
|
||
'</div>'
|
||
].join("");
|
||
}).join("") : '<p class="empty-state">这个账号还没有分析报告。你可以直接点上面的“运行分析”。</p>';
|
||
|
||
snapshotSummaryEl.innerHTML = [
|
||
workspace.latest_public_snapshot ? '<div class="link-item"><strong>最新 public 快照</strong><div class="meta" style="margin-top:8px;">' + escapeHtml(workspace.latest_public_snapshot.source_url || "-") + '</div><div class="meta" style="margin-top:6px;">采集时间:' + escapeHtml(formatDateTime(workspace.latest_public_snapshot.collected_at)) + ',字段数 ' + escapeHtml(workspace.latest_public_snapshot.field_count) + '</div></div>' : '',
|
||
workspace.latest_creator_snapshot ? '<div class="link-item"><strong>最新 creator 快照</strong><div class="meta" style="margin-top:8px;">' + escapeHtml(workspace.latest_creator_snapshot.source_url || "-") + '</div><div class="meta" style="margin-top:6px;">采集时间:' + escapeHtml(formatDateTime(workspace.latest_creator_snapshot.collected_at)) + ',字段数 ' + escapeHtml(workspace.latest_creator_snapshot.field_count) + '</div></div>' : '',
|
||
(!workspace.latest_public_snapshot && !workspace.latest_creator_snapshot) ? '<p class="empty-state">当前没有可展示的最新快照。</p>' : ''
|
||
].join("");
|
||
|
||
snapshotListEl.innerHTML = workbenchState.snapshots.length ? workbenchState.snapshots.map((snapshot) => {
|
||
const selected = workbenchState.selectedSnapshotId === snapshot.id;
|
||
return '<button type="button" class="snapshot-item ' + (selected ? "active" : "") + '" data-snapshot-id="' + escapeHtml(snapshot.id) + '"><strong>' + escapeHtml(snapshot.snapshot_type || "snapshot") + '</strong><div class="meta" style="margin-top:8px;">' + escapeHtml(snapshot.source_url || "-") + '</div><div class="meta" style="margin-top:6px;">字段 ' + escapeHtml(snapshot.field_count) + ' / ' + escapeHtml(formatDateTime(snapshot.collected_at)) + '</div></button>';
|
||
}).join("") : '<p class="empty-state">这个账号当前没有快照记录。</p>';
|
||
renderSnapshotDetail();
|
||
|
||
linkedAccountsEl.innerHTML = linkedAccounts.length ? [
|
||
'<h4 style="margin:0;">已保存对标账号</h4>',
|
||
linkedAccounts.map((link) => '<div class="link-item"><div style="display:flex;justify-content:space-between;gap:12px;align-items:flex-start;"><strong>' + escapeHtml(link.target_nickname || link.target_profile_url || "未命名对标") + '</strong><span class="pill">' + escapeHtml(link.relation_type || "benchmark") + '</span></div><div class="meta" style="margin-top:8px;">' + escapeHtml(link.target_profile_url || "-") + '</div><div class="meta" style="margin-top:6px;">' + escapeHtml(link.note || "无备注") + '</div></div>').join("")
|
||
].join("") : '<p class="empty-state">这个账号还没有保存对标关系。</p>';
|
||
|
||
similarSearchListEl.innerHTML = similarSearches.length ? similarSearches.map((search) => {
|
||
const selected = workbenchState.similarSearchDetail?.id === search.id;
|
||
return '<button type="button" class="similar-item ' + (selected ? "active" : "") + '" data-search-id="' + escapeHtml(search.id) + '"><strong>' + escapeHtml(safeArray(search.keywords).slice(0, 4).join(" / ") || search.id) + '</strong><div class="meta" style="margin-top:8px;">' + escapeHtml(formatDateTime(search.created_at)) + '</div></button>';
|
||
}).join("") : '<p class="empty-state">这个账号还没有相似搜索记录。</p>';
|
||
renderSimilarSearchDetail();
|
||
renderVideos();
|
||
}
|
||
|
||
async function loadSnapshotDetail(snapshotId) {
|
||
if (!snapshotId || !workbenchState.selectedAccountId) {
|
||
workbenchState.selectedSnapshotId = "";
|
||
workbenchState.selectedSnapshotDetail = null;
|
||
renderSnapshotDetail();
|
||
return;
|
||
}
|
||
workbenchState.selectedSnapshotId = snapshotId;
|
||
renderWorkspace();
|
||
try {
|
||
workbenchState.selectedSnapshotDetail = await storyforgeFetch("/v2/douyin/accounts/" + encodeURIComponent(workbenchState.selectedAccountId) + "/snapshots/" + encodeURIComponent(snapshotId));
|
||
} catch (error) {
|
||
workbenchState.selectedSnapshotDetail = {
|
||
snapshot_type: "error",
|
||
source_url: "",
|
||
summary: { error: error.message },
|
||
fields: []
|
||
};
|
||
}
|
||
renderSnapshotDetail();
|
||
renderAccountList();
|
||
}
|
||
|
||
async function loadSimilarSearchDetail(searchId) {
|
||
if (!searchId) {
|
||
workbenchState.similarSearchDetail = null;
|
||
renderSimilarSearchDetail();
|
||
renderWorkspace();
|
||
return;
|
||
}
|
||
try {
|
||
workbenchState.similarSearchDetail = await storyforgeFetch("/v2/douyin/similar-searches/" + encodeURIComponent(searchId));
|
||
} catch (error) {
|
||
workbenchState.similarSearchDetail = {
|
||
id: searchId,
|
||
keywords: [],
|
||
candidates: [],
|
||
context: { error: error.message }
|
||
};
|
||
}
|
||
renderWorkspace();
|
||
}
|
||
|
||
async function selectAccount(accountId, options = {}) {
|
||
if (!accountId) {
|
||
return;
|
||
}
|
||
const preserveFeedback = options.preserveFeedback === true;
|
||
workbenchState.selectedAccountId = accountId;
|
||
workbenchState.loadingAccountId = accountId;
|
||
if (!preserveFeedback) {
|
||
workbenchState.lastAnalysisMessage = "";
|
||
}
|
||
analysisFeedbackEl.textContent = "正在加载账号工作台...";
|
||
renderAccountList();
|
||
try {
|
||
const results = await Promise.all([
|
||
storyforgeFetch("/v2/douyin/accounts/" + encodeURIComponent(accountId) + "/workspace"),
|
||
storyforgeFetch("/v2/douyin/accounts/" + encodeURIComponent(accountId) + "/snapshots").catch(() => []),
|
||
storyforgeFetch("/v2/douyin/accounts/" + encodeURIComponent(accountId) + "/videos?limit=80").catch(() => ({ items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 }))
|
||
]);
|
||
workbenchState.selectedWorkspace = results[0];
|
||
workbenchState.snapshots = safeArray(results[1]);
|
||
workbenchState.videoItems = safeArray(results[2]?.items);
|
||
workbenchState.videoMeta = results[2]?.meta || {};
|
||
workbenchState.topScoredVideoIds = safeArray(results[2]?.top_scored_video_ids);
|
||
workbenchState.latestVideoIds = safeArray(results[2]?.latest_video_ids);
|
||
workbenchState.highScoreThreshold = Number(results[2]?.high_score_threshold || 60);
|
||
workbenchState.selectedSnapshotId = options.snapshotId || workbenchState.snapshots[0]?.id || "";
|
||
workbenchState.selectedSnapshotDetail = null;
|
||
workbenchState.similarSearchDetail = null;
|
||
renderWorkspace();
|
||
if (workbenchState.selectedSnapshotId) {
|
||
await loadSnapshotDetail(workbenchState.selectedSnapshotId);
|
||
}
|
||
const firstSearchId = safeArray(workbenchState.selectedWorkspace?.recent_similarity_searches)[0]?.id;
|
||
if (firstSearchId) {
|
||
await loadSimilarSearchDetail(firstSearchId);
|
||
}
|
||
analysisFeedbackEl.textContent = workbenchState.lastAnalysisMessage || "工作台已加载。";
|
||
} catch (error) {
|
||
analysisFeedbackEl.textContent = "加载工作台失败: " + error.message;
|
||
} finally {
|
||
workbenchState.loadingAccountId = "";
|
||
renderAccountList();
|
||
}
|
||
}
|
||
|
||
async function loadWorkbenchAccounts() {
|
||
if (!workbenchState.session) {
|
||
renderWorkbenchSession();
|
||
return;
|
||
}
|
||
accountsCountPillEl.textContent = "加载中...";
|
||
try {
|
||
workbenchState.accounts = safeArray(await storyforgeFetch("/v2/douyin/accounts"));
|
||
renderAccountList();
|
||
const selectedExists = workbenchState.accounts.some((account) => account.id === workbenchState.selectedAccountId);
|
||
const nextId = selectedExists ? workbenchState.selectedAccountId : workbenchState.accounts[0]?.id;
|
||
if (nextId) {
|
||
await selectAccount(nextId);
|
||
} else {
|
||
renderWorkspace();
|
||
analysisFeedbackEl.textContent = "当前还没有抖音账号数据。";
|
||
}
|
||
} catch (error) {
|
||
accountsCountPillEl.textContent = "加载失败";
|
||
analysisFeedbackEl.textContent = "加载账号列表失败: " + error.message;
|
||
}
|
||
}
|
||
|
||
async function loginWorkbench() {
|
||
const auth = getFormAuthPayload();
|
||
if (!auth.backendUrl) {
|
||
alert("请先填写 StoryForge 地址。");
|
||
return;
|
||
}
|
||
try {
|
||
let session = null;
|
||
if (auth.token) {
|
||
const account = await storyforgeFetch("/v2/me", {
|
||
backendUrl: auth.backendUrl,
|
||
token: auth.token
|
||
});
|
||
session = { backendUrl: auth.backendUrl, token: auth.token, account };
|
||
} else {
|
||
if (!auth.username || !auth.password) {
|
||
alert("登录工作台需要账号密码,或者直接提供 Token。");
|
||
return;
|
||
}
|
||
const loginPayload = await storyforgeFetch("/v2/auth/login", {
|
||
backendUrl: auth.backendUrl,
|
||
method: "POST",
|
||
body: {
|
||
username: auth.username,
|
||
password: auth.password
|
||
}
|
||
});
|
||
session = {
|
||
backendUrl: auth.backendUrl,
|
||
token: loginPayload.token,
|
||
account: loginPayload.account
|
||
};
|
||
}
|
||
persistWorkbenchSession(session);
|
||
await loadWorkbenchAccounts();
|
||
} catch (error) {
|
||
alert("工作台登录失败: " + error.message);
|
||
}
|
||
}
|
||
|
||
function renderActiveRun(run) {
|
||
activeRunId = run?.id || "";
|
||
continueButton.disabled = !run || run.status !== "awaiting_continue";
|
||
if (!run) {
|
||
activeStatusEl.innerHTML = '<p class="hint">当前没有进行中的采集任务。</p>';
|
||
logsEl.textContent = "等待任务启动…";
|
||
return;
|
||
}
|
||
const summary = run.summary || {};
|
||
const syncErrors = (summary.sync_result?.sync_errors || run.syncResponse?.sync_errors || []).join("、");
|
||
activeStatusEl.innerHTML = [
|
||
'<div class="status-line"><strong>状态</strong><span>' + escapeHtml(run.status) + '</span></div>',
|
||
'<div class="status-line"><strong>主页</strong><span>' + escapeHtml(run.profileUrl) + '</span></div>',
|
||
'<div class="status-line"><strong>开始时间</strong><span>' + escapeHtml(run.startedAt) + '</span></div>',
|
||
run.outputDir ? '<div class="status-line"><strong>输出目录</strong><span class="path">' + escapeHtml(run.outputDir) + '</span></div>' : '',
|
||
summary.status ? '<div class="status-line"><strong>采集结果</strong><span>' + escapeHtml(summary.status) + '</span></div>' : '',
|
||
summary.video_link_count !== undefined ? '<div class="status-line"><strong>作品链接数</strong><span>' + escapeHtml(summary.video_link_count) + '</span></div>' : '',
|
||
summary.captured_creator_pages !== undefined ? '<div class="status-line"><strong>creator 页面数</strong><span>' + escapeHtml(summary.captured_creator_pages) + '</span></div>' : '',
|
||
syncErrors ? '<div class="status-line"><strong>同步提示</strong><span>' + escapeHtml(syncErrors) + '</span></div>' : ''
|
||
].filter(Boolean).join("");
|
||
logsEl.textContent = (run.logs || []).join("\\n") || "任务已启动,等待日志…";
|
||
}
|
||
|
||
function renderRecentRuns(items) {
|
||
if (!items.length) {
|
||
recentRunsEl.innerHTML = '<p class="hint">还没有控制台模式的历史运行记录。</p>';
|
||
return;
|
||
}
|
||
recentRunsEl.innerHTML = items.map((item) => {
|
||
const summary = item.summary || {};
|
||
const syncResult = item.syncResponse?.account || summary.sync_result || {};
|
||
const accountId = item.syncResponse?.account?.id || summary.sync_result?.account_id || "";
|
||
return [
|
||
'<article class="recent-item">',
|
||
'<div style="display:flex;justify-content:space-between;gap:12px;align-items:center;">',
|
||
'<strong>' + escapeHtml(summary.profile_url || item.id) + '</strong>',
|
||
'<span class="pill">' + escapeHtml(summary.status || "unknown") + '</span>',
|
||
'</div>',
|
||
'<p class="meta" style="margin:10px 0 0;">作品链接 ' + escapeHtml(summary.video_link_count ?? "-") + ',creator 页面 ' + escapeHtml(summary.captured_creator_pages ?? "-") + '</p>',
|
||
syncResult.nickname ? '<p class="meta" style="margin:8px 0 0;">同步账号:' + escapeHtml(syncResult.nickname) + '</p>' : '',
|
||
item.outputDir ? '<div class="path" style="margin-top:8px;">' + escapeHtml(item.outputDir) + '</div>' : '',
|
||
accountId ? '<div class="inline-actions" style="margin-top:10px;"><button class="secondary" type="button" data-open-account-id="' + escapeHtml(accountId) + '">打开工作台</button></div>' : '',
|
||
'</article>'
|
||
].join("");
|
||
}).join("");
|
||
}
|
||
|
||
async function refreshStatus() {
|
||
const response = await fetch("/api/status", { cache: "no-store" });
|
||
const payload = await response.json();
|
||
renderActiveRun(payload.activeRun);
|
||
renderRecentRuns(payload.recentRuns || []);
|
||
}
|
||
|
||
function loadSavedValues() {
|
||
try {
|
||
const saved = JSON.parse(localStorage.getItem(storageKey) || "{}");
|
||
if (saved.profileUrl) document.getElementById("profile-url").value = saved.profileUrl;
|
||
if (saved.backendUrl) document.getElementById("backend-url").value = saved.backendUrl;
|
||
if (saved.username) document.getElementById("username").value = saved.username;
|
||
if (saved.token) tokenInputEl.value = saved.token;
|
||
if (saved.note) document.getElementById("note").value = saved.note;
|
||
if (saved.maxVideos !== undefined) document.getElementById("max-videos").value = saved.maxVideos;
|
||
if (saved.syncEnabled !== undefined) document.getElementById("sync-enabled").checked = Boolean(saved.syncEnabled);
|
||
if (saved.headless !== undefined) document.getElementById("headless").checked = Boolean(saved.headless);
|
||
if (saved.skipCreatorCenter !== undefined) document.getElementById("skip-creator-center").checked = Boolean(saved.skipCreatorCenter);
|
||
if (saved.allowCreatorCenterFallback !== undefined) document.getElementById("allow-fallback").checked = Boolean(saved.allowCreatorCenterFallback);
|
||
} catch {}
|
||
updateTokenUi();
|
||
}
|
||
|
||
function saveValues(payload) {
|
||
const saved = {
|
||
profileUrl: payload.profileUrl,
|
||
backendUrl: payload.backendUrl,
|
||
username: payload.username,
|
||
token: payload.token,
|
||
note: payload.note,
|
||
maxVideos: payload.maxVideos,
|
||
syncEnabled: payload.syncEnabled,
|
||
headless: payload.headless,
|
||
skipCreatorCenter: payload.skipCreatorCenter,
|
||
allowCreatorCenterFallback: payload.allowCreatorCenterFallback
|
||
};
|
||
localStorage.setItem(storageKey, JSON.stringify(saved));
|
||
}
|
||
|
||
form.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
const payload = {
|
||
profileUrl: document.getElementById("profile-url").value.trim(),
|
||
backendUrl: document.getElementById("backend-url").value.trim(),
|
||
username: document.getElementById("username").value.trim(),
|
||
password: document.getElementById("password").value,
|
||
storyforgeUsername: document.getElementById("username").value.trim(),
|
||
storyforgePassword: document.getElementById("password").value,
|
||
token: tokenInputEl.value.trim(),
|
||
storyforgeToken: tokenInputEl.value.trim(),
|
||
note: document.getElementById("note").value.trim(),
|
||
maxVideos: document.getElementById("max-videos").value,
|
||
syncEnabled: document.getElementById("sync-enabled").checked,
|
||
headless: document.getElementById("headless").checked,
|
||
skipCreatorCenter: document.getElementById("skip-creator-center").checked,
|
||
allowCreatorCenterFallback: document.getElementById("allow-fallback").checked
|
||
};
|
||
if (payload.syncEnabled && !payload.token && workbenchState.session?.token && workbenchState.session.backendUrl === payload.backendUrl) {
|
||
payload.token = workbenchState.session.token;
|
||
payload.storyforgeToken = workbenchState.session.token;
|
||
}
|
||
if (payload.syncEnabled && !payload.token && (!payload.username || !payload.password)) {
|
||
alert("当前页面没有读到 StoryForge 账号或密码。请重新输入,或直接填 Token。");
|
||
return;
|
||
}
|
||
saveValues(payload);
|
||
const response = await fetch("/api/start", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const result = await response.json();
|
||
if (!response.ok) {
|
||
alert(result.error || "启动失败");
|
||
return;
|
||
}
|
||
await refreshStatus();
|
||
});
|
||
|
||
tokenInputEl.addEventListener("focus", () => {
|
||
tokenDetailsEl.open = true;
|
||
});
|
||
tokenInputEl.addEventListener("input", updateTokenUi);
|
||
tokenDetailsEl.addEventListener("toggle", () => {
|
||
if (tokenDetailsEl.open) {
|
||
tokenSummaryEl.textContent = tokenInputEl.value.trim() ? "已填写 Token" : "填写后可跳过密码";
|
||
} else {
|
||
updateTokenUi();
|
||
}
|
||
});
|
||
|
||
workbenchLoginButton.addEventListener("click", loginWorkbench);
|
||
|
||
workbenchRefreshButton.addEventListener("click", async () => {
|
||
if (!workbenchState.session) {
|
||
await loginWorkbench();
|
||
return;
|
||
}
|
||
await loadWorkbenchAccounts();
|
||
});
|
||
|
||
workbenchLogoutButton.addEventListener("click", () => {
|
||
clearWorkbenchSession();
|
||
analysisFeedbackEl.textContent = "会话已清除。";
|
||
});
|
||
|
||
reloadSelectedAccountButton.addEventListener("click", async () => {
|
||
if (!workbenchState.selectedAccountId) {
|
||
return;
|
||
}
|
||
await selectAccount(workbenchState.selectedAccountId);
|
||
});
|
||
|
||
runAnalysisButton.addEventListener("click", async () => {
|
||
if (!workbenchState.selectedAccountId) {
|
||
alert("请先选择一个账号。");
|
||
return;
|
||
}
|
||
const modelId = analysisModelSelectEl.value;
|
||
analysisFeedbackEl.textContent = "正在调用 Agent 生成分析结论...";
|
||
runAnalysisButton.disabled = true;
|
||
try {
|
||
const workspace = workbenchState.selectedWorkspace || {};
|
||
const linkedIds = safeArray(workspace.linked_accounts)
|
||
.map((item) => item.target_account_id)
|
||
.filter(Boolean);
|
||
const result = await storyforgeFetch("/v2/douyin/accounts/" + encodeURIComponent(workbenchState.selectedAccountId) + "/analysis", {
|
||
method: "POST",
|
||
body: {
|
||
model_profile_ids: modelId ? [modelId] : [],
|
||
linked_account_ids: linkedIds,
|
||
include_linked_accounts: true,
|
||
include_recent_similar_candidates: true,
|
||
max_videos: Math.max(1, Math.min(20, Number.parseInt(analysisMaxVideosEl.value || "12", 10) || 12)),
|
||
extra_focus: analysisFocusEl.value.trim(),
|
||
temperature: 0.35
|
||
}
|
||
});
|
||
workbenchState.lastAnalysisMessage = "分析完成,已生成 " + safeArray(result.suggestions).length + " 条建议。";
|
||
analysisFeedbackEl.textContent = workbenchState.lastAnalysisMessage;
|
||
await selectAccount(workbenchState.selectedAccountId, { preserveFeedback: true });
|
||
} catch (error) {
|
||
workbenchState.lastAnalysisMessage = "分析失败: " + error.message;
|
||
analysisFeedbackEl.textContent = workbenchState.lastAnalysisMessage;
|
||
} finally {
|
||
runAnalysisButton.disabled = false;
|
||
}
|
||
});
|
||
|
||
analyzeTopVideosButton.addEventListener("click", async () => {
|
||
if (!workbenchState.selectedAccountId) {
|
||
alert("请先选择一个账号。");
|
||
return;
|
||
}
|
||
analyzeTopVideosButton.disabled = true;
|
||
analysisFeedbackEl.textContent = "正在自动分析高分作品...";
|
||
try {
|
||
await storyforgeFetch("/v2/douyin/accounts/" + encodeURIComponent(workbenchState.selectedAccountId) + "/videos/analyze-top", {
|
||
method: "POST",
|
||
body: {
|
||
model_profile_id: analysisModelSelectEl.value || null,
|
||
top_video_count: Math.max(2, Math.min(8, Number.parseInt(analysisMaxVideosEl.value || "6", 10) || 6)),
|
||
min_score: Number(workbenchState.highScoreThreshold || 45),
|
||
temperature: 0.2
|
||
}
|
||
});
|
||
workbenchState.lastAnalysisMessage = "高分作品自动分析已更新。";
|
||
analysisFeedbackEl.textContent = workbenchState.lastAnalysisMessage;
|
||
await selectAccount(workbenchState.selectedAccountId, { preserveFeedback: true });
|
||
} catch (error) {
|
||
analysisFeedbackEl.textContent = "高分作品自动分析失败: " + error.message;
|
||
} finally {
|
||
analyzeTopVideosButton.disabled = false;
|
||
}
|
||
});
|
||
|
||
accountsFilterEl.addEventListener("input", renderAccountList);
|
||
videosScopeEl.addEventListener("change", renderVideos);
|
||
videosSortEl.addEventListener("change", renderVideos);
|
||
videosTagFilterEl.addEventListener("input", renderVideos);
|
||
videosQueryFilterEl.addEventListener("input", renderVideos);
|
||
|
||
document.addEventListener("click", async (event) => {
|
||
const accountButton = event.target.closest("[data-account-id]");
|
||
if (accountButton) {
|
||
await selectAccount(accountButton.getAttribute("data-account-id"));
|
||
return;
|
||
}
|
||
const snapshotButton = event.target.closest("[data-snapshot-id]");
|
||
if (snapshotButton) {
|
||
await loadSnapshotDetail(snapshotButton.getAttribute("data-snapshot-id"));
|
||
return;
|
||
}
|
||
const searchButton = event.target.closest("[data-search-id]");
|
||
if (searchButton) {
|
||
await loadSimilarSearchDetail(searchButton.getAttribute("data-search-id"));
|
||
return;
|
||
}
|
||
const openAccountButton = event.target.closest("[data-open-account-id]");
|
||
if (openAccountButton) {
|
||
if (!workbenchState.session) {
|
||
await loginWorkbench();
|
||
}
|
||
const targetId = openAccountButton.getAttribute("data-open-account-id");
|
||
if (targetId) {
|
||
if (!workbenchState.accounts.length) {
|
||
await loadWorkbenchAccounts();
|
||
}
|
||
await selectAccount(targetId);
|
||
window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" });
|
||
}
|
||
}
|
||
});
|
||
|
||
continueButton.addEventListener("click", async () => {
|
||
if (!activeRunId) {
|
||
return;
|
||
}
|
||
const response = await fetch("/api/runs/" + encodeURIComponent(activeRunId) + "/continue", {
|
||
method: "POST"
|
||
});
|
||
const result = await response.json();
|
||
if (!response.ok) {
|
||
alert(result.error || "继续失败");
|
||
return;
|
||
}
|
||
await refreshStatus();
|
||
});
|
||
|
||
refreshButton.addEventListener("click", refreshStatus);
|
||
loadSavedValues();
|
||
loadWorkbenchSession();
|
||
renderWorkbenchSession();
|
||
renderAccountList();
|
||
renderWorkspace();
|
||
if (workbenchState.session) {
|
||
loadWorkbenchAccounts();
|
||
}
|
||
refreshStatus();
|
||
setInterval(refreshStatus, 1500);
|
||
</script>
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
const server = http.createServer(async (req, res) => {
|
||
const url = new URL(req.url || "/", "http://127.0.0.1");
|
||
try {
|
||
if (req.method === "GET" && url.pathname === "/") {
|
||
sendHtml(res, renderPage());
|
||
return;
|
||
}
|
||
if (req.method === "GET" && url.pathname === "/api/status") {
|
||
const activeRun = getActiveRun();
|
||
if (activeRun) {
|
||
await refreshRunArtifacts(activeRun);
|
||
}
|
||
sendJson(res, 200, {
|
||
activeRun: serializeRun(activeRun),
|
||
recentRuns: await listRecentRuns()
|
||
});
|
||
return;
|
||
}
|
||
if (req.method === "POST" && url.pathname === "/api/start") {
|
||
const payload = await readJsonBody(req);
|
||
const run = await startRun(payload);
|
||
sendJson(res, 200, { run: serializeRun(run) });
|
||
return;
|
||
}
|
||
if (req.method === "POST" && /^\/api\/runs\/[^/]+\/continue$/.test(url.pathname)) {
|
||
const runId = decodeURIComponent(url.pathname.split("/")[3] || "");
|
||
const run = await continueRun(runId);
|
||
sendJson(res, 200, { run: serializeRun(run) });
|
||
return;
|
||
}
|
||
sendJson(res, 404, { error: "Not found" });
|
||
} catch (error) {
|
||
sendJson(res, 500, { error: error?.message || String(error) });
|
||
}
|
||
});
|
||
|
||
ensureDir(DEFAULT_OUTPUT_ROOT)
|
||
.then(() => {
|
||
server.listen(DEFAULT_PORT, "127.0.0.1", () => {
|
||
console.log(`StoryForge Douyin control panel: http://127.0.0.1:${DEFAULT_PORT}`);
|
||
});
|
||
})
|
||
.catch((error) => {
|
||
console.error(error?.stack || String(error));
|
||
process.exitCode = 1;
|
||
});
|