You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

316 lines
15 KiB

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="referrer" content="no-referrer">
<title>会议纪要</title>
<style>
:root{
--bg:#f5f6f8; --card:#fff; --fg:#1b1d21; --muted:#6b7280; --line:#e5e7eb;
--accent:#2563eb; --accent-soft:#eff4ff; --danger:#b42318;
}
@media (prefers-color-scheme:dark){
:root{
--bg:#101215; --card:#191c21; --fg:#e8eaed; --muted:#9aa1ab; --line:#2a2f37;
--accent:#6ea0ff; --accent-soft:#1b2436; --danger:#ff8a80;
}
}
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{
background:var(--bg); color:var(--fg);
font:15px/1.65 -apple-system,BlinkMacSystemFont,"PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif;
-webkit-text-size-adjust:100%;
}
.wrap{max-width:760px;margin:0 auto;padding:16px 16px 40px}
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px;margin-bottom:12px}
h1{font-size:19px;line-height:1.4;margin:0 0 8px;word-break:break-word}
.meta{color:var(--muted);font-size:13px;display:flex;flex-wrap:wrap;gap:4px 14px}
audio{width:100%;margin-top:14px}
/* 标签栏:内容少时只显示有内容的那几个 */
.tabs{display:flex;gap:6px;border-bottom:1px solid var(--line);margin:0 0 14px;overflow-x:auto;-webkit-overflow-scrolling:touch}
.tab{
flex:0 0 auto;padding:9px 14px;border:0;background:none;color:var(--muted);
font-size:14px;font-family:inherit;cursor:pointer;border-bottom:2px solid transparent;
}
.tab[aria-selected="true"]{color:var(--accent);border-bottom-color:var(--accent);font-weight:600}
.panel[hidden]{display:none}
/* 转写:每句一行,带说话人与时间戳 */
.line{display:flex;gap:10px;padding:7px 0;border-bottom:1px solid var(--line)}
.line:last-child{border-bottom:0}
.line .who{flex:0 0 auto;min-width:74px;color:var(--accent);font-size:13px;font-weight:600}
.line .t{flex:0 0 auto;color:var(--muted);font-size:12px;font-variant-numeric:tabular-nums;padding-top:2px}
.line .txt{flex:1 1 auto;word-break:break-word}
/* Markdown 渲染出来的总结 */
.md h1,.md h2,.md h3,.md h4{line-height:1.4;margin:18px 0 8px}
.md h1{font-size:18px} .md h2{font-size:17px} .md h3{font-size:15px} .md h4{font-size:14px}
.md>:first-child{margin-top:0}
.md p{margin:8px 0}
.md ul,.md ol{margin:8px 0;padding-left:22px}
.md li{margin:3px 0}
.md code{background:var(--accent-soft);padding:1px 5px;border-radius:4px;font-size:13px}
.md pre{background:var(--accent-soft);padding:12px;border-radius:8px;overflow-x:auto}
.md pre code{background:none;padding:0}
.md blockquote{margin:8px 0;padding:2px 0 2px 12px;border-left:3px solid var(--line);color:var(--muted)}
.md hr{border:0;border-top:1px solid var(--line);margin:16px 0}
.md table{border-collapse:collapse;width:100%;margin:10px 0;font-size:14px;display:block;overflow-x:auto}
.md th,.md td{border:1px solid var(--line);padding:7px 10px;text-align:left}
.md th{background:var(--accent-soft);font-weight:600}
.state{text-align:center;padding:52px 16px;color:var(--muted)}
.state strong{display:block;color:var(--fg);font-size:16px;margin-bottom:6px}
.state.err strong{color:var(--danger)}
.foot{text-align:center;color:var(--muted);font-size:12px;margin-top:18px;line-height:1.8}
.foot a{color:var(--accent);text-decoration:none}
.spin{width:22px;height:22px;margin:0 auto 12px;border:2px solid var(--line);border-top-color:var(--accent);border-radius:50%;animation:r .8s linear infinite}
@keyframes r{to{transform:rotate(360deg)}}
</style>
</head>
<body>
<div class="wrap" id="root">
<div class="state"><div class="spin"></div>正在打开…</div>
</div>
<script>
(function () {
'use strict';
// ⚠️ 后端域名白名单。serverHost 是从 URL 路径里读出来的,**不校验就等于**:
// 任何人构造一条 /share/<攻击者域名>/.../<票据> 的链接,就能让打开它的人的浏览器
// 向攻击者的服务器发请求(顺带把票据送过去)。既是钓鱼跳板也是票据泄漏。
// 新增环境要在这里加一行,否则那个环境分享出来的链接打不开。
var ALLOWED_HOSTS = [
'ym.ymaikj.com', // 正式
'ym-dev.ymaikj.com' // 阿龙测试
];
var DOWNLOAD_URL = 'https://dl.ymaikj.com'; // 页脚的 App 下载引导
var root = document.getElementById('root');
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function show(html) { root.innerHTML = html; }
function fail(title, detail) {
show('<div class="state err"><strong>' + esc(title) + '</strong>' + esc(detail || '') + '</div>'
+ foot(''));
}
function foot(extra) {
return '<div class="foot">' + (extra ? extra + '<br>' : '')
+ '由 EAIMAR 生成 · <a href="' + DOWNLOAD_URL + '">下载 App</a></div>';
}
// ── 路径解析 ──────────────────────────────────────────────
// /share/{serverHost}/{brand}/{id}/{ticket}
// brand 段是品牌标识,落地页不使用,但保留在路径里以兼容已发出去的链接形状。
// ⚠️ 票据是 base64url,本身不含 '/',所以按 '/' 切是安全的;真要变格式记得回来改这里。
function parsePath() {
var seg = location.pathname.split('/').filter(Boolean); // ['share', host, brand, id, ticket]
if (seg.length < 5 || seg[0] !== 'share') return null;
return { host: seg[1], id: seg[3], ticket: seg.slice(4).join('/') };
}
// ── 极简 Markdown 渲染(总结是 Markdown,见客户端 _exportMarkdownPdf)────
// 不引第三方库:这个页面要在国内手机上、可能很差的网络下秒开,为渲染几段
// 标题和列表去拉一个 CDN 脚本不值得,而且 CDN 被墙时整页就废了。
// ⚠️ 先 esc 再套正则,顺序不能反 —— 内容来自用户录音与模型输出,反了就是 XSS。
function md(src) {
var lines = esc(src).replace(/\r\n?/g, '\n').split('\n');
var out = [], i = 0;
function inline(t) {
return t
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
}
while (i < lines.length) {
var ln = lines[i];
if (/^\s*$/.test(ln)) { i++; continue; }
// 代码块
if (/^```/.test(ln)) {
var buf = []; i++;
while (i < lines.length && !/^```/.test(lines[i])) { buf.push(lines[i]); i++; }
i++;
out.push('<pre><code>' + buf.join('\n') + '</code></pre>');
continue;
}
// 标题
var h = ln.match(/^(#{1,4})\s+(.*)$/);
if (h) { var lv = h[1].length; out.push('<h' + lv + '>' + inline(h[2]) + '</h' + lv + '>'); i++; continue; }
// 分隔线
if (/^\s*([-*_])\s*\1\s*\1[\s\-*_]*$/.test(ln)) { out.push('<hr>'); i++; continue; }
// 表格:|a|b| 后跟 |---|---|
if (/^\s*\|/.test(ln) && i + 1 < lines.length && /^\s*\|[\s:|-]+\|\s*$/.test(lines[i + 1])) {
var cells = function (r) {
return r.trim().replace(/^\||\|$/g, '').split('|').map(function (c) { return inline(c.trim()); });
};
var head = cells(ln); i += 2;
var body = [];
while (i < lines.length && /^\s*\|/.test(lines[i])) { body.push(cells(lines[i])); i++; }
out.push('<table><thead><tr>' + head.map(function (c) { return '<th>' + c + '</th>'; }).join('')
+ '</tr></thead><tbody>'
+ body.map(function (r) { return '<tr>' + r.map(function (c) { return '<td>' + c + '</td>'; }).join('') + '</tr>'; }).join('')
+ '</tbody></table>');
continue;
}
// 引用
// ⚠️ 这里匹配的是 &gt; 不是 >:上面已经整段 esc() 过了,原文的 '>' 此刻是 '&gt;'。
// 写成 /^\s*>/ 会永远匹配不上,引用块整个退化成普通段落(而且不报错)。
if (/^\s*&gt;\s?/.test(ln)) {
var q = [];
while (i < lines.length && /^\s*&gt;\s?/.test(lines[i])) { q.push(lines[i].replace(/^\s*&gt;\s?/, '')); i++; }
out.push('<blockquote>' + inline(q.join('<br>')) + '</blockquote>');
continue;
}
// 列表(有序/无序)
var isOl = /^\s*\d+[.)]\s+/.test(ln);
if (isOl || /^\s*[-*+]\s+/.test(ln)) {
var items = [];
var re = isOl ? /^\s*\d+[.)]\s+/ : /^\s*[-*+]\s+/;
while (i < lines.length && re.test(lines[i])) { items.push(inline(lines[i].replace(re, ''))); i++; }
var tag = isOl ? 'ol' : 'ul';
out.push('<' + tag + '>' + items.map(function (t) { return '<li>' + t + '</li>'; }).join('') + '</' + tag + '>');
continue;
}
// 普通段落:连续非空行合成一段
var p = [];
while (i < lines.length && !/^\s*$/.test(lines[i]) && !/^(#{1,4}\s|```|\s*[-*+]\s|\s*\d+[.)]\s|\s*&gt;|\s*\|)/.test(lines[i])) {
p.push(lines[i]); i++;
}
if (p.length) out.push('<p>' + inline(p.join('<br>')) + '</p>');
else i++; // 兜底,避免任何情况下的死循环
}
return out.join('');
}
// ── 转写/译文:JSON 数组 [{speaker,content,starttime,endtime}] ────
// ⚠️ 不能假定它一定是合法 JSON:老记录、导入的外部音频里躺着纯文本。
// 解析失败就原样当文本显示,总比整页报错强。
function renderLines(raw) {
if (!raw) return '';
var arr = null;
try { arr = JSON.parse(raw); } catch (e) { /* 落到纯文本分支 */ }
if (!Array.isArray(arr)) return '<p>' + esc(raw).replace(/\n/g, '<br>') + '</p>';
return arr.map(function (it) {
var who = it.speaker ? '<span class="who">' + esc(it.speaker) + '</span>' : '';
var t = (typeof it.starttime === 'number' && it.starttime > 0)
? '<span class="t">' + hms(Math.floor(it.starttime / 1000)) + '</span>' : '';
return '<div class="line">' + who + t + '<span class="txt">' + esc(it.content || '') + '</span></div>';
}).join('');
}
function hms(sec) {
sec = Math.max(0, sec | 0);
var h = (sec / 3600) | 0, m = ((sec % 3600) / 60) | 0, s = sec % 60;
var p = function (n) { return n < 10 ? '0' + n : '' + n; };
return (h ? h + ':' : '') + p(m) + ':' + p(s);
}
function duration(sec) {
if (!sec) return '';
var m = Math.floor(sec / 60), s = sec % 60;
return m ? (m + ' 分 ' + s + ' 秒') : (s + ' 秒');
}
function ymd(unix) {
if (!unix) return '';
var d = new Date(unix * 1000), p = function (n) { return n < 10 ? '0' + n : n; };
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate())
+ ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
}
// ── 渲染 ───────────────────────────────────────────────────
function render(r) {
document.title = r.title || '会议纪要';
var metas = [];
if (r.creationtime) metas.push('<span>' + esc(ymd(r.creationtime)) + '</span>');
if (r.seconds) metas.push('<span>时长 ' + esc(duration(r.seconds)) + '</span>');
var head = '<div class="card"><h1>' + esc(r.title || '未命名会议') + '</h1>'
+ (metas.length ? '<div class="meta">' + metas.join('') + '</div>' : '')
+ (r.audiourl ? '<audio controls preload="none" src="' + esc(r.audiourl) + '"></audio>' : '')
+ '</div>';
// 只放有内容的标签页:空标签点进去一片白,比不显示更让人以为是坏了
var tabs = [];
if (r.summary) tabs.push({ k: 'sum', n: '总结', h: '<div class="md">' + md(r.summary) + '</div>' });
else if (r.overview) tabs.push({ k: 'sum', n: '总结', h: '<div class="md">' + md(r.overview) + '</div>' });
if (r.original) tabs.push({ k: 'org', n: '转写', h: renderLines(r.original) });
if (r.translate) tabs.push({ k: 'trs', n: '译文', h: renderLines(r.translate) });
var body;
if (!tabs.length) {
body = '<div class="card"><div class="state">这条纪要还没有内容<br><span style="font-size:13px">可能仍在转写中,稍后再打开试试</span></div></div>';
} else {
body = '<div class="card">'
+ (tabs.length > 1
? '<div class="tabs" role="tablist">' + tabs.map(function (t, i) {
return '<button class="tab" role="tab" data-k="' + t.k + '" aria-selected="' + (i === 0) + '">' + t.n + '</button>';
}).join('') + '</div>'
: '')
+ tabs.map(function (t, i) {
return '<div class="panel" data-k="' + t.k + '"' + (i === 0 ? '' : ' hidden') + '>' + t.h + '</div>';
}).join('')
+ '</div>';
}
var exp = r.expiretime ? ('本链接于 ' + esc(ymd(r.expiretime)) + ' 失效') : '';
show(head + body + foot(exp));
Array.prototype.forEach.call(document.querySelectorAll('.tab'), function (btn) {
btn.addEventListener('click', function () {
Array.prototype.forEach.call(document.querySelectorAll('.tab'), function (b) {
b.setAttribute('aria-selected', b === btn);
});
Array.prototype.forEach.call(document.querySelectorAll('.panel'), function (p) {
p.hidden = p.getAttribute('data-k') !== btn.getAttribute('data-k');
});
});
});
}
// ── 主流程 ─────────────────────────────────────────────────
var q = parsePath();
if (!q) { fail('链接不完整', '请确认复制了完整的分享链接'); return; }
if (ALLOWED_HOSTS.indexOf(q.host) < 0) { fail('链接无效', '来源不在允许列表中'); return; }
var xhr = new XMLHttpRequest();
xhr.open('POST', 'https://' + q.host + '/api/home/echomeet_getsharedrecord', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.timeout = 20000;
xhr.onload = function () {
var res;
try { res = JSON.parse(xhr.responseText); } catch (e) {
fail('打开失败', '服务返回了无法识别的内容'); return;
}
// 业务码非 0 一律按「链接失效」呈现:服务端刻意不区分「票据错」「已过期」
// 「记录已删」,这里再自作主张分类只会给出不准确的说法。
// ⚠️ 错误消息的字段名是 msg 不是 message(comm.HttpResult 的 json tag)。
if (!res || res.code !== 0 || !res.data) {
fail('链接已失效', '分享链接可能已过期,或已被创建者删除');
return;
}
render(res.data);
};
xhr.onerror = function () { fail('网络异常', '请检查网络后重试'); };
xhr.ontimeout = function () { fail('网络超时', '请检查网络后重试'); };
xhr.send(JSON.stringify({ ticket: q.ticket }));
})();
</script>
</body>
</html>