2026-05-24 17:16:44 +01:00
|
|
|
|
<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)
|
|
|
|
|
|
|
2026-05-24 17:18:37 +01:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 17:16:44 +01:00
|
|
|
|
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>
|
|
|
|
|
|
|
2026-05-24 17:18:37 +01:00
|
|
|
|
<div class="splitter splitter-main" @mousedown="startDrag($event, 'sidebar')" title="拖动调整侧栏宽度"></div>
|
|
|
|
|
|
|
2026-05-24 17:16:44 +01:00
|
|
|
|
<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>
|
2026-05-24 17:18:37 +01:00
|
|
|
|
<div class="splitter splitter-editor" @mousedown="startDrag($event, 'editor')" title="拖动调整源码/预览比例"></div>
|
2026-05-24 17:16:44 +01:00
|
|
|
|
<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>
|