feat(账号分析+工作台): 功能三自动联动生成设定卡+双向联动维护+玫瑰金配色定稿+首页数据预览/周榜优化+CDP验证脚本

- mcn-dou-analysis: 功能三产出设定后自动生成设定卡(Step7); 设定md与卡片SVG双向联动维护规则固化(06文档+03文档+SKILL.md); 浅色系配色定稿玫瑰金(#D6A3A9→#9A5563)+背景文字提亮规则; 新增06生成账号设定卡片文档+模板样例
- mcn-work-shop: 首页数据预览条/周榜预览表加宽/改名AI脚本诊断/tab切换/添加账号弹窗等优化
- 新增 cdp-*.mjs 验证脚本14个(可复用); .gitignore 忽略运行日志/截图/tmp
This commit is contained in:
maogeigei committed 2026-08-31 15:27:54 +08:00
1 parent 96fce56fba
commit 53621279a5
35 files changed
+2567 -117

No files matched your search

@@ -100,30 +100,24 @@ function confirmSaveAccount(name, url, btn) {
});
}
// 导入外部账号数据(移植自 dsh 插件「热门账号 → 导入」):
// 方式一:抖音账号信息(名称/抖音号/地址)→ AI 网页采集账号信息与视频列表入库
// 方式二:本地文件夹绝对路径 → AI 读取目录分析文件结构后入库
// 添加外部账号数据(移植自 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('download', 18)} 导入外部账号数据</h3>
<h3>${svgIcon('user-plus', 18)} 添加账号数据</h3>
<div class="import-form">
<p class="dim" style="margin-bottom:10px">方式一:填写抖音账号信息,AI 通过抖音网页获取账号信息与视频列表。</p>
<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="如:jiu_meng_ai" data-imp-did></div>
<div class="f-row"><label>账号地址(可选)</label>
<input class="input" placeholder="https://www.douyin.com/user/..." data-imp-url></div>
<div class="divider"></div>
<p class="dim" style="margin-bottom:10px">方式二:输入本地数据文件夹绝对路径,AI 读取目录分析文件结构后导入。</p>
<div class="f-row"><input class="input" placeholder="本地文件夹路径,如 D:\\账号对标" data-imp-dir></div>
</div>
<div class="modal-actions">
<button class="ghost" data-cancel>取消</button>
<button class="primary" data-ok>提交导入</button>
<button class="primary" data-ok>提交添加</button>
</div>
</div>`;
document.body.appendChild(mask);
@@ -131,20 +125,13 @@ function openImportAccounts(btn) {
mask.addEventListener('click', (e) => { if (e.target === mask) close(); });
mask.querySelector('[data-cancel]').addEventListener('click', close);
mask.querySelector('[data-ok]').addEventListener('click', () => {
const dir = (mask.querySelector('[data-imp-dir]').value || '').trim();
const name = (mask.querySelector('[data-imp-name]').value || '').trim();
const did = (mask.querySelector('[data-imp-did]').value || '').trim();
const url = (mask.querySelector('[data-imp-url]').value || '').trim();
if (!dir && !name && !did && !url) { showToast('请填写抖音账号信息或文件夹路径'); return; }
let prompt;
if (dir) {
prompt = `请帮我把本地文件夹「${dir}」中的账号数据导入:读取目录、分析文件结构,确认后写入账号列表`;
} else {
const parts = [name ? `账号名称「${name}」` : '', did ? `抖音号「${did}」` : '', url ? `账号地址「${url}」` : ''].filter(Boolean).join(',');
prompt = `请帮我把抖音账号 ${parts} 导入:通过抖音网页获取账号信息与视频列表,写入账号列表并保存到本地`;
}
if (!name && !url) { showToast('请填写账号名称或账号地址'); return; }
const parts = [name ? `账号名称「${name}」` : '', url ? `账号地址「${url}」` : ''].filter(Boolean).join(',');
const prompt = `请帮我把抖音账号 ${parts} 添加:通过抖音网页获取账号信息与视频列表,写入账号列表并保存到本地`;
close();
executeTask({ prompt, label: '导入指令', btn });
executeTask({ prompt, label: '添加指令', btn });
});
}
@@ -168,9 +155,11 @@ const ICON_PATHS = {
'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"/>',
'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"/>',
};
@@ -298,10 +287,17 @@ function renderHome() {
{ icon: 'trending-up', title: '账号周榜', desc: '三方数据 · 赛道/日期筛选', hash: '#/ranking' },
{ icon: 'users', title: '账号列表', desc: '关注的账号 + 视频列表', hash: '#/accounts' },
{ icon: 'pen', title: 'AI写脚本', desc: 'AI生成脚本+分镜提示词', hash: '#/rewrites' },
{ icon: 'flask', title: 'AI写作复盘', desc: '检查清单+复盘报告', hash: '#/reviews' },
{ icon: 'flask', title: 'AI脚本诊断', 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>
@@ -311,8 +307,13 @@ function renderHome() {
</div>
<div class="rank-preview">
<div class="recent-head">
<h3>账号周榜</h3>
<div class="rank-tools">
<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="hot" role="tab">视频热榜</button>
<button class="rank-tab" data-rank-tab="likes" role="tab">点赞榜</button>
<button class="rank-tab" data-rank-tab="fans" role="tab">涨粉榜</button>
</div>
<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>
@@ -320,6 +321,7 @@ function renderHome() {
</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>
@@ -328,6 +330,29 @@ function renderHome() {
</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 `
@@ -442,7 +467,7 @@ async function loadRankingPreview() {
<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>
<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>
</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);
@@ -600,7 +625,7 @@ function openSettings() { renderRootList(); $('#settingsModal').hidden = false;
function closeSettings() { $('#settingsModal').hidden = true; }
/* ================= 路由 ================= */
const CRUMB_TITLES = { 'ranking': '账号周榜', 'accounts': '账号列表', 'rewrites': 'AI写脚本', 'reviews': 'AI写作复盘' };
const CRUMB_TITLES = { 'ranking': '账号周榜', 'accounts': '账号列表', 'rewrites': 'AI写脚本', 'reviews': 'AI脚本诊断' };
function parseHash() {
const h = (location.hash || '#/').replace(/^#/, '');
@@ -622,9 +647,20 @@ async function router() {
updateCrumbs('');
view.innerHTML = renderHome();
view.querySelectorAll('.data-card').forEach((b) => b.addEventListener('click', () => { location.hash = b.dataset.hash; }));
// 周榜 tab 切换:账号周榜显示列表+工具条,其余 3 个新榜先占位
view.querySelectorAll('.rank-tab').forEach((t) => t.addEventListener('click', () => {
view.querySelectorAll('.rank-tab').forEach((x) => x.classList.toggle('active', x === t));
const week = t.dataset.rankTab === 'week';
view.querySelector('#rankTools').hidden = !week;
view.querySelector('#rankPreviewList').hidden = !week;
const ph = view.querySelector('#rankPlaceholder');
ph.hidden = week;
if (!week) ph.textContent = `「${t.textContent.trim()}」建设中,敬请期待`;
}));
loadHomeStats();
loadRankingPreview();
loadRecentScripts();
view._reload = () => { loadRankingPreview(); loadRecentScripts(); };
view._reload = () => { loadHomeStats(); loadRankingPreview(); loadRecentScripts(); };
return;
}
if (name === 'help') {
@@ -645,7 +681,7 @@ async function router() {
}
// 数据功能页
if (typeof window.DataPages !== 'undefined') {
const titles = { ranking: '账号周榜', accounts: '账号列表', account: '账号详情', video: '视频详情', rewrites: 'AI写脚本', reviews: 'AI写作复盘' };
const titles = { ranking: '账号周榜', accounts: '账号列表', account: '账号详情', video: '视频详情', rewrites: 'AI写脚本', reviews: 'AI脚本诊断' };
const pages = {
'ranking': () => window.DataPages.renderRanking(view),
'accounts': () => window.DataPages.renderAccounts(view),
@@ -132,17 +132,18 @@ async function renderRanking(view) {
async function load() {
const d = await api('/api/dsh/ranking?date=' + encodeURIComponent(state.date) + '&category=' + encodeURIComponent(state.category) + '&sortBy=' + state.sortBy + '&order=' + state.order + '&page=' + state.page + '&pageSize=' + state.pageSize);
const di = state.dates.indexOf(state.date);
const sortable = (key, label) => `<th class="sortable" data-sort="${key}">${label}${state.sortBy === key ? (state.order === 'asc' ? ' ↑' : ' ↓') : ''}</th>`;
const WIDE = new Set(['comprehensiveScore', 'followers', 'newFans', 'newLikes', 'newComments', 'newShares']); // 数值列(窄+右对齐)
const sortable = (key, label) => `<th class="sortable${WIDE.has(key) ? ' th-wide' : ''}" data-sort="${key}">${label}${state.sortBy === key ? (state.order === 'asc' ? ' ↑' : ' ↓') : ''}</th>`;
const rows = (d.items || []).map((r) => `<tr>
<td>${r.rank ?? '-'}</td>
<td>${esc(r.category || '-')}</td>
<td>${r.profileUrl ? `<a class="link" data-ext="${esc(r.profileUrl)}" title="在新标签打开账号主页">${esc(r.accountName)}</a>` : esc(r.accountName)}</td>
<td class="score-cell">${fmt(r.comprehensiveScore)}</td>
<td>${fmt(r.followers)}</td>
<td>${fmt(r.newFans)}</td>
<td>${fmt(r.newLikes)}</td>
<td>${fmt(r.newComments)}</td>
<td>${fmt(r.newShares)}</td>
<td class="num score-cell">${fmt(r.comprehensiveScore)}</td>
<td class="num">${fmt(r.followers)}</td>
<td class="num">${fmt(r.newFans)}</td>
<td class="num">${fmt(r.newLikes)}</td>
<td class="num">${fmt(r.newComments)}</td>
<td class="num">${fmt(r.newShares)}</td>
<td class="cell-act">${r.inDb ? `<a class="btn btn-sm btn-view" href="#/account/${r.dbId}">查看</a>` : `<button class="btn btn-sm btn-import" data-import='${esc(JSON.stringify(r))}'>分析</button>`}</td>
</tr>`).join('');
view.innerHTML = `
@@ -161,7 +162,7 @@ async function renderRanking(view) {
</div>
</div>
<div class="table-wrap">
<table class="table">
<table class="table table-rank">
<thead><tr>
${sortable('rank', '排名')}<th>赛道</th><th>账号</th>${sortable('comprehensiveScore', '综合得分')}${sortable('followers', '粉丝')}${sortable('newFans', '新增粉丝')}${sortable('newLikes', '新增点赞')}${sortable('newComments', '新增评论')}${sortable('newShares', '新增分享')}<th class="th-act">功能</th>
</tr></thead>
@@ -224,7 +225,7 @@ async function renderAccounts(view) {
</select>
<select id="accTrack" class="input-select"><option value="">全部赛道</option>${tracks.map((t) => `<option value="${esc(t)}" ${t === state.track ? 'selected' : ''}>${esc(t)}</option>`).join('')}</select>
<input id="accSearch" class="input" placeholder="搜索账号名 / 内容 / 赛道" value="${esc(state.search)}">
<button class="btn" id="accImport" title="导入外部账号数据(抖音账号信息 / 本地文件夹)">${svgIcon('download', 15, 'currentColor')} 导入</button>
<button class="btn" id="accImport" title="添加账号数据(抖音账号信息 / 账号地址)">${svgIcon('user-plus', 15, 'currentColor')} 添加</button>
<button class="btn" id="accReset" title="重置筛选">${svgIcon('refresh', 15, 'currentColor')}</button>
</div>
</div>
@@ -272,11 +273,16 @@ async function renderAccountDetail(view, id) {
const d = await api('/api/dsh/account-videos?accountId=' + accountId + '&page=1&pageSize=20').catch((e) => ({ ok: false, error: e.message }));
if (!d.ok) { view.innerHTML = `<div class="page"><div class="page-toolbar">${toolbarBack()}</div><div class="empty">${esc(d.error || '账号不存在')}</div></div>`; bindBack(view); return; }
const acc = d.account;
let persona = null, analysis = null;
let persona = null, analysis = null, personaCard = null;
let viewMode = 'card'; // 账号设定展示模式:card(卡片版,默认)/ text(文字版)
async function loadPersona() {
const r = await api('/api/dsh/account-persona?accountId=' + accountId);
persona = r.content || null;
const [r, c] = await Promise.all([
api('/api/dsh/account-persona?accountId=' + accountId),
api('/api/dsh/account-card?name=' + encodeURIComponent(acc.account_name || '')),
]);
persona = (r && r.content) || null;
personaCard = (c && c.exists && c.path) ? { path: c.path } : null;
}
async function loadAnalysis() {
const r = await api('/api/dsh/account-analysis?accountId=' + accountId);
@@ -313,12 +319,30 @@ async function renderAccountDetail(view, id) {
<div class="table-wrap"><table class="table"><thead><tr><th>发布时间</th><th class="th-id">视频ID</th><th>视频标题</th><th>点赞</th><th>评论</th><th>分享</th><th>时长</th><th class="th-act">操作</th></tr></thead><tbody>${rows || '<tr><td colspan="8" class="empty">暂无视频数据</td></tr>'}</tbody></table></div>
${pagerHtml(dv.total, dv.page, 20, (p) => { page = p; load(); })}`;
} else if (tab === 'persona') {
panel = persona ? `<div class="md-body">${renderMarkdown(persona)}</div>` : `<div class="empty-state">
<div class="empty-icon">${svgIcon('users', 40)}</div>
<div class="empty-title">该账号暂无账号设定信息</div>
<div class="empty-desc">为达人提炼账号设定后,可基于设定生成脚本</div>
<button class="btn btn-view" data-extract>提炼账号设定</button>
</div>`;
if (!persona) {
panel = `<div class="empty-state">
<div class="empty-icon">${svgIcon('users', 40)}</div>
<div class="empty-title">该账号暂无账号设定信息</div>
<div class="empty-desc">为达人提炼账号设定后,可基于设定生成脚本</div>
<button class="btn btn-view" data-extract>提炼账号设定</button>
</div>`;
} else if (!personaCard) {
// 无卡片:直接文字版(无切换控件)
panel = `<div class="md-body persona-text">${renderMarkdown(persona)}</div>`;
} else {
// 有卡片:右上角图标切换(卡片版 / 文字版),默认卡片版
panel = `
<div class="persona-wrap">
<div class="persona-vs-icons">
<button class="vi-btn ${viewMode === 'card' ? 'vi-active' : ''}" data-vs="card" title="卡片版">${svgIcon('image', 16)}</button>
<button class="vi-btn ${viewMode === 'text' ? 'vi-active' : ''}" data-vs="text" title="文字版">${svgIcon('file-text', 16)}</button>
</div>
<div class="card-view ${viewMode === 'card' ? '' : 'vs-hidden'}">
<img src="/api/file?path=${encodeURIComponent(personaCard.path)}" alt="${esc(acc.account_name)}账号设定卡">
</div>
<div class="md-body persona-text ${viewMode === 'text' ? '' : 'vs-hidden'}">${renderMarkdown(persona)}</div>
</div>`;
}
} else {
panel = analysis ? `<div class="md-body">${renderMarkdown(analysis)}</div>` : `<div class="empty">该账号暂无数据分析</div>`;
}
@@ -341,6 +365,15 @@ async function renderAccountDetail(view, id) {
const open = text.classList.toggle('open');
b.textContent = open ? '收起' : '展开';
}));
// 账号设定:卡片版 / 文字版 切换(直接 DOM 切换,不重渲染,保留滚动位置)
view.querySelectorAll('[data-vs]').forEach((b) => b.addEventListener('click', () => {
viewMode = b.dataset.vs;
view.querySelectorAll('[data-vs]').forEach((x) => x.classList.toggle('vi-active', x.dataset.vs === viewMode));
const cardEl = view.querySelector('.card-view');
const mdEl = view.querySelector('.persona-text');
if (cardEl) cardEl.classList.toggle('vs-hidden', viewMode !== 'card');
if (mdEl) mdEl.classList.toggle('vs-hidden', viewMode !== 'text');
}));
view.querySelectorAll('[data-rewrite]').forEach((b) => b.addEventListener('click', () => {
const r = JSON.parse(b.dataset.rewrite);
const parsed = !!r.parsed;
@@ -427,7 +460,7 @@ async function renderRewrites(view) {
<td>${fmt(v.publish_time)}</td>
<td>${(v.has_source || v.has_analysis) ? '✅已解析' : '未解析'}</td>
<td>${v.rewrite_count > 0 ? `<button class="btn btn-sm btn-view" data-detail="${v.id}" title="查看改写脚本">${v.rewrite_count} 查看</button>` : '—'}</td>
<td class="cell-act"><button class="btn btn-sm btn-view" data-rewrite='${esc(JSON.stringify({ id: v.id, name: v.account_name, title: cleanTitle(v.video_title) }))}'>AI改写</button></td>
<td class="cell-act"><button class="btn btn-sm" data-rewrite='${esc(JSON.stringify({ id: v.id, name: v.account_name, title: cleanTitle(v.video_title) }))}'>AI写脚本</button></td>
</tr>`).join('');
view.innerHTML = `
<div class="page">
@@ -580,7 +613,7 @@ async function renderRewrites(view) {
await load();
}
/* ================= AI写作复盘 ================= */
/* ================= AI脚本诊断 ================= */
async function renderReviews(view) {
const d = await api('/api/dsh/rewrites').catch((e) => ({ ok: false, error: e.message }));
const DIMS = ['设定承接', '框架节奏', '钩子设置', '叙事逻辑', '极致事件', '共鸣和共情', '其他', '总分'];
@@ -624,7 +657,7 @@ async function renderReviews(view) {
view.innerHTML = `
<div class="page">
<div class="page-toolbar">${toolbarBack()}<span class="page-title">AI写作复盘</span><span class="pager-info">按生成时间倒序 · 共 ${items.length} 条</span></div>
<div class="page-toolbar">${toolbarBack()}<span class="page-title">AI脚本诊断</span><span class="pager-info">按生成时间倒序 · 共 ${items.length} 条</span></div>
<div class="table-wrap"><table class="table">
<thead><tr><th>ID</th><th>账号</th><th>生成方式</th><th>脚本标题</th><th>生成时间</th>${DIMS.map((h) => `<th style="text-align:center">${h}</th>`).join('')}<th>操作</th></tr></thead>
<tbody>${rows || '<tr><td colspan="13" class="empty">暂无 AI 写脚本(完成改写的脚本会出现在这里)</td></tr>'}</tbody>
@@ -235,7 +235,7 @@ main { height: calc(100vh - 54px); }
.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; }
@@ -265,6 +265,12 @@ main#view { height: calc(100vh - 54px); overflow: auto; }
/* ================= 首页 ================= */
.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); }
@@ -290,8 +296,8 @@ main#view { height: calc(100vh - 54px); overflow: auto; }
.btn-primary { background: var(--primary); color: #fff; border-color: transparent; }
.btn-primary:hover { background: #2a5fd0; color: #fff; }
/* 功能列按钮区分:保存=浅灰底深字,查看=白底蓝字 */
.btn-import { background: #f0f2f5; border-color: #e1e4e8; color: #495057; }
.btn-import:hover { background: #e4e7eb; border-color: #d0d4da; color: #343a40; }
.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; }
@@ -306,6 +312,15 @@ main#view { height: calc(100vh - 54px); overflow: auto; }
.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 生效) */
/* 周榜表: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; }
.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; } /* 功能 */
.link { color: #4a90e2; cursor: pointer; text-decoration: none; }
.link:hover { text-decoration: underline; }
.link.strong { font-weight: 600; }
@@ -329,6 +344,16 @@ main#view { height: calc(100vh - 54px); overflow: auto; }
.td-id { font-size: 13px; font-variant-numeric: tabular-nums; padding-left: 8px; padding-right: 8px; }
th.th-id { width: 120px; }
/* ================= 账号设定 卡片版/文字版 切换(右上角图标按钮组) ================= */
.persona-wrap { position: relative; padding: 0 20px; } /* 账号设定内容左右各 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; }
/* ================= 信息卡 ================= */
.info-card { border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; margin-bottom: 14px; background: var(--panel); }
/* 账号信息按行分组:行内项目按内容宽度左对齐,保留行间距 */
@@ -396,11 +421,16 @@ th.th-id { width: 120px; }
/* ================= 首页 账号周榜 预览 ================= */
.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; }
.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); }
.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; }
.rank-date-label { font-size: 14px; color: var(--dim); min-width: 62px; text-align: center; white-space: nowrap; }
.rank-preview-list { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
.rank-row { display: grid; grid-template-columns: 34px 84px minmax(80px, 1fr) 52px repeat(4, 58px) 72px; align-items: center; gap: 6px; padding: 6px 10px; font-size: 14px; border-bottom: 1px solid var(--border); }
.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); }
.rank-row:last-child { border-bottom: none; }
.rank-head { background: var(--panel); color: var(--dim); font-weight: 600; font-size: 13px; }
.rk-no { color: var(--dim); font-weight: 600; }