Files
cube/apps/notes/frontend/src/App.vue
T
Fam Zheng f8a7f31427
deploy notes / build-and-deploy (push) Successful in 1m49s
notes(ui): 补 actions 按钮组 CSS(之前 commit 漏了 .action-btn 样式)
2026-05-18 01:51:51 +01:00

818 lines
23 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<!-- pass 时强制弹输入框 -->
<div v-if="needPass" class="auth-overlay">
<div class="auth-modal">
<h2>🔒 输入访问令牌</h2>
<p class="auth-hint">notes 是私密录音库需要 passphrase 才能访问</p>
<form @submit.prevent="submitPass">
<input
v-model="passDraft"
type="password"
autofocus
placeholder="passphrase"
class="auth-input"
/>
<button class="auth-btn" :disabled="!passDraft.trim()">进入</button>
</form>
<p v-if="authError" class="auth-err">{{ authError }}</p>
</div>
</div>
<div v-else class="root">
<aside class="sidebar">
<header class="side-head">
<h1>📝 Notes</h1>
<button class="logout-btn" @click="logout" title="退出 / 改 passphrase"></button>
</header>
<div class="upload-row">
<button
v-if="recState === 'idle'"
class="rec-btn"
:disabled="uploading"
@click="startRec"
>🎙 直接录</button>
<button
v-else
class="rec-btn recording"
@click="stopRec"
> {{ fmtSec(recDuration) }}</button>
<label class="upload-pick">
<input
ref="fileInput"
type="file"
accept="audio/*,video/*"
@change="onFile"
/>
<span class="upload-btn small" :class="{ uploading }">{{ uploading ? '⏳' : ' 文件' }}</span>
</label>
</div>
<p v-if="uploadErr" class="upload-err">{{ uploadErr }}</p>
<ul class="list">
<li v-if="loading" class="list-empty">加载</li>
<li v-else-if="!list.length" class="list-empty">还没录音点上面 传一个</li>
<li
v-for="r in list"
:key="r.id"
class="item"
:class="{ active: selectedId === r.id, [r.status]: true }"
@click="select(r.id)"
>
<div class="item-title">{{ r.title }}</div>
<div class="item-meta">
<span class="status">{{ statusLabel(r.status) }}</span>
<span>· {{ fmtSize(r.size_bytes) }}</span>
<span v-if="r.has_summary">· 纪要</span>
</div>
</li>
</ul>
</aside>
<main class="content">
<p v-if="!selected" class="empty"> 从左边挑一条</p>
<template v-else>
<header class="cont-head">
<div class="title-row">
<h2>
{{ selected.title }}
<button class="rename-btn" title="重命名" @click="rename"></button>
</h2>
<div class="actions">
<button
class="action-btn"
:disabled="['pending','transcribing','cleaning','summarizing'].includes(selected.status)"
:title="selected.transcript ? '已有 transcript,只重跑 LLM 润色 + 纪要' : '重新 ASR + 润色 + 纪要'"
@click="retry"
> 重跑</button>
<button class="action-btn danger" @click="remove">🗑 删除</button>
</div>
</div>
<div class="head-meta">
<span>{{ statusLabel(selected.status) }}</span>
<span>· {{ fmtSize(selected.size_bytes) }}</span>
<span>· {{ selected.created_at }}</span>
</div>
<div v-if="selected.status === 'done'" class="feishu-row">
<a
v-if="selected.feishu_url"
:href="selected.feishu_url"
target="_blank"
rel="noopener"
class="feishu-link"
>📄 飞书文档 · {{ selected.feishu_url.replace(/^https?:\/\//, '').slice(0, 40) }}</a>
<button
class="feishu-btn"
:disabled="feishuPushing"
@click="pushFeishu"
>
{{ feishuPushing ? '⏳ 推送中…'
: selected.feishu_url ? '↻ 重新生成' : '📤 一键转飞书文档' }}
</button>
<p v-if="feishuErr" class="feishu-err">{{ feishuErr }}</p>
</div>
</header>
<audio :src="audioUrl(selected.id)" controls class="audio" />
<section v-if="selected.error" class="block err">
<h3>错误</h3>
<pre>{{ selected.error }}</pre>
</section>
<section class="block">
<h3>📋 会议纪要</h3>
<p v-if="!selected.summary && selected.status === 'done'" class="muted"></p>
<p v-else-if="['pending','transcribing','summarizing'].includes(selected.status)" class="muted">
{{ progressText(selected.status) }}
</p>
<div v-else class="prose" v-html="mdLite(selected.summary)"></div>
</section>
<section class="block">
<h3> 清理润色</h3>
<p v-if="!selected.cleaned && selected.status === 'done'" class="muted">cleanup step 失败看下方原文</p>
<p v-else-if="['pending','transcribing','cleaning','summarizing'].includes(selected.status)" class="muted">
{{ progressText(selected.status) }}
</p>
<div v-else class="prose" v-html="mdLite(selected.cleaned)"></div>
</section>
<section class="block">
<details>
<summary><h3 style="display:inline">🎙 转写原文默认折叠</h3></summary>
<p v-if="!selected.transcript" class="muted">{{ selected.status === 'failed' ? '转写失败' : '尚未生成' }}</p>
<pre v-else class="transcript">{{ selected.transcript }}</pre>
</details>
</section>
</template>
</main>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import {
listRecordings,
getRecording,
uploadRecording,
deleteRecording,
retryRecording,
renameRecording,
convertFeishu,
audioUrl as audioUrlFn,
getPass,
setPass,
clearPass,
} from './lib/api.js'
const needPass = ref(!getPass())
const passDraft = ref('')
const authError = ref('')
const list = ref([])
const loading = ref(false)
const selected = ref(null)
const selectedId = ref(null)
const uploading = ref(false)
const uploadErr = ref('')
const feishuPushing = ref(false)
const feishuErr = ref('')
let pollTimer = null
// 浏览器内录音(iOS 没法选录音机 App 文件,直接 web record 更顺)
const recState = ref('idle') // 'idle' | 'recording'
const recDuration = ref(0)
let mediaRecorder = null
let recChunks = []
let recStream = null
let recTimer = null
async function startRec() {
uploadErr.value = ''
if (!navigator.mediaDevices?.getUserMedia) {
uploadErr.value = '浏览器不支持 mic 录音'
return
}
try {
recStream = await navigator.mediaDevices.getUserMedia({ audio: true })
} catch (e) {
uploadErr.value = 'mic 权限被拒:' + (e.message || e.name)
return
}
// Safari 偏向 audio/mp4Chrome/Edge 优先 audio/webm
const tries = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', '']
let mimeType = ''
for (const t of tries) {
if (!t || (window.MediaRecorder && MediaRecorder.isTypeSupported(t))) {
mimeType = t
break
}
}
recChunks = []
mediaRecorder = mimeType
? new MediaRecorder(recStream, { mimeType })
: new MediaRecorder(recStream)
mediaRecorder.ondataavailable = (e) => { if (e.data && e.data.size) recChunks.push(e.data) }
mediaRecorder.onstop = onRecStop
mediaRecorder.start(1000) // 1s chunks 保证 stop 时有数据
recState.value = 'recording'
recDuration.value = 0
recTimer = setInterval(() => recDuration.value++, 1000)
}
function stopRec() {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop()
}
if (recTimer) { clearInterval(recTimer); recTimer = null }
}
async function onRecStop() {
const mimeType = mediaRecorder?.mimeType || 'audio/webm'
const blob = new Blob(recChunks, { type: mimeType })
if (recStream) {
recStream.getTracks().forEach(t => t.stop())
recStream = null
}
recState.value = 'idle'
// 生成文件名
const ext = mimeType.includes('mp4') ? 'm4a'
: mimeType.includes('webm') ? 'webm'
: mimeType.includes('ogg') ? 'ogg'
: 'bin'
const ts = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')
const file = new File([blob], `录音-${ts}.${ext}`, { type: mimeType })
if (file.size < 1024) {
uploadErr.value = '录音太短(< 1KB),没保存'
return
}
await doUpload(file)
}
function fmtSec(s) {
const m = Math.floor(s / 60)
const sec = s % 60
return m + ':' + (sec < 10 ? '0' : '') + sec
}
async function submitPass() {
setPass(passDraft.value.trim())
try {
await listRecordings()
needPass.value = false
authError.value = ''
await refresh()
syncFromUrl()
startPoll()
} catch (e) {
if (e.unauthorized) {
authError.value = '令牌不对'
clearPass()
} else {
authError.value = e.message || String(e)
}
}
}
function logout() {
clearPass()
needPass.value = true
list.value = []
selected.value = null
selectedId.value = null
history.replaceState(null, '', window.location.pathname)
stopPoll()
}
async function refresh(silent = false) {
if (!silent) loading.value = true
try {
const fresh = await listRecordings()
// 增量更新:尽量复用已有 ref,避免整 array 替换导致闪动
if (!list.value.length) {
list.value = fresh
} else {
const byId = new Map(list.value.map(r => [r.id, r]))
list.value = fresh.map(r => {
const old = byId.get(r.id)
if (old) {
Object.assign(old, r)
return old
}
return r
})
}
}
catch (e) {
if (e.unauthorized) { logout(); return }
}
finally { if (!silent) loading.value = false }
// 同步当前选中
if (selectedId.value) {
try {
const fresh = await getRecording(selectedId.value)
if (selected.value) {
Object.assign(selected.value, fresh)
} else {
selected.value = fresh
}
} catch {}
}
}
async function select(id) {
selectedId.value = id
// URL 同步:?id=N,方便刷新 / 分享 / bookmark
const q = new URLSearchParams(window.location.search)
q.set('id', String(id))
history.replaceState(null, '', '?' + q.toString())
try { selected.value = await getRecording(id) }
catch (e) {
if (e.unauthorized) { logout(); return }
}
}
function syncFromUrl() {
const id = parseInt(new URLSearchParams(window.location.search).get('id'))
if (id && id !== selectedId.value) select(id)
}
function onFile(e) {
const f = e.target.files?.[0]
if (!f) return
doUpload(f).then(() => { e.target.value = '' })
}
async function doUpload(file) {
uploading.value = true
uploadErr.value = ''
try {
const title = file.name.replace(/\.[^.]+$/, '')
const r = await uploadRecording(title, file)
await refresh()
select(r.id)
} catch (e) {
if (e.unauthorized) { logout(); return }
uploadErr.value = e.message || String(e)
} finally {
uploading.value = false
}
}
async function remove() {
if (!confirm('删除这条录音 + 转写 + 纪要?')) return
try {
await deleteRecording(selectedId.value)
selectedId.value = null
selected.value = null
history.replaceState(null, '', window.location.pathname)
await refresh()
} catch (e) { alert(e.message) }
}
async function rename() {
const cur = selected.value?.title || ''
const t = prompt('改个名字', cur)
if (t == null) return
const trimmed = t.trim()
if (!trimmed || trimmed === cur) return
try {
await renameRecording(selectedId.value, trimmed)
if (selected.value) selected.value.title = trimmed
const inList = list.value.find(r => r.id === selectedId.value)
if (inList) inList.title = trimmed
} catch (e) { alert(e.message) }
}
async function retry() {
try {
await retryRecording(selectedId.value)
await refresh()
} catch (e) { alert(e.message) }
}
async function pushFeishu() {
if (feishuPushing.value) return
feishuPushing.value = true
feishuErr.value = ''
try {
const r = await convertFeishu(selectedId.value)
if (selected.value) {
selected.value.feishu_doc_id = r.doc_id
selected.value.feishu_url = r.url
}
} catch (e) {
feishuErr.value = e.message || String(e)
} finally {
feishuPushing.value = false
}
}
function audioUrl(id) { return audioUrlFn(id) }
function statusLabel(s) {
return ({
pending: '⏳ 排队',
transcribing: '🎙️ 转写中',
cleaning: '✨ 清理润色中',
summarizing: '📋 总结中',
done: '✓ 完成',
failed: '✗ 失败',
})[s] || s
}
function progressText(s) {
return ({
pending: '等候处理',
transcribing: '语音转写中(视音频长度可能要几分钟)',
cleaning: 'LLM 分段 + 去口语 + 润色 + 高亮',
summarizing: 'LLM 生成会议纪要',
})[s] || s
}
function fmtSize(b) {
if (!b) return '?'
if (b < 1024) return b + 'B'
if (b < 1024 * 1024) return (b / 1024).toFixed(1) + 'KB'
if (b < 1024 * 1024 * 1024) return (b / 1024 / 1024).toFixed(1) + 'MB'
return (b / 1024 / 1024 / 1024).toFixed(2) + 'GB'
}
// 极简 markdown
function mdLite(s) {
if (!s) return ''
let h = s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
h = h.replace(/^### (.+)$/gm, '<h4>$1</h4>')
h = h.replace(/^## (.+)$/gm, '<h3>$1</h3>')
h = h.replace(/^# (.+)$/gm, '<h2>$1</h2>')
h = h.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
h = h.replace(/`([^`]+)`/g, '<code>$1</code>')
h = h.replace(/(^|\n)\s*[-*]\s+/g, '$1• ')
return h.split(/\n{2,}/).map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`).join('')
}
function startPoll() {
stopPoll()
pollTimer = setInterval(() => refresh(true), 5000)
}
function stopPoll() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
}
onMounted(async () => {
if (!needPass.value) {
await refresh()
syncFromUrl()
startPoll()
}
// 浏览器前进/后退按钮也同步
window.addEventListener('popstate', syncFromUrl)
})
onBeforeUnmount(() => {
stopPoll()
window.removeEventListener('popstate', syncFromUrl)
})
</script>
<style>
:root {
--bg: #0f0f0f;
--bg-elev: #161616;
--bg-card: #1a1a2e;
--bg-hover: #232342;
--bg-active: #2a1a3e;
--border: #2a2a3a;
--border-soft: #1f1f2a;
--text: #e0e0e0;
--text-dim: #a0a0a0;
--text-mute: #666;
--accent: #c084fc;
--accent-strong: #7c5cbf;
--accent-cyan: #06b6d4;
--accent-green: #4ade80;
--accent-amber: #f59e0b;
--accent-red: #ef4444;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body, #app { height: 100%; }
body {
font-family: var(--font-sans);
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
}
button { font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
input, textarea { font-family: inherit; background: transparent; border: none; color: inherit; outline: none; }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
</style>
<style scoped>
.auth-overlay {
position: fixed; inset: 0;
display: flex; align-items: center; justify-content: center;
background: var(--bg);
}
.auth-modal {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 28px;
width: 360px;
max-width: calc(100vw - 32px);
}
.auth-modal h2 { font-size: 20px; margin-bottom: 8px; }
.auth-hint { color: var(--text-mute); font-size: 13px; margin-bottom: 20px; }
.auth-input {
width: 100%;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px 12px;
font-size: 14px;
color: var(--text);
margin-bottom: 12px;
}
.auth-input:focus { border-color: var(--accent-strong); }
.auth-btn {
width: 100%;
background: var(--accent-strong);
color: #fff;
padding: 10px;
border-radius: 6px;
font-weight: 600;
}
.auth-btn:hover:not(:disabled) { background: var(--accent); }
.auth-err {
color: var(--accent-red);
margin-top: 12px;
font-size: 13px;
background: rgba(239,68,68,0.08);
padding: 8px;
border-radius: 4px;
}
.root { height: 100%; display: flex; }
.sidebar {
width: 340px;
border-right: 1px solid var(--border-soft);
display: flex;
flex-direction: column;
flex-shrink: 0;
background: var(--bg-elev);
}
.side-head {
display: flex; justify-content: space-between; align-items: center;
padding: 14px 18px;
border-bottom: 1px solid var(--border-soft);
}
.side-head h1 { font-size: 17px; font-weight: 600; }
.logout-btn {
width: 28px; height: 28px; border-radius: 50%;
font-size: 14px; color: var(--text-mute);
}
.logout-btn:hover { background: rgba(255,255,255,0.06); color: var(--text); }
.upload-row {
padding: 12px;
border-bottom: 1px solid var(--border-soft);
display: flex;
gap: 8px;
}
.rec-btn {
flex: 1;
background: var(--accent-strong);
color: #fff;
padding: 10px;
border-radius: 6px;
font-weight: 600;
font-size: 13px;
transition: background 0.15s;
}
.rec-btn:hover:not(:disabled) { background: var(--accent); }
.rec-btn.recording {
background: var(--accent-red);
animation: rec-pulse 1.4s ease-in-out infinite;
}
@keyframes rec-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.upload-pick { position: relative; display: block; cursor: pointer; flex-shrink: 0; }
.upload-pick input[type=file] { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
.upload-btn {
display: block;
text-align: center;
background: var(--bg-elev);
border: 1px solid var(--border);
color: var(--text-dim);
padding: 10px 14px;
border-radius: 6px;
font-weight: 600;
font-size: 13px;
transition: background 0.15s;
}
.upload-btn:hover { background: var(--bg-hover); color: var(--text); }
.upload-btn.uploading { background: var(--bg-card); color: var(--text-dim); }
.upload-btn.small { padding: 10px 12px; }
.upload-err {
color: var(--accent-red);
font-size: 12px;
margin: 0 12px 8px;
background: rgba(239,68,68,0.08);
padding: 6px 8px;
border-radius: 4px;
}
.list { list-style: none; flex: 1; overflow-y: auto; }
.list-empty { padding: 40px 16px; text-align: center; color: var(--text-mute); font-size: 13px; }
.item {
padding: 10px 14px;
border-bottom: 1px solid var(--border-soft);
cursor: pointer;
}
.item:hover { background: var(--bg-card); }
.item.active { background: var(--bg-active); }
.item.active .item-title { color: var(--accent); }
.item.failed .status { color: var(--accent-red); }
.item.done .status { color: var(--accent-green); }
.item-title {
font-size: 14px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-meta {
margin-top: 2px;
font-size: 11px;
color: var(--text-mute);
display: flex;
gap: 4px;
}
.content {
flex: 1;
overflow-y: auto;
padding: 24px 32px;
}
.empty {
display: flex;
height: 100%;
align-items: center;
justify-content: center;
color: var(--text-mute);
font-size: 15px;
}
.cont-head { margin-bottom: 18px; }
.cont-head h2 { font-size: 22px; margin-bottom: 6px; }
.head-meta {
font-size: 12px;
color: var(--text-mute);
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
}
/* 旧 .danger-btn / .retry-btn 已被 .action-btn 替代 */
.title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 6px;
}
.title-row h2 { flex: 1; min-width: 0; }
.actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.action-btn {
font-size: 12px;
padding: 6px 12px;
border-radius: 6px;
background: var(--bg-elev);
color: var(--text-dim);
border: 1px solid var(--border);
white-space: nowrap;
cursor: pointer;
}
.action-btn:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); }
.action-btn.danger { color: var(--accent-red); }
.action-btn.danger:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.12);
border-color: rgba(239, 68, 68, 0.4);
}
.feishu-row {
margin-top: 12px;
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.feishu-link {
color: var(--accent-cyan);
background: rgba(6, 182, 212, 0.1);
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
text-decoration: none;
}
.feishu-link:hover { background: rgba(6, 182, 212, 0.2); }
.feishu-btn {
background: var(--accent-strong);
color: #fff;
padding: 6px 14px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
}
.feishu-btn:hover:not(:disabled) { background: var(--accent); }
.feishu-err {
width: 100%;
margin: 0;
color: var(--accent-red);
background: rgba(239,68,68,0.08);
padding: 6px 10px;
border-radius: 4px;
font-size: 12px;
}
.retry-btn { background: rgba(124, 92, 191, 0.15); color: var(--accent); }
.retry-btn:hover { background: rgba(124, 92, 191, 0.3); }
.danger-btn { background: rgba(239, 68, 68, 0.1); color: var(--accent-red); }
.danger-btn:hover { background: rgba(239, 68, 68, 0.25); }
.audio { width: 100%; margin-bottom: 20px; }
.block {
background: var(--bg-card);
border-radius: 8px;
padding: 16px 20px;
margin-bottom: 16px;
}
.block.err { background: rgba(239,68,68,0.08); }
.block h3 {
font-size: 13px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 10px;
}
.muted { color: var(--text-mute); font-size: 13px; }
.transcript {
white-space: pre-wrap;
word-break: break-word;
font-size: 13px;
line-height: 1.7;
color: var(--text-dim);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.prose { font-size: 14px; line-height: 1.7; }
.prose :deep(p) { margin-bottom: 10px; }
.prose :deep(h2), .prose :deep(h3), .prose :deep(h4) {
font-size: 14px;
font-weight: 600;
color: var(--accent);
margin: 14px 0 6px;
}
.prose :deep(b) { color: var(--accent); }
.prose :deep(code) {
background: var(--bg-elev);
padding: 1px 6px;
border-radius: 3px;
font-family: ui-monospace, monospace;
font-size: 12px;
}
.block.err pre { white-space: pre-wrap; color: var(--accent-red); font-size: 12px; }
.block details > summary {
cursor: pointer;
list-style: none;
user-select: none;
margin-bottom: 4px;
}
.block details > summary::-webkit-details-marker { display: none; }
.block details > summary::before {
content: '▶';
display: inline-block;
margin-right: 6px;
font-size: 11px;
color: var(--text-mute);
transition: transform 0.15s;
}
.block details[open] > summary::before { transform: rotate(90deg); }
.block details > summary h3 {
margin: 0 !important;
text-transform: none;
letter-spacing: normal;
font-size: 13px;
}
@media (max-width: 768px) {
.root { flex-direction: column; }
.sidebar { width: 100%; height: 45vh; border-right: none; border-bottom: 1px solid var(--border-soft); }
}
</style>