Files
boos e0a5f57793 feat(desktop): embed BoosAPI Desktop client as a subproject
Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as
a monorepo subdirectory under desktop/. Source only — node_modules,
dist, Cargo target and the upstream .git history are excluded.

desktop/ keeps its own independent Cargo workspace (desktop/src-tauri
with crates/*); it is not part of the backend boosapi crate, so a
`cargo check` at the metapi root still compiles only the backend. Build
the desktop app from desktop/src-tauri per desktop/README.md.

Note: app icons are still placeholder assets pending the final BoosAPI
logo. The updater signing keys live outside the repo and are not
included.
2026-06-22 11:32:57 +08:00

605 lines
20 KiB
Vue
Raw Permalink 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.
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from 'lz-string'
import MarkdownRender, {
CodeBlockNode,
disableKatex,
disableMermaid,
enableKatex,
enableMermaid,
getUseMonaco,
isKatexEnabled,
isMermaidEnabled,
MarkdownCodeBlockNode,
PreCodeNode,
setCustomComponents,
setKaTeXWorker,
setMermaidWorker,
} from 'markstream-vue'
import KatexWorker from 'markstream-vue/workers/katexRenderer.worker?worker&inline'
import MermaidWorker from 'markstream-vue/workers/mermaidParser.worker?worker&inline'
import { onMounted, ref, watch } from 'vue'
import { resolveMarkdownTextareaPaste } from '../../playground-shared/markdownPaste'
import 'katex/dist/katex.min.css'
// 用户输入(直接作为 preview 的内容)
const input = ref<string>(`# Hello
这是一个测试页面。左侧编辑输入,右侧实时预览渲染结果。
示例包含:
- **加粗**、*斜体*、` + '`inline code`' + `
强调链接:
- **[DR (Danmarks Radio)](https://www.dr.dk/nyheder)**
- **[DR **(Danmarks Radio)](https://www.dr.dk/nyheder)**
- **[DR (Danmarks Radio)**](https://www.dr.dk/nyheder)**
- **[DR **(Danmarks Radio)**](https://www.dr.dk/nyheder)**
- 代码块:
\`\`\`js
console.log('hello')
\`\`\`
数学:$E=mc^2$
Mermaid 示例:
\`\`\`mermaid
graph TD
A-->B
\`\`\`
[AntV Infographic](https://infographic.antv.vision/) 示例:
\`\`\`infographic
infographic list-row-simple-horizontal-arrow
data
items
- label 步骤 1
desc 开始
- label 步骤 2
desc 进行中
- label 步骤 3
desc 完成
\`\`\`
`)
// 流式渲染相关状态
const streamContent = ref<string>('')
const isStreaming = ref(false)
const streamSpeed = useLocalStorage<number>('vmr-test-stream-speed', 1) // 每次添加的字符数,可调整速度
const streamInterval = useLocalStorage<number>('vmr-test-stream-interval', 16) // 每次更新的时间间隔(毫秒)
const showStreamSettings = useLocalStorage<boolean>('vmr-test-show-settings', false) // 是否显示流式渲染设置
// 渲染配置相关(用于测试不同代码块/渲染模式)
const renderMode = useLocalStorage<'monaco' | 'pre' | 'markdown'>('vmr-test-render-mode', 'monaco')
const codeBlockStream = useLocalStorage<boolean>('vmr-test-code-stream', true)
const viewportPriority = useLocalStorage<boolean>('vmr-test-viewport-priority', true)
const batchRendering = useLocalStorage<boolean>('vmr-test-batch-rendering', true)
const typewriter = useLocalStorage<boolean>('vmr-test-typewriter', true)
const debugParse = useLocalStorage<boolean>('vmr-test-debug-parse', false)
const mathEnabled = useLocalStorage<boolean>('vmr-test-math-enabled', isKatexEnabled())
const mermaidEnabled = useLocalStorage<boolean>('vmr-test-mermaid-enabled', isMermaidEnabled())
// 预加载 Monaco 编辑器和 worker
if (process.client) {
getUseMonaco()
setKaTeXWorker(new KatexWorker())
setMermaidWorker(new MermaidWorker())
}
// 分享链接相关
const shareUrl = ref<string>('')
const tooLong = ref(false)
const notice = ref<string>('')
const noticeType = ref<'success' | 'error' | 'info'>('success')
const isWorking = ref(false)
const isCopied = ref(false)
const issueUrl = ref<string>('')
const MAX_URL_LEN = 2000 // warning threshold — browsers/servers differ; adjust as needed
// Use lz-string to compress to a URL-safe encoded component
function encodeForUrl(str: string) {
try {
return compressToEncodedURIComponent(str)
}
catch {
return ''
}
}
function decodeFromUrl(s: string) {
try {
return decompressFromEncodedURIComponent(s) || ''
}
catch {
return ''
}
}
function generateShareLink() {
const encodedRaw = encodeURIComponent(input.value)
const compressed = encodeForUrl(input.value)
if (!compressed && !encodedRaw)
return
// Choose the shorter representation: compressed (URL-safe) or raw encoded
const data = (compressed && compressed.length < encodedRaw.length) ? compressed : `raw:${encodedRaw}`
const url = new URL(window.location.href)
url.hash = `data=${data}`
const full = url.toString()
console.log(full.length)
if (full.length > MAX_URL_LEN) {
// mark as too long, do not place the huge URL in address bar
tooLong.value = true
shareUrl.value = ''
// prepare an issue URL so user can open it directly
issueUrl.value = buildIssueUrl(input.value)
showToast('内容过长,无法嵌入到 URL。你可以打开 Issue 并提交。', 'info', 5000)
return
}
tooLong.value = false
shareUrl.value = full
window.history.replaceState(undefined, '', full)
}
function buildIssueUrl(text: string) {
const base = 'https://github.com/Simon-He95/markstream-vue/issues/new?template=bug_report.yml'
const body = `**Reproduction input**:\n\nPlease find the reproduction input below:\n\n\`\`\`markdown\n${text}\n\`\`\``
return `${base}&body=${encodeURIComponent(body)}`
}
async function copyShareLink() {
const u = shareUrl.value || window.location.href
try {
await navigator.clipboard.writeText(u)
return true
}
catch (e) {
console.warn('copy failed', e)
return false
}
}
function showToast(msg: string, type: 'success' | 'error' | 'info' = 'success', duration = 2000) {
notice.value = msg
noticeType.value = type
if (duration > 0)
setTimeout(() => (notice.value = ''), duration)
}
async function generateAndCopy() {
// generate share URL then copy it
isWorking.value = true
isCopied.value = false
generateShareLink()
if (tooLong.value) {
isWorking.value = false
return
}
const ok = await copyShareLink()
isWorking.value = false
if (ok) {
isCopied.value = true
showToast('已复制链接到剪贴板', 'success', 2000)
setTimeout(() => (isCopied.value = false), 2000)
}
else {
showToast('复制链接失败,请手动复制或在 HTTPS/localhost 下重试', 'error', 4000)
}
}
async function copyRawInput() {
try {
const url = buildIssueUrl(input.value)
issueUrl.value = url
await navigator.clipboard.writeText(url)
showToast('已复制 issue 链接到剪贴板,打开链接并提交即可。', 'success', 3500)
}
catch (e) {
console.warn('copy failed', e)
showToast('复制失败,请手动选中并复制输入内容。', 'error', 3500)
}
}
function openIssueInNewTab() {
if (!issueUrl.value)
issueUrl.value = buildIssueUrl(input.value)
try {
window.open(issueUrl.value, '_blank')
}
catch {
// fallback: set location
window.location.href = issueUrl.value
}
}
function handleEditorPaste(event: ClipboardEvent) {
const textarea = event.currentTarget
if (!(textarea instanceof HTMLTextAreaElement))
return
const pasted = event.clipboardData?.getData('text/plain') ?? ''
const next = resolveMarkdownTextareaPaste(textarea, pasted)
if (!next)
return
event.preventDefault()
textarea.value = next.nextValue
textarea.selectionStart = next.selectionStart
textarea.selectionEnd = next.selectionEnd
input.value = next.nextValue
}
function restoreFromUrl() {
try {
const hash = window.location.hash || ''
if (!hash)
return
const m = hash.match(/data=([^&]+)/)
if (m && m[1]) {
const payload = m[1]
// support `raw:` fallback where we stored uncompressed (encoded) content
if (payload.startsWith('raw:')) {
try {
input.value = decodeURIComponent(payload.slice(4))
}
catch {
// ignore
}
}
else {
const decoded = decodeFromUrl(payload)
if (decoded)
input.value = decoded
}
}
}
catch {
// ignore
}
}
onMounted(() => {
if (process.client) {
restoreFromUrl()
shareUrl.value = window.location.href
}
})
watch(() => renderMode.value, (mode: string) => {
if (mode === 'pre') {
setCustomComponents({ code_block: PreCodeNode })
}
else if (mode === 'markdown') {
setCustomComponents({ code_block: MarkdownCodeBlockNode })
}
else {
setCustomComponents({ code_block: CodeBlockNode })
}
}, { immediate: true })
watch(mathEnabled, (enabled) => {
if (enabled)
enableKatex()
else
disableKatex()
}, { immediate: true })
watch(mermaidEnabled, (enabled) => {
if (enabled)
enableMermaid()
else
disableMermaid()
}, { immediate: true })
// 流式渲染函数
let streamTimer: number | null = null
function startStreamRender() {
if (isStreaming.value) {
// 如果正在流式渲染,停止它
stopStreamRender()
return
}
// 重置流式内容
streamContent.value = ''
isStreaming.value = true
let currentIndex = 0
const fullText = input.value
const streamStep = () => {
if (currentIndex >= fullText.length) {
// 完成流式渲染
stopStreamRender()
return
}
// 每次截取指定数量的字符
const nextIndex = Math.min(currentIndex + streamSpeed.value, fullText.length)
streamContent.value = fullText.slice(0, nextIndex)
currentIndex = nextIndex
// 继续下一次渲染,使用用户设置的时间间隔
streamTimer = window.setTimeout(streamStep, streamInterval.value)
}
streamStep()
}
function stopStreamRender() {
if (streamTimer !== null) {
clearTimeout(streamTimer)
streamTimer = null
}
isStreaming.value = false
// 确保显示完整内容
if (streamContent.value && streamContent.value !== input.value)
streamContent.value = input.value
}
function toggleStreamSettings() {
showStreamSettings.value = !showStreamSettings.value
}
</script>
<template>
<div class="p-4 app-container h-full bg-gray-50 dark:bg-gray-900">
<div class="max-w-6xl mx-auto h-full overflow-hidden flex flex-col">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold">
Markdown 输入 & 实时渲染 (Nuxt)
</h2>
<div class="text-sm text-gray-500 flex items-center gap-3">
<span>左侧输入,右侧预览</span>
<button
class="px-2 py-1 rounded text-sm flex items-center gap-2"
:class="isStreaming ? 'bg-red-600 text-white' : 'bg-purple-600 text-white'"
@click="startStreamRender"
>
{{ isStreaming ? '停止流式渲染' : '流式渲染' }}
</button>
<button
class="px-2 py-1 bg-gray-500 text-white rounded text-sm"
:class="{ 'bg-gray-700': showStreamSettings }"
@click="toggleStreamSettings"
>
⚙️ 设置
</button>
<button :disabled="isWorking" class="px-2 py-1 bg-blue-600 text-white rounded text-sm flex items-center gap-2" @click="generateAndCopy">
生成并复制分享链接
</button>
<button class="bg-green-600 text-white rounded px-2 py-1 text-sm" @click="openIssueInNewTab">
打开 Issue
</button>
</div>
</div>
<!-- 设置面板:流式渲染 + 渲染配置 -->
<div v-if="showStreamSettings" class="mb-4 p-4 bg-white dark:bg-gray-800 rounded border border-purple-300 dark:border-purple-700 shadow-md space-y-4">
<div>
<h3 class="text-sm font-semibold mb-3 text-gray-800 dark:text-gray-200">
流式渲染设置
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
每次截取字符数: <span class="text-purple-600 dark:text-purple-400 font-semibold">{{ streamSpeed }}</span>
</label>
<input
v-model.number="streamSpeed"
type="range"
min="1"
max="100"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
>
<div class="flex justify-between text-xs text-gray-500 mt-1">
<span>1 (慢)</span>
<span>100 (快)</span>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
更新间隔(毫秒): <span class="text-purple-600 dark:text-purple-400 font-semibold">{{ streamInterval }}ms</span>
</label>
<input
v-model.number="streamInterval"
type="range"
min="10"
max="500"
step="10"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
>
<div class="flex justify-between text-xs text-gray-500 mt-1">
<span>10ms (快)</span>
<span>500ms (慢)</span>
</div>
</div>
</div>
<div class="mt-3 p-2 bg-purple-50 dark:bg-purple-900/20 rounded text-xs text-gray-600 dark:text-gray-400">
💡 提示:字符数越大或间隔越小,渲染速度越快
</div>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
<h3 class="text-sm font-semibold mb-3 text-gray-800 dark:text-gray-200">
渲染配置(用于调试不同模式)
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
<div class="space-y-2">
<div>
<label class="block font-medium mb-1 text-gray-700 dark:text-gray-300">代码块模式</label>
<select
v-model="renderMode"
class="w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-2 py-1"
>
<option value="monaco">
Monaco 编辑器
</option>
<option value="markdown">
MarkdownCodeBlock (stream-markdown)
</option>
<option value="pre">
纯 PreCodeNode
</option>
</select>
</div>
<div class="flex items-center gap-2">
<input id="toggle-code-stream" v-model="codeBlockStream" type="checkbox" class="rounded border-gray-300 dark:border-gray-600">
<label for="toggle-code-stream" class="cursor-pointer">代码块流式渲染</label>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center gap-2">
<input id="toggle-viewport" v-model="viewportPriority" type="checkbox" class="rounded border-gray-300 dark:border-gray-600">
<label for="toggle-viewport" class="cursor-pointer">启用 viewportPriority</label>
</div>
<div class="flex items-center gap-2">
<input id="toggle-batch" v-model="batchRendering" type="checkbox" class="rounded border border-gray-300 dark:border-gray-600">
<label for="toggle-batch" class="cursor-pointer">启用批量渲染 (batchRendering)</label>
</div>
<div class="flex items-center gap-2">
<input id="toggle-typewriter" v-model="typewriter" type="checkbox" class="rounded border-gray-300 dark:border-gray-600">
<label for="toggle-typewriter" class="cursor-pointer">启用打字机过渡 (typewriter)</label>
</div>
<div class="flex items-center gap-2">
<input id="toggle-math" v-model="mathEnabled" type="checkbox" class="rounded border-gray-300 dark:border-gray-600">
<label for="toggle-math" class="cursor-pointer">启用数学 (KaTeX)</label>
</div>
<div class="flex items-center gap-2">
<input id="toggle-debug-parse" v-model="debugParse" type="checkbox" class="rounded border-gray-300 dark:border-gray-600">
<label for="toggle-debug-parse" class="cursor-pointer">调试解析树结构(console)</label>
</div>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 flex-1 overflow-hidden">
<div>
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-200">输入</label>
<textarea v-model="input" rows="18" class="w-full p-3 rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm text-gray-900 dark:text-gray-100 resize-none h-[calc(100%-2rem)]" @paste="handleEditorPaste" />
</div>
<div class="h-full overflow-hidden flex-col flex">
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-200 shrink-0">
预览
<span v-if="streamContent" class="ml-2 text-xs text-purple-600 dark:text-purple-400">
(流式渲染模式 {{ isStreaming ? '- 渲染中...' : '- 已完成' }})
</span>
</label>
<div class="max-w-none p-3 rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 min-h-[14rem] overflow-auto flex-1">
<ClientOnly>
<MarkdownRender
:content="streamContent || input"
:viewport-priority="viewportPriority"
:batch-rendering="batchRendering"
:typewriter="typewriter"
:code-block-stream="codeBlockStream"
:parse-options="{ debug: debugParse }"
/>
</ClientOnly>
</div>
<div class="mt-2 text-xs text-gray-500 break-words shrink-0">
<template v-if="tooLong">
<div class="mb-2 p-2 rounded bg-yellow-50 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200">
内容过长,无法嵌入到 URL。建议在 issue 中粘贴完整输入以便分享。
</div>
<div class="flex gap-2 items-center">
<button class="px-2 py-1 bg-gray-200 dark:bg-gray-700 text-sm rounded" @click="copyRawInput">
复制 Issue 链接
</button>
<button class="px-2 py-1 bg-blue-600 text-white text-sm rounded" @click="openIssueInNewTab">
打开 Issue
</button>
<span class="text-xs text-gray-500">或手动将输入粘贴到 GitHub Issue 中。</span>
</div>
</template>
</div>
<div v-if="notice" class="mt-2">
<div class="p-2 rounded" :class="[noticeType === 'success' ? 'bg-green-50 text-green-800 dark:bg-green-900 dark:text-green-200' : (noticeType === 'error' ? 'bg-red-50 text-red-800 dark:bg-red-900 dark:text-red-200' : 'bg-blue-50 text-blue-800 dark:bg-blue-900 dark:text-blue-200')]">
{{ notice }}
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.app-container {
transition: background-color 0.3s ease;
overflow: hidden;
}
/* Mermaid 块加载时的流光闪烁效果 */
:deep(.is-rendering) {
position: relative;
animation: renderingGlow 2s ease-in-out infinite;
}
@keyframes renderingGlow {
0% {
box-shadow:
0 0 10px rgba(59, 130, 246, 0.4),
0 0 20px rgba(59, 130, 246, 0.2);
}
25% {
box-shadow:
0 0 15px rgba(139, 92, 246, 0.5),
0 0 30px rgba(139, 92, 246, 0.3);
}
50% {
box-shadow:
0 0 20px rgba(236, 72, 153, 0.5),
0 0 40px rgba(236, 72, 153, 0.3);
}
75% {
box-shadow:
0 0 15px rgba(16, 185, 129, 0.5),
0 0 30px rgba(16, 185, 129, 0.3);
}
100% {
box-shadow:
0 0 10px rgba(59, 130, 246, 0.4),
0 0 20px rgba(59, 130, 246, 0.2);
}
}
/* 滑块样式优化 */
input[type="range"]::-webkit-slider-thumb {
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #9333ea;
cursor: pointer;
transition: all 0.2s;
}
input[type="range"]::-webkit-slider-thumb:hover {
background: #7c3aed;
transform: scale(1.2);
}
input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #9333ea;
cursor: pointer;
border: none;
transition: all 0.2s;
}
input[type="range"]::-moz-range-thumb:hover {
background: #7c3aed;
transform: scale(1.2);
}
</style>