工作台:目录改名 mcn-workshop + 任务进度可见 + 会话归属配置化

1. 目录 mcn-work-shop -> mcn-workshop(空间分组名取会话 cwd 的目录名,只改字符串无效,必须真改名)
2. 全仓替换 mcn-work-shop -> mcn-workshop:37 文件 121 处;历史日志按沿革句规矩保留当时目录名
3. 任务进度可见:/api/run/status 产出 已运行时长/工具调用/最近动作/停滞判定(读会话日志尾部),前端新增右下角常驻面板,四处任务入口接入
4. 会话归属目录配置化:新增 config.sessionCwd(留空=工作台自身目录),现指向 D://AI技能//mcn-workshop,使任务显示为命名分组而非「未分组任务」
5. 修前端轮询静默缺陷:连续 3 次查询失败即提示服务断开(原逻辑静默空转到 15 分钟超时,用户零感知)
This commit is contained in:
maogeigei committed 2026-10-08 13:09:06 +08:00
1 parent d07049ecf1
commit f77735b206
115 files changed
+1103 -1514

No files matched your search

@@ -0,0 +1,966 @@
/* 短视频工作台 - 前端核心(原生 JS,零依赖)
路由框架 + 首页 + 产出浏览 + markdown 渲染 + 复制/提示工具
数据功能页(周榜/账号/视频/脚本/复盘)见 data-pages.js */
'use strict';
/* ================= 工具 ================= */
const $ = (sel) => document.querySelector(sel);
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
/* 清理标题首尾装饰性引号(英文/中文双引号、单引号、书名号式引号);仅首尾,中间引号保留 */
const stripTitleQuotes = (s) => String(s || '').trim().replace(/^["“”'‘’「『]+|["“”'‘’」』]+$/g, '');
/* 视频标题清理:标题主体(# 前)去首尾引号,# 标签原样保留(列表页完整标题显示用) */
const cleanTitle = (t) => {
const s = String(t || '').trim();
const idx = s.indexOf('#');
if (idx <= 0) return stripTitleQuotes(s);
return stripTitleQuotes(s.slice(0, idx)) + s.slice(idx);
};
const fmtSize = (n) => { if (n < 1024) return n + ' B'; if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; return (n / 1024 / 1024).toFixed(1) + ' MB'; };
/* 榜单期号显示为区间(09-10):周榜「08/31 ~ 09/06」、日榜「09/08」。
期号 = 该周周一,只显示 2026-08-31 会被误读成"仅 8/31 单日",实际覆盖到 09-06。
ranges 由 /api/dsh/ranking 返回(每期真实 dateStart/dateEnd,读自落盘文件)。 */
const fmtRankPeriod = (date, ranges) => {
const r = ranges && ranges[String(date || '')];
if (!r) return String(date || '');
const mm = (x) => String(x || '').slice(5).replace(/-/g, '/'); // 08/31(斜杠比连字符更易读)
return r.start === r.end ? mm(r.start) : mm(r.start) + ' ~ ' + mm(r.end);
};
const EXT_ICON = { md: 'file-text', json: 'file-code', xlsx: 'table', csv: 'table', txt: 'file-text', log: 'file-text', bak: 'file' };
/* ================= AI 任务触发(确认弹窗 + 执行中状态) =================
流程:点击按钮 → 确认弹窗 → 提交 /api/run → 按钮变「执行中…」禁用
→ 轮询 /api/run/status 直到任务完成(done/error)→ 按钮恢复 */
/* 账号分析技能指令(09-01 修复:F1/F2 入口任务必须按「mcn-dou-analysis」技能执行,与提炼账号设定入口统一。
定位方式跨环境:优先用 Skill 工具按技能名加载;技能位于用户技能目录 ~/.workbuddy/skills/短视频工作台/subskills/mcn-dou-analysis/(部署到用户环境时同此标准位),不写死机器绝对路径 */
const SKILL_HINT_ACCOUNT = '请调用 Skill 工具加载「mcn-dou-analysis」技能(技能根目录 = 用户技能目录 ~/.workbuddy/skills/短视频工作台/subskills/mcn-dou-analysis/,先读取其 SKILL.md 及 references 规范;若 Skill 工具无法加载,请直接读取该目录下的 SKILL.md 文件)。严格按技能工作流执行(获取账号信息 → 视频列表抓取 → 登记达人账号表 15 列规范),遵守技能红线:TOP6 筛选、≥60 条停止、账号身份标识(sec_uid/抖音号/地址)必须由浏览器获取、禁止从数据库回填。';
/* 09-04 R67:业务任务统一挂载的连接器 configId(myai-mcp-production 自定义 MCP)。
宿主 register(connectorIds) 只注入列出的连接器;凡任务可能调用 MCP(视频解析/对标拆解/功能一兜底数据源等)必须挂载,
否则后台会话无 MCP 工具(「找不到 MCP」根因,见 R66)。复盘/分镜/榜单等纯脚本任务不挂。 */
const MYAI_CONNECTORS = ['custom-mcp:myai-mcp-production'];
// 任务完成后自动刷新当前数据页(无需手动刷新即可看到最新数据)
function refreshCurrentPage() {
const view = $('#view');
if (view && typeof view._reload === 'function') { view._reload(); return; }
const { name } = parseHash();
const dataPages = ['ranking', 'accounts', 'account', 'video', 'rewrites', 'reviews'];
if (name === '' || dataPages.includes(name)) router();
}
// 提交任务 + 按钮执行中状态 + 轮询恢复(供 confirmSaveAccount/confirmRun 确认后调用)
// opts.onSubmit(taskId):提交成功拿到任务 id 后回调(调用方可持久化「执行中」状态,供页面刷新后恢复,见视频解析)
// opts.onFinish(s):轮询结束(done/error/notfound/review/超时 s=null)后回调,restore 之前调用
async function executeTask(opts) {
const btn = opts.btn;
const oldText = btn ? btn.innerHTML : '';
const restore = () => { if (btn) { btn.disabled = false; btn.innerHTML = oldText; btn.classList.remove('btn-busy'); } };
if (btn) { btn.disabled = true; btn.innerHTML = '执行中…'; btn.classList.add('btn-busy'); }
let taskId = null;
let finished = false;
let s = null;
let ended = false;
const finish = (st) => { if (ended) return; ended = true; if (opts.onFinish) { try { opts.onFinish(st); } catch (e) {} } }; /* 终态清理须在 refresh 之前,避免刷新渲染读到陈旧执行中记录 */
try {
const d = await api('/api/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: opts.prompt, name: opts.label || '工作台任务', skills: opts.skills, connectorIds: opts.connectorIds }),
});
if (d.error) throw new Error(d.error);
taskId = d.id;
showToast('✅ 任务已提交,执行进度见右下角面板');
TaskProgress.show(opts.label || '工作台任务');
if (opts.onSubmit) { try { opts.onSubmit(taskId); } catch (e) {} }
} catch (e) {
showToast('❌ 任务提交失败: ' + e.message);
restore();
return;
}
// 轮询任务状态直到完成(最长 30 分钟,账号提炼等任务耗时较长)
const deadline = Date.now() + 30 * 60 * 1000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 5000));
try { s = await api('/api/run/status?id=' + encodeURIComponent(taskId)); } catch (e) { continue; }
if (s) TaskProgress.update(s, opts.label || '工作台任务');
if (!s || s.state === 'notfound') { finished = true; finish(s || null); break; }
if (s.state === 'done') { finished = true; finish(s); showToast('✅ 任务执行完成,页面已自动刷新'); refreshCurrentPage(); break; }
if (s.state === 'error') { finished = true; finish(s); showToast('❌ 任务执行失败' + (s.error ? ':' + s.error : '')); break; }
if (s.state === 'review') { finished = true; finish(s); showToast('⏸ 任务待确认,请到左侧会话栏确认执行'); break; }
// queued / pending / running → 继续等待
}
restore();
if (!ended) finish(null);
if (!finished) showToast('⏳ 任务仍在执行,右下角面板可继续观察进度');
// 终态保留 8 秒展示结果摘要(状态/工具调用/最近动作)后自动收起;未完成则保留面板供继续观察
if (finished) setTimeout(() => TaskProgress.hide(), 8000);
}
// 通用确认弹窗:确认后执行 AI 任务(opts: title/desc/target/url/prompt/label/btn/afterText)
function confirmRun(opts) {
const mask = document.createElement('div'); mask.className = 'modal-mask';
mask.innerHTML = `
<div class="modal">
<h3>${svgIcon('zap', 18)} ${esc(opts.title)}</h3>
<p class="dim">${esc(opts.desc)}</p>
${opts.target ? `<div class="confirm-target">${esc(opts.target)}</div>` : ''}
${opts.url ? `<div class="confirm-url">${esc(opts.url)}</div>` : ''}
<div class="modal-actions">
<button class="ghost" data-cancel>取消</button>
<button class="primary" data-ok>确认执行</button>
</div>
</div>`;
document.body.appendChild(mask);
const close = () => mask.remove();
mask.addEventListener('click', (e) => { if (e.target === mask) close(); });
mask.querySelector('[data-cancel]').addEventListener('click', close);
mask.querySelector('[data-ok]').addEventListener('click', () => { close(); executeTask(opts); });
}
// 保存账号确认(账号榜 + 首页周榜 + 作品榜「关注」共用)
// ctx.fromVideo:作品榜(视频热榜/点赞榜)来源。作品榜条目只有作者昵称 + 作品地址(无作者主页地址,
// 作者键是 user_nickname,author_user_id 是数字 uid ≠ sec_uid),故 prompt 必须显式要求
// 「从该作品页进入作者主页」——否则 AI 会把作品链接当账号主页地址去抓,解析失败。
function confirmSaveAccount(name, url, btn, ctx) {
const fv = (ctx && ctx.fromVideo) || null;
const prompt = fv
? `${SKILL_HINT_ACCOUNT}\n榜单「关注」(来源:作品榜${fv.board ? ' · ' + fv.board : ''}):请把抖音账号「${name}」保存并分析(账号信息 + 视频列表)。该账号来自榜单作品${fv.title ? `《${fv.title}》` : ''},作品地址:${url || ''} —— 请先打开该作品页、从作者入口进入其主页,再抓取账号信息与视频列表;不要把作品链接当作账号主页地址。`
: `${SKILL_HINT_ACCOUNT}\n请帮我把抖音账号「${name}」保存并分析(账号信息 + 视频列表):${url || ''}`;
confirmRun({
title: '分析账号',
desc: fv ? '确认分析该作品作者并添加到账号列表(账号信息 + 视频列表)?' : '确认分析该账号并添加到账号列表(账号信息 + 视频列表)?',
target: name,
url: url || '',
prompt,
label: '保存指令',
btn,
skills: ['mcn-dou-analysis'],
connectorIds: MYAI_CONNECTORS, /* R67:功能一浏览器不可用时 MCP 账号库兜底 */
});
}
// 添加外部账号数据(移植自 dsh 插件「热门账号 → 导入」):
// 填写抖音账号信息(名称/地址)→ AI 网页采集账号信息与视频列表入库
// 提交复用 executeTask 任务链路(/api/run → 左侧会话栏可见 → 完成自动刷新)
function openImportAccounts(btn) {
const mask = document.createElement('div'); mask.className = 'modal-mask';
mask.innerHTML = `
<div class="modal" style="max-width:520px">
<h3>${svgIcon('user-plus', 18)} 添加账号数据</h3>
<div class="import-form">
<p class="dim" style="margin-bottom:10px">填写抖音账号信息,AI 通过抖音网页获取账号信息与视频列表。</p>
<div class="f-row"><label>账号名称</label>
<input class="input" placeholder="如:旧梦留声机" data-imp-name></div>
<div class="f-row"><label>账号地址(可选)</label>
<input class="input" placeholder="https://www.douyin.com/user/..." data-imp-url></div>
</div>
<div class="modal-actions">
<button class="ghost" data-cancel>取消</button>
<button class="primary" data-ok>提交添加</button>
</div>
</div>`;
document.body.appendChild(mask);
const close = () => mask.remove();
mask.addEventListener('click', (e) => { if (e.target === mask) close(); });
mask.querySelector('[data-cancel]').addEventListener('click', close);
mask.querySelector('[data-ok]').addEventListener('click', () => {
const name = (mask.querySelector('[data-imp-name]').value || '').trim();
const url = (mask.querySelector('[data-imp-url]').value || '').trim();
if (!name && !url) { showToast('请填写账号名称或账号地址'); return; }
const parts = [name ? `账号名称「${name}」` : '', url ? `账号地址「${url}」` : ''].filter(Boolean).join(',');
const prompt = `${SKILL_HINT_ACCOUNT}\n请帮我把抖音账号 ${parts} 添加:通过抖音网页获取账号信息与视频列表,写入账号列表并保存到本地`;
close();
executeTask({ prompt, label: '添加指令', btn, skills: ['mcn-dou-analysis'], connectorIds: MYAI_CONNECTORS });
});
}
/* ================= 扁平化线性图标(Lucide 风格:24 viewBox / stroke currentColor / 无底色) ================= */
const ICON_PATHS = {
'film': '<path d="M20.2 6 3 11l-.9-2.4c-.3-1.1.3-2.2 1.3-2.5l13.5-4c1.1-.3 2.2.3 2.5 1.3Z"/><path d="m6.2 5.3 3.1 3.9"/><path d="m12.4 3.4 3.1 4"/><path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>',
'home': '<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>',
'help': '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>',
'settings': '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
'trending-up': '<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/>',
'users': '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
'pen': '<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
'flask': '<path d="M10 2v7.527a2 2 0 0 1-.211.896L4.72 20.55a1 1 0 0 0 .9 1.45h12.76a1 1 0 0 0 .9-1.45l-5.069-10.127A2 2 0 0 1 14 9.527V2"/><path d="M8.5 2h7"/><path d="M7 16h10"/>',
'lightbulb': '<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>',
'refresh': '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>',
'chart': '<path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/>',
'wrench': '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>',
'folder': '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
'folder-open': '<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/>',
'file-text': '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>',
'file-code': '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="m10 13-2 2 2 2"/><path d="m14 17 2-2-2-2"/>',
'table': '<path d="M12 3v18"/><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/>',
'file': '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>',
'image': '<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
'arrow-left': '<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>',
'x': '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
'arrow-up': '<path d="m5 12 7-7 7 7"/><path d="M12 19V5"/>',
'check': '<path d="M20 6 9 17l-5-5"/>',
'paperclip': '<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
'check-square': '<polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
'trash': '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>',
'zap': '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
'user-plus': '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" x2="19" y1="8" y2="14"/><line x1="22" x2="16" y1="11" y2="11"/>',
'download': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
'globe': '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
'chevrons-right': '<path d="m6 17 5-5-5-5"/><path d="m13 17 5-5-5-5"/>',
'chevrons-left': '<path d="m11 17-5-5 5-5"/><path d="m18 17-5-5 5-5"/>',
'copy': '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
};
// 功能图标亮色映射(不同功能不同亮色;未列出的保持 currentColor 中性)
const ICON_COLORS = {
'trending-up': '#e8590c', 'users': '#2f6fed', 'pen': '#ec4899', 'flask': '#0ea5e9',
'lightbulb': '#d99a2b', 'refresh': '#0f7f8f', 'chart': '#d6336c', 'wrench': '#5b6470',
};
function svgIcon(name, size = 16, color) {
const p = ICON_PATHS[name] || ICON_PATHS['file'];
// color 参数覆盖彩色映射:传 'currentColor'/'inherit' 等可让图标跟随字体颜色
const col = color !== undefined ? color : ICON_COLORS[name];
// 'currentColor' 不注入内联样式:stroke 本就是 currentColor,注入等于空操作,
// 且内联样式会**盖掉**针对 .ic 的 CSS 配色(如 `.top-actions .ghost .ic{color:var(--dim)}` 及其 hover 变主色),
// 造成同组图标里被显式传色者偏深、hover 不变色。交给 CSS 才是对的。
const style = (col && col !== 'currentColor') ? ` style="color:${col}"` : '';
return `<svg class="ic" viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"${style}>${p}</svg>`;
}
// 把 [data-icon] 占位元素替换为 SVG 图标(静态 HTML 里的 header 按钮等)
// data-color 可选:显式覆盖 ICON_COLORS 的功能色映射(如顶栏图标写 data-color="currentColor",
// 让图标跟随容器字色,从而沿用 .top-actions .ghost 的 dim → 主色 hover 规则)
function initIcons() {
document.querySelectorAll('[data-icon]').forEach((el) => {
const size = Number(el.dataset.size) || 16;
el.innerHTML = svgIcon(el.dataset.icon, size, el.dataset.color || undefined);
});
}
async function api(path, opts) {
const res = await fetch(path, opts);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || ('请求失败 ' + res.status));
return data;
}
async function copyText(text) {
try { await navigator.clipboard.writeText(text); return true; }
catch (e) {
try {
const ta = document.createElement('textarea');
ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.select();
const ok = document.execCommand('copy'); document.body.removeChild(ta); return ok;
} catch (e2) { return false; }
}
}
let _toastTimer = null;
function showToast(msg) {
let t = document.getElementById('toast');
if (!t) { t = document.createElement('div'); t.id = 'toast'; t.className = 'toast'; document.body.appendChild(t); }
t.textContent = msg; t.classList.add('show');
clearTimeout(_toastTimer);
_toastTimer = setTimeout(() => t.classList.remove('show'), 1800);
}
/* ================= 任务进度面板(10-08 新增) =================
后台任务原本只弹一句「已提交」,用户无法判断到底在跑还是卡住、做了什么。
本面板把 /api/run/status 的进度(状态/耗时/工具调用/最近动作/是否停滞)持续显示,
不必再去左侧会话栏翻找。用法:TaskProgress.show(标题) → 轮询中 TaskProgress.update(s) → TaskProgress.hide() */
const TaskProgress = (function () {
let el = null;
const fmtDur = (ms) => {
if (ms == null) return '—';
const s = Math.max(0, Math.round(Number(ms) / 1000));
if (s < 60) return s + ' 秒';
return Math.floor(s / 60) + ' 分 ' + (s % 60) + ' 秒';
};
const STATE_TXT = { queued: '排队中', pending: '等待调度', running: '执行中', done: '已完成', error: '执行失败', review: '待确认', notfound: '任务已清理' };
function ensure() {
if (el && document.body.contains(el)) return el;
el = document.createElement('div');
el.className = 'tp-panel';
document.body.appendChild(el);
return el;
}
function render(title, s) {
const box = ensure();
const st = (s && s.state) || 'queued';
const dotCls = st === 'running' ? 'run' : (st === 'done' ? 'done' : (st === 'error' ? 'err' : ''));
const tc = (s && s.toolCalls) ? s.toolCalls : {};
const tools = Object.keys(tc).map((k) => '<span>' + esc(k) + ' ×' + tc[k] + '</span>').join('');
const stalled = st === 'running' && !!(s && s.stalled);
const ago = (s && s.updatedAgoMs != null) ? fmtDur(s.updatedAgoMs) : null;
box.innerHTML =
'<div class="tp-head"><i class="tp-dot ' + dotCls + '"></i>'
+ '<div class="tp-title">' + esc(title || (s && s.sessionTitle) || 'AI 任务') + '</div>'
+ '<button class="tp-close" title="关闭">×</button></div>'
+ '<div class="tp-body">'
+ '<div class="tp-row"><span class="k">状态</span><span class="v">' + esc(STATE_TXT[st] || st)
+ (s && s.elapsedMs != null ? '(已运行 ' + fmtDur(s.elapsedMs) + ')' : '') + '</span></div>'
+ (tools ? '<div class="tp-row"><span class="k">工具</span><span class="v"><div class="tp-tools">' + tools + '</div></span></div>' : '')
+ (s && s.lastAction ? '<div class="tp-row"><span class="k">最近动作</span><span class="v"></span></div><div class="tp-act">' + esc(s.lastAction) + '</div>' : '')
+ (ago && st === 'running' ? '<div class="tp-row" style="margin-top:6px"><span class="k">活跃</span><span class="v">'
+ (stalled ? '已 ' : '') + esc(ago) + '前有输出</span></div>' : '')
+ (stalled ? '<div class="tp-warn">⚠️ 超过 90 秒没有新输出,可能卡住或等待中</div>' : '')
+ (s && s.error ? '<div class="tp-warn">错误:' + esc(String(s.error)) + '</div>' : '')
+ '</div>';
const btn = box.querySelector('.tp-close');
if (btn) btn.onclick = hide;
}
function show(title, s) { render(title, s || null); }
function update(s, title) { render(title, s); }
function hide() { if (el && el.parentNode) el.parentNode.removeChild(el); el = null; }
return { show, update, hide };
})();
window.TaskProgress = TaskProgress;
/* ================= 迷你 Markdown 渲染器 ================= */
function inlineMd(s) {
let out = esc(s);
out = out.replace(/`([^`]+)`/g, '<code>$1</code>');
out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
out = out.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
out = out.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
return out;
}
function renderMarkdown(src) {
const lines = String(src || '').replace(/\r\n/g, '\n').split('\n');
let html = '';
let inCode = false, codeBuf = [], inTable = false, tableBuf = [], listType = null;
const flushTable = () => { if (!tableBuf.length) return; const rows = tableBuf.map((r) => '<tr>' + r.split('|').slice(1, -1).map((c) => '<td>' + inlineMd(c.trim()) + '</td>').join('') + '</tr>').join(''); html += '<table>' + rows + '</table>'; tableBuf = []; };
const flushList = () => { if (listType) { html += '</' + listType + '>'; listType = null; } };
for (const raw of lines) {
if (/^```/.test(raw)) {
if (inCode) { html += '<pre><code>' + esc(codeBuf.join('\n')) + '</code></pre>'; inCode = false; codeBuf = []; }
else { flushTable(); flushList(); inCode = true; }
continue;
}
if (inCode) { codeBuf.push(raw); continue; }
const line = raw.replace(/\s+$/, '');
if (!line.trim()) { flushTable(); flushList(); continue; }
if (/^\|.+\|$/.test(line)) { if (/^\|[\s:|-]+\|$/.test(line)) continue; if (!inTable) { flushList(); flushTable(); inTable = true; } tableBuf.push(line); continue; }
else if (inTable) { flushTable(); inTable = false; }
const h = line.match(/^(#{1,4})\s+(.*)/);
if (h) { flushList(); const n = h[1].length; html += `<h${n}>${inlineMd(h[2])}</h${n}>`; continue; }
if (/^>\s?/.test(line)) { flushList(); html += '<blockquote>' + inlineMd(line.replace(/^>\s?/, '')) + '</blockquote>'; continue; }
if (/^(-{3,}|\*{3,})$/.test(line)) { flushList(); html += '<hr>'; continue; }
if (/^[-*]\s+/.test(line)) { if (listType !== 'ul') { flushList(); html += '<ul>'; listType = 'ul'; } html += '<li>' + inlineMd(line.replace(/^[-*]\s+/, '')) + '</li>'; continue; }
if (/^\d+\.\s+/.test(line)) { if (listType !== 'ol') { flushList(); html += '<ol>'; listType = 'ol'; } html += '<li>' + inlineMd(line.replace(/^\d+\.\s+/, '')) + '</li>'; continue; }
flushList();
html += '<p>' + inlineMd(line) + '</p>';
}
flushTable(); flushList();
if (inCode) html += '<pre><code>' + esc(codeBuf.join('\n')) + '</code></pre>';
return html;
}
function prettyJson(text) { try { return JSON.stringify(JSON.parse(text), null, 2); } catch (e) { return text; } }
/* ================= 技能导航数据 ================= */
const FUNC_GROUPS = [
{ icon: 'lightbulb', title: '找选题 & 灵感', desc: '爆款选题生成、热点结合选题', qa: [
['"帮我想几个美妆赛道的爆款选题"', '按赛道生成差异化选题'],
['"最近有什么热点可以做学习类选题"', '联网搜索热点 + 事件库查询灵感'],
]},
{ icon: 'pen', title: '写脚本', desc: '全流程创作、带货植入、分镜展开、替换事件重写(可从账号分析衔接)', qa: [
['"帮我写一个美妆种草脚本,30秒"', '人设→选题→框架→大纲→脚本全流程 + 质检'],
['"基于「旧梦留声机」账号设定,替换事件写一个脚本"', '读账号设定,按账号套路写全新事件脚本'],
['"把刚写的脚本展开成分镜"', '按分镜技能生成镜头级描述'],
]},
{ icon: 'refresh', title: '账号分析', desc: '导入账号 · 提炼账号设定 · 分析视频 · 分析账号', qa: [
['"把抖音账号「俊希」导入账号列表"', '网页采集账号信息 + 视频列表入库'],
['"帮我设计一个三农赛道的达人IP"', '提炼账号设定(人设卡)'],
['"帮我拆解这个抖音视频"', '解析视频,拆解钩子/节奏/情绪/选题'],
['"分析一下这个账号的内容套路"', '账号内容结构拆解 + 定位诊断'],
]},
{ icon: 'chart', title: '诊断 & 评分', desc: '按 mcn-script-review(脚本复盘)技能评分式复盘 + 对比分析', qa: [
['"帮我诊断这个脚本"', '评分式复盘(脚本复盘技能)→ 各维得分 + 问题清单和修改建议'],
['"对比 AI脚本 和 原视频脚本"', 'AI脚本 vs 源脚本对比分析(三段式)'],
['"检查这个带货脚本有没有违规风险"', '合规专项审查(广告法/违禁词逐项扣分)'],
]},
{ icon: 'wrench', title: '工具接口', desc: 'MCP 解析抖音链接 / 本地视频 / 拉取账号设定与代表作', qa: [
['"解析这个抖音链接"', 'MCP 自动解析视频内容'],
['"解析这个本地视频文件"', '读取本地视频并分析'],
]},
];
/* ================= 首页 ================= */
function renderHome() {
const cards = [
{ icon: 'trending-up', title: '热点数据', desc: '账号日榜/周榜 · 视频热榜 · 点赞榜', hash: '#/ranking' },
{ icon: 'users', title: '账号列表', desc: '关注的账号 + 视频列表', hash: '#/accounts' },
{ icon: 'pen', title: 'AI写脚本', desc: 'AI脚本+分镜提示词', hash: '#/rewrites' },
{ icon: 'flask', title: '脚本诊断', desc: '检查清单+复盘报告', hash: '#/reviews' },
];
return `
<div class="home">
<div class="home-stats" id="homeStats" aria-label="数据预览">
<div class="home-stat-item"><span class="home-stat-value">–</span><span class="home-stat-label">关注账号</span></div>
<div class="home-stat-item"><span class="home-stat-value">–</span><span class="home-stat-label">视频解析</span></div>
<div class="home-stat-item"><span class="home-stat-value">–</span><span class="home-stat-label">AI写脚本</span></div>
<div class="home-stat-item"><span class="home-stat-value">–</span><span class="home-stat-label">AI脚本诊断</span></div>
<div class="home-stat-item"><span class="home-stat-value">–</span><span class="home-stat-label">AI脚本评分</span></div>
</div>
<div class="data-cards">
${cards.map((c) => `<button class="data-card" data-hash="${c.hash}">
<span class="data-card-icon">${svgIcon(c.icon, 22)}</span>
<span class="data-card-title">${c.title}</span>
<span class="data-card-desc">${c.desc}</span>
</button>`).join('')}
</div>
<div class="rank-preview">
<div class="recent-head">
<div class="rank-tabs" role="tablist">
<button class="rank-tab active" data-rank-tab="week" role="tab">账号周榜</button>
<button class="rank-tab" data-rank-tab="day" title="每日更新,回溯 7 天" role="tab">账号日榜</button>
<button class="rank-tab" data-rank-tab="hot" title="近7日数据" role="tab">视频热榜</button>
<button class="rank-tab" data-rank-tab="likes" title="近7日数据" role="tab">点赞榜</button>
</div>
<button class="btn btn-icon home-rank-update" id="homeRankUpdate" title="更新榜单数据:后台任务重新抓取四榜(账号日榜 全品类 1 次 / 账号周榜 5 赛道 / 视频热榜 / 点赞榜)最新一期并落盘;30 分钟内已更新会拦截防重复">${svgIcon('globe', 16, 'currentColor')}</button>
<div class="rank-tools" id="rankTools">
<select id="rankCat" class="input-select rank-filter" title="赛道筛选"></select>
<button class="btn btn-sm" id="rankPrevDate" title="上一期">◀</button>
<span id="rankDateLabel" class="rank-date-label"></span>
<button class="btn btn-sm" id="rankNextDate" title="下一期">▶</button>
</div>
</div>
<div id="rankPreviewList" class="rank-preview-list"><span class="dim" style="padding:12px">加载中…</span></div>
<div id="rankPlaceholder" class="rank-placeholder" hidden></div>
</div>
<div class="recent-scripts">
<div class="recent-head"><h3>AI脚本</h3><a class="link" href="#/rewrites">查看全部</a></div>
<div id="recentScriptsList" class="recent-list"><span class="dim">加载中…</span></div>
</div>
</div>`;
}
/* ================= 首页 数据预览(顶部统计条,参考 dsh-plugin-mcn 首页 stats) ================= */
// 挂载时拉取一次(后端 SQLite 直查,无缓存),失败时保留占位「–」
async function loadHomeStats() {
const el = $('#homeStats');
if (!el) return;
try {
const d = await api('/api/dsh/stats');
const s = d.stats || {};
const items = [
['关注账号', s.accounts],
['视频解析', s.analyses],
['AI写脚本', s.rewrites],
['AI脚本诊断', s.reviews],
['AI脚本评分', s.writingScore],
];
el.innerHTML = items.map(([label, value]) =>
`<div class="home-stat-item"><span class="home-stat-value">${value ?? '–'}</span><span class="home-stat-label">${label}</span></div>`
).join('');
} catch (e) {
el.innerHTML = `<div class="home-stat-item"><span class="home-stat-value">–</span><span class="home-stat-label">数据不可用</span></div>`;
}
}
/* ================= 帮助页(功能帮助 + 快速提问) ================= */
function renderHelp() {
return `
<div class="home">
<div class="page-toolbar">
<button class="btn" data-back title="返回">←</button>
<span class="page-title">功能帮助</span>
</div>
<div class="intro">
<p>直接对 WorkBuddy 说需求即可,无需记指令。<strong>点击任意问法即可复制</strong>,粘贴到对话框回车执行:</p>
</div>
<div class="func-grid">
${FUNC_GROUPS.map((g) => `
<div class="func-card">
<h3>${svgIcon(g.icon, 18)} ${g.title}</h3>
<div class="desc">${g.desc}</div>
<div class="qa">
${g.qa.map(([q, a]) => `<div class="qa-row copyable" data-q="${esc(q)}" title="点击复制此问法"><span class="q">${esc(q)}</span><span class="a">→ ${esc(a)}</span></div>`).join('')}
</div>
</div>`).join('')}
</div>
</div>`;
}
/* ================= 首页 AI写剧本 快捷卡片 ================= */
function fmtDate(t) {
if (!t) return '';
const d = new Date(t);
if (Number.isNaN(d.getTime())) return String(t);
const p = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
}
// 剧本正文预览:去空行/标题行,取前 n 行(每行不截断,交给 CSS ellipsis 撑满卡片宽度)
function previewLines(text, n = 5) {
const lines = String(text || '').replace(/\r\n/g, '\n').split('\n')
.map((l) => l.trim())
.filter((l) => l && !/^#{1,4}\s/.test(l));
return lines.slice(0, n);
}
let _recentScriptsCache = {};
async function loadRecentScripts() {
const el = $('#recentScriptsList');
if (!el) return;
try {
const d = await api('/api/dsh/recent-scripts?limit=8');
const items = (d.items || []);
if (!items.length) { el.innerHTML = '<span class="dim">暂无 AI 写剧本</span>'; return; }
_recentScriptsCache = {};
// 评分徽章配色与诊断页一致:≥75 绿 / ≥60 橙 / <60 红
const scoreColor = (v) => (v >= 75 ? '#3a9e6d' : v >= 60 ? '#d99a2b' : '#d9534f');
el.innerHTML = items.map((s) => {
_recentScriptsCache[s.id] = s;
const title = cleanTitle(s.title || s.video_title) || '未命名剧本';
const meta = [s.account_name, s.created_time ? fmtDate(s.created_time) : ''].filter(Boolean).join(' | ');
const preview = previewLines(s.script_text, 5);
// 有 AI 复盘评分 → 卡片右下角徽章;无评分不显示
const sc = Number(s.score);
const scoreBadge = (s.score !== null && s.score !== undefined && s.score !== '' && Number.isFinite(sc))
? `<span class="recent-card-score" style="background:${scoreColor(sc)}1a;color:${scoreColor(sc)}">评分 ${sc % 1 === 0 ? sc : sc.toFixed(1)}</span>` : '';
return `<button class="recent-card" data-script="${s.id}" title="点击查看完整剧本">
<span class="recent-card-title">${esc(title)}</span>
<span class="recent-card-preview">${preview.map((l) => `<span class="recent-preview-line">${esc(l)}</span>`).join('')}</span>
<span class="recent-card-foot">
<span class="recent-card-meta">${esc(meta)}</span>
${scoreBadge}
</span>
</button>`;
}).join('');
el.querySelectorAll('.recent-card').forEach((b) => b.addEventListener('click', () => {
const s = _recentScriptsCache[b.dataset.script];
if (s) openRecentScript(s);
}));
} catch (e) { el.innerHTML = '<span class="dim">' + esc(e.message) + '</span>'; }
}
/* ================= 首页 榜单预览(账号周榜/视频热榜/点赞榜 三榜;前10 + 赛道/日期筛选,无分页/排序) ================= */
let _rankPreview = { board: 'week', pageSize: 10, dates: [], categories: [], ranges: {}, date: '', category: '', _metaBoard: '' };
function renderRankTools() {
const has = _rankPreview.dates.length > 0;
const tools = $('#rankTools');
if (tools) tools.hidden = !has;
const catSel = $('#rankCat');
if (catSel) {
catSel.innerHTML = '<option value="">全部赛道</option>' + _rankPreview.categories.map((c) => `<option value="${esc(c)}" ${c === _rankPreview.category ? 'selected' : ''}>${esc(c)}</option>`).join('');
catSel.onchange = () => { _rankPreview.category = catSel.value; loadRankingPreview(); };
}
const dateLabel = $('#rankDateLabel');
const di = _rankPreview.dates.indexOf(_rankPreview.date);
if (dateLabel) dateLabel.textContent = fmtRankPeriod(_rankPreview.date, _rankPreview.ranges);
const prevBtn = $('#rankPrevDate'), nextBtn = $('#rankNextDate');
if (prevBtn) { prevBtn.disabled = di <= 0; prevBtn.onclick = () => { if (di > 0) { _rankPreview.date = _rankPreview.dates[di - 1]; renderRankTools(); loadRankingPreview(); } }; }
if (nextBtn) { nextBtn.disabled = di < 0 || di >= _rankPreview.dates.length - 1; nextBtn.onclick = () => { if (di >= 0 && di < _rankPreview.dates.length - 1) { _rankPreview.date = _rankPreview.dates[di + 1]; renderRankTools(); loadRankingPreview(); } }; }
}
// 首页各榜列模板:head → 表头行;row(r) → 数据行。wk-num 数值(万显示);标题/作者吃剩余宽
const prevAcctCol = () => ({
gridCls: '',
head: `<span class="rk-no">排名</span><span class="rk-cat">赛道</span><span class="rk-name">账号</span><span class="rk-score">综合得分</span><span class="rk-num">新增粉丝</span><span class="rk-num">新增点赞</span><span class="rk-num">新增评论</span><span class="rk-num">新增分享</span><span class="rk-act">功能</span>`,
row: (r) => `<span class="rk-no">${r.rank ?? '-'}</span>
<span class="rk-cat">${esc(r.category || '-')}</span>
<span class="rk-name">${r.inDb && r.dbId ? `<a class="link" href="#/account/${r.dbId}">${esc(r.accountName)}</a>` : (r.profileUrl ? `<a class="link" data-ext="${esc(r.profileUrl)}">${esc(r.accountName)}</a>` : esc(r.accountName))}</span>
<span class="rk-score">${fmt(r.comprehensiveScore)}</span>
<span class="rk-num">${esc(r.newFans ?? '-')}</span>
<span class="rk-num">${esc(r.newLikes ?? '-')}</span>
<span class="rk-num">${esc(r.newComments ?? '-')}</span>
<span class="rk-num">${esc(r.newShares ?? '-')}</span>
<span class="rk-act">${r.inDb && r.dbId ? `<a class="btn btn-sm btn-view" href="#/account/${r.dbId}">查看</a>` : `<button class="btn btn-sm btn-import" data-rk-import data-rk-name="${esc(r.accountName)}" data-rk-url="${esc(r.profileUrl || '')}" title="关注该账号并加入账号列表">关注</button>`}</span>`,
});
const _PREVIEW_COLS = {
week: prevAcctCol(),
day: prevAcctCol(),
hot: {
// likesRank 周窗口作品榜
gridCls: ' rk-video',
head: `<span class="rk-no">排名</span><span class="rk-cat">赛道</span><span class="rk-name">作品标题</span><span class="rk-author">作者</span><span class="rk-num">点赞</span><span class="rk-num">评论</span><span class="rk-num">分享</span><span class="rk-num">收藏</span>`,
row: (r) => `<span class="rk-no">${r.rank ?? '-'}</span>
<span class="rk-cat">${esc(r.category || '-')}</span>
<span class="rk-name" title="${esc(String(r.title || '').replace(/"/g, '&quot;'))}">${r.workUrl ? `<a class="title-plain" data-ext="${esc(r.workUrl)}" title="打开作品页">${esc(r.title || '-')}</a>` : esc(r.title || '-')}</span>
<span class="rk-author" title="${esc(String(r.accountName || '').replace(/"/g, '&quot;'))}">${esc(r.accountName || '-')}</span>
<span class="rk-num">${fmtWan(r.likeCount)}</span>
<span class="rk-num">${fmtWan(r.commentCount)}</span>
<span class="rk-num">${fmtWan(r.shareCount)}</span>
<span class="rk-num">${fmtWan(r.collectCount)}</span>`,
},
likes: {
// hotContentRank 七日点赞飙升
gridCls: ' rk-video',
head: `<span class="rk-no">排名</span><span class="rk-cat">赛道</span><span class="rk-name">作品标题</span><span class="rk-author">作者</span><span class="rk-num">新增点赞</span><span class="rk-num">新增收藏</span><span class="rk-num">新增评论</span><span class="rk-num">新增分享</span>`,
row: (r) => `<span class="rk-no">${r.rank ?? '-'}</span>
<span class="rk-cat">${esc(r.category || '-')}</span>
<span class="rk-name" title="${esc(String(r.aweme_desc || '').replace(/"/g, '&quot;'))}">${r.share_url ? `<a class="title-plain" data-ext="${esc(r.share_url)}" title="打开作品页">${esc(r.aweme_desc || '-')}</a>` : esc(r.aweme_desc || '-')}</span>
<span class="rk-author" title="${esc(String(r.user_nickname || '').replace(/"/g, '&quot;'))}">${esc(r.user_nickname || '-')}</span>
<span class="rk-num">${fmtWan(r.add_digg_count)}</span>
<span class="rk-num">${fmtWan(r.add_collect_count)}</span>
<span class="rk-num">${fmtWan(r.add_comment_count)}</span>
<span class="rk-num">${fmtWan(r.add_share_count)}</span>`,
},
};
async function loadRankingPreview() {
const listEl = $('#rankPreviewList');
if (!listEl) return;
const ph = $('#rankPlaceholder');
try {
const board = _rankPreview.board;
// meta 按榜独立拉取(board 变化或首次进入)
if (_rankPreview._metaBoard !== board) {
const m = await api('/api/dsh/ranking?board=' + board + '&page=1&pageSize=1');
_rankPreview.dates = m.dates || [];
_rankPreview.categories = m.categories || [];
_rankPreview.ranges = m.ranges || {};
_rankPreview.date = m.date || (_rankPreview.dates[0] || '');
_rankPreview.category = m.category || '';
_rankPreview._metaBoard = board;
renderRankTools();
}
if (!_rankPreview.dates.length) {
listEl.hidden = true;
if (ph) { ph.hidden = false; ph.textContent = '「' + ({ week: '账号周榜', day: '账号日榜', hot: '视频热榜', likes: '点赞榜' }[board] || '') + '」暂无本地数据文件,先运行 mcn-data-insight 抓取技能落盘'; }
return;
}
if (ph) ph.hidden = true;
listEl.hidden = false;
const d = await api('/api/dsh/ranking?board=' + board + '&page=1&pageSize=' + _rankPreview.pageSize + '&date=' + encodeURIComponent(_rankPreview.date) + '&category=' + encodeURIComponent(_rankPreview.category));
const items = d.items || [];
const col = _PREVIEW_COLS[board];
listEl.innerHTML = items.length ? `<div class="rank-row rank-head${col.gridCls}">${col.head}</div>` + items.map((r) => `<div class="rank-row${col.gridCls}">${col.row(r)}</div>`).join('') : '<span class="dim" style="padding:12px">暂无周榜数据</span>';
listEl.querySelectorAll('[data-rk-import]').forEach((b) => b.addEventListener('click', () => {
confirmSaveAccount(b.dataset.rkName, b.dataset.rkUrl || '', b);
}));
} catch (e) { listEl.innerHTML = '<span class="dim" style="padding:12px">' + esc(e.message) + '</span>'; }
}
function setRankBoard(board, activeEl) {
if (_rankPreview.board === board && activeEl) return;
_rankPreview.board = board;
// meta 缓存按 board 失效 → 强制重拉该榜 dates/categories
_rankPreview._metaBoard = '';
_rankPreview.date = ''; _rankPreview.category = '';
if (activeEl) {
const root = activeEl.closest('.rank-tabs');
if (root) root.querySelectorAll('.rank-tab').forEach((x) => x.classList.toggle('active', x === activeEl));
}
const listEl = $('#rankPreviewList');
if (listEl) { listEl.hidden = true; listEl.innerHTML = '<span class="dim" style="padding:12px">加载中…</span>'; }
const ph = $('#rankPlaceholder');
if (ph) ph.hidden = true;
renderRankTools();
loadRankingPreview();
}
function openRecentScript(s) {
// R34:首页「AI脚本」卡点击 → 打开与 AI写脚本页查看一致的三列统一弹窗(源脚本可折叠 | AI脚本版本 | 右侧 脚本诊断/分镜提示词 tab,默认脚本诊断)
if (window.DataPages && typeof window.DataPages.openScriptModal === 'function') {
window.DataPages.openScriptModal({ rewriteId: Number(s.id) || 0, videoId: Number(s.video_id) || 0 });
return;
}
// 兜底(data-pages.js 未就绪时):单列全文弹窗
const title = cleanTitle(s.title || s.video_title) || 'AI 写剧本';
const mask = document.createElement('div'); mask.className = 'modal-mask';
mask.innerHTML = `<div class="modal rw-modal">
<div class="modal-title-row"><span>${svgIcon('pen')} ${esc(title)}</span><button class="btn" data-x>${svgIcon('x', 14)}</button></div>
<div class="recent-meta">${esc([s.account_name, cleanTitle(s.video_title), s.created_time ? fmtDate(s.created_time) : ''].filter(Boolean).join(' | '))}</div>
<div class="rw-content md-body" style="max-height:70vh">${renderMarkdown(s.script_text || '(暂无内容)')}</div>
</div>`;
document.body.appendChild(mask);
mask.addEventListener('click', (e) => { if (e.target === mask) mask.remove(); });
mask.querySelector('[data-x]').addEventListener('click', () => mask.remove());
}
/* ================= 产出浏览 ================= */
const fileState = { roots: { defaultRoot: '', extraRoots: [], defaultExists: false }, currentRoot: '', selectedPath: '', tree: [], expandMap: new Set(), search: '' };
function renderFiles() {
return `
<div class="files-layout">
<aside class="sidebar">
<div class="sidebar-top">
<select id="rootSelect" title="项目地址"></select>
<input id="fileSearch" type="search" placeholder="搜索文件名…">
</div>
<div id="fileTree" class="file-tree"><div class="tree-empty">加载中…</div></div>
</aside>
<div class="content-view">
<div id="previewEmpty" class="preview-empty">
<p>👈 从左侧选择文件查看</p>
<p class="dim">支持 Markdown / JSON / 文本在线预览;表格类(xlsx)点击下载后用本地软件打开</p>
</div>
<div id="preview" class="preview" hidden></div>
</div>
</div>`;
}
async function initFiles() {
const r = await api('/api/roots');
fileState.roots = r;
const sel = $('#rootSelect');
const opts = [];
if (r.defaultExists) opts.push(`<option value="${esc(r.defaultRoot)}">📁 桌面 MCNSkill项目(默认)</option>`);
else opts.push(`<option value="">📁 桌面 MCNSkill项目(目录不存在)</option>`);
r.extraRoots.forEach((p) => opts.push(`<option value="${esc(p)}">📂 ${esc(p)}</option>`));
sel.innerHTML = opts.join('');
const saved = localStorage.getItem('mcnws_root');
if (saved && [...sel.options].some((o) => o.value === saved)) sel.value = saved;
fileState.currentRoot = sel.value;
sel.addEventListener('change', () => {
fileState.currentRoot = sel.value;
localStorage.setItem('mcnws_root', fileState.currentRoot);
loadTree();
});
$('#fileSearch').addEventListener('input', (e) => {
clearTimeout(fileState._st);
fileState._st = setTimeout(() => { fileState.search = e.target.value; renderTree(); }, 200);
});
await loadTree();
}
async function loadTree() {
const treeEl = $('#fileTree');
if (!fileState.currentRoot) { treeEl.innerHTML = '<div class="tree-empty">默认项目地址不存在,请在设置中添加项目地址</div>'; return; }
treeEl.innerHTML = '<div class="tree-empty">加载中…</div>';
try {
const data = await api('/api/tree?root=' + encodeURIComponent(fileState.currentRoot));
fileState.tree = data.tree;
fileState.expandMap.clear();
fileState.tree.forEach((n) => { if (n.type === 'dir') fileState.expandMap.add(n.path); });
renderTree();
} catch (e) { treeEl.innerHTML = '<div class="tree-empty">' + esc(e.message) + '</div>'; }
}
function filterNodes(nodes) {
const kw = fileState.search.trim().toLowerCase();
if (!kw) return nodes;
const out = [];
for (const n of nodes) {
if (n.type === 'file') { if (n.name.toLowerCase().includes(kw)) out.push(n); }
else { const children = filterNodes(n.children || []); if (children.length) out.push({ ...n, children }); }
}
return out;
}
function nodeHtml(n, depth) {
if (n.type === 'dir') {
const open = fileState.expandMap.has(n.path);
const children = open ? (n.children || []) : [];
return `<div class="tree-node"><div class="tree-row" data-path="${esc(n.path)}" data-type="dir"><span class="caret">${open ? '▾' : '▸'}</span><span class="icon">${svgIcon(open ? 'folder-open' : 'folder', 15)}</span><span class="fname">${esc(n.name)}</span></div>${open && children.length ? `<div class="tree-children">${children.map((c) => nodeHtml(c, depth + 1)).join('')}</div>` : ''}</div>`;
}
const ext = (n.ext || '').slice(1);
return `<div class="tree-row" data-path="${esc(n.path)}" data-type="file"><span class="caret"></span><span class="icon">${svgIcon(EXT_ICON[ext] || 'file', 15)}</span><span class="fname ${'ext-' + (ext || 'txt')}">${esc(n.name)}</span><span style="margin-left:auto;font-size:13px;color:var(--dim)">${fmtSize(n.size)}</span></div>`;
}
function renderTree() {
const treeEl = $('#fileTree');
const nodes = filterNodes(fileState.tree);
if (!nodes.length) { treeEl.innerHTML = '<div class="tree-empty">' + (fileState.search ? '无匹配文件' : '空目录') + '</div>'; return; }
treeEl.innerHTML = '<div class="tree-root">' + nodes.map((n) => nodeHtml(n, 0)).join('') + '</div>';
treeEl.querySelectorAll('.tree-row').forEach((row) => row.addEventListener('click', () => onRowClick(row)));
}
function onRowClick(row) {
const p = row.dataset.path, type = row.dataset.type;
if (type === 'dir') { if (fileState.expandMap.has(p)) fileState.expandMap.delete(p); else fileState.expandMap.add(p); renderTree(); }
else {
document.querySelectorAll('.tree-row').forEach((r) => r.classList.remove('selected'));
row.classList.add('selected');
fileState.selectedPath = p;
openFile(p);
}
}
async function openFile(p) {
$('#previewEmpty').hidden = true;
const pv = $('#preview');
pv.hidden = false; pv.innerHTML = '<p class="dim">加载中…</p>';
try {
const d = await api('/api/read?path=' + encodeURIComponent(p));
let body = '';
if (d.ext === '.md' || d.ext === '.markdown') body = '<div class="md-body">' + renderMarkdown(d.content) + '</div>';
else if (d.ext === '.json') body = '<div class="json-view"><pre>' + esc(prettyJson(d.content)) + '</pre></div>';
else body = '<div class="md-body"><pre style="white-space:pre-wrap">' + esc(d.content) + '</pre></div>';
pv.innerHTML = `<div class="file-head"><strong>${esc(d.name)}</strong><span class="fpath">${esc(d.path)}</span><span class="fmeta">${fmtSize(d.size)}</span><a class="btn-download" href="/api/download?path=${encodeURIComponent(p)}">⬇ 下载</a></div>${body}`;
} catch (e) { pv.innerHTML = '<p class="dim">' + esc(e.message) + '</p>'; }
}
/* ================= 设置弹窗 ================= */
function renderRootList() {
const list = $('#rootList');
const roots = fileState.roots;
list.innerHTML = (roots.extraRoots || []).map((p) => `<div class="root-item"><span class="rp">${esc(p)}</span><button class="rm" data-rm="${esc(p)}" title="移除">${svgIcon('x', 13)}</button></div>`).join('') || '<div class="dim" style="font-size:14px">暂无自定义目录</div>';
list.querySelectorAll('.rm').forEach((btn) => btn.addEventListener('click', async () => {
const p = btn.dataset.rm;
roots.extraRoots = roots.extraRoots.filter((x) => x !== p);
await saveRoots();
}));
}
async function saveRoots() {
try {
const r = await api('/api/roots', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ extraRoots: fileState.roots.extraRoots }) });
fileState.roots.extraRoots = r.extraRoots;
$('#rootMsg').textContent = '✅ 已保存';
setTimeout(() => { $('#rootMsg').textContent = ''; }, 1500);
renderRootList();
} catch (e) { $('#rootMsg').textContent = '保存失败:' + e.message; }
}
function openSettings() { renderRootList(); $('#settingsModal').hidden = false; }
function closeSettings() { $('#settingsModal').hidden = true; }
/* ================= 路由 ================= */
const CRUMB_TITLES = { 'ranking': '热点数据', 'accounts': '账号列表', 'rewrites': 'AI写脚本', 'reviews': '脚本诊断' };
function parseHash() {
const h = (location.hash || '#/').replace(/^#/, '');
const parts = h.split('/').filter(Boolean);
return { name: parts[0] || '', param: parts[1] || '' };
}
function updateCrumbs(name, title) {
const c = $('#crumbs');
if (!name) { c.innerHTML = '<span class="crumb-home">短视频工作台</span>'; return; }
c.innerHTML = `<a class="crumb-home" href="#/">短视频工作台</a><span class="crumb-sep">/</span><span class="crumb-current">${esc(title || CRUMB_TITLES[name] || '功能页')}</span>`;
}
async function router() {
const { name, param } = parseHash();
const view = $('#view');
view.className = '';
if (name === '' || name === 'home') {
updateCrumbs('');
view.innerHTML = renderHome();
view.querySelectorAll('.data-card').forEach((b) => b.addEventListener('click', () => { location.hash = b.dataset.hash; }));
// 首页榜单下载按钮(复用榜单页同款后台任务;data-pages.js updateRankingData 已导出到 window.DataPages)
const hUpd = view.querySelector('#homeRankUpdate');
if (hUpd) hUpd.addEventListener('click', () => window.DataPages && window.DataPages.updateRankingData(view, hUpd));
// 榜单 tab 切换:week/hot/likes 各自按 board 拉数据渲染(meta 与数据均带 board 参数)
view.querySelectorAll('.rank-tab').forEach((t) => t.addEventListener('click', () => {
setRankBoard(t.dataset.rankTab, t);
}));
loadHomeStats();
loadRankingPreview();
loadRecentScripts();
view._reload = () => { loadHomeStats(); loadRankingPreview(); loadRecentScripts(); };
return;
}
if (name === 'help') {
updateCrumbs('help', '功能帮助');
// 09-04:板块化帮助 + 模拟操作引导(help-guide.js);文件缺失时回退旧版问法帮助
if (window.HelpGuide && typeof window.HelpGuide.renderHelp === 'function') {
view.innerHTML = window.HelpGuide.renderHelp();
bindBack(view); // 返回 = 上一页(无历史则回首页)
window.HelpGuide.bindHelp(view);
} else {
view.innerHTML = renderHelp();
bindBack(view); // 返回 = 上一页(无历史则回首页)
view.querySelectorAll('.qa-row.copyable').forEach((row) => row.addEventListener('click', async () => {
const text = (row.dataset.q || '').replace(/^"|"$/g, '');
if (text) showToast((await copyText(text)) ? '✅ 已复制:' + text : '❌ 复制失败');
}));
}
return;
}
if (name === 'files') {
updateCrumbs('', ''); // 产出浏览也回到无面包屑态
view.innerHTML = renderFiles();
await initFiles();
return;
}
// 数据功能页
if (typeof window.DataPages !== 'undefined') {
const titles = { ranking: '热点数据', accounts: '账号列表', account: '账号详情', video: '视频详情', rewrites: 'AI写脚本', reviews: '脚本诊断' };
const pages = {
'ranking': () => window.DataPages.renderRanking(view),
'accounts': () => window.DataPages.renderAccounts(view),
'account': () => window.DataPages.renderAccountDetail(view, param),
'video': () => window.DataPages.renderVideoDetail(view, param),
'rewrites': () => window.DataPages.renderRewrites(view, param),
'reviews': () => window.DataPages.renderReviews(view),
};
if (pages[name]) {
updateCrumbs(name, titles[name]);
try {
await pages[name]();
} catch (err) {
console.error('[router]', name, err);
view.innerHTML = '<div class="empty">页面渲染失败:' + esc(err.message) + '</div>';
}
return;
}
}
// 数据页脚本(data-pages.js)尚未加载完时,不要误跳首页——等待下次路由
if (name && ['ranking', 'accounts', 'account', 'video', 'rewrites', 'reviews'].includes(name) && typeof window.DataPages === 'undefined') {
return;
}
// 未知路由 → 首页
location.hash = '#/';
}
window.addEventListener('hashchange', router);
$('#btnHome').addEventListener('click', () => { location.hash = '#/'; });
$('#btnHelp').addEventListener('click', () => { location.hash = '#/help'; });
$('#btnSettings').addEventListener('click', openSettings);
/* 顶栏「刷新」(09-18 新增):整页重载 —— 重新请求 index.html 与其引用的 ?v= 静态资源。
存在的意义:站内 hash 跳转/SPA 原地重渲染**不会**重新请求文档(规范坑 13),
改完前端在预览面板里点它最可靠。静态资源均 Cache-Control: no-store,普通 reload 即可,无需 cache-buster。
hash 原样保留(reload 不改 URL),另有 saveRoute 双写兜底。 */
$('#btnReload').addEventListener('click', () => { location.reload(); });
$('#btnCloseSettings').addEventListener('click', closeSettings);
$('#settingsModal').addEventListener('click', (e) => { if (e.target === e.currentTarget) closeSettings(); });
$('#btnAddRoot').addEventListener('click', async () => {
const input = $('#newRoot');
const p = input.value.trim();
if (!p) return;
fileState.roots.extraRoots.push(p);
input.value = '';
await saveRoots();
});
$('#newRoot').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('#btnAddRoot').click(); });
/* ================= 外链新标签打开(JS 强制 + 被拦截兜底复制) ================= */
document.addEventListener('click', (e) => {
const a = e.target.closest('a[data-ext]');
if (!a) return;
e.preventDefault();
const url = a.dataset.ext;
if (!url) return;
let w = null;
try { w = window.open(url, '_blank', 'noopener,noreferrer'); } catch (err) { w = null; }
if (!w) {
copyText(url);
showToast('⚠️ 新窗口被拦截,链接已复制,请粘贴到浏览器地址栏打开');
}
});
initIcons();
/* ================= 路由记忆(刷新/面板重载后恢复到上次页面) =================
内置浏览器刷新 iframe 时可能丢失 URL hash → 回首页。
localStorage + cookie 双写(任一可用),hash 丢失时自动恢复。 */
function saveRoute() {
const h = location.hash || '#/';
try { localStorage.setItem('wb_route', h); } catch (e) {}
try { document.cookie = 'wb_route=' + encodeURIComponent(h) + '; path=/; max-age=86400'; } catch (e) {}
}
function readRoute() {
try { const v = localStorage.getItem('wb_route'); if (v) return v; } catch (e) {}
try {
const m = document.cookie.match(/(?:^|;\s*)wb_route=([^;]*)/);
if (m) return decodeURIComponent(m[1]);
} catch (e) {}
return null;
}
window.addEventListener('hashchange', saveRoute);
(function boot() {
// 不再自动恢复上次路由:URL 无 hash(访问 http://localhost:8899 根地址)一律回首页;
// 带 hash(#/accounts 等)走正常路由。原恢复逻辑会误伤"主动访问根地址想回首页"的场景。
saveRoute(); // 记录当前路由(保留机制,供后续按需使用)
// 关键:data-pages.js 在 app.js 之后的 script 标签,下载/执行可能未完成。
// 须等其就绪(DataPages 存在)再路由,否则数据页会被当成未知路由。
function bootRouter() {
if (typeof window.DataPages === 'undefined') { setTimeout(bootRouter, 50); return; }
router();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootRouter);
} else {
bootRouter();
}
})();
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,426 @@
/* =========================================================================
短视频工作台 - 帮助文档 + 模拟操作引导(09-04 新增 help-guide)
-------------------------------------------------------------------------
板块化帮助:按工作台实际页面组织(首页/热点数据/账号列表/账号详情/视频详情/AI写脚本/脚本诊断)。
每个板块卡提供:用途说明 + 功能清单 + 「▶ 模拟操作指引」+ 「进入板块」+ 常见问法(可复制)。
模拟操作引导(startGuide):
- 跳转到该板块真实页面(详情类自动取库中第一条数据作示例);
- 全屏遮罩(拦截误点)+ 高亮目标控件 + 气泡讲解;
- 无副作用控件(tab/下拉/搜索/翻页)→ 引导你真实点击体验,点对即进下一步;
- 有副作用控件(更新数据/关注/AI创作/复盘等)→ 只讲解 + 「下一步」,防误触发;
- 步骤目标找不到(无示例数据)→ 自动跳过该步,不卡死。
========================================================================= */
(function () {
const $ = (s, r) => (r || document).querySelector(s);
/* ---------- 详情类示例路由:取库中第一条数据作引导样本 ---------- */
async function firstAccountRoute() {
try {
const d = await api('/api/dsh/accounts?type=all&search=&track=&page=1&pageSize=1');
const a = (d && d.items && d.items[0]);
if (a && a.id) return '#/account/' + a.id;
} catch (e) { /* 接口失败走兜底 */ }
return null;
}
async function firstVideoRoute() {
try {
const d = await api('/api/dsh/videos?search=&parsed=&days=&page=1&pageSize=1');
const v = (d && d.items && d.items[0]);
if (v && v.id) return '#/video/' + v.id;
} catch (e) { /* 接口失败走兜底 */ }
return null;
}
/* ---------- 板块定义 ----------
steps[].sel 目标选择器(页面渲染后查找;找不到自动跳过)
steps[].mode 'click' 真实点击推进(安全控件);'input' 输入回车推进;'info' 仅讲解 + 下一步
steps[].danger 危险/有副作用控件:加 pointer-events:none 防误触
steps[].emptyOk 目标缺失时仍展示文字(不跳过) */
const BOARDS = [
{
key: 'home', icon: 'home', title: '首页总览', route: '#/',
desc: '工作台主入口:数据预览 + 四大功能卡 + 榜单速览 + 最近 AI 脚本',
steps: [
{ sel: '.home-stats', mode: 'info', title: '数据总览条', text: '顶部统计条一屏看全库规模:关注账号数、视频解析数、AI写脚本数、AI脚本诊断数、AI脚本平均评分。数据会随库中变化自动刷新。' },
{ sel: '.data-cards', mode: 'info', title: '四大功能卡', text: '首页四张卡片 = 工作台四大板块:热点数据(榜单)、账号列表(达人库)、AI写脚本(脚本中心)、脚本诊断(复盘评分)。点击卡片即可进入对应板块。' },
{ sel: '.rank-preview .rank-tab[data-rank-tab="hot"]', mode: 'click', title: '榜单速览', text: '首页内置榜单预览,无需进完整页即可扫一眼:账号周榜 / 账号日榜 / 视频热榜 / 点赞榜。' + '\n' + '👉 现在点一下「视频热榜」tab 试试切换(可来回点四个 tab)。' },
{ sel: '#recentScriptsList .recent-card', mode: 'info', emptyOk: true, title: '最近 AI 脚本', text: '最近生成的 AI 脚本列表,点卡片可打开脚本详情(含源脚本对比/诊断/分镜)。若尚无脚本则为空。' },
],
qa: [
['"帮我写一个美妆种草脚本,30秒"', '全流程创作:人设→选题→框架→大纲→脚本 + 质检'],
['"帮我想几个美妆赛道的爆款选题"', '按赛道生成差异化选题'],
],
},
{
key: 'ranking', icon: 'trending-up', title: '热点数据', route: '#/ranking',
desc: '三榜浏览与更新:账号周榜 · 视频热榜 · 点赞榜(支持赛道筛选 / 历史期切换 / 点击表头排序)',
steps: [
{ sel: '.rank-tab[data-rank-tab="hot"]', mode: 'click', title: '切换榜单', text: '页面上方四个 tab 对应四张榜:账号周榜 / 账号日榜(达人综合实力)、视频热榜(近7日最热作品)、点赞榜(近7日点赞飙升)。' + '\n' + '👉 点「视频热榜」tab 切换到该榜。' },
{ sel: '.rank-tab[data-rank-tab="likes"]', mode: 'click', title: '再切一榜', text: '👉 再点「点赞榜」,感受榜单间切换。各榜列字段不同(周榜=粉丝/新增互动;热榜=点赞/评论/分享/收藏)。' },
{ sel: '#rankCat', mode: 'click', title: '赛道筛选', text: '右侧「赛道筛选」下拉只保留你想看的赛道(如 亲子/美食)。' + '\n' + '👉 点开下拉任选一个赛道,再切回「全部赛道」。' },
{ sel: '#rankPrev', mode: 'click', emptyOk: true, title: '历史期切换', text: '◀ ▶ 切换历史榜单期次,中间的日期即当前数据期(红狐日榜每日更新)。' + '\n' + '👉 点一下 ◀ 回看上一期。' },
{ sel: '#rankUpdate', mode: 'info', danger: true, title: '更新榜单数据', text: '🌐 按钮触发后台任务,重新抓取四榜最新一期并落盘(账号日榜 1 次 + 账号周榜 5 赛道 + 视频热榜 + 点赞榜,约 8 次红狐调用,按次计费;30 分钟内重复点会被拦截)。此处仅讲解,不实际点击。' },
{ sel: '.rank-tabs', mode: 'info', title: '榜单行操作', text: '四榜每行末列都有「查看/关注」:该账号已在达人库时点「查看」直达账号详情;未入库则点「关注」,工作台会分析该账号并加入达人库(作品榜按作者名关联,作品链接作为进作者主页的线索)。另:关注标记 ⭐ 在「账号列表」页维护。数值列表头可点击排序。' },
],
qa: [
['"更新一下热点数据三榜"', '触发后台任务抓取三榜最新一期并落盘'],
['"看看上周账号周榜的亲子赛道"', '切换期次 + 赛道筛选'],
],
},
{
key: 'accounts', icon: 'users', title: '账号列表', route: '#/accounts',
desc: '达人库:添加 / 关注 / 批量更新账号数据,行内直达账号详情与 AI 创作',
steps: [
{ sel: '#accType', mode: 'click', title: '类型筛选', text: '按账号类型过滤:全部 / 内部(公司签约)/ 外部(对标观察)。' + '\n' + '👉 点开下拉切换「外部」再切回「全部」。' },
{ sel: '#accSearch', mode: 'input', title: '搜索账号', text: '搜索框支持按账号名 / 简介内容 / 赛道检索。' + '\n' + '👉 输入关键词(如「亲子」)后按 回车 过滤,再看效果。' },
{ sel: '#accImport', mode: 'click', title: '添加账号', text: '「添加」弹窗填入抖音账号名称 / 地址,AI 将通过抖音网页抓取账号信息 + 视频列表入库(新增账号入口)。' + '\n' + '👉 点击打开弹窗看看表单,再点「取消」关闭(不实际添加)。' },
{ sel: '.modal-mask [data-cancel]', mode: 'click', title: '关闭弹窗', text: '👉 点「取消」关闭添加弹窗,回到账号列表。' },
{ sel: '#accUpdate', mode: 'info', danger: true, title: '更新关注账号', text: '🌐 重新抓取所有 ⭐ 已关注账号的最新数据与视频列表(后台逐账号串行,每个数分钟)。此处仅讲解,不实际点击。' },
{ sel: '.fav-btn', mode: 'info', danger: true, emptyOk: true, title: '关注 / 取消关注', text: '行首 ☆/⭐ 切换关注:关注的账号参与「更新」批量刷新,也是「关注账号优先」排榜的来源。此处仅讲解。' },
{ sel: '[data-create]', mode: 'info', danger: true, emptyOk: true, title: '行内 AI 创作', text: '行尾「AI创作」= 基于该账号设定创作全新脚本(弹窗可填选题维度,完成后脚本自动入库到「AI写脚本」)。此处仅讲解。' },
],
qa: [
['"把抖音账号「俊希」导入账号列表"', '网页采集账号信息 + 视频列表入库'],
['"更新一下所有关注账号的数据"', '后台逐账号重抓最新数据与视频'],
],
},
{
key: 'account', icon: 'image', title: '账号详情', route: firstAccountRoute,
desc: '单账号档案:账号信息 + 账号设定(人设卡)+ 视频列表(趋势图)+ 数据分析',
steps: [
{ sel: '.tabs [data-tab="persona"]', mode: 'click', emptyOk: true, title: '切到视频列表', text: '账号详情顶部三个 tab:账号信息 / 视频列表 / 数据分析。' + '\n' + '👉 点「视频列表」:上方是点赞/评论趋势折线图,下方是该账号全部视频表。' },
{ sel: '.tabs [data-tab="info"]', mode: 'click', emptyOk: true, title: '账号信息与设定', text: '👉 点回「账号信息」:上半是基础档案(抖音号/粉丝/赛道/AI制作等,抖音号可跳主页),下半是「账号设定」(人设卡,支持卡片版/文字版切换)。' },
{ sel: '.persona-vs-icons [data-vs="text"]', mode: 'click', emptyOk: true, title: '设定卡片切换', text: '账号设定右上角两个小图标:卡片版(设计感人设卡)/ 文字版(结构化全文)。' + '\n' + '👉 点文字版图标再点回卡片版。' },
{ sel: '.tabs [data-tab="analysis"]', mode: 'click', emptyOk: true, title: '数据分析', text: '👉 点「数据分析」:账号内容结构拆解与定位诊断(由账号解析产物生成,无数据时为空态)。' },
{ sel: '[data-rewrite]', mode: 'info', danger: true, emptyOk: true, title: '基于视频 AI 创作', text: '视频列表行尾「AI创作」= 以该视频为选题来源,结合账号设定创作全新脚本。此处仅讲解,不实际触发。' },
{ sel: '[data-extract]', mode: 'info', danger: true, emptyOk: true, title: '提炼账号设定', text: '账号无设定时可点「提炼账号设定」按钮,AI 基于库中解析数据生成人设卡。此处仅讲解。' },
],
qa: [
['"帮我设计一个三农赛道的达人IP"', '提炼账号设定(人设卡)'],
['"分析一下这个账号的内容套路"', '账号内容结构拆解 + 定位诊断'],
],
},
{
key: 'video', icon: 'file-text', title: '视频详情', route: firstVideoRoute,
desc: '单视频档案:视频脚本(分镜头表)+ 视频拆解(结构化分析)+ 解析/刷新',
steps: [
{ sel: '.tabs [data-tab="deconstruct"]', mode: 'click', emptyOk: true, title: '视频拆解 tab', text: '视频详情顶部两个 tab:视频脚本(原视频口播/分镜还原)与视频拆解(钩子/节奏/情绪/选题结构化分析)。' + '\n' + '👉 点「视频拆解」查看结构化拆解。' },
{ sel: '.tabs [data-tab="script"]', mode: 'click', emptyOk: true, title: '视频脚本 tab', text: '👉 点回「视频脚本」:展示该视频的脚本/分镜头表(由 MCP 解析产出)。' },
{ sel: '[data-parse]', mode: 'info', danger: true, emptyOk: true, title: '解析 / 刷新', text: '「解析」= 对该视频做 MCP 内容解析(约 3-5 分钟,产出脚本+拆解);已解析视频按钮变为「刷新」可覆盖更新。此处仅讲解。' },
{ sel: '.info-card.video-info', mode: 'info', title: '视频信息卡', text: '页面顶部信息卡:所属账号(可点进账号详情)、视频ID、时长、发布时间与互动数据,右上「查看视频」直接打开抖音原页。' },
],
qa: [
['"解析这个抖音链接"', 'MCP 自动解析视频内容'],
['"帮我拆解这个抖音视频"', '解析 + 钩子/节奏/情绪/选题拆解'],
],
},
{
key: 'rewrites', icon: 'pen', title: 'AI写脚本', route: '#/rewrites',
desc: '脚本中心:参考选题创作(有原视频)/ 原创选题创作(无原视频)/ ABC 融合创作',
steps: [
{ sel: '.rw-tabs [data-tab="custom"]', mode: 'click', title: '原创选题创作', text: 'AI写脚本 分三种创作入口,先看「原创选题创作」:无原视频的脚本(来自账号列表/详情 AI创作弹窗)都收在这里,带徽标计数。' + '\n' + '👉 点「原创选题创作」tab。' },
{ sel: '.rw-tabs [data-tab="ref"]', mode: 'click', title: '参考选题创作', text: '「参考选题创作」:以库中视频为选题来源(关注账号优先、按发布时间倒序),每行视频可「AI创作」新脚本,完成后自动挂到该视频下。' + '\n' + '👉 点回「参考选题创作」。' },
{ sel: '#rwSearch', mode: 'input', emptyOk: true, title: '搜索视频', text: '按视频标题 / 账号名搜索候选视频,配时间范围与「已解析/未解析」状态过滤。' + '\n' + '👉 输入关键词回车试试,或直接下一步。' },
{ sel: '.rw-tabs [data-tab="abc"]', mode: 'info', title: 'ABC 融合创作', text: '「ABC融合创作」:将同账号多条视频的爆款元素融合成一条全新脚本(功能开发中,暂为占位说明页)。' },
{ sel: '[data-rewrite]', mode: 'info', danger: true, emptyOk: true, title: '行内 AI 创作', text: '视频行尾「AI创作」:读取该账号人设 + 视频选题 → 创作全新故事脚本 → 自动入库挂到该视频下。此处仅讲解,不实际触发。' },
{ sel: '[data-detail]', mode: 'info', danger: true, emptyOk: true, title: '查看脚本详情', text: '行内「N 查看」打开脚本详情弹窗:左侧源脚本(可折叠)+ AI脚本(多版本切换),右侧诊断评分 / 分镜提示词。此处仅讲解。' },
],
qa: [
['"基于「旧梦留声机」账号设定,替换事件写一个脚本"', '读账号设定,按账号套路写全新事件脚本'],
['"把刚写的脚本展开成分镜"', '按分镜技能生成镜头级描述'],
],
},
{
key: 'reviews', icon: 'flask', title: '脚本诊断', route: '#/reviews',
desc: '复盘评分中心:AI 脚本质量评分表 + 复盘任务 + 完整诊断报告',
steps: [
{ sel: '.table thead', mode: 'info', title: '评分表结构', text: '列表展示全部 AI 脚本的复盘状态:每行含 生成方式(参考选题/输入选题)、脚本标题、以及各维度得分(创意看点/设定承接/框架节奏/爆款开场/叙事逻辑/极致事件/…/总分)。' },
{ sel: '[data-open]', mode: 'info', danger: true, emptyOk: true, title: '打开脚本详情', text: '点脚本标题打开脚本详情弹窗(右侧默认收起诊断,可展开看该版本的评分与问题清单)。此处仅讲解。' },
{ sel: '[data-review-row]', mode: 'info', danger: true, emptyOk: true, title: '发起复盘', text: '行尾「复盘 / 更新」按钮:对脚本发起评分式复盘(有原视频时含 AI/源/对比三模式,无则独立 10 维评分),后台任务数分钟完成。此处仅讲解。' },
{ sel: '[data-review]', mode: 'info', danger: true, emptyOk: true, title: '完整复盘报告', text: '点「总分」数字可打开完整复盘报告:各维得分条 + 问题清单(高/中/低)+ 修改建议。此处仅讲解。' },
],
qa: [
['"帮我诊断这个脚本"', '评分式复盘:各维得分 + 问题清单 + 修改建议'],
['"对比 AI脚本 和 原视频脚本"', 'AI 脚本 vs 源脚本对比分析(三段式)'],
],
},
];
/* ============ 帮助页渲染 ============ */
function renderHelp() {
return `
<div class="home help-page">
<div class="page-toolbar">
<button class="btn" data-back title="返回">←</button>
<span class="page-title">功能帮助</span>
</div>
<div class="help-head">
<div class="help-head-icon">${svgIcon('help', 26)}</div>
<div>
<h2>短视频工作台 · 使用帮助</h2>
<p class="help-head-sub">跟着「模拟操作指引」在真实界面上一步步点一遍,比看文字快得多。也可以直接对 AI 说需求(点击问法即复制)。</p>
</div>
</div>
<div class="help-grid">
${BOARDS.map((b) => `
<div class="help-card" data-board="${b.key}">
<div class="help-card-head">
<span class="help-card-icon">${svgIcon(b.icon, 20)}</span>
<span class="help-card-title">${b.title}</span>
</div>
<div class="help-card-desc">${b.desc}</div>
<div class="help-card-acts">
<button class="btn btn-sm btn-primary" data-guide="${b.key}">${svgIcon('zap', 14, '#fff')} 模拟操作指引</button>
<button class="btn btn-sm" data-goto="${b.key}">进入板块 →</button>
</div>
<details class="help-card-qa">
<summary>常见问法(点击复制)</summary>
<div class="qa">
${(b.qa || []).map(([q, a]) => `<div class="qa-row copyable" data-q="${esc(q)}" title="点击复制此问法"><span class="q">${esc(q)}</span><span class="a">→ ${esc(a)}</span></div>`).join('')}
</div>
</details>
</div>`).join('')}
</div>
</div>`;
}
/* ============ 帮助页事件绑定(进入按钮 / 指引按钮 / 问法复制) ============ */
function bindHelp(view) {
view.querySelectorAll('.qa-row.copyable').forEach((row) => row.addEventListener('click', async () => {
const text = (row.dataset.q || '').replace(/^"|"$/g, '');
if (text) showToast((await copyText(text)) ? '✅ 已复制:' + text : '❌ 复制失败');
}));
view.querySelectorAll('[data-guide]').forEach((b) => b.addEventListener('click', () => {
startGuide(b.dataset.guide, b);
}));
view.querySelectorAll('[data-goto]').forEach((b) => b.addEventListener('click', async () => {
const board = BOARDS.find((x) => x.key === b.dataset.goto);
if (!board) return;
let r = board.route;
if (typeof r === 'function') { try { r = await r(); } catch (e) { r = null; } }
if (!r) {
showToast('📌 暂无示例数据,请先在库中添加账号 / 解析视频');
return;
}
location.hash = (typeof r === 'string' && r.startsWith('#')) ? r : '#/';
}));
}
/* ============ 模拟操作引导引擎 ============ */
let guide = null; // { board, steps, idx, mask, pop, targetEl, clearFns[] }
const GUIDE_Z = 1100; // 遮罩/气泡层级(modal 为 100,须高于它)
const WAIT_MS = 9000; // 步骤目标最长等待
const POLL_MS = 180;
function waitFor(sel, timeout) {
return new Promise((resolve) => {
const t0 = Date.now();
(function poll() {
const el = sel ? $(sel) : null;
if (el || Date.now() - t0 > timeout) resolve(el || null);
else setTimeout(poll, POLL_MS);
})();
});
}
/* 路由是否已生效(当前 hash 与目标一致,且页面已渲染出内容) */
function routedTo(targetHash) {
const cur = (location.hash || '#/').split('?')[0];
if (cur !== targetHash) return false;
const view = $('#view');
return !!(view && view.innerHTML && view.innerHTML.length > 200 && !view.querySelector('.view-empty'));
}
async function gotoRoute(targetHash) {
if (!targetHash) return false;
if (!routedTo(targetHash)) {
location.hash = targetHash;
const t0 = Date.now();
while (Date.now() - t0 < WAIT_MS) {
await new Promise((r) => setTimeout(r, POLL_MS));
if (routedTo(targetHash)) break;
}
}
return routedTo(targetHash);
}
function cleanupGuide() {
if (!guide) return;
if (guide.mask && guide.mask.parentNode) guide.mask.parentNode.removeChild(guide.mask);
if (guide.pop && guide.pop.parentNode) guide.pop.parentNode.removeChild(guide.pop);
if (guide.targetEl) {
guide.targetEl.style.outline = '';
guide.targetEl.style.boxShadow = '';
guide.targetEl.style.zIndex = '';
guide.targetEl.style.position = '';
guide.targetEl.style.pointerEvents = '';
}
(guide.clearFns || []).forEach((fn) => { try { fn(); } catch (e) {} });
guide = null;
}
/* 定位高亮 + 气泡位置(fixed,相对视口;容器滚动不重算——气泡跟随目标下方固定,超界自动收敛到可视区) */
function placePop() {
if (!guide || !guide.targetEl || !guide.pop) return;
const r = guide.targetEl.getBoundingClientRect();
if (r.width === 0 && r.height === 0) return;
const pw = guide.pop.offsetWidth || 420;
const ph = guide.pop.offsetHeight || 120;
let x = Math.min(Math.max(12, r.left + r.width / 2 - pw / 2), window.innerWidth - pw - 12);
let y = r.bottom + 14;
if (y + ph > window.innerHeight - 12) y = Math.max(12, r.top - ph - 14);
guide.pop.style.left = x + 'px';
guide.pop.style.top = y + 'px';
}
function highlightTarget(el) {
if (!el) return;
el.style.outline = '3px solid var(--primary)';
el.style.outlineOffset = '2px';
el.style.boxShadow = '0 0 0 6px rgba(47,111,237,.25)';
// 提升层级使其可点/可见于遮罩之上
const pos = getComputedStyle(el).position;
if (pos === 'static') el.style.position = 'relative';
el.style.zIndex = String(GUIDE_Z + 2);
if (guide.step.danger) el.style.pointerEvents = 'none'; // 防误触危险控件
guide.targetEl = el;
el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth' });
}
/* 渲染气泡并挂一次性交互 */
function showStep() {
const g = guide;
g.step = g.steps[g.idx];
g.clearFns.forEach((fn) => { try { fn(); } catch (e) {} });
g.clearFns = [];
if (g.targetEl) {
g.targetEl.style.outline = ''; g.targetEl.style.boxShadow = ''; g.targetEl.style.zIndex = '';
g.targetEl.style.position = ''; g.targetEl.style.pointerEvents = '';
g.targetEl = null;
}
const st = g.step;
const total = g.steps.length;
const isLast = g.idx >= total - 1;
const needClick = st.mode === 'click';
const needInput = st.mode === 'input';
const actionHint = needClick ? '👉 点击上面高亮的控件体验(点对自动下一步)'
: needInput ? '👉 在上面高亮输入框输入后按 回车(或直接下一步)'
: (st.danger ? '⚠️ 该操作有副作用,此处仅讲解,请勿点击' : '');
g.pop.innerHTML = `
<div class="g-head"><span class="g-board">${esc(g.board.title)}</span><span class="g-count">${g.idx + 1}/${total}</span></div>
<div class="g-title">${esc(st.title || '')}</div>
<div class="g-text">${esc(st.text || '')}</div>
${actionHint ? `<div class="g-hint ${needClick || needInput ? 'g-hint-act' : 'g-hint-warn'}">${esc(actionHint)}</div>` : ''}
<div class="g-actions">
<button class="ghost btn-sm" data-g-prev ${g.idx === 0 ? 'disabled' : ''}>‹ 上一步</button>
<button class="primary btn-sm" data-g-next>${isLast ? '✓ 完成' : (needClick || needInput ? '跳过 →' : '下一步 →')}</button>
</div>`;
g.pop.style.display = 'block';
g.mask.style.display = 'block';
// 按钮
const prevBtn = g.pop.querySelector('[data-g-prev]');
if (prevBtn) prevBtn.addEventListener('click', () => { if (g.idx > 0) { g.idx--; showStep(); } });
g.pop.querySelector('[data-g-next]').addEventListener('click', () => {
if (isLast) return finishGuide();
g.idx++; showStep();
});
// 找目标控件
(async () => {
const el = await waitFor(st.sel, WAIT_MS);
if (!guide || guide !== g) return; // 已退出
if (!el) {
if (st.emptyOk) {
// 目标缺失但允许纯文字展示
g.pop.querySelector('.g-hint')?.remove();
placePop();
} else {
// 自动跳到下一步
if (g.idx < total - 1) { g.idx++; showStep(); }
else finishGuide();
}
return;
}
highlightTarget(el);
// 目标在弹窗(.modal-mask)内时隐藏全屏遮罩——modal z-index(100) < 遮罩(1100),留着会挡住弹窗操作
const inModal = !!el.closest('.modal-mask');
g.mask.style.display = inModal ? 'none' : 'block';
if (needClick && !st.danger) {
const fn = () => { if (guide === g) { if (g.idx < total - 1) { g.idx++; showStep(); } else finishGuide(); } };
el.addEventListener('click', fn, { once: true });
g.clearFns.push(() => el.removeEventListener('click', fn));
} else if (needInput) {
const fn = (e) => { if (e.key === 'Enter' && guide === g) { if (g.idx < total - 1) { g.idx++; showStep(); } else finishGuide(); } };
el.addEventListener('keydown', fn);
g.clearFns.push(() => el.removeEventListener('keydown', fn));
}
placePop();
// 页面内容可能异步替换(如切换 tab 重渲染),延迟后重定位一次
setTimeout(placePop, 500);
})();
}
function buildOverlay() {
/* 注意:不要在此调 cleanupGuide()——它会把 guide 置 null,导致下方 guide.mask 赋值抛错。
旧引导清理由调用方 startGuide(入口处 if (guide) cleanupGuide())负责;新建 mask/pop 挂到当前 guide */
const mask = document.createElement('div');
mask.className = 'g-mask';
mask.style.zIndex = String(GUIDE_Z);
document.body.appendChild(mask);
const pop = document.createElement('div');
pop.className = 'g-pop';
pop.style.zIndex = String(GUIDE_Z + 1);
document.body.appendChild(pop);
guide.mask = mask;
guide.pop = pop;
// 点遮罩非高亮区 = 忽略(仅提示)
mask.addEventListener('click', () => showToast('请按气泡指引操作:点击高亮控件或气泡按钮'));
// Esc 随时退出引导
const escFn = (e) => { if (e.key === 'Escape') finishGuide(); };
document.addEventListener('keydown', escFn);
guide.clearFns.push(() => document.removeEventListener('keydown', escFn));
}
function finishGuide() {
const name = guide ? guide.board.title : '';
cleanupGuide();
showToast('🎉 已看完「' + name + '」指引,可自行继续操作');
}
/* 入口:boardKey 或 {route,title,steps} */
async function startGuide(boardKey, btn) {
const board = BOARDS.find((b) => b.key === boardKey);
if (!board) { showToast('❌ 未找到该板块指引'); return; }
if (guide) cleanupGuide();
// 详情类板块:先解析示例路由(取库中第一条)
let route = board.route;
if (typeof route === 'function') {
try { route = await route(); } catch (e) { route = null; }
}
if (typeof route === 'string' && !route.startsWith('#')) route = null;
if (!route) {
const needFirst = board.key === 'account' ? '还没有账号数据——请先到「账号列表」添加一个账号,再回来体验账号详情指引' : '还没有视频数据——请先在「热点数据/账号列表」解析或添加视频,再回来体验视频详情指引';
showToast('📌 ' + needFirst);
if (board.key === 'account') location.hash = '#/accounts';
else if (board.key === 'video') location.hash = '#/accounts';
return;
}
// 跳转 + 等渲染
const ok = await gotoRoute(route);
if (!ok) { showToast('❌ 页面加载超时,请重试'); return; }
guide = { board, steps: board.steps, idx: 0, clearFns: [] };
buildOverlay();
showStep();
}
/* 暴露到全局:renderHelp/bindHelp/startGuide/BOARDS(供 app.js 路由调用) */
window.HelpGuide = { renderHelp, bindHelp, startGuide, BOARDS };
})();
@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>短视频工作台</title>
<link rel="stylesheet" href="style.css?v=20260918a">
<script src="vendor/echarts.min.js"></script>
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="logo" data-icon="film" data-size="20"></span>
</div>
<nav class="crumbs" id="crumbs"></nav>
<div class="top-actions">
<button id="btnHome" class="ghost" title="回到首页"><i data-icon="home"></i> 首页</button>
<button id="btnHelp" class="ghost" title="功能帮助与快速提问"><i data-icon="help"></i> 帮助</button>
<button id="btnSettings" class="ghost" title="设置项目地址"><i data-icon="settings"></i> 设置</button>
<button id="btnReload" class="ghost icon-only" title="刷新页面:重新加载最新前端(改了样式/脚本后点这里最稳)" aria-label="刷新页面"><i data-icon="refresh" data-color="currentColor"></i></button>
</div>
</header>
<main id="view">
<div class="view-empty">加载中…</div>
</main>
<!-- ========== 设置弹窗 ========== -->
<div id="settingsModal" class="modal-mask" hidden>
<div class="modal">
<div class="modal-title-row">
<span><i data-icon="settings"></i> 项目文件地址</span>
<button class="btn" id="btnCloseSettings" title="关闭"><i data-icon="x"></i></button>
</div>
<p class="dim">默认读取桌面 <code>MCNSkill项目</code>。</p>
<div id="rootList" class="root-list"></div>
<div class="root-add">
<input id="newRoot" type="text" placeholder="C:\Users\maidou\Desktop\MCNSkill项目">
<button id="btnAddRoot" class="primary">确认</button>
</div>
<p id="rootMsg" class="root-msg"></p>
</div>
</div>
<script src="app.js?v=20260918e"></script>
<script src="help-guide.js?v=20260918c"></script>
<script src="data-pages.js?v=20260918c"></script>
</body>
</html>
@@ -0,0 +1,907 @@
/* 短视频工作台样式 - 亮色主题 */
:root {
--bg: #f5f6f8;
--panel: #ffffff;
--border: #e3e6ea;
--text: #24292f;
--dim: #6b7280;
--primary: #2f6fed;
--primary-soft: #eaf1fe;
--accent: #e8590c;
--danger: #d93026;
--shadow: 0 1px 3px rgba(0,0,0,.08);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; } /* 09-01 禁止 html/body 滚动,消除 topbar 1px 边框导致的第二层滚动条;滚动统一由 main#view 承担 */
body {
font-family: "Microsoft YaHei", "PingFang SC", "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
font-size: 16px;
}
/* ---- 顶栏 ---- */
.topbar {
display: flex; align-items: center; gap: 20px;
padding: 10px 20px;
background: var(--panel);
border-bottom: 1px solid var(--border);
box-shadow: var(--shadow);
position: sticky; top: 0; z-index: 10;
}
.brand { display: flex; align-items: center; gap: 8px; }
.logo { display: inline-flex; align-items: center; color: var(--primary); }
.title { font-size: 21px; font-weight: 700; }
.subtitle { font-size: 14px; color: var(--dim); }
.tabs { display: flex; gap: 4px; margin-left: 20px; }
.tab {
border: none; background: none; padding: 8px 16px;
font-size: 16px; color: var(--dim); cursor: pointer;
border-radius: 8px;
}
.tab:hover { background: var(--primary-soft); color: var(--primary); }
.tab.active { background: var(--primary); color: #fff; font-weight: 600; }
.top-actions { margin-left: auto; }
.ghost {
border: 1px solid var(--border); background: var(--panel);
color: var(--text); padding: 6px 14px; border-radius: 8px;
cursor: pointer; font-size: 15px;
display: inline-flex; align-items: center; gap: 6px;
}
.ghost:hover { border-color: var(--primary); color: var(--primary); }
.primary {
border: none; background: var(--primary); color: #fff;
padding: 6px 14px; border-radius: 8px; cursor: pointer; font-size: 15px;
}
/* ---- 主体 ---- */
main { height: calc(100vh - 54px); }
.panel { display: none; height: 100%; overflow: auto; padding: 24px; }
.panel.active { display: block; }
/* ---- 技能导航 ---- */
.intro { max-width: 900px; margin: 0 auto 20px; color: var(--dim); }
.func-grid {
max-width: 900px; margin: 0 auto;
display: grid; grid-template-columns: 1fr; /* 单列,卡片独占一行 */
gap: 14px;
}
.func-card {
background: var(--panel); border: 1px solid var(--border);
border-radius: 12px; padding: 16px 18px; box-shadow: var(--shadow);
}
.func-card h3 { font-size: 18px; margin-bottom: 10px; display: flex; align-items: center; gap: 8px; }
.func-card .desc { color: var(--dim); font-size: 15px; margin-bottom: 10px; }
.func-card .qa { border-top: 1px dashed var(--border); padding-top: 8px; }
.qa-row { padding: 6px 8px; font-size: 15px; line-height: 1.55; }
.qa-row .q { color: var(--accent); font-weight: 500; display: block; white-space: normal; }
.qa-row .a { color: var(--dim); display: block; padding-left: 1.2em; margin-top: 2px; }
.qa-row.copyable { cursor: pointer; border-radius: 8px; margin: 0 -8px; transition: background .15s; }
.qa-row.copyable:hover { background: var(--primary-soft); }
.qa-row.copyable:hover .q::after { content: ' 📋'; font-size: 14px; opacity: .7; }
/* ---- 产出浏览 ---- */
.files-layout { display: flex; height: 100%; gap: 0; }
.sidebar {
width: 320px; min-width: 320px; background: var(--panel);
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
}
.sidebar-top { padding: 12px; display: flex; flex-direction: column; gap: 8px; border-bottom: 1px solid var(--border); }
.sidebar-top select, .sidebar-top input {
padding: 7px 10px; border: 1px solid var(--border); border-radius: 8px;
font-size: 15px; width: 100%; background: #fff; color: var(--text);
}
.file-tree { flex: 1; overflow: auto; padding: 8px; }
.tree-empty { color: var(--dim); padding: 12px; font-size: 15px; }
.tree-node { font-size: 15px; }
.tree-row {
display: flex; align-items: center; gap: 6px;
padding: 4px 8px; border-radius: 6px; cursor: pointer; user-select: none;
white-space: nowrap; overflow: hidden;
}
.tree-row:hover { background: var(--primary-soft); }
.tree-row.selected { background: var(--primary); color: #fff; }
.tree-row .caret { width: 14px; text-align: center; font-size: 11px; flex-shrink: 0; }
.tree-row .icon { flex-shrink: 0; }
.tree-row .fname { overflow: hidden; text-overflow: ellipsis; }
.tree-children { margin-left: 18px; border-left: 1px dashed var(--border); padding-left: 4px; }
.ext-md { color: #8250df; }
.ext-json { color: #cf222e; }
.ext-xlsx { color: #1a7f37; }
.ext-txt, .ext-csv { color: #57606a; }
.content-view { flex: 1; overflow: auto; background: var(--panel); }
.preview-empty {
height: 100%; display: flex; flex-direction: column; align-items: center;
justify-content: center; color: var(--dim); gap: 8px;
}
.preview-empty p { font-size: 17px; }
.preview-empty .dim { font-size: 14px; }
.preview { padding: 24px 32px; max-width: 900px; margin: 0 auto; }
.preview .file-head {
display: flex; align-items: center; gap: 10px;
border-bottom: 1px solid var(--border); padding-bottom: 10px; margin-bottom: 16px;
}
.preview .file-head .fpath { font-size: 14px; color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.preview .file-head .fmeta { font-size: 14px; color: var(--dim); white-space: nowrap; }
.btn-download {
border: 1px solid var(--primary); color: var(--primary); background: #fff;
padding: 4px 12px; border-radius: 6px; cursor: pointer; font-size: 14px;
margin-left: auto; text-decoration: none;
}
.btn-download:hover { background: var(--primary-soft); }
/* ---- Markdown 渲染 ---- */
.md-body { line-height: 1.75; font-size: 16px; word-break: break-word; }
.md-body h1, .md-body h2, .md-body h3, .md-body h4 { margin: 18px 0 10px; line-height: 1.4; }
.md-body h1 { font-size: 25px; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
.md-body h2 { font-size: 21px; border-bottom: 1px solid var(--border); padding-bottom: 4px; }
.md-body h3 { font-size: 18px; }
.md-body p { margin: 8px 0; }
.md-body strong { font-weight: 700; }
.md-body code {
background: #f0f2f5; border-radius: 4px; padding: 2px 5px;
font-family: Consolas, monospace; font-size: 15px;
}
.md-body pre {
background: #f6f8fa; border: 1px solid var(--border); border-radius: 8px;
padding: 12px 14px; overflow: auto; margin: 10px 0;
}
.md-body pre code { background: none; padding: 0; }
.md-body blockquote {
border-left: 4px solid var(--primary); background: var(--primary-soft);
padding: 8px 14px; margin: 10px 0; border-radius: 0 8px 8px 0; color: #333;
}
.md-body table { border-collapse: collapse; margin: 10px 0; width: 100%; display: block; overflow-x: auto; }
.md-body table th:first-child, .md-body table td:first-child { width: 100px; } /* markdown 表格第一列统一 100px(如人设卡维度列) */
.md-body th, .md-body td { border: 1px solid var(--border); padding: 6px 10px; font-size: 15px; }
.md-body th { background: #f6f8fa; font-weight: 600; }
.md-body ul, .md-body ol { margin: 8px 0 8px 22px; }
.md-body li { margin: 3px 0; }
.md-body hr { border: none; border-top: 1px solid var(--border); margin: 14px 0; }
.md-body a { color: #4a90e2; }
/* ---- JSON 预览 ---- */
.json-view pre {
background: #f6f8fa; border: 1px solid var(--border); border-radius: 8px;
padding: 14px; overflow: auto; font-size: 15px; line-height: 1.6;
font-family: Consolas, monospace;
}
/* ---- 弹窗 ---- */
.modal-mask {
position: fixed; inset: 0; background: rgba(0,0,0,.35);
display: flex; align-items: center; justify-content: center; z-index: 100;
}
.modal-mask[hidden] { display: none; } /* 修复:display:flex 会覆盖 hidden 属性,需显式复位 */
.modal {
background: #fff; border-radius: 14px; padding: 22px 26px;
width: 520px; max-width: 92vw; box-shadow: 0 8px 30px rgba(0,0,0,.18);
}
.modal h3 { margin-bottom: 10px; display: flex; align-items: center; gap: 8px; }
.modal .dim { color: var(--dim); font-size: 15px; margin-bottom: 12px; }
.modal code { background: #f0f2f5; padding: 1px 5px; border-radius: 4px; font-size: 14px; }
/* 保存确认弹窗:账号名/链接展示 */
.confirm-target {
background: var(--primary-soft); border-radius: 8px; padding: 10px 12px;
font-size: 16px; font-weight: 600; color: var(--primary); margin-bottom: 6px;
}
.confirm-url {
color: var(--dim); font-size: 13px; word-break: break-all;
padding: 0 2px 4px; margin-bottom: 6px;
}
/* AI创作 弹窗(09-02 v3):需求维度块 + 对话流 + 一体输入条(发送图标内嵌,参考 WorkBuddy 输入框) */
.req-modal {
width: 960px; max-width: 96vw; box-sizing: border-box; /* 09-16:宽 760→960(+200) */
/* 高度:上下各留 100px(height = 100vh − 200,交给 mask 居中 → 顶/底相等)。
09-16 曾用 align-self:flex-end + margin-bottom:100px 做"底 100 / 顶 20",用户要求上下一致已改回居中。 */
height: calc(100vh - 200px);
display: flex; flex-direction: column; gap: 10px;
overflow: hidden; /* v21:内容超高时裁在弹窗内,防止输入条被挤出弹窗视觉外 */
}
.req-modal .modal-title-row { flex: none; margin-bottom: 0; }
/* 需求区:一个维度一张卡片(09-02 v12 改:维度名=卡片内小标题置左上角,内容在其下;卡片 padding 10px),超高内部滚动 */
.req-sheet {
flex: none; display: grid; grid-template-columns: 1fr; gap: 10px 0; /* 10px:与弹窗自身 gap 节奏一致;6px 偏挤,卡片有边框后更需要呼吸感 */
/* 限高:与下方 .req-sheet-box.open .req-sheet 同值(那条特指度更高、才是真正生效的;此处保持同值防规则增删后失配)。
公式:弹窗(100vh−200) − 固定 215 − 内边距 44 − 子项间距 60 − 对话框最小 60 = 100vh − 579。 */
max-height: calc(100vh - 580px); overflow: auto; align-content: start; padding: 2px 2px 6px;
/* 单列:需求内容普遍较长,窄列不直观(09-16 双列方案已被用户否掉,勿再改双列) */
}
.req-field {
display: flex; flex-direction: column; gap: 6px;
background: #fff; border: 1px solid var(--border); border-radius: 8px;
padding: 10px; text-align: left; /* text-align 显式声明:防被外层居中类继承(见下方 rf-empty 说明) */
}
/* ⚠️ 空态类名必须是 rf-empty,禁止改回 empty——.empty 是全站通用空态占位类
(style.css 下方 .empty{padding:30px;text-align:center}),与 .req-field 特指度相同且位置更靠后,
会覆盖卡片的 padding 与对齐,导致空卡 30px 居中、已填卡 10px 左对齐的"两套排版"。 */
/* 未填态:虚线框 + 极浅底 → 与已填态(实线框 + 白底)一眼可辨;不额外加装饰(09-16 曾加左侧主色状态条,用户判定过度设计已移除) */
.req-field.rf-empty { background: #fbfcfe; border-style: dashed; border-color: #d8dfe9; }
/* 三档层次:已填标签(主色 #2f6fed / 12px / 600)→ 内容(近黑 #24292f / 14.5px)→ 空态(标签 #7b8694、占位 #b6bfcc)
空态标签 #7b8694 而非更浅的灰:浅底上 #96a0ae 对比度不足 3:1,字段名要能看清,只是语气轻 */
.rf-title { font-size: 12px; color: var(--primary); font-weight: 600; letter-spacing: .4px; line-height: 1.4; }
.rf-val { font-size: 14.5px; color: var(--text); line-height: 1.5; word-break: break-word; white-space: pre-wrap; min-height: 22px; }
.req-field.rf-empty .rf-title { color: #7b8694; }
.req-field.rf-empty .rf-val { color: #b6bfcc; font-size: 13px; }
/* 需求区折叠容器(09-02):标题行「创作需求 + n/5 已填 + 箭头」点击展开/收起,默认收起 */
.req-sheet-box { flex: none; background: #fff; border: 1px solid var(--border); border-radius: 10px; padding: 2px 10px 4px; }
.req-sheet-toggle {
width: 100%; display: flex; align-items: center; gap: 8px;
background: none; border: none; padding: 8px 2px 6px; margin: 0;
font-size: 14px; font-weight: 600; color: var(--text); cursor: pointer; text-align: start;
}
.req-sheet-toggle:hover .rst-label { color: var(--primary); }
.req-sheet-toggle .rst-count {
font-size: 12px; font-weight: 400; line-height: 1.6; color: var(--dim);
background: #f0f2f5; border-radius: 10px; padding: 0 8px;
}
/* 09-16:删掉「创作需求」标题前的"已填"小圆点(has-filled 状态由右侧 n/8 已填徽章的变色表达,圆点多余) */
.req-sheet-box.has-filled .rst-count { background: var(--primary-soft); color: var(--primary); }
.req-sheet-toggle .rst-caret { margin-left: auto; display: inline-flex; align-items: center; color: var(--dim); }
.req-sheet-toggle .rst-caret svg { transition: transform .2s ease; transform: rotate(180deg); } /* 收起(默认):箭头朝下提示可展开 */
.req-sheet-box.open .rst-caret svg { transform: rotate(0deg); }
.req-sheet-box .req-sheet { max-height: 0; overflow: hidden; padding: 0; transition: max-height .2s ease; }
/* ⚠️ 展开态的真正限高在本条(特指度 0,3,0 高于 .req-sheet 的 0,1,0)——改「需求区能显示多高」只改这里,
改 .req-sheet 上的 max-height 会被本条覆盖(09-16 踩过)。公式:弹窗(100vh−120) − 固定 215 − 内边距 44 − 子项间距 60 − 对话框最小 60 = 100vh − 499 */
.req-sheet-box.open .req-sheet { max-height: calc(100vh - 580px); overflow: auto; padding: 2px 2px 6px; }
/* 只读变体(选题详情弹窗「点标题」):标题行不可点,去掉按钮态与 hover 变色 */
.req-sheet-toggle.static { cursor: default; }
.req-sheet-toggle.static:hover .rst-label { color: var(--text); }
/* 需求栏下方功能选项条(09-02 v11):时长目标下拉(30/60/120/240/自定义),与需求栏同宽同视觉层级 */
.req-opts { flex: none; display: flex; align-items: center; gap: 8px; background: #fff; border: 1px solid var(--border); border-radius: 10px; padding: 7px 10px; }
.req-opts .opts-label { flex: none; font-size: 13px; font-weight: 600; color: var(--text); }
/* 09-16:下拉/数字框 110→92 / 76→70,字号 15→13(与同行「时长」标签 13px 一致,也更省宽)。
⚠️ 必须带 `.req-opts` 父级限定(0,2,0)——`.input-select`(0,1,0)在本文件里更靠后,
同特指度时后出现者胜,只写 `.req-dur-sel` 会导致 font-size/padding 被 `.input-select` 覆盖(实测踩过)。
宽度依据实测:最宽项「自定义…」13px 下 52px,可用 = 92 − 12(内边距) − 22(原生箭头) = 58px ✓ 不截断。 */
.req-opts .req-dur-sel { flex: none; width: 92px; font-size: 13px; padding: 6px 6px; }
.req-opts .req-dur-num { flex: none; width: 70px; font-size: 13px; }
.req-dur-custom { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; color: var(--dim); }
.req-dur-custom[hidden] { display: none; } /* 防 display 覆盖 hidden:默认隐藏,选中自定义才显示 */
/* 提交创作确认弹窗 */
.req-confirm-label { font-size: 13px; color: var(--dim); margin: 10px 0 6px; }
.req-confirm-box {
background: #f6f8fb; border: 1px solid #edf0f4; border-radius: 8px;
padding: 10px 12px; font-size: 14px; line-height: 1.6; color: var(--text);
max-height: 260px; overflow: auto; white-space: pre-wrap; word-break: break-word;
}
.req-confirm-attach { font-size: 13px; color: var(--primary); margin-top: 8px; }
.req-dialog {
flex: 1 1 auto; min-height: 60px; overflow: auto; /* v21:min-height 130→60,需求区展开时给输入条让位 */
display: flex; flex-direction: column; gap: 12px; padding: 2px; /* 09-16:消息间距 6→12px(用户气泡与 AI 回复挨太紧) */
}
.req-dialog-empty { color: var(--dim); font-size: 13px; line-height: 1.7; padding: 2px; }
.req-msg { display: flex; }
.req-msg.mine { justify-content: flex-end; }
.req-msg.ai { justify-content: flex-start; }
/* 09-07 v27:GPT 风格气泡——用户主色实心圆角右对齐,AI 无背景直接 markdown 铺排左对齐 */
/* 09-16:用户气泡由「主色实底白字」改浅色——实底太抢眼,整屏视觉重心都在用户消息上 */
.req-bubble-mine {
max-width: 80%;
background: var(--primary-soft); color: var(--text); border: 1px solid #d7e5fd;
padding: 10px 14px; border-radius: 14px;
font-size: 14px; line-height: 1.55; white-space: pre-wrap; word-break: break-word;
}
.req-md {
max-width: 100%;
color: var(--text); font-size: 14.5px; line-height: 1.7;
white-space: pre-wrap; word-break: break-word;
}
/* 流式占位容器(流结束时会被替换为最终 markdown 节点) */
.req-md-stream { min-height: 1.6em; }
/* 思考动画:三个点交替跳动 + "正在思考"提示(不展示模型思考内容) */
.req-thinking {
display: inline-flex; align-items: center; gap: 6px;
color: var(--dim); font-size: 13px;
padding: 4px 2px;
}
.req-thinking .dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--primary); display: inline-block;
animation: req-dot 1.2s ease-in-out infinite;
}
.req-thinking .dot:nth-child(2) { animation-delay: .15s; }
.req-thinking .dot:nth-child(3) { animation-delay: .30s; }
.req-thinking-label { margin-left: 2px; }
@keyframes req-dot {
0%, 80%, 100% { opacity: .25; transform: translateY(0); }
40% { opacity: 1; transform: translateY(-3px); }
}
.req-msg.mine .req-bubble { background: var(--primary-soft); color: var(--text); border: 1px solid #d7e5fd; border-bottom-right-radius: 3px; }
.req-msg.ai .req-bubble { background: #f0f2f5; color: var(--text); border-bottom-left-radius: 3px; }
/* 09-07:AI 消息内嵌候选按钮行(flova 式:气泡下方紧跟可点选项,无额外提示语/虚线框) */
.req-msg.ai {
flex-wrap: wrap; column-gap: 8px;
/* 09-07 v26:AI 消息改纵向——气泡在下层自然左对齐,选项按钮行紧随其后,视觉同属一条消息 */
flex-direction: column; align-items: flex-start; row-gap: 6px;
}
.req-opt-row { display: flex; flex-wrap: wrap; gap: 6px; max-width: 100%; padding: 0 2px; }
.req-opt-chip {
display: inline-flex; align-items: center;
background: #fff; border: 1px solid #cfd8e6; border-radius: 8px;
padding: 6px 13px; font-size: 13px; color: var(--text); cursor: pointer;
line-height: 1.45; transition: border-color .15s, color .15s, background .15s, box-shadow .15s;
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
}
.req-opt-chip:not(:disabled):hover { border-color: var(--primary); color: var(--primary); background: #f2f6ff; }
.req-opt-chip:not(:disabled):active { transform: translateY(1px); }
/* 09-16:候选两态禁用——已选(.used,带 ✓)与已翻篇(.stale)。禁止再点,避免用前几轮方向覆盖当前需求 */
/* 09-16:候选三态一眼可辨——【可点】白底实线正文色 / 【已选】浅青底青字+✓ / 【已翻篇】灰底虚线灰字淡化。
已选原为主色实底白字,用户要求改浅一些的青色底(取设计里 refresh 图标的青绿 #0f7f8f 家族)。 */
.req-opt-chip:disabled { cursor: default; box-shadow: none; }
.req-opt-chip.used { gap: 5px; background: #e1f4f6; border-color: #9fd8e0; color: #0f7f8f; font-weight: 600; }
.req-opt-chip.stale { background: #f1f3f7; border: 1px dashed #d5dbe5; color: #9aa3b0; opacity: .85; }
/* 09-16:「换一批」挂最新一轮候选行尾(虚线弱化按钮);连换 2 次后转为"先给方向"的内联输入 */
.req-opt-more {
display: inline-flex; align-items: center; gap: 4px;
background: transparent; border: 1px dashed #cfd8e6; border-radius: 8px;
padding: 5px 11px; font-size: 13px; color: var(--dim); cursor: pointer;
transition: border-color .15s, color .15s;
}
.req-opt-more:not(:disabled):hover { border-color: var(--primary); color: var(--primary); }
.req-opt-more:disabled { cursor: default; opacity: .6; }
.req-more-hint { font-size: 12px; color: #a9b1bd; align-self: center; }
.req-dir-box { display: inline-flex; align-items: center; gap: 6px; flex: 1 1 320px; min-width: 0; }
.req-dir-input {
flex: 1 1 auto; min-width: 180px;
border: 1px solid var(--border); border-radius: 8px; padding: 5px 9px;
font-size: 13px; background: #fff; color: var(--text);
}
.req-dir-input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-soft); }
/* 09-16:标题栏「清空重来」改浅色样式(与 AI 回复里的「换一批」同一视觉语言:虚线细边 + 透明底 + 弱化字色) */
.req-reset-btn {
display: inline-flex; align-items: center; gap: 4px;
background: transparent; border: 1px dashed #cfd8e6; border-radius: 8px;
padding: 5px 11px; font-size: 13px; color: var(--dim); cursor: pointer;
transition: border-color .15s, color .15s;
}
.req-reset-btn:hover { border-color: var(--primary); color: var(--primary); }
/* 底部功能条:输入框(圆角块内嵌 textarea + 底部工具栏:回形针 · ✓ 提交创作 · ↑ 发送) */
.req-bar { flex: none; display: flex; align-items: flex-end; gap: 10px; }
.req-create {
/* 工具栏内:与 send 同尺寸同形状,描边白底(弱主操作),hover 时填实主色,与 send 实心形成「次主+主」配对 */
flex: none; width: 32px; height: 32px; border-radius: 50%;
border: 1px solid var(--primary); background: #fff; color: var(--primary);
display: inline-flex; align-items: center; justify-content: center;
cursor: pointer; padding: 0; margin-left: 4px; margin-right: 8px; /* ✓ 与 ↑ 发送间隔 = 2px gap + 8px = 10px */
}
.req-create:hover { background: var(--primary); color: #fff; border-color: var(--primary); }
/* 输入框:参考 WorkBuddy 风格——输入区在上(带 padding),工具栏在下(回形针 + 弹性间距 + 发送),外框统一圆角描边 */
.req-input-box {
flex: 1; display: flex; flex-direction: column;
border: 1px solid var(--border); border-radius: 14px; padding: 0;
background: #fff; transition: border-color .15s, box-shadow .15s;
}
.req-input-box:focus-within { border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-soft); }
.req-input {
width: 100%; box-sizing: border-box;
border: none; outline: none; resize: none; background: transparent;
font-size: 14px; font-family: inherit; line-height: 1.5;
/* 默认两行输入空间(行高 21px × 2 + padding 16 = 58 → 取 60 留 2px 余量) */
min-height: 60px; max-height: 100px;
padding: 10px 14px 6px;
}
.req-input::placeholder { color: #a6aebc; }
.req-input-toolbar {
display: flex; align-items: center; gap: 2px;
padding: 2px 4px 6px;
}
.req-spacer { flex: 1; }
.req-send {
flex: none; width: 32px; height: 32px; border-radius: 50%; border: none;
background: var(--primary); color: #fff; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center;
}
.req-send:hover:not(:disabled) { background: #2a5fd0; }
.req-send:disabled { background: #c7cedb; cursor: default; }
.req-send-busy {
width: 14px; height: 14px; border-radius: 50%;
border: 2px solid rgba(255,255,255,.45); border-top-color: #fff;
display: inline-block; animation: req-spin .8s linear infinite;
}
@keyframes req-spin { to { transform: rotate(360deg); } }
/* 附件:chip 行(对话流与输入条之间)+ 回形针上传钮(输入框区域左侧) */
.req-attach-row { flex: none; min-height: 0; }
.req-chip {
display: inline-flex; align-items: center; gap: 7px; max-width: 100%;
background: var(--primary-soft); border: 1px solid #d3e1f6; border-radius: 999px;
padding: 3px 6px 3px 10px; font-size: 12.5px; color: var(--primary);
}
.chip-ic { display: inline-flex; flex: none; }
.chip-name { max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
.chip-meta { flex: none; color: #7a8494; font-size: 11.5px; }
.chip-x {
flex: none; display: inline-flex; align-items: center; justify-content: center;
width: 18px; height: 18px; border: none; border-radius: 50%;
background: transparent; color: var(--primary); cursor: pointer; padding: 0;
}
.chip-x:hover { background: rgba(30, 70, 180, .12); }
.req-paper {
flex: none; display: inline-flex; align-items: center; justify-content: center;
width: 32px; height: 32px; padding: 0; border: none; border-radius: 8px;
background: transparent; color: #8a93a3; cursor: pointer;
}
.req-paper:hover:not(:disabled) { color: var(--primary); background: var(--primary-soft); }
.req-paper:disabled { cursor: default; opacity: .6; }
.req-paper.loading { color: var(--primary); animation: req-spin 1.1s linear infinite; }
.root-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
.root-item {
display: flex; align-items: center; gap: 8px;
border: 1px solid var(--border); border-radius: 8px; padding: 8px 12px; font-size: 15px;
}
.root-item .rp { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.root-item .rm {
border: none; background: none; color: #cf222e; cursor: pointer; font-size: 18px; line-height: 1;
}
.root-item .rm:hover { opacity: .7; }
.root-add { display: flex; gap: 8px; }
.root-add input { flex: 1; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; font-size: 15px; }
.root-msg { margin-top: 8px; font-size: 14px; color: var(--accent); min-height: 16px; }
.modal-actions { text-align: right; margin-top: 14px; }
/* 添加账号弹窗:账号信息(名称/地址) */
.import-form .f-row { margin-bottom: 10px; }
.import-form label { display: block; font-size: 13px; color: var(--dim); margin-bottom: 4px; }
.import-form .input { width: 100%; box-sizing: border-box; }
.import-form .divider { height: 1px; background: var(--border); margin: 14px 0; }
.dim { color: var(--dim); }
/* ---- Toast 提示 ---- */
.toast {
position: fixed; left: 50%; bottom: 32px; transform: translateX(-50%) translateY(20px);
background: #24292f; color: #fff; padding: 10px 18px; border-radius: 8px;
font-size: 15px; opacity: 0; pointer-events: none; transition: all .25s;
max-width: 70vw; z-index: 200; box-shadow: 0 4px 16px rgba(0,0,0,.2);
word-break: break-all;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
/* ================= 面包屑 ================= */
.crumbs { display: flex; align-items: baseline; gap: 8px; margin-left: 0; min-width: 0; }
.crumb-home { font-size: 21px; font-weight: 700; color: var(--text); cursor: pointer; text-decoration: none; }
a.crumb-home:hover { color: var(--primary); }
.crumb-sep { color: var(--dim); font-size: 16px; }
.crumb-current { color: var(--text); font-weight: 600; font-size: 16px; }
main#view { height: calc(100vh - 55px); overflow: auto; } /* 55px=topbar 实际高度(padding20+行高34+边框1);54px 时底部溢出 1px 触发第二层滚动条 */
.view-empty { padding: 40px; text-align: center; color: var(--dim); }
/* ================= 首页 ================= */
.home { max-width: 960px; margin: 0 auto; padding: 24px; }
/* 数据预览条(顶部统计,参考 dsh-plugin-mcn 首页 stats:浅蓝底横条 + 均分竖排数字/标签) */
.home-stats { display: flex; width: 100%; box-sizing: border-box; background: rgba(47,111,237,.07); border-radius: 12px; padding: 20px 0; margin-bottom: 6px; }
.home-stat-item { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 5px; padding: 2px 12px; border-right: 1px solid rgba(47,111,237,.16); }
.home-stat-item:last-child { border-right: none; }
.home-stat-value { font-size: 26px; font-weight: 700; color: var(--primary); line-height: 1.2; font-variant-numeric: tabular-nums; }
.home-stat-label { font-size: 13px; color: var(--dim); }
.data-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 14px; margin: 18px 0 24px; }
.data-card { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 16px 18px; cursor: pointer; text-align: left; box-shadow: var(--shadow); transition: all .15s; }
.data-card:hover { border-color: var(--primary); transform: translateY(-2px); box-shadow: 0 4px 12px rgba(47,111,237,.15); }
.data-card-icon { color: var(--primary); display: inline-flex; }
.data-card-title { font-size: 17px; font-weight: 600; color: var(--text); }
.data-card-desc { font-size: 14px; color: var(--dim); }
/* ================= 功能页通用 ================= */
.page { padding: 20px 24px; max-width: 1440px; margin: 0 auto; }
/* ================= 视频详情页 md-body:内容相对 tab 缩进 20px + 标题字号收紧 ================= */
.page-detail .md-body { font-size: 15px; margin-left: 20px; }
/* 视频详情内容右上角工具条(09-03 R28,09-04 已并入 tab 行 .tab-tools 不再独立成行) */
.page-detail .md-body h1 { font-size: 20px; border-bottom: 1px solid var(--border); padding-bottom: 4px; margin: 14px 0 8px; }
.page-detail .md-body h2 { font-size: 17px; border-bottom: 1px solid var(--border); padding-bottom: 3px; margin: 12px 0 6px; }
.page-detail .md-body h3 { font-size: 15px; margin: 10px 0 6px; }
.page-detail .md-body ul, .page-detail .md-body ol { margin: 6px 0 6px 18px; }
.page-detail .md-body p { margin: 6px 0; }
/* 账号详情页:仅内容区(信息卡/列表/面板)右移 10px,返回按钮与 tab 保持原位;视频详情页内容贴近左边(2026-09-01 改为 0) */
.page-detail .detail-body { padding-left: 0; }
.page-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 24px; }
.toolbar-right { display: flex; align-items: center; gap: 8px; margin-left: auto; }
.toolbar-note { font-size: 12px; color: var(--dim); text-align: right; margin: -14px 10px 16px auto; } /* 功能栏下方小字说明,右对齐 */
.page-title { font-size: 18px; font-weight: 600; display: inline-flex; align-items: center; gap: 8px; }
.input { border: 1px solid var(--border); border-radius: 8px; padding: 6px 10px; font-size: 15px; min-width: 200px; background: #fff; color: var(--text); }
.input-select { border: 1px solid var(--border); border-radius: 8px; padding: 6px 8px; font-size: 15px; background: #fff; color: var(--text); }
/* 09-16:干掉浏览器默认聚焦轮廓(Chrome 会给近黑色 outline rgb(16,16,16),点下拉/输入框时像一道黑框),
换成与「输入框容器」一致的聚焦反馈(主色描边 + 主色浅底光圈),键盘聚焦同样可见 */
.input:focus, .input-select:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-soft); }
.btn { border: 1px solid var(--border); background: var(--panel); color: var(--text); padding: 6px 14px; border-radius: 8px; cursor: pointer; font-size: 15px; text-decoration: none; }
.btn:hover { border-color: var(--primary); color: var(--primary); }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn-busy { background: var(--primary-soft) !important; color: var(--primary) !important; border-color: var(--primary) !important; opacity: .8; }
.btn-icon { padding: 5px 9px; display: inline-flex; align-items: center; justify-content: center; } /* 纯图标按钮(09-03:榜单更新等) */
#rankUpdate { padding-left: 11.5px; padding-right: 11.5px; background: transparent; border-color: transparent; color: var(--dim); } /* 09-03 更新按钮加宽 5px(icon 16 + 左右 padding 9→11.5),更易点中;去白底/边框只显 icon,常态浅灰 hover 蓝(.btn:hover) */
#rankUpdate:hover { background: transparent; border-color: transparent; } /* 覆盖 .btn:hover 默认主色边框 */
.home-rank-update { background: transparent; border-color: transparent; padding-left: 11.5px; padding-right: 11.5px; color: var(--dim); } /* 09-03 首页榜单下载按钮:去白底只显 icon,padding 与榜单 #rankUpdate 对齐,常态浅灰 */
.home-rank-update:hover { background: transparent; border-color: transparent; }
.btn-busy .ic { animation: req-spin 1s linear infinite; } /* busy 态图标旋转(复用弹窗 spinner 动画) */
.btn-sm { padding: 3px 10px; font-size: 14px; border-radius: 6px; }
.btn-primary { background: var(--primary); color: #fff; border-color: transparent; }
.btn-primary:hover { background: #2a5fd0; color: #fff; }
/* 功能列按钮区分:保存=浅灰底深字,查看=白底蓝字 */
.btn-import { background: #fff; border-color: #d0d4da; color: #495057; }
.btn-import:hover { background: var(--primary-soft); border-color: var(--primary); color: var(--primary); }
.btn-view { background: #fff; border-color: var(--primary); color: var(--primary); }
.btn-view:hover { background: var(--primary-soft); color: var(--primary); }
.empty { padding: 30px; text-align: center; color: var(--dim); font-size: 15px; }
/* ================= 表格 ================= */
.table-wrap { overflow: auto; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); }
.table { width: 100%; border-collapse: collapse; font-size: 15px; }
.table th { position: sticky; top: 0; background: #f6f8fa; text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); font-weight: 600; white-space: nowrap; z-index: 1; }
.table td { padding: 7px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
.table tbody tr:hover { background: var(--primary-soft); }
.cell-content { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cell-loc { max-width: 110px; } /* 账号定位列缩窄 */
.sortable { cursor: pointer; }
.sortable:hover { color: var(--primary); }
.table th.th-wide { min-width: 90px; text-align: right; } /* 周榜数值列表头(综合得分/粉丝/新增粉丝/点赞/评论/分享):窄 + 右对齐(提特异性压过 .table th 默认左对齐) */
.table td.num { text-align: right; font-size: 13px; font-variant-numeric: tabular-nums; padding-left: 8px; padding-right: 8px; } /* 数值列单元格:右对齐 + 紧凑(等宽数字,内容窄于 90px 让 min-width 生效) */
.table th.num { text-align: right; } /* 数值列表头(点赞/评论/分享/收藏/时长):与 td.num 右对齐配套 */
/* 周榜表:fixed 布局固定列宽(auto 布局下 100% 表格富余空间会按比例摊薄拉宽列,min-width 压不住;fixed 下 width 为硬约束,账号列吃剩余) */
.table.table-rank { table-layout: fixed; }
.table.table-rank th, .table.table-rank td { overflow: hidden; text-overflow: ellipsis; }
/* R49 三 tab 行高对齐(09-03):账号周榜因「功能」列按钮(.btn-sm)行高 42px,热榜/点赞榜无按钮仅 34.7px——
统一 td 高度 42px(box-sizing:border-box,含 padding 7×2),三 tab 切换行高不再跳动 */
.table.table-rank td { height: 42px; }
.table.table-rank th.th-wide { width: 90px; }
.table.table-rank th:nth-child(1) { width: 40px; } /* 排名 */
.table.table-rank th:nth-child(2) { width: 90px; } /* 赛道 */
.table.table-rank th:nth-child(10) { width: 85px; } /* 功能 */
/* 视频热榜/点赞榜(rank-video):第2列=作品标题(长标题吃宽),第3作者/第4赛道/第9发布时间 */
.table.table-rank.rank-video th:nth-child(1) { width: 44px; }
.table.table-rank.rank-video th:nth-child(2) { width: 30%; } /* 作品标题:占比列宽,截断省略 */
.table.table-rank.rank-video th:nth-child(3) { width: 120px; } /* 作者 */
.table.table-rank.rank-video th:nth-child(4) { width: 88px; } /* 赛道 */
.table.table-rank.rank-video th:nth-child(9) { width: 140px; } /* 发布时间 */
.table.table-rank.rank-video td.cell-tl { max-width: 0; } /* 标题单元格允许被压缩(配合占位列宽省略) */
.table.table-rank.rank-video .rk-tl { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.link { color: #4a90e2; cursor: pointer; text-decoration: none; }
.link:hover { text-decoration: underline; }
.link.strong { font-weight: 600; }
/* 无颜色区分的链接(视频标题等):默认同正文色,hover 才提示可点 */
.title-plain { color: inherit; cursor: pointer; text-decoration: none; }
.title-plain:hover { color: #4a90e2; text-decoration: underline; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 13px; }
.badge-signed { background: var(--primary-soft); color: var(--primary); }
.badge-ext { background: #fff4e5; color: var(--accent); }
.pager { display: flex; align-items: center; justify-content: flex-end; gap: 12px; padding: 12px 4px; }
.pager-total, .pager-info { color: var(--dim); font-size: 15px; }
/* ================= 数据页 tab(下划线式:无背景,激活蓝色文字+直线下划线,与内容左对齐) ================= */
.tabs { display: flex; align-items: center; gap: 4px; margin-bottom: 20px; margin-left: 0; border-bottom: 1px solid var(--border); }
.tab { border: none; background: none; padding: 8px 16px; font-size: 15px; color: var(--dim); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; border-radius: 0; }
.tab:hover { color: var(--primary); }
.tab-active { color: var(--primary); font-weight: 600; border-bottom-color: var(--primary); }
/* 行尾工具(09-04 并入 tab 行右侧:刷新/卡文切换等,内容区不再空工具行);tab-spacer 把工具推到最右 */
.tab-spacer { flex: 1; }
.tab-tools { display: inline-flex; align-items: center; gap: 8px; margin-right: 8px; }
.tab-tools .vk-switch { display: inline-flex; align-items: center; }
/* 数据页列表标题(如账号视频列表):与上方内容间隔 20px */
.video-list-title { margin: 20px 0 10px; font-size: 17px; font-weight: 600; }
/* 账号详情视频列表:发布时间列收窄 20px(155→135);标题列 th 不设固定宽度(auto 自然分配,避免钳制列宽),
td 内容显示上限 220→240px(标题多显示 20px 内容) */
th.th-pub { width: 135px; }
td.td-pub { font-variant-numeric: tabular-nums; }
td.td-title { max-width: 240px; }
/* 账号详情视频列表标签列:固定 60px,超长省略,悬停 title 显示完整标签 */
.table th.th-tag { width: 60px; min-width: 60px; padding: 8px 2px; text-align: center; }
.table td.td-tag { max-width: 60px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 7px 2px; }
/* ================= 账号设定 卡片版/文字版 切换(右上角图标按钮组) ================= */
.persona-wrap { position: relative; } /* 账号设定内容左右无间隔(09-02 用户要求去掉 20px) */
.persona-vs-icons { position: absolute; top: 0; right: 0; z-index: 2; display: inline-flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; background: var(--panel); }
.vi-btn { border: none; background: none; padding: 5px 8px; cursor: pointer; color: var(--dim); display: flex; align-items: center; }
.vi-btn + .vi-btn { border-left: 1px solid var(--border); }
.vi-btn:hover { color: var(--primary); }
.vi-btn.vi-active { background: var(--primary); color: #fff; }
.card-view img { width: 100%; height: auto; display: block; border-radius: 8px; }
.vs-hidden { display: none !important; }
/* ================= 点赞/评论趋势折线图(视频列表上方,lieflat-charts G8 骨架) ================= */
.trend-chart { border: 1px solid var(--border); border-radius: 8px; padding: 8px 12px 4px; margin-bottom: 12px; background: var(--panel); }
.trend-chart #trendChart, .trend-chart .trend-empty { height: 240px; width: 100%; }
.trend-empty { display: flex; align-items: center; justify-content: center; color: var(--dim); font-size: 13px; }
/* ================= 信息卡 ================= */
.info-card { border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; margin-bottom: 14px; background: var(--panel); }
.info-card-title { font-size: 15px; font-weight: 600; color: var(--text); margin-bottom: 12px; padding-bottom: 10px; border-bottom: 1px solid var(--border); }
/* 账号信息按行分组:行内项目按内容宽度左对齐,保留行间距 */
.info-row { display: flex; flex-wrap: wrap; gap: 10px 80px; }
.info-row .info-item { flex: 0 0 auto; min-width: 100px; }
.info-row + .info-row { margin-top: 10px; }
/* 账号信息字段对齐(09-03 R44):与视频详情同款解法——抖音号/作品数/获取视频 与 赛道/AI制作/粉丝/获赞 同处一个 grid,
列轨道跨行共享(a列=抖音号与赛道同宽,b列=作品数与AI制作严格对齐);原独立 .info-row 各行按内容宽排,抖音号长短会把「作品数」顶偏造成跨行错位 */
/* R45 间隔回调(终版):横向 gap 保持 80px、与视频详情 .video-grid 完全一致;「宽」的真根因是账号定位整行留在网格内(原 h h h h 区)
把 4 列轨道撑成等宽 232.5px、每格右侧大片留白——故将其移出共享网格(见 .account-info > .info-item),8→7 字段列轨贴内容收缩,
值与值之间视觉距离恢复为「内容宽+80px」,与视频详情属性区观感一致 */
.account-info .account-grid { display: grid; grid-template-columns: repeat(4, minmax(min-content, max-content)); grid-template-areas: 'a b c .' 'd e f g'; gap: 10px 0; justify-content: start; }
.account-info .account-grid .info-item { width: 180px; }
/* 账号信息属性内容统一宽度(09-03 R48 终版,09-04 宽度 140→180):所有属性值固定 width 180px,四列严格等宽;超宽单行省略号 + hover 看全量;
横向间隔 0(用户定:内容统一宽度即可,不要其他间隔) */
.account-info .account-grid .info-item .info-value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* 账号定位整行(R45):作为网格容器的直接子项平级输出,占满卡片宽度,与网格保持纵向间距 */
.account-info > .info-item { margin-top: 12px; }
.info-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px 18px; }
.info-item { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.info-wide { grid-column: 1/-1; }
.info-row .info-item.info-wide { flex: 1 1 auto; min-width: 0; max-width: 100%; } /* 09-01 修复横向溢出:info-wide 允许收缩,否则长内容把 main 撑出横向滚动条 */
.info-label { font-size: 14px; color: var(--dim); }
.info-value { font-size: 15px; color: var(--text); word-break: break-word; white-space: pre-wrap; line-height: 1.6; }
/* 视频详情信息卡:标题独立一行(大字号),下方按行分组 */
/* 视频详情字段对齐(09-02 终版):8 字段同处一个 grid 容器(2 行 × 4 列),列轨道跨行共享——第 i 列严格对齐(分享↔时长、收藏↔发布时间);列宽贴合内容(min-content~max-content,非均分);横向间隔 80px 与账号信息一致。之前用两个独立 .info-row 容器导致跨行不对齐 */
.video-info .video-grid { display: grid; grid-template-columns: repeat(4, minmax(min-content, max-content)); gap: 10px 0; justify-content: start; }
.video-info .video-grid .info-item { width: 180px; }
.video-info .video-grid .info-item.info-wide { grid-column: 1 / -1; width: auto; }
/* 视频详情属性内容统一宽度(09-03 R48,09-04 宽度 140→180):与账号详情同款——8 字段固定 width 180px、横向间隔 0;超宽单行省略号 + hover 看全量;
标签(info-wide)占整行不受限宽保持换行;原视频ID vid-limited 独立 max-width 220 已被统一 180 覆盖,移除冗余规则 */
.video-info .video-grid .info-item:not(.info-wide) .info-value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ================= R59 视频关键信息卡(拆解 tab 默认视图,卡/文切换) ================= */
.vk-switch { display: inline-flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; background: var(--panel); }
.vk-switch .vi-btn { padding: 4px 9px; }
.vd-wrap .md-body { margin-left: 20px; }
/* SVG 关键信息卡:白底阴影浮卡,图片随宽自适应 */
.vk-view { margin-left: 20px; }
.vk-view svg { display: block; width: auto; max-width: 100%; height: auto; border-radius: 12px; box-shadow: 0 2px 10px rgba(0,0,0,.08); }
/* 长文本默认最多 4 行,可点击展开/收起(账号定位等) */
/* 09-01 改用 block+max-height 裁剪:display:-webkit-box 下长句不折行,内容宽被撑到 1896px 导致 main 横向滚动条 */
.clamp-text { display: block; max-height: 96px; overflow: hidden; word-break: break-word; overflow-wrap: anywhere; } /* 96px=4行×15px×1.6 */
.clamp-text.open { max-height: none; }
.clamp-btn { border: none; background: none; color: #4a90e2; cursor: pointer; font-size: 13px; padding: 2px 0; }
.clamp-btn:hover { text-decoration: underline; }
/* ================= 对比弹窗 ================= */
.modal-title-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 14px; font-size: 18px; font-weight: 600; }
.modal-title-row span { white-space: pre-wrap; word-break: break-word; } /* 标题内换行(标签/共创信息分行显示) */
.mtr-actions { display: inline-flex; align-items: center; gap: 6px; flex: none; }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
.btn-ghost:hover { border-color: var(--primary); color: var(--primary); }
.rw-modal { width: min(1600px, calc(100% - 100px)); height: calc(100vh - 60px); overflow: auto; } /* 宽度:距左右各 50px,最大 1600px;固定高度距屏幕上下各 30px */
.rw-split { display: flex; gap: 14px; }
.rw-pane { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.rw-pane-title { font-weight: 600; margin-bottom: 8px; }
.rw-content { flex: 1; overflow: auto; border: 1px solid var(--border); border-radius: 8px; padding: 12px 14px; max-height: 60vh; }
.rw-ver { display: flex; gap: 6px; margin-bottom: 8px; }
.rw-sb { margin-top: 14px; }
/* 查看弹窗横向三板块:等高、与页面高度匹配,底部保留版本切换功能区;宽度不足时源脚本列收起 */
.rw-modal { display: flex; flex-direction: column; overflow: hidden; }
.rw-modal .modal-title-row { flex: none; }
.rw-stack { flex: 1; min-height: 0; display: flex; flex-direction: row; gap: 12px; }
.rw-sec { display: flex; flex-direction: column; flex: 1; min-width: 0; min-height: 0; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
.rw-sec-head { flex: none; display: flex; align-items: center; gap: 10px; padding: 8px 12px; font-weight: 600; background: var(--panel); border-bottom: 1px solid var(--border); } /* 标题右侧可放操作按钮(展开源脚本),间隔 10px */
.rw-sec-body { flex: 1; min-height: 0; overflow: auto; padding: 12px 14px; }
.rw-sec-foot { flex: none; min-height: 46px; display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 8px 12px; border-top: 1px solid var(--border); } /* min-height 保证空 foot(源脚本列占位)与其他列等高,内容区一致 */
.rw-sec-count { margin-right: auto; color: var(--dim); font-size: 14px; }
.rw-ver-new { display: inline-block; margin-left: 6px; padding: 0 7px; border-radius: 9px; background: var(--primary); color: #fff; font-size: 12px; font-weight: 600; line-height: 17px; vertical-align: 1px; } /* R55:最新版徽标(版本条最右=最新) */
.rw-sec-hidden { display: none; } /* 源脚本列默认收起 */
/* R34:脚本详情弹窗 右侧列头部 tab(脚本诊断 / 分镜提示词 切换) */
.rw-side-head { padding: 4px 6px; gap: 0; }
.rw-side-tabs { display: inline-flex; gap: 2px; align-items: center; }
.rw-side-tab { border: none; background: transparent; color: var(--dim); font-size: 14px; font-weight: 500; padding: 5px 12px; border-radius: 6px; cursor: pointer; line-height: 1.4; transition: color .15s; }
.rw-side-tab:hover { color: var(--primary); }
.rw-side-tab.active { color: var(--primary); font-weight: 600; }
.rw-side-sep { color: var(--border); font-size: 14px; line-height: 1; user-select: none; } /* R40:脚本诊断 | 分镜提示词 两 tab 间分隔符 */
/* R41:右侧「脚本诊断|分镜提示词」面板收起/展开钮(AI 列头部最右;右列收起后头部不可见,入口必须常驻 AI 列) */
.rw-side-toggle { margin-left: auto; border: none; background: transparent; color: var(--dim); padding: 4px; border-radius: 6px; cursor: pointer; display: inline-flex; align-items: center; line-height: 1; transition: color .15s; }
.rw-side-toggle:hover { color: var(--primary); background: var(--primary-soft); }
.rw-side-toggle.side-off { color: var(--primary); background: var(--primary-soft); } /* 收起态高亮:提示当前右列已收起、可点此展开 */
.src-on { background: #e8f1fd; border-color: #4a90e2; color: #2f6fed; } /* 源脚本展开态高亮 */
.rw-scroll { padding: 4px 2px; max-height: 75vh; overflow: auto; }
.sb-block { border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px; overflow: hidden; background: var(--panel); }
.sb-head { display: flex; align-items: center; gap: 10px; padding: 6px 12px; background: #f6f8fa; border-bottom: 1px solid var(--border); }
.sb-title { font-weight: 600; color: var(--primary); }
.sb-meta { margin-left: auto; font-size: 14px; color: var(--dim); }
.sb-body { padding: 8px 12px; }
/* ================= 评分 ================= */
.score-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.score-name { width: 88px; font-size: 14px; color: var(--dim); white-space: nowrap; flex-shrink: 0; }
.score-bar { flex: 1; height: 6px; border-radius: 3px; background: #eef1f5; overflow: hidden; }
.score-fill { height: 100%; }
.score-val { width: 36px; text-align: right; font-size: 14px; font-weight: 600; flex-shrink: 0; }
.score-dual { display: flex; gap: 12px; margin-bottom: 12px; }
.score-box { flex: 1; padding: 10px; border: 1px solid var(--border); border-radius: 8px; }
.score-box-title { font-weight: 600; margin-bottom: 8px; }
.review-issues { margin-bottom: 10px; }
.issue-row { display: flex; gap: 6px; font-size: 14px; margin-bottom: 4px; flex-wrap: wrap; } /* 09-10:问题/建议较长时换行,避免挤压成一行读不清 */
.review-summary { margin-bottom: 10px; font-size: 15px; }
.diag-empty { color: var(--dim); font-size: 14px; padding: 14px 2px 10px; line-height: 1.6; } /* AI脚本诊断弹窗:该版本未诊断时的占位提示 */
/* ================= 首页 AI写剧本 快捷卡片 ================= */
.recent-scripts { margin: 4px 0 24px; }
.recent-head { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
.recent-head h3 { font-size: 17px; }
.recent-head .link { font-size: 13px; margin-left: auto; margin-right: 10px; }
.recent-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 14px; }
/* ================= 首页 账号周榜 预览 ================= */
.rank-preview { margin: 4px 0 20px; }
.rank-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); padding-bottom: 0; margin-bottom: 10px; align-self: stretch; }
/* 首页预览:tab 组与 ⬇/筛选同行需垂直居中对齐——全局 .rank-tabs 的 stretch+margin-bottom 会使 tab 组相对兄弟控件偏上 ~5px */
.rank-preview .recent-head .rank-tabs { align-self: center; margin-bottom: 0; }
.rank-tab { border: none; background: transparent; color: var(--dim); padding: 6px 12px 4px; border-radius: 8px 8px 0 0; cursor: pointer; font-size: 14px; border-bottom: 2px solid transparent; }
.rank-tab:hover { color: var(--primary); background: var(--primary-soft); }
.rank-tab.active { color: var(--primary); font-weight: 600; background: var(--primary-soft); border-bottom: 2px solid var(--primary); }
/* 热点数据页:tab 并入 page-toolbar 同一行(与赛道/时间筛选同行),去整行分隔条 */
.page-toolbar .rank-tabs { border-bottom: none; margin-bottom: 0; align-self: center; }
.page-toolbar .rank-tab { padding: 4px 10px 3px; font-size: 13px; }
.rank-placeholder { border: 1px dashed var(--border); border-radius: 10px; padding: 36px 12px; text-align: center; color: var(--dim); background: var(--panel); font-size: 14px; }
.rank-tools { display: flex; align-items: center; gap: 6px; margin-left: auto; }
.rank-filter { padding: 3px 6px; font-size: 14px; max-width: 110px; }
/* 09-10:显示期区间「08/31 ~ 09/06」替代裸期号,宽度按最长文本留足避免切期时按钮抖动 */
.rank-date-label { font-size: 14px; color: var(--dim); min-width: 104px; text-align: center; white-space: nowrap; }
.rank-period { min-width: 104px; text-align: center; display: inline-block; white-space: nowrap; }
.rank-preview-list { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; background: var(--panel); box-shadow: var(--shadow); }
.rank-row { display: grid; grid-template-columns: 34px 84px minmax(80px, 1fr) 76px repeat(4, 76px) 72px; align-items: center; gap: 6px; padding: 6px 10px; font-size: 14px; border-bottom: 1px solid var(--border); }
/* R49 首页三 tab 行高对齐(09-03):账号周榜行高 40px(功能列按钮撑高),热榜/点赞榜 30.7px——统一数据行 min-height 40px */
.rank-preview-list .rank-row:not(.rank-head) { min-height: 40px; }
.rank-row:last-child { border-bottom: none; }
.rank-head { background: var(--panel); color: var(--dim); font-weight: 600; font-size: 13px; }
/* 首页 视频热榜/点赞榜 预览(8 列:排名/赛道/作品标题/作者/4 数值)——标题与作者吃剩余宽 */
.rank-row.rk-video { grid-template-columns: 34px 76px minmax(140px, 1fr) minmax(80px, 1.1fr) repeat(4, 74px); }
.rank-row.rk-video .rk-author { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dim); }
.rk-no { color: var(--dim); font-weight: 600; }
.rk-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rk-cat { color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rk-score { text-align: right; font-weight: 600; color: var(--accent); }
.rk-num { text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rk-act { display: flex; align-items: center; justify-content: center; white-space: nowrap; }
.cell-act { text-align: center; white-space: nowrap; }
.table th.th-act { text-align: center; }
/* 收藏按钮(⭐/☆) */
.fav-btn { border: none; background: none; cursor: pointer; font-size: 16px; padding: 2px 4px; line-height: 1; border-radius: 6px; }
.fav-btn:hover { background: var(--primary-soft); }
.fav-btn:disabled { opacity: .5; cursor: wait; }
.table td.score-cell { color: var(--accent); font-weight: 600; }
.rank-pager { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin-top: 8px; }
/* 空状态(如账号暂无设定):居中图标+标题+说明+按钮,清爽留白 */
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 56px 20px; gap: 10px; text-align: center; }
.empty-state .empty-icon { color: var(--dim); opacity: .45; margin-bottom: 2px; }
.empty-state .empty-title { font-size: 17px; font-weight: 600; color: var(--text); }
.empty-state .empty-desc { font-size: 14px; color: var(--dim); }
.empty-state .btn { margin-top: 10px; }
.rank-page-info { font-size: 14px; color: var(--dim); }
.recent-card { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; cursor: pointer; text-align: left; transition: all .15s; min-height: 172px; }
.recent-card:hover { border-color: var(--primary); box-shadow: 0 2px 8px rgba(47,111,237,.12); }
.recent-card-title { font-size: 15px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; width: 100%; display: flex; align-items: center; gap: 6px; }
.recent-card-preview { width: 100%; display: flex; flex-direction: column; gap: 2px; }
.recent-preview-line { display: block; font-size: 14px; color: #57606a; line-height: 1.55; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; width: 100%; }
.recent-card-foot { width: 100%; margin-top: auto; display: flex; align-items: center; gap: 8px; }
.recent-card-meta { font-size: 14px; color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; }
.recent-card-score { flex: none; font-size: 12px; font-weight: 600; padding: 2px 8px; border-radius: 10px; line-height: 1.5; }
.recent-meta { font-size: 14px; color: var(--dim); margin-bottom: 12px; }
/* ================= 扁平化向量图标 ================= */
.ic { display: inline-block; vertical-align: -0.18em; flex-shrink: 0; }
.top-actions .ghost .ic { color: var(--dim); }
.top-actions .ghost:hover .ic { color: var(--primary); }
/* 顶栏纯图标按钮(刷新):收成方形,且**高度不得超过文字按钮**——topbar 55px 是硬约束(main#view 高 = 100vh−55px),
撑高会重新触发底部 1px 溢出 + 第二层滚动条(见规范坑 1) */
.top-actions .ghost.icon-only { padding: 6px 9px; }
/* ================= 列表批量操作(左下角,与分页同行)================= */
.list-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
/* 无批量栏(仅分页器)时也靠右对齐,与批量栏+分页器布局一致 */
.list-footer > :only-child { margin-left: auto; }
.list-batchbar { display: flex; align-items: center; gap: 10px; padding: 12px 4px; min-height: 34px; }
.btn-icon { display: inline-flex; align-items: center; justify-content: center; padding: 5px 9px; }
.btn-danger { background: #fff; border-color: var(--danger); color: var(--danger); }
.btn-danger:hover { background: var(--danger); border-color: var(--danger); color: #fff; }
.btn-del { background: #fdf0ef; border-color: #f0c9c6; color: #d9534f; display: inline-flex; align-items: center; gap: 4px; } /* R58:删除按钮(浅红底红字) */
.btn-del:hover { background: #fbe0de; border-color: #d9534f; }
.btn-del svg { flex: none; }
.batch-count { font-size: 14px; color: var(--dim); }
.batch-count-n { color: var(--danger); font-weight: 600; }
.cb-cell { width: 36px; text-align: center; }
.cb-cell input { width: 15px; height: 15px; cursor: pointer; vertical-align: middle; accent-color: var(--primary); }
/* ---- AI写脚本 三 tab(09-02 v25:参考选题创作 / 原创选题创作 / ABC融合创作) ---- */
/* 标题行(返回|标题)下方独立一行:左=tab 切换,右=搜索/时间/状态筛选(tab 与筛选同一行,均不与标题/返回同行) */
.rw-tabs { display: flex; align-items: center; gap: 2px; border-bottom: 1px solid var(--border); margin: -12px 0 12px; padding: 0 4px; }
.rw-tabs .tab { font-size: 15px; padding: 9px 14px; }
.rw-tab-badge { display: inline-block; min-width: 18px; margin-left: 6px; padding: 0 6px; border-radius: 10px; background: var(--primary-soft); color: var(--primary); font-size: 12px; line-height: 18px; font-weight: 600; text-align: center; }
.tab-active .rw-tab-badge { background: var(--primary); color: #fff; }
.rw-note { font-size: 12px; color: var(--dim); margin: 0 0 12px; }
/* 列表页统一:标题下一行 = tab + 筛选/搜索/功能按钮(均不与标题/返回同行) */
.acc-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: -20px 0 16px; padding: 0 4px; }
.rank-toolbar { display: flex; align-items: center; gap: 14px; margin: -8px 0 16px; padding: 0 4px; }
.rank-toolbar .rank-tabs { flex: 1; }
/* 视频状态「已解析」:浅绿色文字(去图标),区别于默认灰「未解析」 */
.st-parsed { color: #52b788; font-weight: 500; }
.rw-chip { display: inline-flex; align-items: center; gap: 2px; max-width: 260px; padding: 2px 8px 2px 12px; background: var(--primary-soft); color: var(--primary); border-radius: 999px; font-size: 13px; }
.rw-chip > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rw-chip button { border: none; background: none; color: inherit; cursor: pointer; font-size: 15px; line-height: 1; padding: 0 3px; border-radius: 50%; }
.rw-chip button:hover { opacity: .7; }
/* 原创选题脚本 查看弹窗 */
.cw-modal { width: min(980px, calc(100% - 80px)); height: calc(100vh - 120px); display: flex; flex-direction: column; overflow: hidden; padding: 0; }
.cw-modal .modal-title-row { flex: none; padding: 14px 18px; border-bottom: 1px solid var(--border); margin: 0; }
.cw-meta { flex: none; padding: 8px 18px; border-bottom: 1px dashed var(--border); color: var(--dim); font-size: 13px; }
.cw-body { flex: 1; overflow: auto; padding: 14px 18px; }
.cw-body .md-body h1 { font-size: 20px; }
.cw-body .md-body h2 { font-size: 17px; }
.cw-foot { flex: none; padding: 10px 18px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 8px; }
/* ================= 帮助页(help-guide.js 09-04) ================= */
.help-page { max-width: 1080px; }
.help-head { display: flex; align-items: center; gap: 14px; margin-bottom: 20px; }
.help-head-icon { width: 52px; height: 52px; border-radius: 14px; background: var(--primary-soft); color: var(--primary); display: flex; align-items: center; justify-content: center; flex: none; }
.help-head h2 { font-size: 22px; margin-bottom: 4px; }
.help-head-sub { color: var(--dim); font-size: 15px; }
.help-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; } /* 09-04 用户定稿:卡片固定一行 2 个(全屏不再 auto-fill 变 3 列);窄视口降 1 列 */
@media (max-width: 820px) { .help-grid { grid-template-columns: 1fr; } }
.help-card {
background: var(--panel); border: 1px solid var(--border); border-radius: 12px;
padding: 16px 18px; box-shadow: var(--shadow);
display: flex; flex-direction: column; gap: 10px;
}
.help-card-head { display: flex; align-items: center; gap: 10px; }
.help-card-icon { color: var(--primary); display: inline-flex; }
.help-card-title { font-size: 18px; font-weight: 700; }
.help-card-desc { color: var(--dim); font-size: 14px; line-height: 1.55; min-height: 42px; }
.help-card-acts { display: flex; gap: 8px; flex-wrap: wrap; }
.help-card-acts .btn { display: inline-flex; align-items: center; gap: 5px; }
.help-card-qa summary { cursor: pointer; color: var(--primary); font-size: 13px; user-select: none; }
.help-card-qa summary:hover { text-decoration: underline; }
.help-card-qa .qa { margin-top: 8px; border-top: 1px dashed var(--border); padding-top: 6px; }
/* ================= 模拟操作引导遮罩 / 气泡(help-guide.js 09-04) ================= */
.g-mask {
position: fixed; inset: 0;
background: rgba(18, 24, 40, .42);
display: none;
}
.g-pop {
position: fixed;
display: none;
width: min(440px, calc(100vw - 24px));
background: var(--panel); border-radius: 14px;
box-shadow: 0 12px 40px rgba(0, 0, 0, .28);
border: 1px solid var(--border);
padding: 16px 18px 14px;
font-size: 15px; line-height: 1.6;
}
.g-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.g-board {
font-size: 12px; font-weight: 600; color: var(--primary);
background: var(--primary-soft); border-radius: 20px; padding: 2px 10px;
}
.g-count { font-size: 12px; color: var(--dim); font-variant-numeric: tabular-nums; }
.g-title { font-size: 17px; font-weight: 700; margin-bottom: 6px; }
.g-text { color: var(--text); font-size: 14.5px; }
.g-text code { background: #f0f2f5; border-radius: 4px; padding: 0 4px; font-size: 13px; }
.g-hint { margin-top: 10px; padding: 8px 10px; border-radius: 8px; font-size: 14px; }
.g-hint-act { background: var(--primary-soft); color: var(--primary); font-weight: 600; }
.g-hint-warn { background: #fff3e6; color: #b3541e; font-weight: 600; }
.g-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
.g-actions .btn-sm { padding: 4px 12px; font-size: 14px; }
.g-actions .ghost:disabled { opacity: .5; cursor: default; }
/* ===== 任务进度面板(10-08 新增:后台 AI 任务执行过程可见) ===== */
.tp-panel {
position: fixed; right: 18px; bottom: 18px; z-index: 9999;
width: 380px; max-width: calc(100vw - 36px);
background: var(--panel); border: 1px solid var(--border); border-radius: 12px;
box-shadow: 0 8px 28px rgba(0,0,0,.16); overflow: hidden;
font-size: 13.5px; color: var(--text);
}
.tp-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border); background: var(--primary-soft); }
.tp-title { font-weight: 700; font-size: 14px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tp-close { border: none; background: transparent; color: var(--dim); cursor: pointer; font-size: 16px; line-height: 1; padding: 2px 4px; }
.tp-body { padding: 10px 12px; }
.tp-row { display: flex; gap: 8px; margin-bottom: 6px; }
.tp-row .k { color: var(--dim); flex: 0 0 62px; }
.tp-row .v { flex: 1; word-break: break-all; }
.tp-tools { display: flex; flex-wrap: wrap; gap: 6px; }
.tp-tools span { background: #f0f2f5; border-radius: 20px; padding: 2px 9px; font-size: 12px; font-variant-numeric: tabular-nums; }
.tp-act { max-height: 92px; overflow: auto; background: #fafbfc; border: 1px solid var(--border); border-radius: 8px; padding: 8px 9px; color: var(--dim); font-size: 12.5px; line-height: 1.55; white-space: pre-wrap; word-break: break-word; }
.tp-warn { margin-top: 8px; padding: 7px 9px; border-radius: 8px; background: #fff3e6; color: #b3541e; font-size: 12.5px; font-weight: 600; }
.tp-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--primary); flex: 0 0 8px; }
.tp-dot.run { animation: tp-pulse 1.1s ease-in-out infinite; }
.tp-dot.done { background: #2f9e44; }
.tp-dot.err { background: var(--danger); }
@keyframes tp-pulse { 0%,100% { opacity: 1; } 50% { opacity: .25; } }
@@ -0,0 +1,45 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).echarts={})}(this,function(t){"use strict";
/*! *****************************************************************************
Copyright (c) Microsoft Corporation.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
***************************************************************************** */var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&(t[n]=e[n])},e(t,n)};function n(t,n){if("function"!=typeof n&&null!==n)throw new TypeError("Class extends value "+String(n)+" is not a constructor or null");function i(){this.constructor=t}e(t,n),t.prototype=null===n?Object.create(n):(i.prototype=n.prototype,new i)}var i=function(){this.firefox=!1,this.ie=!1,this.edge=!1,this.newEdge=!1,this.weChat=!1},r=new function(){this.browser=new i,this.node=!1,this.wxa=!1,this.worker=!1,this.svgSupported=!1,this.touchEventsSupported=!1,this.pointerEventsSupported=!1,this.domSupported=!1,this.transformSupported=!1,this.transform3dSupported=!1,this.hasGlobalWindow="undefined"!=typeof window};"object"==typeof wx&&"function"==typeof wx.getSystemInfoSync?(r.wxa=!0,r.touchEventsSupported=!0):"undefined"==typeof document&&"undefined"!=typeof self?r.worker=!0:!r.hasGlobalWindow||"Deno"in window||"undefined"!=typeof navigator&&"string"==typeof navigator.userAgent&&navigator.userAgent.indexOf("Node.js")>-1?(r.node=!0,r.svgSupported=!0):function(t,e){var n=e.browser,i=t.match(/Firefox\/([\d.]+)/),r=t.match(/MSIE\s([\d.]+)/)||t.match(/Trident\/.+?rv:(([\d.]+))/),o=t.match(/Edge?\/([\d.]+)/),a=/micromessenger/i.test(t);i&&(n.firefox=!0,n.version=i[1]);r&&(n.ie=!0,n.version=r[1]);o&&(n.edge=!0,n.version=o[1],n.newEdge=+o[1].split(".")[0]>18);a&&(n.weChat=!0);e.svgSupported="undefined"!=typeof SVGRect,e.touchEventsSupported="ontouchstart"in window&&!n.ie&&!n.edge,e.pointerEventsSupported="onpointerdown"in window&&(n.edge||n.ie&&+n.version>=11);var s=e.domSupported="undefined"!=typeof document;if(s){var l=document.documentElement.style;e.transform3dSupported=(n.ie&&"transition"in l||n.edge||"WebKitCSSMatrix"in window&&"m11"in new WebKitCSSMatrix||"MozPerspective"in l)&&!("OTransition"in l),e.transformSupported=e.transform3dSupported||n.ie&&+n.version>=9}}(navigator.userAgent,r);var o="sans-serif",a="12px "+o;var s,l,u=function(t){var e={};if("undefined"==typeof JSON)return e;for(var n=0;n<t.length;n++){var i=String.fromCharCode(n+32),r=(t.charCodeAt(n)-20)/100;e[i]=r}return e}("007LLmW'55;N0500LLLLLLLLLL00NNNLzWW\\\\WQb\\0FWLg\\bWb\\WQ\\WrWWQ000CL5LLFLL0LL**F*gLLLL5F0LF\\FFF5.5N"),c={createCanvas:function(){return"undefined"!=typeof document&&document.createElement("canvas")},measureText:function(t,e){if(!s){var n=c.createCanvas();s=n&&n.getContext("2d")}if(s)return l!==e&&(l=s.font=e||a),s.measureText(t);t=t||"";var i=/((?:\d+)?\.?\d*)px/.exec(e=e||a),r=i&&+i[1]||12,o=0;if(e.indexOf("mono")>=0)o=r*t.length;else for(var h=0;h<t.length;h++){var d=u[t[h]];o+=null==d?r:d*r}return{width:o}},loadImage:function(t,e,n){var i=new Image;return i.onload=e,i.onerror=n,i.src=t,i},getTime:function(){return Date.now?Date.now():+new Date}};function h(t){for(var e in c)c.hasOwnProperty(e)&&t[e]&&(c[e]=t[e])}var d=G(["Function","RegExp","Date","Error","CanvasGradient","CanvasPattern","Image","Canvas"],function(t,e){return t["[object "+e+"]"]=!0,t},{}),p=G(["Int8","Uint8","Uint8Clamped","Int16","Uint16","Int32","Uint32","Float32","Float64"],function(t,e){return t["[object "+e+"Array]"]=!0,t},{}),f=Object.prototype.toString,g=Array.prototype,v=g.forEach,y=g.filter,m=g.slice,x=g.map,_=function(){}.constructor,b=_?_.prototype:null,w="__proto__",S=2311,M=Math.pow(2,53)-1;function I(){return S>=M&&(S=0),S++}function T(){for(var t=[],e=0;e<arguments.length;e++)t[e]=arguments[e];"undefined"!=typeof console&&console.error.apply(console,t)}function C(t){if(null==t||"object"!=typeof t)return t;var e=t,n=f.call(t);if("[object Array]"===n){if(!ft(t)){e=[];for(var i=0,r=t.length;i<r;i++)e[i]=C(t[i])}}else if(p[n]){if(!ft(t)){var o=t.constructor;if(o.from)e=o.from(t);else{e=new o(t.length);for(i=0,r=t.length;i<r;i++)e[i]=t[i]}}}else if(!d[n]&&!ft(t)&&!tt(t))for(var a in e={},t)t.hasOwnProperty(a)&&a!==w&&(e[a]=C(t[a]));return e}function D(t,e,n){if(!$(e)||!$(t))return n?C(e):t;for(var i in e)if(e.hasOwnProperty(i)&&i!==w){var r=t[i],o=e[i];!$(o)||!$(r)||X(o)||X(r)||tt(o)||tt(r)||J(o)||J(r)||ft(o)||ft(r)?!n&&i in t||(t[i]=C(e[i])):D(r,o,n)}return t}function A(t,e){for(var n=t[0],i=1,r=t.length;i<r;i++)n=D(n,t[i],e);return n}function k(t,e){if(Object.assign)Object.assign(t,e);else for(var n in e)e.hasOwnProperty(n)&&n!==w&&(t[n]=e[n]);return t}function L(t,e,n){t=t||{};for(var i=0;i<n.length;i++){var r=n[i];t[r]=e[r]}return t}function P(t,e,n){for(var i=W(e),r=0,o=i.length;r<o;r++){var a=i[r];(n?null!=e[a]:null==t[a])&&(t[a]=e[a])}return t}var O=c.createCanvas;function R(t,e){if(t){if(t.indexOf)return t.indexOf(e);for(var n=0,i=t.length;n<i;n++)if(t[n]===e)return n}return-1}function N(t,e){var n=t.prototype;function i(){}for(var r in i.prototype=e.prototype,t.prototype=new i,n)n.hasOwnProperty(r)&&(t.prototype[r]=n[r]);t.prototype.constructor=t,t.supLine truncated
/*!
* ZRender, a high performance 2d drawing library.
*
* Copyright (c) 2013, Baidu Inc.
* All rights reserved.
*
* LICENSE
* https://github.com/ecomfe/zrender/blob/master/LICENSE
*/
var yo={},mo={};var xo,_o=function(){function t(t,e,n){var i=this;this._sleepAfterStill=10,this._stillFrameAccum=0,this._needsRefresh=!0,this._needsRefreshHover=!1,this._darkMode=!1,n=n||{},this.dom=e,this.id=t;var o=new Mn,a=n.renderer||"canvas";yo[a]||(a=W(yo)[0]),n.useDirtyRect=null!=n.useDirtyRect&&n.useDirtyRect;var s=new yo[a](e,o,n,t),l=n.ssr||s.ssrOnly;this.storage=o,this.painter=s;var u,c=r.node||r.worker||l?null:new Ir(s.getViewportRoot(),s.root),h=n.useCoarsePointer;(null==h||"auto"===h?r.touchEventsSupported:!!h)&&(u=at(n.pointerSize,44)),this.handler=new hn(o,s,c,s.root,u),this.animation=new lr({stage:{update:l?null:function(){return i._flush(!1)}}}),l||this.animation.start()}return t.prototype.add=function(t){!this._disposed&&t&&(this.storage.addRoot(t),t.addSelfToZr(this),this.refresh())},t.prototype.remove=function(t){!this._disposed&&t&&(this.storage.delRoot(t),t.removeSelfFromZr(this),this.refresh())},t.prototype.configLayer=function(t,e){this._disposed||(this.painter.configLayer&&this.painter.configLayer(t,e),this.refresh())},t.prototype.setBackgroundColor=function(t){this._disposed||(this.painter.setBackgroundColor&&this.painter.setBackgroundColor(t),this.refresh(),this._backgroundColor=t,this._darkMode=function(t){if(!t)return!1;if("string"==typeof t)return Ii(t,1)<.4;if(t.colorStops){for(var e=t.colorStops,n=0,i=e.length,r=0;r<i;r++)n+=Ii(e[r].color,1);return(n/=i)<.4}return!1}(t))},t.prototype.getBackgroundColor=function(){return this._backgroundColor},t.prototype.setDarkMode=function(t){this._darkMode=t},t.prototype.isDarkMode=function(){return this._darkMode},t.prototype.refreshImmediately=function(t){this._disposed||this._refresh({animUpdate:!t,refresh:!0,refreshHover:!1})},t.prototype._refresh=function(t){t.animUpdate&&this.animation.update(!0),this._needsRefresh=this._needsRefreshHover=!1,this.painter.refresh({refresh:t.refresh,refreshHover:t.refreshHover}),this._needsRefresh=this._needsRefreshHover=!1},t.prototype.refresh=function(){this._disposed||(this._needsRefresh=!0,this.animation.start())},t.prototype.flush=function(){this._disposed||this._flush(!0)},t.prototype._flush=function(t){var e,n=or(),i=this._needsRefresh,r=this._needsRefreshHover;(i||r)&&(e=!0,this._refresh({animUpdate:t,refresh:i,refreshHover:r}));var o=or();e?(this._stillFrameAccum=0,this.trigger("rendered",{elapsedTime:o-n})):this._sleepAfterStill>0&&(this._stillFrameAccum++,this._stillFrameAccum>this._sleepAfterStill&&this.animation.stop())},t.prototype.setSleepAfterStill=function(t){this._sleepAfterStill=t},t.prototype.wakeUp=function(){this._disposed||(this.animation.start(),this._stillFrameAccum=0)},t.prototype.refreshHover=function(){this._needsRefreshHover=!0},t.prototype.refreshHoverImmediately=function(){this._disposed||this._refresh({animUpdate:!1,refresh:!1,refreshHover:!0})},t.prototype.resize=function(t){this._disposed||(t=t||{},this.painter.resize(t.width,t.height),this.handler.resize())},t.prototype.clearAnimation=function(){this._disposed||this.animation.clear()},t.prototype.getWidth=function(){if(!this._disposed)return this.painter.getWidth()},t.prototype.getHeight=function(){if(!this._disposed)return this.painter.getHeight()},t.prototype.setCursorStyle=function(t){this._disposed||this.handler.setCursorStyle(t)},t.prototype.findHover=function(t,e){if(!this._disposed)return this.handler.findHover(t,e)},t.prototype.on=function(t,e,n){return this._disposed||this.handler.on(t,e,n),this},t.prototype.off=function(t,e){this._disposed||this.handler.off(t,e)},t.prototype.trigger=function(t,e){this._disposed||this.handler.trigger(t,e)},t.prototype.clear=function(){if(!this._disposed){for(var t=this.storage.getRoots(),e=0;e<t.length;e++)t[e]instanceof vo&&t[e].removeSelfFromZr(this);this.storage.delAllRoots(),this.painter.clear()}},t.prototype.dispose=function(){var t;this._disposed||(this.animation.stop(),this.clear(),this.storage.dispose(),this.painter.dispose(),this.handler.dispose(),this.animation=this.storage=this.painter=this.handler=null,this._disposed=!0,t=this.id,delete mo[t])},t}();function bo(t,e){var n=new _o(I(),t,e);return mo[n.id]=n,n}function wo(t,e){yo[t]=e}function So(t){if("function"==typeof xo)return xo(t)}function Mo(t){xo=t}var Io=Object.freeze({__proto__:null,init:bo,dispose:function(t){t.dispose()},disposeAll:function(){for(var t in mo)mo.hasOwnProperty(t)&&mo[t].dispose();mo={}},getInstance:function(t){return mo[t]},registerPainter:wo,getElementSSRData:So,registerSSRDataGetter:Mo,version:"6.1.0"}),To=1e-4;var Co=Math.min,Do=Math.max,Ao=Math.abs,ko=Math.round,Lo=Math.floor,Po=Math.ceil,Oo=Math.pow,Ro=Math.log,No=Math.LN10,Eo=Math.PI,zo=Math.random;function Bo(t,e,n,i){var r=e[0],o=e[1],a=n[0],s=n[1],l=o-r,u=s-a;if(0===l)return 0===u?a:(a+s)/2;if(i)if(l>0){if(t<=r)return a;if(t>=o)return s}else{if(t>=r)return a;if(t<=o)return s}else{if(t===r)return a;if(t===o)return s}return(t-r)/l*u+a}var Vo=function(t,e,n){switch(t){case"center":case"middle":t="50%";break;case"left":case"top":t="Line truncated