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
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
|
|
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;
|
|
}
|
|
// 引用
|
|
// ⚠️ 这里匹配的是 > 不是 >:上面已经整段 esc() 过了,原文的 '>' 此刻是 '>'。
|
|
// 写成 /^\s*>/ 会永远匹配不上,引用块整个退化成普通段落(而且不报错)。
|
|
if (/^\s*>\s?/.test(ln)) {
|
|
var q = [];
|
|
while (i < lines.length && /^\s*>\s?/.test(lines[i])) { q.push(lines[i].replace(/^\s*>\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*>|\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>
|
|
|