notes: 新建 notes.famzheng.me — 录音 → ASR → LLM 会议纪要
deploy articulate / build-and-deploy (push) Successful in 1m21s
deploy cube / build-and-deploy (push) Successful in 1m44s
deploy karaoke / build-and-deploy (push) Successful in 1m13s
deploy music / build-and-deploy (push) Successful in 2m23s
deploy notes / build-and-deploy (push) Successful in 2m16s
deploy simpleasm / build-and-deploy (push) Successful in 1m44s
deploy werewolf / build-and-deploy (push) Successful in 1m7s
deploy articulate / build-and-deploy (push) Successful in 1m21s
deploy cube / build-and-deploy (push) Successful in 1m44s
deploy karaoke / build-and-deploy (push) Successful in 1m13s
deploy music / build-and-deploy (push) Successful in 2m23s
deploy notes / build-and-deploy (push) Successful in 2m16s
deploy simpleasm / build-and-deploy (push) Successful in 1m44s
deploy werewolf / build-and-deploy (push) Successful in 1m7s
- 后端 axum + sqlite (recordings 表):上传 multipart 流式落 PVC;spawn worker pending → transcribing (调 mochi 那边 ASR endpoint, fireredasr2 token, Whisper-style multipart) → summarizing (调 gemma-4-31b-it OpenAI 兼容接口) → done
- 鉴权 middleware:Authorization: token <PASSPHRASE>;audio 流播放 ?token= query 兜底;passphrase 走 k8s Secret 不写死
- 前端 Vue3:首次访问弹 passphrase modal;sidebar 录音列表(带状态 chip)+ content 选中显示音频 + 转写 + markdown 纪要;5s polling 进度
- k8s manifest: ns cube-notes / PVC 30Gi / Ingress notes.famzheng.me / bodylimit 600M;Secret notes-creds = {passphrase, asr_token, llm_token}
- portal apps.ts 加 notes entry
This commit is contained in:
@@ -0,0 +1,502 @@
|
||||
<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">
|
||||
<label class="upload-pick">
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="audio/*,video/*"
|
||||
@change="onFile"
|
||||
/>
|
||||
<span class="upload-btn" :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">
|
||||
<h2>{{ selected.title }}</h2>
|
||||
<div class="head-meta">
|
||||
<span>{{ statusLabel(selected.status) }}</span>
|
||||
<span>· {{ fmtSize(selected.size_bytes) }}</span>
|
||||
<span>· {{ selected.created_at }}</span>
|
||||
<button v-if="selected.status === 'failed'" class="retry-btn" @click="retry">↻ 重试</button>
|
||||
<button class="danger-btn" @click="remove">删除</button>
|
||||
</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.transcript" class="muted">{{ selected.status === 'failed' ? '转写失败' : '尚未生成' }}</p>
|
||||
<pre v-else class="transcript">{{ selected.transcript }}</pre>
|
||||
</section>
|
||||
</template>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import {
|
||||
listRecordings,
|
||||
getRecording,
|
||||
uploadRecording,
|
||||
deleteRecording,
|
||||
retryRecording,
|
||||
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('')
|
||||
let pollTimer = null
|
||||
|
||||
async function submitPass() {
|
||||
setPass(passDraft.value.trim())
|
||||
try {
|
||||
await listRecordings()
|
||||
needPass.value = false
|
||||
authError.value = ''
|
||||
refresh()
|
||||
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
|
||||
stopPoll()
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
try { list.value = await listRecordings() }
|
||||
catch (e) {
|
||||
if (e.unauthorized) { logout(); return }
|
||||
}
|
||||
finally { loading.value = false }
|
||||
// 同步当前选中
|
||||
if (selectedId.value) {
|
||||
try { selected.value = await getRecording(selectedId.value) } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async function select(id) {
|
||||
selectedId.value = id
|
||||
try { selected.value = await getRecording(id) }
|
||||
catch (e) {
|
||||
if (e.unauthorized) { logout(); return }
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
await refresh()
|
||||
} catch (e) { alert(e.message) }
|
||||
}
|
||||
|
||||
async function retry() {
|
||||
try {
|
||||
await retryRecording(selectedId.value)
|
||||
await refresh()
|
||||
} catch (e) { alert(e.message) }
|
||||
}
|
||||
|
||||
function audioUrl(id) { return audioUrlFn(id) }
|
||||
|
||||
function statusLabel(s) {
|
||||
return ({
|
||||
pending: '⏳ 排队',
|
||||
transcribing: '🎙️ 转写中',
|
||||
summarizing: '✏️ 总结中',
|
||||
done: '✓ 完成',
|
||||
failed: '✗ 失败',
|
||||
})[s] || s
|
||||
}
|
||||
function progressText(s) {
|
||||
return ({
|
||||
pending: '等候处理',
|
||||
transcribing: '语音转写中(视音频长度可能要几分钟)',
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
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, 5000)
|
||||
}
|
||||
function stopPoll() {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!needPass.value) { refresh(); startPoll() }
|
||||
})
|
||||
onBeforeUnmount(stopPoll)
|
||||
</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);
|
||||
}
|
||||
.upload-pick { position: relative; display: block; cursor: pointer; }
|
||||
.upload-pick input[type=file] { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
|
||||
.upload-btn {
|
||||
display: block;
|
||||
text-align: center;
|
||||
background: var(--accent-strong);
|
||||
color: #fff;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.upload-btn:hover { background: var(--accent); }
|
||||
.upload-btn.uploading { background: var(--bg-card); color: var(--text-dim); }
|
||||
.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 {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.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; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.root { flex-direction: column; }
|
||||
.sidebar { width: 100%; height: 45vh; border-right: none; border-bottom: 1px solid var(--border-soft); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
// 鉴权:每个请求加 Authorization: token <pass>,<audio> 用 ?token= 兜底。
|
||||
|
||||
const KEY = 'notes.pass'
|
||||
|
||||
export function getPass() {
|
||||
return localStorage.getItem(KEY) || ''
|
||||
}
|
||||
export function setPass(v) {
|
||||
localStorage.setItem(KEY, v || '')
|
||||
}
|
||||
export function clearPass() {
|
||||
localStorage.removeItem(KEY)
|
||||
}
|
||||
|
||||
async function jreq(path, opts = {}) {
|
||||
const pass = getPass()
|
||||
const h = { 'Authorization': 'token ' + pass, ...(opts.headers || {}) }
|
||||
if (opts.body && !(opts.body instanceof FormData) && !h['Content-Type']) {
|
||||
h['Content-Type'] = 'application/json'
|
||||
}
|
||||
const r = await fetch(path, { ...opts, headers: h })
|
||||
if (r.status === 401) {
|
||||
const err = new Error('unauthorized')
|
||||
err.unauthorized = true
|
||||
throw err
|
||||
}
|
||||
if (!r.ok) {
|
||||
const t = await r.text().catch(() => '')
|
||||
throw new Error(t || `${r.status}`)
|
||||
}
|
||||
return r.json()
|
||||
}
|
||||
|
||||
export function listRecordings() { return jreq('/api/recordings') }
|
||||
export function getRecording(id) { return jreq('/api/recordings/' + id) }
|
||||
export function deleteRecording(id) { return jreq('/api/recordings/' + id, { method: 'DELETE' }) }
|
||||
export function retryRecording(id) { return jreq('/api/recordings/' + id + '/retry', { method: 'POST' }) }
|
||||
|
||||
export function uploadRecording(title, file) {
|
||||
const fd = new FormData()
|
||||
if (title) fd.append('title', title)
|
||||
fd.append('audio', file, file.name)
|
||||
return jreq('/api/recordings', { method: 'POST', body: fd })
|
||||
}
|
||||
|
||||
export function audioUrl(id) {
|
||||
return `/api/recordings/${id}/audio?token=${encodeURIComponent(getPass())}`
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
createApp(App).mount('#app')
|
||||
Reference in New Issue
Block a user