Files
cube/apps/write/frontend/src/App.vue
T
Fam Zheng 7b868852d2
deploy articulate / build-and-deploy (push) Successful in 1m15s
deploy cube / build-and-deploy (push) Successful in 1m40s
deploy karaoke / build-and-deploy (push) Successful in 1m6s
deploy llm-proxy / build-and-deploy (push) Successful in 2m3s
deploy music / build-and-deploy (push) Successful in 2m16s
deploy notes / build-and-deploy (push) Successful in 2m2s
deploy simpleasm / build-and-deploy (push) Successful in 1m30s
deploy werewolf / build-and-deploy (push) Successful in 1m12s
deploy write / build-and-deploy (push) Successful in 1m54s
write(ui): 三栏宽度可拖拽 + localStorage 持久化
- sidebar | workspace 拖拽条:调整侧栏宽(180-500px)
- editor | preview 拖拽条:调整源码/预览比例(15%-85%)
- CSS var --sidebar-w / --editor-fr / --preview-fr 驱动 grid-template-columns
- 鼠标 down 开始 drag,move 实时算 px/dx,up 落盘 localStorage
- 移动端(<768px)自动隐藏拖拽条,回到 100% 切 tab 模式
2026-05-24 17:18:37 +01:00

347 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
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.
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { marked } from 'marked'
import { api, getToken, setToken, type DocMeta } from './lib/api'
import { AsrSession } from './lib/asr'
const docs = ref<DocMeta[]>([])
const activeId = ref<number | null>(null)
const title = ref('')
const content = ref('')
const inputText = ref('')
const status = ref('')
const statusErr = ref(false)
const busy = ref(false)
const recording = ref(false)
onMounted(() => applyLayoutVars())
// ====== 拖拽布局:sidebar 宽(px + editor/preview 比例(0-1======
const sidebarW = ref(parseInt(localStorage.getItem('write.sidebarW') || '260'))
const editorRatio = ref(parseFloat(localStorage.getItem('write.editorRatio') || '0.5'))
function applyLayoutVars(): void {
const r = document.documentElement
r.style.setProperty('--sidebar-w', sidebarW.value + 'px')
r.style.setProperty('--editor-fr', editorRatio.value + 'fr')
r.style.setProperty('--preview-fr', (1 - editorRatio.value) + 'fr')
}
let dragKind: 'sidebar' | 'editor' | null = null
let dragStartX = 0
let dragStartVal = 0
let dragPaneW = 0
function startDrag(e: MouseEvent, kind: 'sidebar' | 'editor'): void {
dragKind = kind
dragStartX = e.clientX
if (kind === 'sidebar') {
dragStartVal = sidebarW.value
} else {
dragStartVal = editorRatio.value
const pane = (e.currentTarget as HTMLElement).parentElement
dragPaneW = pane ? pane.offsetWidth : window.innerWidth
}
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
window.addEventListener('mousemove', onDrag)
window.addEventListener('mouseup', endDrag)
e.preventDefault()
}
function onDrag(e: MouseEvent): void {
if (!dragKind) return
const dx = e.clientX - dragStartX
if (dragKind === 'sidebar') {
sidebarW.value = Math.max(180, Math.min(500, dragStartVal + dx))
} else {
const newR = dragStartVal + dx / Math.max(1, dragPaneW)
editorRatio.value = Math.max(0.15, Math.min(0.85, newR))
}
applyLayoutVars()
}
function endDrag(): void {
if (!dragKind) return
localStorage.setItem('write.sidebarW', String(sidebarW.value))
localStorage.setItem('write.editorRatio', String(editorRatio.value))
dragKind = null
document.body.style.cursor = ''
document.body.style.userSelect = ''
window.removeEventListener('mousemove', onDrag)
window.removeEventListener('mouseup', endDrag)
}
const needToken = ref(!getToken())
const tokenInput = ref('')
// mobile UI state
const drawerOpen = ref(false)
const mobilePane = ref<'editor' | 'preview'>('preview')
let saveTimer: ReturnType<typeof setTimeout> | null = null
let asr: AsrSession | null = null
let voiceBase = '' // committed transcript so partials don't clobber it
function setStatus(msg: string, err = false): void {
status.value = msg
statusErr.value = err
if (msg && !err) {
setTimeout(() => {
if (status.value === msg) status.value = ''
}, 3000)
}
}
async function loadDocs(): Promise<void> {
try {
docs.value = await api.listDocs()
if (docs.value.length && activeId.value === null) {
await selectDoc(docs.value[0].id)
}
} catch (e: any) {
if (String(e).includes('401')) {
needToken.value = true
} else {
setStatus(String(e), true)
}
}
}
async function selectDoc(id: number): Promise<void> {
try {
const d = await api.getDoc(id)
activeId.value = d.id
title.value = d.title
content.value = d.content
drawerOpen.value = false // close drawer on mobile after pick
} catch (e: any) {
setStatus(String(e), true)
}
}
async function newDoc(): Promise<void> {
try {
const d = await api.createDoc('未命名文档')
await loadDocs()
await selectDoc(d.id)
} catch (e: any) {
setStatus(String(e), true)
}
}
async function deleteDoc(id: number): Promise<void> {
if (!confirm('删除这个文档?')) return
await api.deleteDoc(id)
if (activeId.value === id) activeId.value = null
await loadDocs()
}
function scheduleSave(): void {
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(saveNow, 800)
}
async function saveNow(): Promise<void> {
if (activeId.value === null) return
try {
await api.saveContent(activeId.value, content.value, title.value)
// refresh doc list ordering
docs.value = await api.listDocs()
} catch (e: any) {
setStatus(String(e), true)
}
}
watch(content, scheduleSave)
watch(title, scheduleSave)
async function sendMessage(): Promise<void> {
if (!inputText.value.trim() || busy.value || activeId.value === null) return
const text = inputText.value.trim()
busy.value = true
setStatus('思考中…')
inputText.value = ''
try {
// Ensure latest edits are saved before claude touches the file
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null }
await api.saveContent(activeId.value, content.value, title.value)
const r = await api.chat(activeId.value, text)
content.value = r.content
const cost = r.cost_usd?.toFixed(4) ?? '?'
const turns = r.turns ?? '?'
setStatus(`${r.reply || '完成'} • cost=$${cost} turns=${turns}`)
} catch (e: any) {
setStatus(String(e), true)
} finally {
busy.value = false
docs.value = await api.listDocs()
}
}
function onInputKeydown(e: KeyboardEvent): void {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault()
sendMessage()
}
}
async function toggleMic(): Promise<void> {
if (recording.value) {
// stop
recording.value = false
await asr?.stop()
asr = null
} else {
voiceBase = inputText.value
if (voiceBase && !voiceBase.endsWith(' ')) voiceBase += ' '
asr = new AsrSession((ev) => {
if (ev.kind === 'error') {
setStatus(`ASR: ${ev.message}`, true)
return
}
// The Qwen3-ASR server returns cumulative text per partial.
inputText.value = voiceBase + ev.text
if (ev.kind === 'final') {
voiceBase = inputText.value
if (voiceBase && !voiceBase.endsWith(' ')) voiceBase += ' '
}
})
try {
await asr.start()
recording.value = true
setStatus('🎙 录音中…再点一次停止')
} catch (e: any) {
recording.value = false
asr = null
setStatus(String(e), true)
}
}
}
function saveToken(): void {
if (!tokenInput.value.trim()) return
setToken(tokenInput.value.trim())
needToken.value = false
tokenInput.value = ''
loadDocs()
}
const renderedPreview = computed(() => {
return marked.parse(content.value || '*(空文档)*', { breaks: true, gfm: true }) as string
})
onMounted(() => {
if (!needToken.value) loadDocs()
})
</script>
<template>
<div v-if="needToken" class="modal-backdrop">
<div class="modal">
<h2>请输入 passphrase</h2>
<input
v-model="tokenInput"
type="password"
placeholder="WRITE_PASSPHRASE"
@keydown.enter="saveToken"
/>
<div class="modal-actions">
<button class="btn primary" @click="saveToken">进入</button>
</div>
</div>
</div>
<div
class="app"
:class="{ 'drawer-open': drawerOpen, 'pane-editor': mobilePane === 'editor', 'pane-preview': mobilePane === 'preview' }"
v-show="!needToken"
>
<div class="mobile-bar">
<button class="hamburger" @click="drawerOpen = true" aria-label="open sidebar"></button>
<div class="doc-title">
<input
v-if="activeId !== null"
v-model="title"
placeholder="文档标题"
style="border:none;outline:none;background:transparent;font:inherit;font-weight:600;width:100%;"
/>
<span v-else style="color:#888;">没选中文档</span>
</div>
<div class="pane-tabs">
<button :class="{ active: mobilePane === 'editor' }" @click="mobilePane = 'editor'" title="源码"></button>
<button :class="{ active: mobilePane === 'preview' }" @click="mobilePane = 'preview'" title="预览">👁</button>
</div>
</div>
<div class="sidebar-backdrop" @click="drawerOpen = false"></div>
<div class="splitter splitter-main" @mousedown="startDrag($event, 'sidebar')" title="拖动调整侧栏宽度"></div>
<aside class="sidebar">
<div class="sidebar-header">
<h1> write</h1>
<button class="new-btn" @click="newDoc">+ 新建</button>
</div>
<div class="doc-list">
<div
v-for="d in docs"
:key="d.id"
class="doc-item"
:class="{ active: d.id === activeId }"
@click="selectDoc(d.id)"
>
<div class="title">{{ d.title || '未命名' }}</div>
<button class="del" @click.stop="deleteDoc(d.id)"></button>
</div>
<div v-if="!docs.length" style="padding: 12px; color: #888; font-size: 12px;">
还没有文档 + 新建一个
</div>
</div>
</aside>
<main class="workspace">
<div class="editor-pane">
<div class="title-row">
<input
v-model="title"
placeholder="文档标题"
:disabled="activeId === null"
/>
</div>
<div class="editor">
<textarea
v-model="content"
placeholder="markdown 源码(此处可手动编辑,也可以让 AI 改)"
:disabled="activeId === null"
></textarea>
</div>
<div class="splitter splitter-editor" @mousedown="startDrag($event, 'editor')" title="拖动调整源码/预览比例"></div>
<div class="preview" v-html="renderedPreview"></div>
</div>
<div class="input-bar">
<div class="input-row">
<textarea
v-model="inputText"
placeholder="对话框(Enter 发送 / Shift+Enter 换行)"
:disabled="busy || activeId === null"
@keydown="onInputKeydown"
></textarea>
<button
class="btn mic"
:class="{ on: recording }"
:disabled="busy || activeId === null"
@click="toggleMic"
:title="recording ? '停止录音' : '开始语音输入'"
>{{ recording ? '◼' : '🎙' }}</button>
<button
class="btn primary"
:disabled="busy || !inputText.trim() || activeId === null"
@click="sendMessage"
>发送</button>
</div>
<div class="status" :class="{ err: statusErr }">{{ status }}</div>
</div>
</main>
</div>
</template>