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
- 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 模式
347 lines
10 KiB
Vue
347 lines
10 KiB
Vue
<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>
|