工作台:目录改名 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:
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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, '"'))}">${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, '"'))}">${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, '"'))}">${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, '"'))}">${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
|
||||
Reference in new issue
Block a user