feat(ui): add file explorer, TTS UI, views and routing

Add a file tree UI and corresponding composable for local file management.
Introduce TTS menu and player components for voice synthesis integration.
Add new EditorView and DocsView routes and update SettingsPanel view switching.
Enhance Mermaid plugin with improved styling and action buttons.
This commit is contained in:
2026-04-05 23:22:00 +08:00
parent 818baa349a
commit 01b132266a
19 changed files with 2118 additions and 171 deletions

View File

@@ -1,19 +1,10 @@
<script setup>
import { computed, defineAsyncComponent, ref } from 'vue'
import { computed } from 'vue'
import { useSettingsStore } from './stores/settings'
import SettingsPanel from './components/SettingsPanel.vue'
const MilkdownEditor = defineAsyncComponent(() => import('./components/MilkdownEditor.vue'))
const markdown = ref('')
const emit = defineEmits(['update:markdown'])
const settings = useSettingsStore()
function onChange(markdownValue) {
markdown.value = markdownValue
emit('update:markdown', markdownValue)
}
const appStyle = computed(() => {
const style = {}
if (settings.backgroundType === 'warm') {
@@ -52,9 +43,7 @@ const backgroundStyle = computed(() => {
<div class="app-shell" :style="appStyle">
<div v-if="settings.backgroundType === 'image'" class="app-bg-layer" :style="backgroundStyle"></div>
<div class="editor-container">
<MilkdownEditor @update:markdown="onChange" />
</div>
<router-view />
<SettingsPanel />
</div>
@@ -64,17 +53,10 @@ const backgroundStyle = computed(() => {
.app-shell {
position: relative;
width: 100%;
height: 100%;
height: 100vh;
background: var(--app-bg);
color: var(--app-text);
transition: background 0.3s, color 0.3s;
isolation: isolate;
}
.editor-container {
position: relative;
z-index: 1;
height: 100%;
}
</style>

View File

@@ -182,13 +182,19 @@ onUnmounted(() => {
margin: 8px 0;
border-radius: 12px;
border: 1px solid rgba(59, 130, 246, 0.12);
background: rgba(255, 255, 255, 0.78);
background: rgba(255, 255, 255, 0.8);
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06), 0 1px 3px rgba(0, 0, 0, 0.04);
overflow: hidden;
backdrop-filter: blur(10px);
position: relative;
}
:root[data-theme='dark'] .doc-card {
background: rgba(26, 30, 39, 0.8);
border-color: rgba(96, 165, 250, 0.15);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3), 0 1px 3px rgba(0, 0, 0, 0.2);
}
.doc-card__header {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
@@ -196,7 +202,12 @@ onUnmounted(() => {
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid rgba(59, 130, 246, 0.1);
background: rgba(255, 255, 255, 0.6);
background: rgba(255, 255, 255, 0.8);
}
:root[data-theme='dark'] .doc-card__header {
background: rgba(26, 30, 39, 0.8);
border-bottom-color: rgba(96, 165, 250, 0.15);
}
.doc-card__badge {
@@ -225,12 +236,20 @@ onUnmounted(() => {
text-overflow: ellipsis;
}
:root[data-theme='dark'] .doc-card__name {
color: #e5e7eb;
}
.doc-card__time {
margin-top: 2px;
color: #64748b;
font-size: 10px;
}
:root[data-theme='dark'] .doc-card__time {
color: #aeb6c5;
}
.doc-card__actions {
display: flex;
gap: 4px;
@@ -250,6 +269,12 @@ onUnmounted(() => {
transition: all 0.15s ease;
}
:root[data-theme='dark'] .doc-card__btn {
background: rgba(34, 40, 52, 0.5);
border-color: rgba(96, 165, 250, 0.15);
color: #aeb6c5;
}
.doc-card__btn:hover {
background: rgba(59, 130, 246, 0.1);
border-color: rgba(59, 130, 246, 0.25);
@@ -264,17 +289,26 @@ onUnmounted(() => {
.doc-card__body {
padding: 8px 10px;
background: rgba(248, 250, 252, 0.5);
background: rgba(248, 250, 252, 0.8);
}
:root[data-theme='dark'] .doc-card__body {
background: rgba(18, 22, 30, 0.8);
}
.doc-card__editor {
min-height: 48px;
border-radius: 8px;
border: 1px solid rgba(59, 130, 246, 0.08);
background: rgba(255, 255, 255, 0.6);
background: rgba(255, 255, 255, 0.8);
overflow: hidden;
}
:root[data-theme='dark'] .doc-card__editor {
background: rgba(26, 30, 39, 0.8);
border-color: rgba(96, 165, 250, 0.12);
}
.doc-card__editor :deep(.milkdown) {
background: transparent !important;
}

View File

@@ -110,12 +110,18 @@ const downloadDoc = () => {
margin: 8px 0;
border-radius: 10px;
overflow: hidden;
background: rgba(255, 255, 255, 0.72);
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(12px);
border: 1px solid rgba(59, 130, 246, 0.15);
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
}
:root[data-theme='dark'] .doc-block {
background: rgba(26, 30, 39, 0.8);
border-color: rgba(96, 165, 250, 0.15);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2);
}
.doc-block.collapsed .doc-content {
display: none;
}
@@ -124,11 +130,16 @@ const downloadDoc = () => {
display: flex;
align-items: center;
padding: 6px 10px;
background: rgba(255, 255, 255, 0.85);
background: rgba(255, 255, 255, 0.8);
border-bottom: 1px solid rgba(59, 130, 246, 0.12);
gap: 8px;
}
:root[data-theme='dark'] .doc-header {
background: rgba(26, 30, 39, 0.8);
border-bottom-color: rgba(96, 165, 250, 0.15);
}
.doc-icon {
display: flex;
align-items: center;
@@ -146,6 +157,10 @@ const downloadDoc = () => {
white-space: nowrap;
}
:root[data-theme='dark'] .doc-name {
color: #e5e7eb;
}
.doc-actions {
display: flex;
align-items: center;
@@ -175,11 +190,15 @@ const downloadDoc = () => {
.doc-content {
padding: 8px 10px;
background: rgba(248, 250, 252, 0.6);
background: rgba(248, 250, 252, 0.8);
max-height: 240px;
overflow-y: auto;
}
:root[data-theme='dark'] .doc-content {
background: rgba(18, 22, 30, 0.8);
}
.doc-content pre {
margin: 0;
padding: 0;
@@ -190,4 +209,8 @@ const downloadDoc = () => {
white-space: pre-wrap;
word-break: break-word;
}
:root[data-theme='dark'] .doc-content pre {
color: #cbd5e1;
}
</style>

576
src/components/FileTree.vue Normal file
View File

@@ -0,0 +1,576 @@
<script setup>
import { ref } from 'vue'
defineProps({
nodes: { type: Array, required: true },
selectedId: { type: String, default: null },
expandedIds: { type: Set, required: true },
clipboard: { type: Object, default: null },
getFileIcon: { type: Function, required: true }
})
const emit = defineEmits([
'select', 'toggle', 'create-file', 'create-folder',
'rename', 'remove', 'copy', 'cut', 'paste',
'context-menu', 'drop', 'drag-start', 'drag-over'
])
const renameId = ref(null)
const renameValue = ref('')
const creatingInFolder = ref(null)
const creatingType = ref(null)
const creatingName = ref('')
function startRename(node) {
renameId.value = node.id
renameValue.value = node.name
}
function finishRename(node) {
if (renameId.value === node.id && renameValue.value.trim()) {
emit('rename', node.id, renameValue.value.trim())
}
renameId.value = null
renameValue.value = ''
}
function cancelRename() {
renameId.value = null
renameValue.value = ''
}
function startCreate(parentId, type) {
creatingInFolder.value = parentId
creatingType.value = type
creatingName.value = ''
}
function finishCreate() {
if (creatingInFolder.value !== undefined && creatingName.value.trim()) {
if (creatingType.value === 'file') {
emit('create-file', creatingInFolder.value, creatingName.value.trim())
} else {
emit('create-folder', creatingInFolder.value, creatingName.value.trim())
}
}
creatingInFolder.value = null
creatingType.value = null
creatingName.value = ''
}
function cancelCreate() {
creatingInFolder.value = null
creatingType.value = null
creatingName.value = ''
}
function handleContextMenu(event, node) {
event.preventDefault()
event.stopPropagation()
emit('context-menu', event.clientX, event.clientY, node)
}
function handleDrop(event, targetNode) {
event.preventDefault()
event.stopPropagation()
const draggedId = event.dataTransfer.getData('text/plain')
if (draggedId) {
emit('drop', draggedId, targetNode ? targetNode.id : null)
}
}
function isClipped(id) {
return props.clipboard && props.clipboard.node && props.clipboard.node.id === id
}
function getIconClass(type, name) {
if (type === 'folder') return 'icon-folder'
return `icon-file icon-${props.getFileIcon(name)}`
}
</script>
<template>
<div class="file-tree">
<div class="tree-header">
<span class="tree-title">文件</span>
<div class="tree-header-actions">
<button class="header-action-btn" title="新建文件" @click="emit('create-file', null)">
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M3.75 1.5a.25.25 0 00-.25.25v12.5c0 .138.112.25.25.25h8.5a.25.25 0 00.25-.25V4.664a.25.25 0 00-.073-.177l-2.914-2.914a.25.25 0 00-.177-.073H3.75zM3 1.75C3 .784 3.784 0 4.75 0h5.339c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0113 16H4.75A1.75 1.75 0 013 14.25V1.75z"/><path d="M8.5 4V1.5H10a.5.5 0 01.5.5v1.5a.5.5 0 01-.5.5H9a.5.5 0 01-.5-.5zM6 8.5a.5.5 0 01.5-.5h3a.5.5 0 010 1h-3a.5.5 0 01-.5-.5zm.5 2.5a.5.5 0 000 1h3a.5.5 0 000-1h-3z"/></svg>
</button>
<button class="header-action-btn" title="新建文件夹" @click="emit('create-folder', null)">
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M.5 2.5A1.5 1.5 0 012 1h3.5a.5.5 0 01.354.146l1.5 1.5a.5.5 0 00.354.146H13a1.5 1.5 0 011.5 1.5v7.5a1.5 1.5 0 01-1.5 1.5H2a1.5 1.5 0 01-1.5-1.5v-7.5zM6 2v1.5h4.5V2H6zm-2 5a.5.5 0 01.5-.5h5a.5.5 0 010 1h-5a.5.5 0 01-.5-.5zm.5 2.5a.5.5 0 000 1h5a.5.5 0 000-1h-5z"/></svg>
</button>
</div>
</div>
<div
class="tree-content"
@drop.self="handleDrop($event, null)"
@dragover.self="(e) => e.preventDefault()"
>
<template v-if="nodes.length === 0">
<div class="tree-empty">
<p>暂无文件</p>
<div class="tree-empty-actions">
<button @click="startCreate(null, 'file')">+ 新建文件</button>
<button @click="startCreate(null, 'folder')">+ 新建文件夹</button>
</div>
</div>
</template>
<template v-for="node in nodes" :key="node.id">
<TreeNodeItem
:node="node"
:level="0"
:selected-id="selectedId"
:expanded-ids="expandedIds"
:clipboard="clipboard"
:get-icon-class="getIconClass"
:rename-id="renameId"
:rename-value="renameValue"
:creating-in-folder="creatingInFolder"
:creating-type="creatingType"
:creating-name="creatingName"
@select="(id) => emit('select', id)"
@toggle="(id) => emit('toggle', id)"
@start-rename="startRename"
@finish-rename="finishRename"
@cancel-rename="cancelRename"
@start-create="startCreate"
@finish-create="finishCreate"
@cancel-create="cancelCreate"
@context-menu="handleContextMenu"
@drop="handleDrop"
@drag-start="(e, id) => emit('drag-start', e, id)"
@drag-over="(e, id) => emit('drag-over', e, id)"
/>
</template>
</div>
</div>
</template>
<script>
import { h } from 'vue'
export const TreeNodeItem = {
name: 'TreeNodeItem',
props: {
node: { type: Object, required: true },
level: { type: Number, required: true },
selectedId: { type: String, default: null },
expandedIds: { type: Set, required: true },
clipboard: { type: Object, default: null },
getIconClass: { type: Function, required: true },
renameId: { type: String, default: null },
renameValue: { type: String, default: '' },
creatingInFolder: { type: String, default: null },
creatingType: { type: String, default: null },
creatingName: { type: String, default: '' }
},
emits: [
'select', 'toggle', 'start-rename', 'finish-rename', 'cancel-rename',
'start-create', 'finish-create', 'cancel-create',
'context-menu', 'drop', 'drag-start', 'drag-over'
],
setup(props, { emit }) {
function isSelected() {
return props.node.id === props.selectedId
}
function isExpanded() {
return props.expandedIds.has(props.node.id)
}
function isClipped() {
return props.clipboard && props.clipboard.node && props.clipboard.node.id === props.node.id
}
function isRenaming() {
return props.renameId === props.node.id
}
function isCreating() {
return props.creatingInFolder === props.node.id
}
function handleContextMenu(event) {
event.preventDefault()
event.stopPropagation()
emit('context-menu', event.clientX, event.clientY, props.node)
}
function handleDrop(event) {
event.preventDefault()
event.stopPropagation()
const draggedId = event.dataTransfer.getData('text/plain')
if (draggedId) {
emit('drop', draggedId, props.node)
}
}
function handleDragStart(event) {
event.dataTransfer.setData('text/plain', props.node.id)
emit('drag-start', event, props.node.id)
}
function handleDragOver(event) {
event.preventDefault()
emit('drag-over', event, props.node.id)
}
return () => {
const node = props.node
const children = node.type === 'folder' ? (node.children || []) : []
const paddingLeft = `${props.level * 16 + 8}px`
const nodeVNode = h('div', {
class: ['tree-node', { selected: isSelected(), clipped: isClipped() }],
style: { paddingLeft },
onClick: () => emit('select', node.id),
onContextmenu: handleContextMenu,
onDragstart: handleDragStart,
onDragover: handleDragOver,
onDrop: handleDrop,
draggable: 'true'
}, [
node.type === 'folder'
? h('span', {
class: 'chevron',
onClick: (e) => { e.stopPropagation(); emit('toggle', node.id) }
}, isExpanded()
? h('svg', { viewBox: '0 0 16 16', width: 16, height: 16, fill: 'currentColor' }, h('path', { d: 'M5 6l3 3 3-3z' }))
: h('svg', { viewBox: '0 0 16 16', width: 16, height: 16, fill: 'currentColor' }, h('path', { d: 'M6 5l3 3-3 3z' }))
)
: h('span', { class: 'chevron-placeholder' }),
h('span', { class: props.getIconClass(node.type, node.name) }),
isRenaming()
? h('input', {
class: 'rename-input',
value: props.renameValue,
onInput: (e) => { props.renameValue = e.target.value },
onKeydown: (e) => { if (e.key === 'Enter') emit('finish-rename', node); if (e.key === 'Escape') emit('cancel-rename') },
onBlur: () => emit('finish-rename', node),
autofocus: true
})
: h('span', { class: 'node-name' }, node.name),
h('span', { class: 'node-actions' }, [
node.type === 'folder' ? [
h('button', {
class: 'action-btn',
title: '新建文件',
onClick: (e) => { e.stopPropagation(); emit('start-create', node.id, 'file') }
}, h('svg', { viewBox: '0 0 16 16', width: 14, height: 14, fill: 'currentColor' }, h('path', { d: 'M8 2a5.53 5.53 0 00-3.594 1.342c-.766.66-1.321 1.52-1.464 2.383C1.266 6.095 0 7.555 0 9.318 0 11.366 1.708 13 3.781 13h8.906C14.502 13 16 11.57 16 9.773c0-1.636-1.242-2.969-2.834-3.194C12.923 3.999 10.69 2 8 2zm2.354 6H9.698v.656h.656v.688H9.698v.656H9.042v-.656H8.386v-.688h.656V8h-.656V7.344h.656V6.688h.656V7.344h.656v.656zM8 3a4.69 4.69 0 013.293 1.342c.612.586 1.038 1.32 1.143 2.122.16.99.478 1.89 1.39 2.136C15.346 9.02 16 10.37 16 11.773 16 13.17 14.902 14 12.687 14H3.781C2.108 14 1 12.902 1 11.318c0-1.343.96-2.494 2.234-2.652.105-.612.53-1.346 1.143-1.932A4.69 4.69 0 018 3z' }))),
h('button', {
class: 'action-btn',
title: '新建文件夹',
onClick: (e) => { e.stopPropagation(); emit('start-create', node.id, 'folder') }
}, h('svg', { viewBox: '0 0 16 16', width: 14, height: 14, fill: 'currentColor' }, h('path', { d: 'M.75 3a.75.75 0 01.75-.75h4.59c.3 0 .584.12.793.332l.967.968H13.5a.75.75 0 010 1.5H7.19L6.22 4.08A.25.25 0 006.043 4H1.5A.75.75 0 01.75 3zM1 6.75A.75.75 0 011.75 6h12.5a.75.75 0 010 1.5H1.75A.75.75 0 011 6.75zm0 3a.75.75 0 01.75-.75h4.5a.75.75 0 010 1.5h-4.5a.75.75 0 01-.75-.75z' })))
] : null
].filter(Boolean))
].filter(Boolean))
const createVNode = isCreating()
? h('div', {
class: ['tree-node', 'tree-node-new'],
style: { paddingLeft: `${(props.level + 1) * 16 + 8}px` }
}, [
h('span', { class: 'chevron-placeholder' }),
h('span', { class: `icon-file ${props.creatingType === 'folder' ? 'icon-folder' : 'icon-file'}` }),
h('input', {
class: 'rename-input',
value: props.creatingName,
placeholder: props.creatingType === 'file' ? '文件名.md' : '文件夹名',
onInput: (e) => { props.creatingName = e.target.value },
onKeydown: (e) => { if (e.key === 'Enter') emit('finish-create'); if (e.key === 'Escape') emit('cancel-create') },
onBlur: () => emit('finish-create'),
autofocus: true
})
])
: null
const childrenVNodes = (node.type === 'folder' && isExpanded())
? children.map(child => h(TreeNodeItem, {
node: child,
level: props.level + 1,
selectedId: props.selectedId,
expandedIds: props.expandedIds,
clipboard: props.clipboard,
getIconClass: props.getIconClass,
renameId: props.renameId,
renameValue: props.renameValue,
creatingInFolder: props.creatingInFolder,
creatingType: props.creatingType,
creatingName: props.creatingName,
onSelect: (id) => emit('select', id),
onToggle: (id) => emit('toggle', id),
onStartRename: (n) => emit('start-rename', n),
onFinishRename: (n) => emit('finish-rename', n),
onCancelRename: () => emit('cancel-rename'),
onStartCreate: (id, type) => emit('start-create', id, type),
onFinishCreate: () => emit('finish-create'),
onCancelCreate: () => emit('cancel-create'),
onContextMenu: (x, y, n) => emit('context-menu', x, y, n),
onDrop: (id, n) => emit('drop', id, n),
onDragStart: (e, id) => emit('drag-start', e, id),
onDragOver: (e, id) => emit('drag-over', e, id)
}))
: []
return [nodeVNode, createVNode, ...childrenVNodes].filter(Boolean)
}
}
}
</script>
<style scoped>
.file-tree {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: var(--app-bg);
overflow: hidden;
}
.tree-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--panel-border);
min-height: 40px;
flex-shrink: 0;
}
.tree-title {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
color: var(--muted-text);
letter-spacing: 0.05em;
}
.tree-header-actions {
display: flex;
gap: 4px;
}
.header-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
background: none;
color: var(--muted-text);
cursor: pointer;
border-radius: 4px;
}
.header-action-btn:hover {
background: var(--ghost-code-bg);
color: var(--app-text);
}
.tree-content {
flex: 1;
overflow-y: auto;
padding: 4px 0;
min-height: 0;
}
.tree-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 16px;
color: var(--muted-text);
font-size: 13px;
text-align: center;
gap: 12px;
}
.tree-empty p {
margin: 0;
}
.tree-empty-actions {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.tree-empty-actions button {
padding: 6px 12px;
border: 1px dashed var(--panel-border);
background: none;
color: var(--muted-text);
cursor: pointer;
border-radius: 6px;
font-size: 12px;
}
.tree-empty-actions button:hover {
border-color: var(--focus-ring);
color: var(--focus-ring);
background: var(--ghost-code-bg);
}
.tree-node {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
cursor: pointer;
font-size: 13px;
line-height: 28px;
white-space: nowrap;
user-select: none;
position: relative;
}
.tree-node:hover {
background: var(--ghost-code-bg);
}
.tree-node.selected {
background: var(--focus-ring);
color: #fff;
}
.tree-node.selected .chevron,
.tree-node.selected .node-actions,
.tree-node.selected .action-btn {
color: #fff;
}
.tree-node.clipped {
opacity: 0.5;
}
.tree-node-new {
background: var(--ghost-code-bg);
border: 1px dashed var(--focus-ring);
margin: 1px 4px;
border-radius: 4px;
}
.chevron {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: var(--muted-text);
cursor: pointer;
flex-shrink: 0;
}
.chevron:hover {
color: var(--app-text);
}
.chevron-placeholder {
width: 16px;
flex-shrink: 0;
}
.icon-file,
.icon-folder {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
flex-shrink: 0;
}
.icon-folder::before {
content: '';
display: block;
width: 16px;
height: 16px;
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 16 16' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='%2354aeff' d='M0 2.5A1.5 1.5 0 011.5 1h2.793a.5.5 0 01.353.146l1.5 1.5a.5.5 0 00.354.146H13.5A1.5 1.5 0 0115 4.5v7.5a1.5 1.5 0 01-1.5 1.5h-11A1.5 1.5 0 011 12v-9.5z'/%3E%3C/svg%3E") no-repeat center;
background-size: contain;
}
[data-theme='dark'] .icon-folder::before {
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 16 16' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='%2358a6ff' d='M0 2.5A1.5 1.5 0 011.5 1h2.793a.5.5 0 01.353.146l1.5 1.5a.5.5 0 00.354.146H13.5A1.5 1.5 0 0115 4.5v7.5a1.5 1.5 0 01-1.5 1.5h-11A1.5 1.5 0 011 12v-9.5z'/%3E%3C/svg%3E") no-repeat center;
background-size: contain;
}
.icon-markdown::before {
content: '';
display: block;
width: 16px;
height: 16px;
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 16 16' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='%236e7781' d='M14.85 3H1.15C.52 3 0 3.52 0 4.15v7.69C0 12.48.52 13 1.15 13h13.69c.64 0 1.15-.52 1.15-1.15V4.15C16 3.52 15.48 3 14.85 3zM9 11H7.5V8.5L6.25 10l-1.25-1.5V11H3.5V5H5l1.25 1.5L7.5 5H9v6zm4-2.5c0 .28-.22.5-.5.5h-1v1c0 .28-.22.5-.5.5s-.5-.22-.5-.5v-1h-1c-.28 0-.5-.22-.5-.5s.22-.5.5-.5h1v-1c0-.28.22-.5.5-.5s.5.22.5.5v1h1c.28 0 .5.22.5.5z'/%3E%3C/svg%3E") no-repeat center;
background-size: contain;
}
.icon-text::before,
.icon-json::before,
.icon-file::before {
content: '';
display: block;
width: 16px;
height: 16px;
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 16 16' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='%236e7781' d='M3.75 1.5a.25.25 0 00-.25.25v12.5c0 .138.112.25.25.25h8.5a.25.25 0 00.25-.25V4.664a.25.25 0 00-.073-.177l-2.914-2.914a.25.25 0 00-.177-.073H3.75zM3 1.75C3 .784 3.784 0 4.75 0h5.339c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0113 16H4.75A1.75 1.75 0 013 14.25V1.75z'/%3E%3C/svg%3E") no-repeat center;
background-size: contain;
}
.node-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.rename-input {
flex: 1;
padding: 2px 6px;
border: 1px solid var(--focus-ring);
border-radius: 4px;
background: var(--app-bg);
color: var(--app-text);
font-size: 13px;
outline: none;
height: 22px;
}
.node-actions {
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 0.15s;
}
.tree-node:hover .node-actions {
opacity: 1;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: none;
background: none;
color: var(--muted-text);
cursor: pointer;
border-radius: 4px;
}
.action-btn:hover {
background: var(--panel-border);
color: var(--app-text);
}
.tree-content::-webkit-scrollbar {
width: 8px;
}
.tree-content::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 4px;
}
.tree-content::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
}
</style>

View File

@@ -164,16 +164,20 @@ import { Selection } from '@milkdown/prose/state'
import { undo, redo, undoDepth, redoDepth } from '@milkdown/prose/history'
import { copilotPlugin, copilotConfigCtx, copilotGhostMark, setCopilotEnabled, interruptCopilot, COPILOT_PLUGIN_KEY, SIZE_LIMIT, checkSizeLimit, clearGhostSuggestion } from '../plugins/copilotPlugin'
import { docBlockNode, docBlockRemark, docBlockView } from '../plugins/docBlockPlugin'
import { mermaidRenderPreview, codeBlockConfig } from '../plugins/mermaidPlugin'
import { fetchSuggestion } from '../utils/api.js'
import { mermaidRenderPreview, refreshMermaidPreviews, codeBlockConfig } from '../plugins/mermaidPlugin'
import { fetchSuggestion, fetchTTS } from '../utils/api.js'
import { useSettingsStore } from '../stores/settings'
import { useTheme } from '../composables/useTheme.js'
import { OCR_URL, EXPORT_PDF_URL } from '../utils/config.js'
import TTSMenu from './TTSMenu.vue'
import TTSPlayer from './TTSPlayer.vue'
import { convertFileToMarkdown } from '../utils/convert.js'
import { setOcrCache, clearOcrCache, clearAllOcrCache, IMAGE_SIZE_LIMIT, calculateImageHash, getOcrByHash, setOcrByHash } from '../utils/ocrCache.js'
import { DOC_BLOCK_NODE_TYPE, getDocTypeFromFilename, isSupportedDocFile, transformDocBlockMarkdownForClipboard, transformLegacyDocBlocksForExport, transformSpecialDocBlocksToLegacy } from '../utils/docBlock.js'
const emit = defineEmits(['update:markdown'])
const settings = useSettingsStore()
const { isDark } = useTheme()
const t = (key) => settings.t[key]
const initialMarkdown = computed(() => settings.initialMarkdown)
@@ -325,6 +329,13 @@ watch(
}
)
watch(
() => isDark.value,
() => {
refreshMermaidPreviews()
}
)
const revokeObjectUrl = (url) => {
if (!objectUrls.has(url)) return
URL.revokeObjectURL(url)
@@ -841,11 +852,7 @@ const handleUpload = async (event) => {
const results = []
const errors = []
for (let index = 0; index < docFiles.length; index++) {
const file = docFiles[index]
uploadProgress.value = { current: index + 1, total: docFiles.length, filename: file.name }
try {
const parsePromises = docFiles.map(async (file, index) => {
const docType = getDocTypeFromFilename(file.name)
let content = ''
@@ -861,17 +868,20 @@ const handleUpload = async (event) => {
throw new Error('文档解析结果为空')
}
results.push({
docType,
docName: file.name || `document.${docType}`,
content,
index,
return { docType, docName: file.name || `document.${docType}`, content, index }
})
} catch (e) {
const message = e instanceof Error ? e.message : ''
errors.push({ filename: file.name, message })
}
const settled = await Promise.allSettled(parsePromises)
settled.forEach((result, idx) => {
uploadProgress.value = { current: idx + 1, total: docFiles.length, filename: docFiles[idx].name }
if (result.status === 'fulfilled') {
results.push(result.value)
} else {
const message = result.reason instanceof Error ? result.reason.message : String(result.reason)
errors.push({ filename: docFiles[idx].name, message })
}
})
uploadProgress.value = null

View File

@@ -1,11 +1,14 @@
<script setup>
import { ref, watch, computed, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useSettingsStore } from '../stores/settings'
import { useTheme } from '../composables/useTheme'
import packageJson from '../../package.json'
const store = useSettingsStore()
const { setTheme } = useTheme()
const router = useRouter()
const route = useRoute()
const VERSION = packageJson.version || '0.0.0'
@@ -129,6 +132,11 @@ const handleImageUpload = (event) => {
}
const t = (key) => store.t[key]
const switchView = (view) => {
router.push(view === 'editor' ? '/' : '/docs')
closePanel()
}
</script>
<template>
@@ -197,6 +205,38 @@ const t = (key) => store.t[key]
</div>
</section>
<!-- View Switch -->
<section class="settings-section">
<h3>{{ t('view') || '视图' }}</h3>
<div class="view-switch">
<button
class="view-btn"
:class="{ active: route.path === '/' }"
@click="switchView('editor')"
>
<svg viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9"></path>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
</svg>
<span>{{ t('editor') || '编辑器' }}</span>
</button>
<button
class="view-btn"
:class="{ active: route.path === '/docs' }"
@click="switchView('docs')"
>
<svg viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
<span>{{ t('docs') || '文档' }}</span>
</button>
</div>
</section>
<!-- Model Section -->
<section class="settings-section">
<h3>{{ t('modelIntelligence') }}</h3>
@@ -311,16 +351,15 @@ const t = (key) => store.t[key]
.settings-panel {
position: fixed;
top: 10vh;
bottom: auto;
top: 0;
bottom: 0;
left: 0;
width: 350px;
max-height: 80vh;
background: var(--panel-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-right: 1px solid var(--panel-border);
border-radius: 0 12px 12px 0;
border-radius: 0 8px 8px 0;
box-shadow: var(--panel-shadow);
z-index: 10000;
transform: translateX(-100%);
@@ -351,8 +390,8 @@ const t = (key) => store.t[key]
/* Mobile Fullscreen */
@media (max-width: 640px) {
.settings-panel {
top: 10vh;
max-height: 80vh;
top: 0;
bottom: 0;
width: 100%;
border-right: none;
border-radius: 0;
@@ -568,5 +607,38 @@ const t = (key) => store.t[key]
font-size: 0.8rem;
opacity: 0.7;
}
.view-switch {
display: flex;
gap: 8px;
}
.view-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--panel-border);
background: var(--ghost-code-bg);
color: var(--muted-text);
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.view-btn:hover {
color: var(--app-text);
border-color: var(--focus-ring);
}
.view-btn.active {
background: var(--app-bg);
color: var(--app-text);
border-color: var(--focus-ring);
font-weight: 600;
}
</style>

100
src/components/TTSMenu.vue Normal file
View File

@@ -0,0 +1,100 @@
<template>
<Teleport to="body">
<Transition name="tts-menu-fade">
<div
v-if="visible"
class="tts-menu"
:style="menuStyle"
@mousedown.stop
>
<button class="tts-menu__btn" @click="$emit('speak')" :disabled="loading">
<svg v-if="!loading" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 5L6 9H2v6h4l5 4V5z"/>
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
</svg>
<svg v-else class="tts-menu__spinner" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>
{{ loading ? '生成中...' : '朗读' }}
</button>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
visible: { type: Boolean, default: false },
x: { type: Number, default: 0 },
y: { type: Number, default: 0 },
loading: { type: Boolean, default: false },
})
defineEmits(['speak'])
const menuStyle = computed(() => ({
left: `${props.x}px`,
top: `${props.y}px`,
transform: 'translate(-50%, -100%)',
}))
</script>
<style scoped>
.tts-menu {
position: fixed;
z-index: 100000;
pointer-events: auto;
}
.tts-menu__btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border: 1px solid var(--panel-border);
border-radius: 20px;
background: var(--panel-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
color: var(--app-text);
font-size: 13px;
font-weight: 500;
cursor: pointer;
box-shadow: var(--panel-shadow);
white-space: nowrap;
transition: all 0.15s ease;
}
.tts-menu__btn:hover:not(:disabled) {
background: var(--btn-hover-bg);
color: var(--btn-hover-fg);
border-color: var(--btn-hover-bg);
}
.tts-menu__btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.tts-menu__spinner {
animation: tts-spin 1s linear infinite;
}
@keyframes tts-spin {
to { transform: rotate(360deg); }
}
.tts-menu-fade-enter-active,
.tts-menu-fade-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.tts-menu-fade-enter-from,
.tts-menu-fade-leave-to {
opacity: 0;
transform: translate(-50%, calc(-100% + 4px));
}
</style>

View File

@@ -0,0 +1,432 @@
<template>
<Teleport to="body">
<Transition name="tts-player-slide">
<div v-if="visible" class="tts-player">
<div class="tts-player__inner">
<div class="tts-player__controls">
<button class="tts-player__btn" @click="skipBackward" title="快退5秒">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 4v6h6"/><path d="M11 12a7 7 0 0 0-7-7"/><path d="M11 12l-3-3"/>
<text x="14" y="16" font-size="8" fill="currentColor" stroke="none">5</text>
</svg>
</button>
<button class="tts-player__btn tts-player__btn--primary" @click="togglePlay" :title="isPlaying ? '暂停' : '播放'">
<svg v-if="!isPlaying" width="20" height="20" viewBox="0 0 24 24" fill="currentColor" stroke="none">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
<svg v-else width="20" height="20" viewBox="0 0 24 24" fill="currentColor" stroke="none">
<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>
</svg>
</button>
<button class="tts-player__btn" @click="skipForward" title="快进5秒">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M23 4v6h-6"/><path d="M13 12a7 7 0 0 1 7-7"/><path d="M13 12l3-3"/>
<text x="4" y="16" font-size="8" fill="currentColor" stroke="none">5</text>
</svg>
</button>
</div>
<div class="tts-player__progress">
<span class="tts-player__time">{{ currentTimeStr }}</span>
<div class="tts-player__bar" @click="seek">
<div class="tts-player__bar-fill" :style="{ width: progressPercent + '%' }">
<div class="tts-player__bar-thumb"></div>
</div>
</div>
<span class="tts-player__time">{{ durationStr }}</span>
</div>
<div class="tts-player__settings">
<div class="tts-player__volume">
<button class="tts-player__btn" @click="toggleMute" :title="isMuted ? '取消静音' : '静音'">
<svg v-if="!isMuted && volume > 0.5" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
</svg>
<svg v-else-if="!isMuted" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
</svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 5L6 9H2v6h4l5 4V5z"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/>
</svg>
</button>
<input type="range" class="tts-player__slider" v-model.number="volume" min="0" max="1" step="0.05" title="音量">
</div>
<select class="tts-player__speed-select" v-model.number="playbackRate" title="播放速度">
<option value="0.5">0.5x</option>
<option value="0.75">0.75x</option>
<option value="1">1x</option>
<option value="1.25">1.25x</option>
<option value="1.5">1.5x</option>
<option value="2">2x</option>
</select>
<button class="tts-player__btn" @click="downloadAudio" title="下载音频">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</button>
<button class="tts-player__btn tts-player__btn--close" @click="$emit('close')" title="关闭">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
</div>
<audio ref="audioRef" :src="audioSrc" @timeupdate="onTimeUpdate" @ended="onEnded" @loadedmetadata="onLoadedMetadata"></audio>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
const props = defineProps({
visible: { type: Boolean, default: false },
audioBase64: { type: String, default: '' },
format: { type: String, default: 'wav' },
durationMs: { type: Number, default: 0 },
})
const emit = defineEmits(['close'])
const audioRef = ref(null)
const isPlaying = ref(false)
const currentTime = ref(0)
const duration = ref(0)
const volume = ref(1)
const isMuted = ref(false)
const playbackRate = ref(1)
let blobUrl = ''
const audioSrc = computed(() => {
if (!props.audioBase64) return ''
if (blobUrl) URL.revokeObjectURL(blobUrl)
const byteChars = atob(props.audioBase64)
const byteNumbers = new Array(byteChars.length)
for (let i = 0; i < byteChars.length; i++) {
byteNumbers[i] = byteChars.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
const blob = new Blob([byteArray], { type: `audio/${props.format}` })
blobUrl = URL.createObjectURL(blob)
return blobUrl
})
const currentTimeStr = computed(() => formatTime(currentTime.value))
const durationStr = computed(() => formatTime(duration.value))
const progressPercent = computed(() => {
if (!duration.value) return 0
return Math.min(100, (currentTime.value / duration.value) * 100)
})
function formatTime(seconds) {
if (!seconds || !isFinite(seconds)) return '0:00'
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
function togglePlay() {
const audio = audioRef.value
if (!audio) return
if (isPlaying.value) {
audio.pause()
} else {
audio.play()
}
}
function skipBackward() {
const audio = audioRef.value
if (!audio) return
audio.currentTime = Math.max(0, audio.currentTime - 5)
}
function skipForward() {
const audio = audioRef.value
if (!audio) return
audio.currentTime = Math.min(duration.value, audio.currentTime + 5)
}
function seek(event) {
const audio = audioRef.value
if (!audio || !duration.value) return
const rect = event.currentTarget.getBoundingClientRect()
const ratio = (event.clientX - rect.left) / rect.width
audio.currentTime = Math.max(0, Math.min(duration.value, ratio * duration.value))
}
function toggleMute() {
isMuted.value = !isMuted.value
if (audioRef.value) {
audioRef.value.muted = isMuted.value
}
}
function downloadAudio() {
if (!blobUrl) return
const a = document.createElement('a')
a.href = blobUrl
a.download = `tts-${Date.now()}.${props.format}`
a.click()
}
function onTimeUpdate() {
const audio = audioRef.value
if (!audio) return
currentTime.value = audio.currentTime
}
function onLoadedMetadata() {
const audio = audioRef.value
if (!audio) return
duration.value = audio.duration || (props.durationMs / 1000)
audio.volume = volume.value
audio.playbackRate = playbackRate.value
audio.play()
}
function onEnded() {
isPlaying.value = false
currentTime.value = 0
}
watch(isPlaying, (val) => {
const audio = audioRef.value
if (!audio) return
if (val) {
audio.play().catch(() => { isPlaying.value = false })
} else {
audio.pause()
}
})
watch(volume, (val) => {
if (audioRef.value) {
audioRef.value.volume = val
isMuted.value = val === 0
}
})
watch(playbackRate, (val) => {
if (audioRef.value) {
audioRef.value.playbackRate = val
}
})
watch(() => props.visible, (val) => {
if (val) {
isPlaying.value = true
currentTime.value = 0
} else {
if (audioRef.value) {
audioRef.value.pause()
audioRef.value.currentTime = 0
}
isPlaying.value = false
}
})
onUnmounted(() => {
if (audioRef.value) {
audioRef.value.pause()
}
if (blobUrl) {
URL.revokeObjectURL(blobUrl)
blobUrl = ''
}
})
</script>
<style scoped>
.tts-player {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 99999;
padding: 8px 16px;
background: var(--panel-bg);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-top: 1px solid var(--panel-border);
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.1);
}
.tts-player__inner {
display: flex;
align-items: center;
gap: 12px;
max-width: 1200px;
margin: 0 auto;
}
.tts-player__controls {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.tts-player__btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 50%;
background: transparent;
color: var(--app-text);
cursor: pointer;
transition: all 0.15s ease;
}
.tts-player__btn:hover {
background: rgba(128, 128, 128, 0.15);
}
.tts-player__btn--primary {
width: 36px;
height: 36px;
background: var(--btn-hover-bg);
color: var(--btn-hover-fg);
}
.tts-player__btn--primary:hover {
filter: brightness(1.1);
}
.tts-player__btn--close:hover {
background: rgba(220, 38, 38, 0.15);
color: var(--danger-text);
}
.tts-player__progress {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.tts-player__time {
font-size: 11px;
color: var(--muted-text);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
min-width: 36px;
}
.tts-player__bar {
flex: 1;
height: 4px;
background: rgba(128, 128, 128, 0.2);
border-radius: 2px;
cursor: pointer;
position: relative;
transition: height 0.1s ease;
}
.tts-player__bar:hover {
height: 6px;
}
.tts-player__bar:hover .tts-player__bar-thumb {
opacity: 1;
}
.tts-player__bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--btn-hover-bg), #60a5fa);
border-radius: 2px;
position: relative;
transition: width 0.1s linear;
}
.tts-player__bar-thumb {
position: absolute;
right: -5px;
top: 50%;
transform: translateY(-50%);
width: 10px;
height: 10px;
background: #fff;
border-radius: 50%;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
opacity: 0;
transition: opacity 0.1s ease;
}
.tts-player__settings {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.tts-player__volume {
display: flex;
align-items: center;
gap: 4px;
}
.tts-player__slider {
width: 60px;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: rgba(128, 128, 128, 0.2);
border-radius: 2px;
outline: none;
cursor: pointer;
}
.tts-player__slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
background: var(--btn-hover-bg);
border-radius: 50%;
cursor: pointer;
}
.tts-player__slider::-moz-range-thumb {
width: 12px;
height: 12px;
background: var(--btn-hover-bg);
border-radius: 50%;
border: none;
cursor: pointer;
}
.tts-player__speed-select {
padding: 2px 6px;
border: 1px solid var(--panel-border);
border-radius: 4px;
background: transparent;
color: var(--app-text);
font-size: 11px;
cursor: pointer;
outline: none;
}
.tts-player__speed-select:hover {
border-color: var(--btn-hover-bg);
}
.tts-player-slide-enter-active,
.tts-player-slide-leave-active {
transition: transform 0.25s ease, opacity 0.2s ease;
}
.tts-player-slide-enter-from,
.tts-player-slide-leave-to {
transform: translateY(100%);
opacity: 0;
}
</style>

View File

@@ -0,0 +1,362 @@
import { ref, computed, watch } from 'vue'
const STORAGE_KEY = 'llm-in-text-file-system'
const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50MB
const MAX_FILES = 100
const MAX_FOLDERS = 50
function generateId() {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 9)
}
function countFilesAndFolders(nodes) {
let files = 0
let folders = 0
function traverse(items) {
for (const item of items) {
if (item.type === 'folder') {
folders++
traverse(item.children || [])
} else {
files++
}
}
}
traverse(nodes)
return { files, folders }
}
function findNode(nodes, id) {
for (const node of nodes) {
if (node.id === id) return node
if (node.type === 'folder') {
const found = findNode(node.children || [], id)
if (found) return found
}
}
return null
}
function findParent(nodes, id, parent = null) {
for (const node of nodes) {
if (node.id === id) return parent
if (node.type === 'folder') {
const found = findParent(node.children || [], id, node)
if (found !== undefined) return found
}
}
return undefined
}
function removeNode(nodes, id) {
return nodes.filter(n => n.id !== id).map(n => {
if (n.type === 'folder') {
return { ...n, children: removeNode(n.children || [], id) }
}
return n
})
}
function getPath(nodes, id, path = []) {
for (const node of nodes) {
if (node.id === id) return [...path, node]
if (node.type === 'folder') {
const found = getPath(node.children || [], id, [...path, node])
if (found) return found
}
}
return null
}
export function useFileSystem() {
const tree = ref([])
const selectedId = ref(null)
const expandedIds = ref(new Set())
const clipboard = ref(null) // { mode: 'copy' | 'cut', node: {...} }
const contextMenu = ref(null) // { x, y, node }
const error = ref(null)
function load() {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
tree.value = JSON.parse(stored)
}
} catch {
tree.value = []
}
}
function save() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(tree.value))
} catch {
error.value = '存储空间不足'
}
}
watch(tree, save, { deep: true })
function createFile(parentId, name, content = '') {
const { files } = countFilesAndFolders(tree.value)
if (files >= MAX_FILES) {
error.value = `文件数量已达上限(${MAX_FILES}个)`
return false
}
if (new Blob([content]).size > MAX_FILE_SIZE) {
error.value = '文件大小不能超过 50MB'
return false
}
const newFile = {
id: generateId(),
name,
type: 'file',
content,
parentId,
createdAt: Date.now(),
updatedAt: Date.now()
}
if (!parentId) {
tree.value.push(newFile)
} else {
const parent = findNode(tree.value, parentId)
if (parent && parent.type === 'folder') {
parent.children = parent.children || []
parent.children.push(newFile)
}
}
error.value = null
return true
}
function createFolder(parentId, name) {
const { folders } = countFilesAndFolders(tree.value)
if (folders >= MAX_FOLDERS) {
error.value = `文件夹数量已达上限(${MAX_FOLDERS}个)`
return false
}
const newFolder = {
id: generateId(),
name,
type: 'folder',
children: [],
parentId,
createdAt: Date.now(),
updatedAt: Date.now()
}
if (!parentId) {
tree.value.push(newFolder)
} else {
const parent = findNode(tree.value, parentId)
if (parent && parent.type === 'folder') {
parent.children = parent.children || []
parent.children.push(newFolder)
}
}
error.value = null
return true
}
function rename(id, newName) {
const node = findNode(tree.value, id)
if (node) {
node.name = newName
node.updatedAt = Date.now()
error.value = null
return true
}
return false
}
function remove(id) {
tree.value = removeNode(tree.value, id)
if (selectedId.value === id) selectedId.value = null
error.value = null
}
function select(id) {
selectedId.value = id
const node = findNode(tree.value, id)
if (node && node.type === 'folder') {
toggleFolder(id)
}
}
function toggleFolder(id) {
const node = findNode(tree.value, id)
if (!node || node.type !== 'folder') return
const set = new Set(expandedIds.value)
if (set.has(id)) {
set.delete(id)
} else {
set.add(id)
}
expandedIds.value = set
}
function copy(id) {
const node = findNode(tree.value, id)
if (node) {
clipboard.value = { mode: 'copy', node: JSON.parse(JSON.stringify(node)) }
}
}
function cut(id) {
const node = findNode(tree.value, id)
if (node) {
clipboard.value = { mode: 'cut', node: JSON.parse(JSON.stringify(node)) }
}
}
function paste(targetParentId) {
if (!clipboard.value) return
const { mode, node } = clipboard.value
if (mode === 'cut') {
const oldParent = findParent(tree.value, node.id)
if (oldParent) {
oldParent.children = (oldParent.children || []).filter(c => c.id !== node.id)
} else {
tree.value = tree.value.filter(n => n.id !== node.id)
}
node.parentId = targetParentId || null
if (targetParentId) {
const target = findNode(tree.value, targetParentId)
if (target && target.type === 'folder') {
target.children = target.children || []
target.children.push(node)
}
} else {
tree.value.push(node)
}
clipboard.value = null
} else {
const { files, folders } = countFilesAndFolders(tree.value)
function countInNode(n) {
let f = 0, fl = 0
if (n.type === 'folder') {
fl = 1
for (const c of (n.children || [])) {
const sub = countInNode(c)
f += sub.f
fl += sub.fl
}
} else {
f = 1
}
return { f, fl }
}
const counts = countInNode(node)
if (files + counts.f > MAX_FILES) {
error.value = `文件数量将达上限`
return
}
if (folders + counts.fl > MAX_FOLDERS) {
error.value = `文件夹数量将达上限`
return
}
function cloneWithNewIds(n) {
const clone = { ...n, id: generateId(), createdAt: Date.now(), updatedAt: Date.now() }
if (clone.type === 'folder') {
clone.children = (n.children || []).map(c => cloneWithNewIds(c))
}
return clone
}
const cloned = cloneWithNewIds(node)
cloned.parentId = targetParentId || null
if (targetParentId) {
const target = findNode(tree.value, targetParentId)
if (target && target.type === 'folder') {
target.children = target.children || []
target.children.push(cloned)
}
} else {
tree.value.push(cloned)
}
}
error.value = null
}
function canPaste() {
return clipboard.value !== null
}
function clearClipboard() {
clipboard.value = null
}
function getSelectedNode() {
return selectedId.value ? findNode(tree.value, selectedId.value) : null
}
function getBreadcrumbPath(id) {
return getPath(tree.value, id) || []
}
function showContextMenu(x, y, node) {
contextMenu.value = { x, y, node }
}
function hideContextMenu() {
contextMenu.value = null
}
function getExtension(name) {
const parts = name.split('.')
return parts.length > 1 ? parts.pop().toLowerCase() : ''
}
function getFileIcon(name) {
const ext = getExtension(name)
const iconMap = {
md: 'markdown',
txt: 'text',
json: 'json',
js: 'javascript',
ts: 'typescript',
css: 'css',
html: 'html',
py: 'python',
vue: 'vue',
xml: 'xml',
yaml: 'yaml',
yml: 'yaml',
csv: 'csv',
log: 'log',
sql: 'sql'
}
return iconMap[ext] || 'file'
}
return {
tree,
selectedId,
expandedIds,
clipboard,
contextMenu,
error,
load,
createFile,
createFolder,
rename,
remove,
select,
toggleFolder,
copy,
cut,
paste,
canPaste,
clearClipboard,
getSelectedNode,
getBreadcrumbPath,
showContextMenu,
hideContextMenu,
getFileIcon,
getExtension,
MAX_FILE_SIZE,
MAX_FILES,
MAX_FOLDERS
}
}

View File

@@ -1,5 +1,6 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './style.css'
import { createPinia } from 'pinia'
@@ -8,6 +9,7 @@ import '@milkdown/crepe/theme/frame.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
if (import.meta.env.PROD && 'serviceWorker' in navigator && false) {

View File

@@ -1,4 +1,4 @@
import { codeBlockConfig } from '@milkdown/kit/component/code-block'
import { codeBlockConfig } from '@milkdown/kit/component/code-block'
import mermaid from 'mermaid'
let mermaidReadyTheme = ''
@@ -24,14 +24,37 @@ function ensureMermaid() {
const theme = getMermaidTheme()
if (mermaidReadyTheme === theme) return
const dark = window.matchMedia?.('(prefers-color-scheme: dark)').matches
const dark = theme === 'dark'
mermaid.initialize({
startOnLoad: false,
theme: theme || (dark ? 'dark' : 'default'),
theme: dark ? 'dark' : 'base',
securityLevel: 'loose',
fontFamily: 'inherit',
flowchart: {
htmlLabels: false,
flowchart: { htmlLabels: false },
themeVariables: dark ? {
primaryColor: '#1e2d45',
primaryTextColor: '#c9d6e8',
primaryBorderColor: '#3b5278',
lineColor: '#5a7aa8',
secondaryColor: '#162236',
tertiaryColor: '#0f1926',
edgeLabelBackground: '#1a2a40',
clusterBkg: '#111e2e',
titleColor: '#c9d6e8',
nodeBorder: '#3b5278',
mainBkg: '#1e2d45',
} : {
primaryColor: '#e8f0fe',
primaryTextColor: '#1e3a5f',
primaryBorderColor: '#93b4d9',
lineColor: '#4a7cb5',
secondaryColor: '#dbeafe',
tertiaryColor: '#f0f7ff',
edgeLabelBackground: '#f0f7ff',
clusterBkg: '#f5f8ff',
titleColor: '#1e3a5f',
nodeBorder: '#93b4d9',
mainBkg: '#e8f0fe',
},
})
mermaidReadyTheme = theme
@@ -69,17 +92,26 @@ function makeMermaidFilename() {
}
function buildMermaidPreviewMarkup(code: string, token: number) {
const encoded = encodeMermaidCode(code)
// 剥离首尾的 ```mermaid 或 ``` 标识符,防止其被误认为图表节点
const cleanCode = code
.replace(/^```[a-z]*\s*\n?/i, '')
.replace(/\n?```\s*$/i, '')
.trim()
const encoded = encodeMermaidCode(cleanCode)
const filename = makeMermaidFilename()
const zoomSvg = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>`
const dlSvg = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`
return `
<div class="mermaid-block" data-mermaid-code="${encoded}" data-mermaid-token="${token}">
<div class="mermaid-controls">
<button type="button" class="mermaid-action-btn" data-mermaid-action="zoom" disabled>Zoom</button>
<button type="button" class="mermaid-action-btn" data-mermaid-action="download" data-mermaid-filename="${filename}" disabled>Download PNG</button>
<button type="button" class="mermaid-action-btn mermaid-zoom-btn" data-mermaid-action="zoom" disabled aria-label="放大查看">${zoomSvg}</button>
<button type="button" class="mermaid-action-btn mermaid-download-btn" data-mermaid-action="download" data-mermaid-filename="${filename}" disabled aria-label="下载图表">${dlSvg}<span>下载</span></button>
</div>
<div class="mermaid-inner">
<div class="mermaid-loading">...</div>
<div class="mermaid-loading"><span></span><span></span><span></span></div>
</div>
</div>`.trim()
}
@@ -102,7 +134,6 @@ function setMermaidActionsState(block: HTMLElement, payload: MermaidImagePayload
}
if (action === 'download') {
node.textContent = 'Download PNG'
if (payload) {
node.removeAttribute('disabled')
node.setAttribute('data-mermaid-url', payload.downloadUrl || payload.previewUrl)
@@ -130,6 +161,7 @@ function setMermaidActionsState(block: HTMLElement, payload: MermaidImagePayload
}
function getSvgSize(svg: string) {
// 优先读取 viewBox (取第三、四个值作为宽高)
const viewBox = svg.match(/viewBox\s*=\s*["']\s*[-\d.]+\s+[-\d.]+\s+([-\d.]+)\s+([-\d.]+)\s*["']/i)
if (viewBox) {
const width = Number(viewBox[1])
@@ -139,14 +171,16 @@ function getSvgSize(svg: string) {
}
}
const widthAttr = svg.match(/width\s*=\s*["']([-\d.]+)(px)?["']/i)
const heightAttr = svg.match(/height\s*=\s*["']([-\d.]+)(px)?["']/i)
const width = widthAttr ? Number(widthAttr[1]) : 960
const height = heightAttr ? Number(heightAttr[1]) : 540
return {
width: Number.isFinite(width) && width > 0 ? width : 960,
height: Number.isFinite(height) && height > 0 ? height : 540,
// 次选读取数字像素属性
const widthAttr = svg.match(/\bwidth\s*=\s*["']([\d.]+)(?:px)?["']/i)
const heightAttr = svg.match(/\bheight\s*=\s*["']([\d.]+)(?:px)?["']/i)
const attrW = widthAttr ? Number(widthAttr[1]) : 0
const attrH = heightAttr ? Number(heightAttr[1]) : 0
if (attrW > 0 && attrH > 0) {
return { width: attrW, height: attrH }
}
return { width: 960, height: 540 }
}
function svgToDataUrl(svg: string) {
@@ -159,6 +193,38 @@ function stripExternalSvgResources(svg: string) {
.replace(/url\(\s*["']?https?:\/\/[^"')]*["']?\s*\)/gi, 'none')
}
/** 给 SVG viewBox 四周加 padding同步更新 width/height 并注入字体样式防止截断 */
function padSvgViewBox(svg: string, pad = 48): string {
let result = svg
// 注入显式字体样式,确保测量与渲染一致
const styleInject = `
<style>
svg { font-family: 'Inter', system-ui, sans-serif !important; }
.node text, .edgeLabel text { font-family: 'Inter', system-ui, sans-serif !important; }
</style>`
if (result.includes('</style>')) {
result = result.replace('</style>', ` svg { font-family: 'Inter', system-ui, sans-serif !important; }\n .node text, .edgeLabel text { font-family: 'Inter', system-ui, sans-serif !important; }\n</style>`)
} else {
result = result.replace(/>/, `>${styleInject}`)
}
// 匹配 viewBox
const vbMatch = result.match(/viewBox\s*=\s*["']\s*([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s*["']/i)
if (vbMatch) {
const x = parseFloat(vbMatch[1]) - pad
const y = parseFloat(vbMatch[2]) - pad
const w = parseFloat(vbMatch[3]) + pad * 2
const h = parseFloat(vbMatch[4]) + pad * 2
result = result.replace(vbMatch[0], `viewBox="${x} ${y} ${w} ${h}"`)
// 覆盖外层 width/height 为实际像素值
result = result.replace(/\bwidth\s*=\s*["'][^"']*["']/i, `width="${w}"`)
result = result.replace(/\bheight\s*=\s*["'][^"']*["']/i, `height="${h}"`)
}
return result
}
function getRasterDpr(width: number, height: number) {
const rawDpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1
const baseDpr = Math.min(3, Math.max(1, rawDpr))
@@ -196,7 +262,8 @@ async function rasterizeSvgToPngDataUrl(svg: string, width: number, height: numb
}
async function svgToImageDataUrl(svg: string): Promise<MermaidImagePayload> {
const fallback = getSvgSize(svg)
const paddedSvg = padSvgViewBox(svg)
const fallback = getSvgSize(paddedSvg)
const sourceWidth = fallback.width
const sourceHeight = fallback.height
@@ -208,8 +275,8 @@ async function svgToImageDataUrl(svg: string): Promise<MermaidImagePayload> {
width = Math.max(1, Math.round(width * scale))
height = Math.max(1, Math.round(height * scale))
const normalizedSvg = stripExternalSvgResources(svg)
const candidates = normalizedSvg === svg ? [svg] : [svg, normalizedSvg]
const normalizedSvg = stripExternalSvgResources(paddedSvg)
const candidates = normalizedSvg === paddedSvg ? [paddedSvg] : [paddedSvg, normalizedSvg]
let lastError: unknown = null
for (const candidate of candidates) {
@@ -270,7 +337,7 @@ async function renderMermaidBlock(block: HTMLElement, token: number): Promise<vo
const encodedCode = block.getAttribute('data-mermaid-code') || ''
const code = decodeMermaidCode(encodedCode).trim() || 'graph TD\nA-->B'
inner.innerHTML = '<div class="mermaid-loading">...</div>'
inner.innerHTML = '<div class="mermaid-loading"><span></span><span></span><span></span></div>'
setMermaidActionsState(block, null)
try {

View File

@@ -3,8 +3,13 @@ import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
name: 'Home',
component: () => import('../App.vue')
name: 'Editor',
component: () => import('../views/EditorView.vue')
},
{
path: '/docs',
name: 'Docs',
component: () => import('../views/DocsView.vue')
}
]

View File

@@ -37,16 +37,21 @@
--toggle-moon: #475569;
--ghost-text: #7d8796;
--ghost-code-bg: rgba(15, 23, 42, 0.06);
--mermaid-max-height: 420px;
--code-inline-bg: rgba(15, 23, 42, 0.06);
--code-block-bg: #f8fafc;
--code-block-border: rgba(148, 163, 184, 0.28);
--code-text: #0f172a;
--mermaid-max-height: 480px;
--mermaid-mobile-max-height: 320px;
--mermaid-action-bg: linear-gradient(180deg, #ffffff 0%, #f4f7fc 100%);
--mermaid-action-hover-bg: linear-gradient(180deg, #ffffff 0%, #e9f2ff 100%);
--mermaid-action-fg: #1f2937;
--mermaid-action-border: #cfd8e6;
--mermaid-action-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
--mermaid-action-shadow-hover: 0 4px 10px rgba(37, 99, 235, 0.16);
--mermaid-action-disabled-bg: rgba(148, 163, 184, 0.18);
--mermaid-action-disabled-fg: #9aa4b2;
--mermaid-glass-bg: rgba(255, 255, 255, 0.55);
--mermaid-glass-border: rgba(180, 200, 230, 0.5);
--mermaid-glass-border-hover: rgba(59, 130, 246, 0.45);
--mermaid-glass-shadow: 0 4px 24px rgba(15, 23, 42, 0.08), 0 1px 4px rgba(15, 23, 42, 0.05);
--mermaid-glass-shadow-hover: 0 8px 32px rgba(59, 130, 246, 0.12), 0 2px 8px rgba(15, 23, 42, 0.08);
--mermaid-btn-bg: rgba(255, 255, 255, 0.8);
--mermaid-btn-hover-bg: rgba(239, 246, 255, 0.95);
--mermaid-btn-fg: #374151;
--mermaid-btn-border: rgba(180, 200, 230, 0.6);
--crepe-color-background: #ffffff;
--crepe-color-on-background: #000000;
@@ -95,14 +100,21 @@
--toggle-moon: #e2e8f0;
--ghost-text: #95a0b4;
--ghost-code-bg: rgba(226, 232, 240, 0.12);
--mermaid-action-bg: linear-gradient(180deg, #30394b 0%, #242c3a 100%);
--mermaid-action-hover-bg: linear-gradient(180deg, #3a4760 0%, #2a3445 100%);
--mermaid-action-fg: #e5e7eb;
--mermaid-action-border: #3e4a61;
--mermaid-action-shadow: 0 1px 2px rgba(2, 6, 23, 0.45);
--mermaid-action-shadow-hover: 0 5px 12px rgba(2, 6, 23, 0.55);
--mermaid-action-disabled-bg: rgba(82, 93, 110, 0.3);
--mermaid-action-disabled-fg: #9aa4b2;
--code-inline-bg: rgba(30, 41, 59, 0.92);
--code-block-bg: rgba(15, 23, 42, 0.92);
--code-block-border: rgba(71, 85, 105, 0.55);
--code-text: #e2e8f0;
--mermaid-max-height: 480px;
--mermaid-mobile-max-height: 320px;
--mermaid-glass-bg: rgba(20, 28, 44, 0.6);
--mermaid-glass-border: rgba(60, 80, 120, 0.4);
--mermaid-glass-border-hover: rgba(96, 165, 250, 0.45);
--mermaid-glass-shadow: 0 4px 24px rgba(0, 0, 0, 0.3), 0 1px 4px rgba(0, 0, 0, 0.2);
--mermaid-glass-shadow-hover: 0 8px 32px rgba(96, 165, 250, 0.15), 0 2px 8px rgba(0, 0, 0, 0.3);
--mermaid-btn-bg: rgba(40, 52, 75, 0.85);
--mermaid-btn-hover-bg: rgba(55, 70, 100, 0.95);
--mermaid-btn-fg: #c9d6e8;
--mermaid-btn-border: rgba(70, 95, 140, 0.55);
--crepe-color-background: #1a1a1a;
--crepe-color-on-background: #e6e6e6;
@@ -210,114 +222,158 @@ body {
/* ── Mermaid diagram blocks ─────────────────────────────────────────── */
.mermaid-block {
position: relative;
display: block;
width: fit-content;
width: 100%;
max-width: 100%;
margin: 1em auto;
padding: 12px;
background: var(--crepe-color-surface, #f7f7f7);
border: 1px solid var(--panel-border, #d7deea);
border-radius: 10px;
transition: border-color 160ms ease, box-shadow 160ms ease, background-color 160ms ease;
margin: 1.25em 0;
padding: 0;
background: var(--mermaid-glass-bg);
border: 1px solid var(--mermaid-glass-border);
border-radius: 16px;
box-shadow: var(--mermaid-glass-shadow);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
transition: border-color 240ms ease, box-shadow 240ms ease;
overflow: hidden;
}
.mermaid-block:hover {
border-color: var(--focus-ring, #3b82f6);
border-color: var(--mermaid-glass-border-hover);
box-shadow: var(--mermaid-glass-shadow-hover);
}
/* 悬浮控制栏右上角hover 时渐现 */
.mermaid-controls {
position: absolute;
top: 10px;
right: 10px;
z-index: 10;
display: flex;
justify-content: flex-end;
gap: 8px;
margin-bottom: 10px;
flex-wrap: wrap;
align-items: center;
gap: 6px;
opacity: 0;
transform: translateY(-4px);
transition: opacity 200ms ease, transform 200ms ease;
pointer-events: none;
}
.mermaid-block:hover .mermaid-controls {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.mermaid-action-btn {
appearance: none;
border: 1px solid var(--mermaid-action-border);
border-radius: 999px;
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.01em;
line-height: 1.2;
background: var(--mermaid-action-bg);
color: var(--mermaid-action-fg);
border: 1px solid var(--mermaid-btn-border);
background: var(--mermaid-btn-bg);
color: var(--mermaid-btn-fg);
cursor: pointer;
box-shadow: var(--mermaid-action-shadow);
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease, background 160ms ease;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
font-size: 12px;
font-weight: 500;
line-height: 1;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
box-shadow: 0 1px 4px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.1);
transition: background 160ms ease, border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
}
.mermaid-zoom-btn {
width: 30px;
height: 30px;
padding: 0;
border-radius: 50%;
}
.mermaid-download-btn {
height: 30px;
padding: 0 12px;
border-radius: 999px;
}
.mermaid-action-btn:hover:not([disabled]) {
background: var(--mermaid-btn-hover-bg);
border-color: var(--focus-ring);
background: var(--mermaid-action-hover-bg);
box-shadow: var(--mermaid-action-shadow-hover);
box-shadow: 0 0 0 3px rgba(59,130,246,0.18), 0 2px 8px rgba(0,0,0,0.2);
transform: translateY(-1px);
}
.mermaid-action-btn:active:not([disabled]) {
transform: translateY(0);
box-shadow: 0 1px 4px rgba(0,0,0,0.15);
}
.mermaid-action-btn[disabled] {
background: var(--mermaid-action-disabled-bg);
color: var(--mermaid-action-disabled-fg);
opacity: 0.35;
cursor: not-allowed;
pointer-events: none;
}
.mermaid-inner {
display: block;
max-width: 100%;
width: 100%;
max-height: var(--mermaid-max-height);
overflow: auto;
border-radius: 8px;
padding: 8px;
background: rgba(255, 255, 255, 0.88);
}
.mermaid-inner::-webkit-scrollbar {
width: 8px;
height: 8px;
padding: 20px;
background: transparent;
}
.mermaid-inner::-webkit-scrollbar { width: 6px; height: 6px; }
.mermaid-inner::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-thumb);
border-radius: 4px;
border-radius: 3px;
}
.mermaid-inner::-webkit-scrollbar-thumb:hover {
background-color: var(--scrollbar-thumb-hover);
}
.mermaid-image {
display: block;
width: auto;
height: auto;
max-width: none;
max-width: 100%;
margin: 0 auto;
border-radius: 6px;
}
.mermaid-inner svg {
display: block;
}
.mermaid-inner svg { display: block; margin: 0 auto; }
/* 三点弹跳加载动画 */
.mermaid-loading {
padding: 24px;
text-align: center;
font-size: 1.4em;
color: var(--muted-text, #6b7280);
letter-spacing: 0.2em;
animation: mermaid-pulse 1.2s ease-in-out infinite;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 36px;
}
@keyframes mermaid-pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
.mermaid-loading span {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--muted-text, #6b7280);
animation: mermaid-bounce 1.2s ease-in-out infinite;
}
.mermaid-loading span:nth-child(2) { animation-delay: 0.2s; }
.mermaid-loading span:nth-child(3) { animation-delay: 0.4s; }
@keyframes mermaid-bounce {
0%, 80%, 100% { transform: scale(0.55); opacity: 0.35; }
40% { transform: scale(1); opacity: 1; }
}
.mermaid-error {
margin: 12px;
padding: 12px 16px;
margin: 0;
background: rgba(220, 38, 38, 0.08);
border: 1px solid var(--danger-text, #dc2626);
border-radius: 6px;
border-radius: 8px;
color: var(--danger-text, #dc2626);
font-size: 12px;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', monospace;
@@ -325,20 +381,122 @@ body {
word-break: break-word;
}
:root[data-theme='dark'] .milkdown .katex {
:root .milkdown .katex {
color: var(--crepe-color-on-background);
}
:root[data-theme='dark'] .milkdown .katex .mord,
:root[data-theme='dark'] .milkdown .katex .mbin,
:root[data-theme='dark'] .milkdown .katex .mrel,
:root[data-theme='dark'] .milkdown .katex .minner {
color: var(--crepe-color-on-background);
}
:root[data-theme='dark'] .milkdown .katex .mord.text {
color: var(--crepe-color-on-background);
}
:root[data-theme='dark'] .milkdown .cm-editor,
:root[data-theme='dark'] .milkdown .cm-scroller {
background-color: rgba(237, 237, 237, 0.86);
color: var(--crepe-color-on-surface);
background-color: var(--code-block-bg);
color: var(--code-text);
}
:root[data-theme='dark'] .milkdown .cm-gutters {
background-color: rgba(237, 237, 237, 0.86);
background-color: var(--code-block-bg);
color: var(--crepe-color-on-surface-variant);
border-right-color: var(--panel-border);
border-right: 1px solid var(--code-block-border);
}
:root[data-theme='dark'] .milkdown .cm-activeLine {
background-color: rgba(255, 255, 255, 0.05);
}
:root[data-theme='dark'] .milkdown .cm-activeLineGutter {
background-color: rgba(255, 255, 255, 0.08);
}
:root[data-theme='dark'] .milkdown .cm-selectionBackground,
:root[data-theme='dark'] .milkdown .cm-selectionBackground::selection {
background-color: rgba(59, 130, 246, 0.25);
}
:root[data-theme='dark'] .milkdown .cm-cursor {
border-left-color: var(--crepe-color-on-background);
}
:root .milkdown .ProseMirror pre,
:root .milkdown .ProseMirror code {
background: var(--code-inline-bg);
color: var(--code-text);
border: 1px solid var(--code-block-border);
}
:root .milkdown .ProseMirror pre {
padding: 12px 16px;
border-radius: 8px;
background: var(--code-block-bg);
}
:root .milkdown .ProseMirror pre code {
background: none;
border: none;
padding: 0;
}
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.comment,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.prolog,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.doctype,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.cdata {
color: #6b7280;
}
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.punctuation {
color: #9ca3af;
}
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.property,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.tag,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.boolean,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.number,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.constant,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.symbol,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.deleted {
color: #f472b6;
}
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.selector,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.attr-name,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.string,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.char,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.builtin,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.inserted {
color: #a78bfa;
}
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.operator,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.entity,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.url,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .language-css .token.string,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .style .token.string {
color: #60a5fa;
}
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.atrule,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.attr-value,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.keyword {
color: #34d399;
}
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.function,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.class-name {
color: #fbbf24;
}
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.regex,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.important,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.variable {
color: #fb923c;
}
@media (max-width: 768px) {

View File

@@ -1,4 +1,4 @@
import { API_URL, API_KEY } from './config.js'
import { API_URL, API_KEY, TTS_URL, TTS_STATUS_URL } from './config.js'
import { useSettingsStore } from '../stores/settings'
function generateRequestId() {
@@ -116,3 +116,33 @@ export async function fetchSuggestion(prefix, suffix, languageId, signal, apiUrl
}
}
}
export async function fetchTTS(text, voice = 'af_bella', rate = 1.0, apiUrl = TTS_URL) {
const res = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': API_KEY,
},
body: JSON.stringify({ text, voice, rate, format: 'wav' }),
})
if (!res.ok) {
const errorText = await res.text()
throw new Error(`TTS HTTP ${res.status}: ${errorText}`)
}
return res.json()
}
export async function fetchTTSStatus(apiUrl = TTS_STATUS_URL) {
const res = await fetch(apiUrl, {
headers: { 'X-API-Key': API_KEY },
})
if (!res.ok) {
throw new Error(`TTS Status HTTP ${res.status}`)
}
return res.json()
}

View File

@@ -7,4 +7,6 @@ export const API_URL = import.meta.env.VITE_API_URL || `${API_BASE_URL}/v1/compl
export const OCR_URL = import.meta.env.VITE_OCR_URL || `${API_BASE_URL}/v1/ocr`
export const CONVERT_URL = import.meta.env.VITE_CONVERT_URL || `${API_BASE_URL}/v1/convert`
export const EXPORT_PDF_URL = import.meta.env.VITE_EXPORT_PDF_URL || '/v1/export/pdf'
export const TTS_URL = import.meta.env.VITE_TTS_URL || `${API_BASE_URL}/v1/tts-asr/tts`
export const TTS_STATUS_URL = import.meta.env.VITE_TTS_STATUS_URL || `${API_BASE_URL}/v1/tts-asr/status`
export const API_KEY = import.meta.env.VITE_API_KEY || 'your-secret-key-here'

View File

@@ -55,7 +55,12 @@ export const translations = {
cancel: 'Cancel',
imgTooLarge: 'Image too large',
docTooLarge: 'Document too large, AI disabled',
initialMarkdown: '# Welcome to LLM-IN-TEXT\n\nAn instant LLM system\n\nStart your creative work below...'
initialMarkdown: '# Welcome to LLM-IN-TEXT\n\nAn instant LLM system\n\nStart your creative work below...',
view: 'View',
editor: 'Editor',
docs: 'Docs',
docsManagement: 'Document Management',
docsEmptyDesc: 'Document management interface is under development...'
},
zh: {
settings: '设置',
@@ -113,7 +118,12 @@ export const translations = {
cancel: '取消',
imgTooLarge: '图片过大',
docTooLarge: '文档过大AI已禁用',
initialMarkdown: '# 欢迎使用 LLM-IN-TEXT\n\n即时可用的 LLM 系统\n\n在下方开始创作吧...'
initialMarkdown: '# 欢迎使用 LLM-IN-TEXT\n\n即时可用的 LLM 系统\n\n在下方开始创作吧...',
view: '视图',
editor: '编辑器',
docs: '文档',
docsManagement: '文档管理',
docsEmptyDesc: '文档管理界面开发中...'
},
ja: {
settings: '設定',
@@ -166,7 +176,12 @@ export const translations = {
cancel: 'キャンセル',
imgTooLarge: '画像が大きすぎます',
docTooLarge: 'ドキュメントが大きすぎます、AI無効',
initialMarkdown: '# LLM-IN-TEXTへようこそ\n\nすぐに使えるLLMシステム\n\n下から創作を始めましょう...'
initialMarkdown: '# LLM-IN-TEXTへようこそ\n\nすぐに使えるLLMシステム\n\n下から創作を始めましょう...',
view: 'ビュー',
editor: 'エディター',
docs: 'ドキュメント',
docsManagement: 'ドキュメント管理',
docsEmptyDesc: 'ドキュメント管理画面は開発中です...'
},
ko: {
settings: '설정',
@@ -216,7 +231,12 @@ export const translations = {
cancel: '취소',
imgTooLarge: '이미지가 너무 큽니다',
docTooLarge: '문서가 너무 큽니다, AI 비활성화됨',
initialMarkdown: '# LLM-IN-TEXT에 오신 것을 환영합니다\n\n즉시 사용할 수 있는 LLM 시스템\n\n아래에서 창작을 시작하세요...'
initialMarkdown: '# LLM-IN-TEXT에 오신 것을 환영합니다\n\n즉시 사용할 수 있는 LLM 시스템\n\n아래에서 창작을 시작하세요...',
view: '보기',
editor: '에디터',
docs: '문서',
docsManagement: '문서 관리',
docsEmptyDesc: '문서 관리 화면은 개발 중입니다...'
},
de: {
settings: 'Einstellungen',
@@ -266,7 +286,12 @@ export const translations = {
cancel: 'Abbrechen',
imgTooLarge: 'Bild zu groß',
docTooLarge: 'Dokument zu groß, KI deaktiviert',
initialMarkdown: '# Willkommen bei LLM-IN-TEXT\n\nEin sofort verfügbares LLM-System\n\nStarten Sie Ihre kreative Arbeit unten...'
initialMarkdown: '# Willkommen bei LLM-IN-TEXT\n\nEin sofort verfügbares LLM-System\n\nStarten Sie Ihre kreative Arbeit unten...',
view: 'Ansicht',
editor: 'Editor',
docs: 'Dokumente',
docsManagement: 'Dokumentenverwaltung',
docsEmptyDesc: 'Die Dokumentenverwaltung ist in Entwicklung...'
},
fr: {
settings: 'Paramètres',
@@ -316,6 +341,11 @@ export const translations = {
cancel: 'Annuler',
imgTooLarge: 'Image trop grande',
docTooLarge: 'Document trop grand, IA désactivée',
initialMarkdown: '# Bienvenue sur LLM-IN-TEXT\n\nUn système LLM instantané\n\nCommencez votre création ci-dessous...'
initialMarkdown: '# Bienvenue sur LLM-IN-TEXT\n\nUn système LLM instantané\n\nCommencez votre création ci-dessous...',
view: 'Vue',
editor: 'Éditeur',
docs: 'Documents',
docsManagement: 'Gestion des documents',
docsEmptyDesc: 'L\'interface de gestion des documents est en développement...'
}
}

40
src/views/DocsView.vue Normal file
View File

@@ -0,0 +1,40 @@
<script setup>
import { useSettingsStore } from '../stores/settings'
const settings = useSettingsStore()
const t = (key) => settings.t[key]
</script>
<template>
<div class="docs-view">
<div class="docs-empty">
<h2>{{ t('docsManagement') || '文档管理' }}</h2>
<p>{{ t('docsEmptyDesc') || '文档管理界面开发中...' }}</p>
</div>
</div>
</template>
<style scoped>
.docs-view {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.docs-empty {
text-align: center;
color: var(--muted-text);
}
.docs-empty h2 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
color: var(--app-text);
}
.docs-empty p {
font-size: 1rem;
}
</style>

22
src/views/EditorView.vue Normal file
View File

@@ -0,0 +1,22 @@
<script setup>
import { ref } from 'vue'
import { defineAsyncComponent } from 'vue'
const MilkdownEditor = defineAsyncComponent(() => import('../components/MilkdownEditor.vue'))
const markdown = ref('')
</script>
<template>
<div class="editor-view">
<MilkdownEditor v-model:markdown="markdown" />
</div>
</template>
<style scoped>
.editor-view {
position: relative;
z-index: 1;
height: 100%;
}
</style>

View File

@@ -8,7 +8,7 @@ export default defineConfig({
port: 5173,
proxy: {
'/v1': {
target: 'http://localhost:8001',
target: 'https://api.imageteach.tech:8002',
changeOrigin: true
}
}