R59: 视频详情拆解tab默认视图升级SVG关键信息卡(analysis 13维自动抽取无AI/md标签表双解析/深浅色系按调性/内容结构整卡板块化/卡文切换+cache bump e/i)

This commit is contained in:
maogeigei committed 2026-09-04 14:47:35 +08:00
1 parent 1f59acd16b
commit 92fccbb768
3 files changed
+275 -5

No files matched your search

@@ -1187,9 +1187,251 @@ async function renderAccountDetail(view, id) {
}
/* ================= 视频详情 ================= */
/* ===== R59 视频关键信息卡(拆解 tab 默认视图)= 前端 SVG 生成器,复刻账号设定卡(F6)视觉语言:
竖版长图信息图(viewBox 0 0 680 自适应高度),渐变底 + 顶部标题框 + 圆角内容卡,色系按拆解调性自动选深/浅;
数据从 MCP 解析 analysis.json(13 维 dict)自动抽取(无 AI),维度/标签以数据实存为准(有则展示、缺失即跳过)===== */
/* 提取 markdown 文本中的 `**标签**: 值` 行 → [['标签','值']...](支持中文/英文冒号;值仅取当行) */
function mdTagLines(text) {
const out = [];
String(text || '').split('\n').forEach((ln) => {
const m = /^\s*\*\*([^*]+)\*\*\s*[::]\s*(.+)$/.exec(ln);
if (m && m[2].trim()) out.push([m[1].trim(), m[2].trim()]);
});
return out;
}
/* 解析 markdown 管道表格 → { head:[], rows:[[...]] }(跳过分隔行,自动去首尾空单元格) */
function mdTableRows(text) {
const head = []; const rows = [];
String(text || '').split('\n').forEach((ln) => {
const t = ln.trim();
if (!t.startsWith('|')) return;
const cells = t.replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
if (cells.every((c) => /^:?-{2,}:?$/.test(c))) return; /* 分隔行 |---| */
if (!head.length) head.push(...cells); else rows.push(cells);
});
return { head, rows };
}
/* —— SVG 布局工具(中文按 1 全角宽、ASCII 按 0.55 估算折行/截断) —— */
const _u = (ch) => (ch.charCodeAt(0) > 255 ? 1 : 0.55);
const _uLen = (s) => { let w = 0; for (const c of String(s)) w += _u(c); return w; };
function svgWrap(text, maxU) { /* 按全角单位宽折行 → 行数组(不截断) */
const lines = []; let cur = ''; let w = 0;
for (const ch of String(text || '')) {
const cw = _u(ch);
if (w + cw > maxU && cur) { lines.push(cur); cur = ''; w = 0; }
cur += ch; w += cw;
}
if (cur) lines.push(cur);
return lines;
}
function svgCut(text, maxU) { /* 超宽截断加省略号 */
const s = String(text || '');
return _uLen(s) <= maxU ? s : (svgWrap(s, maxU - 1)[0] + '…');
}
/* —— 配色:按拆解文本情绪调性选 深色系/浅色系(复用 F6 配色规范;赛道标签歧义时以情绪锚点为准) —— */
function vkPalette(analysis) {
const blob = ['赛道', '账号核心定位', '内容核心', '内容风格', '可复用的爆款内容体系'].map((k) => String((analysis || {})[k] || '')).join(' ');
const deep = /深沉|催泪|怀旧|忧郁|厚重|悲情|救赎|泪目|旧时光|岁月|思念|遗憾|守护/.test(blob);
const light = /明快|欢乐|治愈|解压|暖心|温馨|可爱|元气|清新|甜蜜|宠溺|搞笑|积极/.test(blob);
/* 深色词命中 → 深色系(如催泪向治愈);否则浅色系(默认) */
return deep && !light ? 'deep' : 'light';
}
/* 抽取拆解为「关键信息模块数据」→ [{ title, kind:'kv', rows:[[k,v]...] } | { title, kind:'blocks', blocks:[[b, [[k,v]...]]] }](无内容模块不返回) */
function vkCollect(analysis) {
if (!analysis || typeof analysis !== 'object') return [];
const A = analysis;
const first = (k) => { const v = A[k]; return (v == null || v === '') ? '' : String(v).trim(); };
const tagItems = (k, skipTag) => mdTagLines(A[k]).filter(([t, v]) => v && !(skipTag && skipTag === t));
let track = first('赛道');
if (!track) { const tl = mdTagLines(A['账号核心定位']).find(([t]) => t === '赛道'); if (tl) track = tl[1]; }
const g1 = [...tagItems('账号核心定位', track ? '赛道' : ''), ...tagItems('内容核心', '')].slice(0, 8); /* ① 内容定位 */
const g2 = tagItems('可复用的爆款内容体系', '').slice(0, 6); /* ② 可复用爆款内容 */
/* ③ 内容结构:完整段落板块表(段落板块|维度|内容)→ 独立整卡板块,行全量保留(板块≤7);
无内容结构表时回退:板块式框架表 → 「结构与钩子」速览;镜号表/无表 → 不展示 */
const struct = [];
const cs = mdTableRows(A['内容结构']);
if (cs.head.length >= 2 && cs.rows.length) {
const map = {}; const order = [];
cs.rows.forEach((r) => {
const b = r[0] || '其他', d = r[1] || '', v = r[2] || '';
if (!v) return;
if (!map[b]) { map[b] = []; order.push(b); }
map[b].push([d, v]);
});
order.slice(0, 7).forEach((b) => { if (map[b].length) struct.push([b, map[b]]); });
}
let g3 = [];
if (!struct.length) {
const fw = mdTableRows(A['框架']);
if (fw.head.length >= 3 && /段落板块|板块|段落/.test(fw.head[0]) && fw.rows.length) {
const seen = {};
fw.rows.forEach((r) => {
const b = r[0] || '其他'; if (seen[b] || Object.keys(seen).length >= 5) return;
seen[b] = 1;
const tail = (r[2] || '') + (r[3] ? ' · ' + r[3] : '');
g3.push([b, [['', tail]]]);
});
}
}
const g4 = [...tagItems('内容风格', ''), ...tagItems('传播元素', '')].slice(0, 8); /* ④ 风格与传播 */
const g5 = []; /* ⑤ 台词金句/网络热梗(有才展示) */
Object.keys(A).forEach((k) => { mdTagLines(A[k]).forEach(([t, v]) => { if (/台词|金句/.test(t)) g5.push([t, v]); }); });
const meme = first('梗元素');
if (meme && !/^无/.test(meme) && meme !== '-' && meme !== '暂无') g5.push(['网络热梗', meme]);
const mods = [];
if (g1.length) mods.push({ title: '内容定位', kind: 'kv', rows: g1 });
if (g2.length) mods.push({ title: '可复用爆款内容', kind: 'kv', rows: g2 });
if (struct.length) mods.push({ title: '内容结构', kind: 'struct', groups: struct }); /* ③ 完整结构板块(含钩子/节奏等维度行) */
else if (g3.length) mods.push({ title: '结构与钩子', kind: 'blocks', blocks: g3 }); /* 无内容结构时回退框架速览 */
if (g4.length) mods.push({ title: '风格与传播', kind: 'kv', rows: g4 });
if (g5.length) mods.push({ title: '台词金句 · 网络热梗', kind: 'kv', rows: g5 });
return { track, mods };
}
/* SVG 生成器:analysis=拆解 dict,vt=视频标题(含话题标签原样,卡内自行清理),acc=账号名。
排版模型:全宽 680,头部标题框 520×170(同账号设定卡),其下逐模块排版——模块标题(底色上书写)→ 内容卡(圆角白/米卡)。
文本折行按「全角单位」计算:中文 1u≈字号宽、ASCII 0.55u;行 y 均为 text 基线,行高按字号 + 视觉间距直接给出(无字符串后处理)。 */
function renderVideoKeySvg(analysis, vt, acc) {
const D = vkCollect(analysis);
if (!D || !D.mods || !D.mods.length) return '';
const deep = vkPalette(analysis) === 'deep';
/* 色板(复用账号设定卡深浅两系规范) */
const P = deep ? {
bgA: '#2B0F0A', bgB: '#1A0805', /* 深棕红渐变底(深沉向) */
box: '#FAEEDA', boxBd: '#EDCBA4', boxTitle: '#7B2D1B', boxText: '#6F5B4A', boxTag: '#C13B2F',
headBox: '#FAEEDA', headBd: '#EDCBA4', headTitleA: '#8C2F1E', headTitleB: '#C05A32', headSub: '#8C2F1E', headDim: '#A88770',
secTitle: '#FAEEDA', deco: '#A88770',
} : {
bgA: '#D6A3A9', bgB: '#9A5563', /* 玫瑰金渐变底(明快向,王微斯定稿) */
box: '#FFFFFF', boxBd: '#E8C9DC', boxTitle: '#7B2D5E', boxText: '#6F4A5E', boxTag: '#E0437F',
headBox: '#FFFFFF', headBd: '#E8C9DC', headTitleA: '#C0457F', headTitleB: '#E876A9', headSub: '#7B2D5E', headDim: '#B98AA6',
secTitle: '#FDE8EC', deco: '#EAC6CE',
};
const W = 680, LX = 40, CW = W - LX * 2; /* 内容区:x 40,宽 600 */
const X = (u) => Math.max(4, Math.floor(u)); /* 全角单位 → 字数 */
const titleRaw = String(vt || '').split('#')[0].trim() || '视频拆解';
const headY0 = 30, headH = 170, headCY = headY0 + headH / 2; /* 头部框:y30 h170 同账号设定卡 */
/* —— 收集绘制元素(全部绝对坐标,直接输出) —— */
const els = []; /* {tag, x,y,w,h, fill,stroke,rx,rw} rect | {x,y,text,size,fill,weight,anchor,middle} text */
const R = (x, y, w, h, fill, stroke, rx = 12, rw = 1) => els.push({ tag: 'r', x, y, w, h, fill, stroke, rx, rw });
const T = (x, y, text, size, fill, weight, anchor) => els.push({ tag: 't', x, y, text, size, fill, weight, anchor });
/* 文字辅助:基线定位。text 行 y 直接为基线;中文高度约 size*1.35,行距取 size+5~8 */
/* 折行(maxU=该行最多全角字数)→ 行数组 */
const lineH = (size) => Math.round(size * 1.5); /* 行高(基线间距) */
/* —— 顶部标题框 —— */
R(80, headY0, 520, headH, P.headBox, P.headBd, 14, 1);
const tl = svgWrap(titleRaw, 20).slice(0, 2); /* 主标题最多 2 行,每行 ≤20 全角字 */
const tlMore = svgWrap(titleRaw, 20).length > 2;
const ty = tl.length === 2 ? headY0 + 52 : headY0 + 84;
tl.forEach((ln, i) => { T(340, ty + i * 32, (i === 1 && tlMore) ? ln + '…' : ln, 21, `url(#vkT${deep ? 'D' : 'L'})`, 700, 'middle'); });
T(340, headY0 + 146, '视频拆解 · 关键信息', 11, P.headSub, 600, 'middle');
T(340, headY0 + 162, `${acc ? acc + ' · ' : ''}赛道:${D.track || '未知'}`, 9.5, P.headDim, null, 'middle');
/* —— 逐模块排版 —— */
let y = headY0 + headH + 22; /* 头部框下起点 */
const vPadLR = 16, vPadT = 15, vPadB = 14; /* 卡内左右/上/下留白(F6:文字禁止贴靠卡边) */
const insetTop = (s) => Math.round(s * 0.9); /* 字形顶 → 基线(中文 glyph 顶在基线之上约 0.9em) */
const kvU = (size) => X((CW - vPadLR * 2 - 6) / (size + 1.1)); /* 卡内正文每行最多全角字数 */
D.mods.forEach((mod, mi) => {
if (mi > 0) y += 14;
T(LX, y, '▍' + mod.title, 14, P.secTitle, 700);
y += 30;
if (mod.kind === 'kv') {
/* kv 卡:label 独占行(主题粗体)→ 值折行 ≤2 行;游标自卡顶 vPadT 起步 → 首行文字顶与卡顶留白 vPadT */
const rows = mod.rows.filter(([, v]) => String(v).trim());
const items = []; /* {text,size,fill,weight,top} top=文字顶(相对卡顶) */
let cur = vPadT;
rows.forEach(([k, v]) => {
items.push({ text: k, size: 12.5, fill: P.boxTitle, weight: 700, top: cur }); cur += 22;
const ws = svgWrap(v, kvU(11.5)); const more = ws.length > 2;
ws.slice(0, 2).forEach((ln, i) => { items.push({ text: more && i === 1 ? ln + '…' : ln, size: 11.5, fill: P.boxText, top: cur }); cur += 17; });
cur += 8; /* 条目间距 */
});
if (!items.length) return;
cur -= 8;
const h = cur + vPadB;
R(LX, y, CW, h, P.box, P.boxBd, 12, 1);
items.forEach((it) => T(LX + vPadLR, y + it.top + insetTop(it.size), it.text, it.size, it.fill, it.weight));
y += h + 16;
} else if (mod.kind === 'struct') {
/* 内容结构整卡:板块小节 = 板块名行(左侧竖条) + 其下「维度:内容」全量行(值折行 ≤3 行) */
const items = [];
let cur = vPadT;
mod.groups.forEach(([b, rows], gi) => {
if (gi > 0) cur += 8;
items.push({ text: b, size: 12.5, fill: P.boxTitle, weight: 700, top: cur, bar: true }); cur += 23;
rows.forEach(([d, v]) => {
const s = (d ? d + ':' : '') + v;
const ws = svgWrap(s, kvU(11.5)); const more = ws.length > 3;
ws.slice(0, 3).forEach((ln, i) => { items.push({ text: more && i === 2 ? ln + '…' : ln, size: 11.5, fill: P.boxText, top: cur }); cur += 17; });
cur += 3;
});
});
cur -= 3;
const h = cur + vPadB;
R(LX, y, CW, h, P.box, P.boxBd, 12, 1);
items.forEach((it) => {
if (it.bar) R(LX + vPadLR, y + it.top + 4, 4, 14, P.boxTag, null, 2, 0);
T(LX + vPadLR + (it.bar ? 13 : 0), y + it.top + insetTop(it.size), it.text, it.size, it.fill, it.weight);
});
y += h + 16;
} else {
/* blocks 多列小卡(无内容结构时的框架速览等):板块名 + 要点行 ≤2 行折行 */
const nb = mod.blocks.length, gap = 12;
const bw = (CW - gap * (nb - 1)) / nb;
const bu = X((bw - 24) / 11.8); /* 小卡正文每行全角字数 */
const segs = mod.blocks.map(([b, rows]) => {
const its = []; let cur = 13;
its.push({ text: b, size: 12, fill: P.boxTitle, weight: 700, top: cur }); cur += 21;
rows.slice(0, 4).forEach(([d, v]) => {
const s = (d ? d + ':' : '') + v;
const ws = svgWrap(s, bu); const more = ws.length > 2;
ws.slice(0, 2).forEach((ln, i) => { its.push({ text: more && i === 1 ? ln + '…' : ln, size: 10.5, fill: P.boxText, top: cur }); cur += 16; });
cur += 4;
});
cur -= 4;
return { its, h: cur + 12 };
});
const rowH = Math.max(...segs.map((s) => s.h));
segs.forEach((sg, si) => {
const x = LX + si * (bw + gap);
R(x, y, bw, rowH, P.box, P.boxBd, 10, 1);
sg.its.forEach((it) => T(x + 12, y + it.top + insetTop(it.size), it.text, it.size, it.fill, it.weight));
});
y += rowH + 16;
}
});
const totalH = y + 6;
/* —— 输出 —— */
const escX = esc;
let out = '';
els.forEach((e) => {
if (e.tag === 'r') out += `<rect x="${e.x}" y="${e.y}" width="${e.w}" height="${e.h}" rx="${e.rx}" fill="${e.fill}" stroke="${e.stroke}" stroke-width="${e.rw}"/>`;
else out += `<text x="${e.x}" y="${e.y}" font-size="${e.size}"${e.fill ? ` fill="${e.fill}"` : ''}${e.weight ? ` font-weight="${e.weight}"` : ''}${e.anchor ? ` text-anchor="${e.anchor}"` : ''}>${escX(e.text)}</text>`;
});
return `<svg viewBox="0 0 ${W} ${totalH}" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" style="display:block">
<defs>
<linearGradient id="bgGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="${P.bgA}"/>
<stop offset="1" stop-color="${P.bgB}"/>
</linearGradient>
<linearGradient id="vkT${deep ? 'D' : 'L'}" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="${P.headTitleA}"/>
<stop offset="1" stop-color="${P.headTitleB}"/>
</linearGradient>
</defs>
<rect x="0" y="0" width="${W}" height="${totalH}" fill="url(#bgGrad)"/>
${out}
</svg>`;
}
async function renderVideoDetail(view, id) {
const videoId = Number(id);
let tab = 'script';
let vkMode = 'card'; /* R59:拆解 tab 默认展示「视频关键信息卡」,可切换「拆解全文」文案版 */
const d = await api('/api/dsh/video-detail?videoId=' + videoId).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 v = d.video, analysis = d.analysis, parse = d.parse || {};
@@ -1200,17 +1442,27 @@ async function renderVideoDetail(view, id) {
const viewLink = v.video_url ? `<a class="link" data-ext="${esc(v.video_url)}" title="打开视频网页">查看视频 ↗</a>` : '无';
const scriptMd = parse.content || null;
const deconstructMd = parse.analysis ? analysisToMd(parse.analysis) : null;
const keyCardHtml = renderVideoKeySvg(parse.analysis, v.video_title, v.account_name); /* R59:关键信息卡(SVG 生成器,拆解维度自动抽取,无则空 → 纯全文) */
/* 解析/刷新按钮(09-03 R28):未解析=空态「解析」;已解析=内容右上角「刷新」(同 F5 解析任务,幂等覆盖) */
const parseBtn = (mode) => `<button class="btn btn-sm" data-parse="${mode}" title="${mode === 'refresh' ? '重新解析该视频(覆盖更新视频脚本与视频拆解)' : '对该视频做 MCP 解析(产出视频脚本与视频拆解)'}">${svgIcon(mode === 'refresh' ? 'refresh' : 'zap', 14, 'currentColor')} ${mode === 'refresh' ? '刷新' : '解析'}</button>`;
const emptyAct = (icon, title, desc) => `<div class="empty-state"><div class="empty-icon">${svgIcon(icon, 40)}</div><div class="empty-title">${title}</div><div class="empty-desc">${desc}</div><div style="margin-top:12px">${parseBtn('parse')}</div></div>`;
const tabBtns = [['script', '视频脚本'], ['deconstruct', '视频拆解']].map(([k, label]) => `<button class="tab ${tab === k ? 'tab-active' : ''}" data-tab="${k}">${label}</button>`).join('');
/* R59:拆解 tab 内容区 = 关键信息卡(默认)/ 拆解全文 切换视图(同账号设定卡片交互:右上角卡/文切换钮,无 AI、纯前端抽取) */
const deconPanel = () => {
if (!deconstructMd) return emptyAct('chart', '暂无视频拆解', '该视频尚未完成结构化拆解。点击「解析」对该视频做 MCP 内容解析(同时产出「视频脚本」与「视频拆解」,约需数分钟)。');
if (!keyCardHtml) return `<div class="vd-tools">${parseBtn('refresh')}</div><div class="md-body">${renderMarkdown(deconstructMd)}</div>`;
const vkSwitch = `<div class="vk-switch" title="切换视图"><button class="vi-btn ${vkMode === 'card' ? 'vi-active' : ''}" data-vk="card" title="关键信息卡片版">${svgIcon('image', 15)}</button><button class="vi-btn ${vkMode === 'text' ? 'vi-active' : ''}" data-vk="text" title="拆解全文版">${svgIcon('file-text', 15)}</button></div>`;
return `<div class="vd-tools">${vkSwitch}${parseBtn('refresh')}</div>
<div class="vd-wrap">
<div class="vk-view ${vkMode === 'card' ? '' : 'vs-hidden'}">${keyCardHtml}</div>
<div class="md-body persona-text vk-text ${vkMode === 'text' ? '' : 'vs-hidden'}">${renderMarkdown(deconstructMd)}</div>
</div>`;
};
let body = '';
if (tab === 'script') body = scriptMd
? `<div class="vd-tools">${parseBtn('refresh')}</div><div class="md-body">${renderMarkdown(scriptMd)}</div>`
: emptyAct('file-text', '暂无视频脚本', '该视频尚未解析。点击「解析」对该视频做 MCP 内容解析(同时产出「视频脚本」与「视频拆解」,约需数分钟)。');
else if (tab === 'deconstruct') body = deconstructMd
? `<div class="vd-tools">${parseBtn('refresh')}</div><div class="md-body">${renderMarkdown(deconstructMd)}</div>`
: emptyAct('chart', '暂无视频拆解', '该视频尚未完成结构化拆解。点击「解析」对该视频做 MCP 内容解析(同时产出「视频脚本」与「视频拆解」,约需数分钟)。');
else if (tab === 'deconstruct') body = deconPanel();
view.innerHTML = `
<div class="page page-detail">
<div class="page-toolbar">${toolbarBack()}<span class="page-title">${esc(cleanTitle(v.video_title) || '视频详情')}</span></div>
@@ -1230,6 +1482,14 @@ async function renderVideoDetail(view, id) {
bindBack(view);
view.querySelectorAll('[data-tab]').forEach((b) => b.addEventListener('click', () => { tab = b.dataset.tab; load(); }));
view.querySelectorAll('[data-parse]').forEach((b) => b.addEventListener('click', () => confirmVideoParse(b, v, b.dataset.parse)));
/* R59:关键信息卡 / 拆解全文 切换(DOM 切换不重渲染,保留滚动位置;同账号设定 data-vs 交互) */
view.querySelectorAll('[data-vk]').forEach((b) => b.addEventListener('click', () => {
vkMode = b.dataset.vk;
view.querySelectorAll('[data-vk]').forEach((x) => x.classList.toggle('vi-active', x.dataset.vk === vkMode));
const cv = view.querySelector('.vk-view'), tv = view.querySelector('.vk-text');
if (cv) cv.classList.toggle('vs-hidden', vkMode !== 'card');
if (tv) tv.classList.toggle('vs-hidden', vkMode !== 'text');
}));
view._reload = load; // 静默自动刷新(保留当前 tab)
}
load();
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>短视频工作台</title>
<link rel="stylesheet" href="style.css?v=20260904c">
<link rel="stylesheet" href="style.css?v=20260904e">
<script src="vendor/echarts.min.js"></script>
</head>
<body>
@@ -43,6 +43,6 @@
<script src="app.js?v=20260904e"></script>
<script src="help-guide.js?v=20260904b"></script>
<script src="data-pages.js?v=20260904f"></script>
<script src="data-pages.js?v=20260904i"></script>
</body>
</html>
@@ -549,6 +549,16 @@ td.td-title { max-width: 240px; }
/* 视频详情属性内容统一宽度(09-03 R48):与账号详情同款——8 字段固定 width 140px、横向间隔 0;超宽单行省略号 + hover 看全量;
标签(info-wide)占整行不受限宽保持换行;原视频ID vid-limited 独立 max-width 220 已被统一 140 覆盖,移除冗余规则 */
.video-info .video-grid .info-item:not(.info-wide) .info-value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ================= R59 视频关键信息卡(拆解 tab 默认视图,卡/文切换) ================= */
.vd-tools { display: flex; justify-content: flex-end; align-items: center; gap: 8px; margin: 2px 0 8px 20px; }
.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 */