优化: UI 去 AI 味——SVG 图标体系替代 emoji/字符图标,首页产品化重设计(文档网格+右侧新建栏),主色统一 #5b5bd6,补键盘焦点可见性

This commit is contained in:
lxy
2026-08-30 03:32:17 +08:00
parent dd9c3db530
commit c7c94283c0
18 changed files with 543 additions and 336 deletions
+26 -24
View File
@@ -15,6 +15,7 @@ import {
describeReport
} from '../../core/importer'
import { putAsset, isOssEnabled } from '../../core/assets'
import Icon from '../common/Icon.vue'
const props = withDefaults(defineProps<{ visible: boolean }>(), { visible: false })
const emit = defineEmits<{
@@ -137,7 +138,7 @@ async function runAiAnalysis() {
if (docs.length === 0) return
if (!aiReady.value) {
analyzeError.value = '请先在设置中配置 AI API Key'
analyzeError.value = '请先在设置中配置 AI API Key'
return
}
@@ -196,7 +197,7 @@ async function doImport() {
try { deckChars = JSON.stringify(store.getDeck()).length } catch (e) { /* ignore */ }
const QUOTA_CHARS = 4_500_000
if (deckChars + imgChars > QUOTA_CHARS) {
emit('toast', '图片过大:导入后约 ' + ((deckChars + imgChars) / 1048576).toFixed(1) + 'MB,超本地存储上限(约 5MB),刷新可能丢失。请压缩图片、减少数量,或在云存储中启用 OSS')
emit('toast', '图片过大:导入后约 ' + ((deckChars + imgChars) / 1048576).toFixed(1) + 'MB,超本地存储上限(约 5MB),刷新可能丢失。请压缩图片、减少数量,或在云存储中启用 OSS')
return
}
}
@@ -294,15 +295,15 @@ function textPreview(data: string, maxLen = 80): string {
<div v-if="props.visible" class="modal-mask" @click.self="emit('close')">
<div class="modal import-modal">
<header class="modal-header">
<h2>📂 导入本地资料</h2>
<button class="close" @click="emit('close')"></button>
<h2>导入本地资料</h2>
<button class="close" @click="emit('close')" title="关闭"><Icon name="x" :size="16" /></button>
</header>
<!-- ======== 选择区域 ======== -->
<div v-if="!loaded" class="import-select">
<div class="import-tabs">
<button class="tab" :class="{ active: activeTab === 'files' }" @click="activeTab = 'files'">📄 选择文件</button>
<button class="tab" :class="{ active: activeTab === 'dir' }" @click="activeTab = 'dir'">📁 读取目录</button>
<button class="tab" :class="{ active: activeTab === 'files' }" @click="activeTab = 'files'"><Icon name="file" :size="14" /> 选择文件</button>
<button class="tab" :class="{ active: activeTab === 'dir' }" @click="activeTab = 'dir'"><Icon name="folder-open" :size="14" /> 读取目录</button>
</div>
<div
@@ -314,7 +315,7 @@ function textPreview(data: string, maxLen = 80): string {
@dragleave="onDropzoneDragLeave"
@drop="onDropzoneDrop"
>
<div class="dropzone-icon">📄</div>
<div class="dropzone-icon"><Icon name="file" :size="34" /></div>
<div class="dropzone-text">
<strong>点击选择或拖入文件</strong>
<span class="hint">图片直接插入 · 文档PDF/DOCX/MD/TXT AI 分析生成幻灯片</span>
@@ -330,7 +331,7 @@ function textPreview(data: string, maxLen = 80): string {
@dragleave="onDropzoneDragLeave"
@drop="onDropzoneDrop"
>
<div class="dropzone-icon">📁</div>
<div class="dropzone-icon"><Icon name="folder-open" :size="34" /></div>
<div class="dropzone-text">
<strong>点击选择目录</strong>
<span class="hint">读取目录下所有支持的图片和文档AI 自动分析生成</span>
@@ -354,9 +355,9 @@ function textPreview(data: string, maxLen = 80): string {
<!-- 过滤 -->
<div class="filter-bar">
<label class="chk"><input type="checkbox" v-model="showImages" /> 🖼 图片 ({{ imageEntries.length }})</label>
<label class="chk"><input type="checkbox" v-model="showVideos" /> 🎬 视频 ({{ videoEntries.length }})</label>
<label class="chk"><input type="checkbox" v-model="showDocs" /> 📄 文档 ({{ docEntries.length }})</label>
<label class="chk"><input type="checkbox" v-model="showImages" /> <Icon name="image" :size="13" /> 图片 ({{ imageEntries.length }})</label>
<label class="chk"><input type="checkbox" v-model="showVideos" /> <Icon name="video" :size="13" /> 视频 ({{ videoEntries.length }})</label>
<label class="chk"><input type="checkbox" v-model="showDocs" /> <Icon name="file-text" :size="13" /> 文档 ({{ docEntries.length }})</label>
</div>
<!-- AI 分析进度 -->
@@ -366,7 +367,7 @@ function textPreview(data: string, maxLen = 80): string {
<!-- AI 分析错误 -->
<div v-if="analyzeError && !analyzing" class="ai-error">
{{ analyzeError }}
<Icon name="alert" :size="13" /> {{ analyzeError }}
<template v-if="!aiReady">
<button class="btn-sm" @click="emit('open-settings')">去配置</button>
</template>
@@ -375,20 +376,20 @@ function textPreview(data: string, maxLen = 80): string {
<!-- 文件列表 -->
<div class="file-list">
<div v-for="(entry, i) in filteredEntries" :key="i" class="file-item" :class="entry.kind">
<span class="file-icon">{{ entry.kind === 'image' ? '🖼' : entry.kind === 'video' ? '🎬' : '📄' }}</span>
<span class="file-icon"><Icon :name="entry.kind === 'image' ? 'image' : entry.kind === 'video' ? 'video' : 'file-text'" :size="15" /></span>
<span class="file-name" :title="entry.name">{{ entry.name }}</span>
<span class="file-size">{{ fmtSize(entry.file.size) }}</span>
<span class="file-status">
<!-- 图片 -->
<template v-if="entry.kind === 'image' && entry.data"> 图片就绪</template>
<template v-if="entry.kind === 'image' && entry.data"><Icon name="check" :size="12" /> 图片就绪</template>
<!-- 视频 -->
<template v-else-if="entry.kind === 'video' && entry.data"> 视频就绪</template>
<template v-else-if="entry.kind === 'video' && entry.data"><Icon name="check" :size="12" /> 视频就绪</template>
<!-- 文档AI 分析结果 -->
<template v-else-if="entry.kind === 'document' && entry.slides"> AI {{ entry.slides.length }} </template>
<template v-else-if="entry.kind === 'document' && entry.slides"><Icon name="check" :size="12" /> AI {{ entry.slides.length }} </template>
<template v-else-if="entry.kind === 'document' && entry.data && analyzeDone"> 待分析</template>
<template v-else-if="entry.kind === 'document' && entry.data && analyzing"> AI 分析中</template>
<template v-else-if="entry.kind === 'document' && entry.data">📄 {{ textPreview(entry.data) }}</template>
<template v-else-if="entry.error"> {{ entry.error }}</template>
<template v-else-if="entry.kind === 'document' && entry.data">{{ textPreview(entry.data) }}</template>
<template v-else-if="entry.error"><Icon name="alert" :size="12" /> {{ entry.error }}</template>
<template v-else-if="entry.kind === 'unsupported'"> 跳过</template>
<template v-else> 读取中</template>
</span>
@@ -397,7 +398,7 @@ function textPreview(data: string, maxLen = 80): string {
<!-- 文档幻灯片预览 -->
<div v-if="docEntries.filter(e => e.slides).length > 0" class="preview-slides">
<span class="preview-title">🤖 AI 解析结果</span>
<span class="preview-title">AI 解析结果</span>
<div class="preview-scroll">
<div v-for="(entry, i) in docEntries.filter(e => e.slides)" :key="'p' + i" class="preview-item">
<strong>{{ entry.name }}</strong>
@@ -415,7 +416,8 @@ function textPreview(data: string, maxLen = 80): string {
<!-- 操作按钮 -->
<div class="import-actions">
<button class="btn primary" @click="doImport" :disabled="filteredEntries.length === 0 || analyzing">
{{ hasDocs && !analyzeDone ? '🤖 AI 分析文档并导入' : '✅ 导入以上内容' }}
<template v-if="hasDocs && !analyzeDone"><Icon name="sparkles" :size="14" /> AI 分析文档并导入</template>
<template v-else><Icon name="check" :size="14" /> 导入以上内容</template>
</button>
<button class="btn" @click="emit('close')">取消</button>
</div>
@@ -433,14 +435,14 @@ function textPreview(data: string, maxLen = 80): string {
background: var(--bg, #fff); border-radius: 8px; cursor: pointer;
font-size: 14px; transition: all .15s;
}
.import-tabs .tab.active { background: var(--primary, #4f46e5); color: #fff; border-color: var(--primary, #4f46e5); }
.import-tabs .tab.active { background: var(--primary, #5b5bd6); color: #fff; border-color: var(--primary, #5b5bd6); }
.dropzone {
border: 2px dashed var(--border, #cbd5e1); border-radius: 12px; padding: 36px 24px;
display: flex; align-items: center; gap: 20px; cursor: pointer; transition: all .2s;
background: var(--panel, #f8fafc);
}
.dropzone:hover { border-color: var(--primary, #4f46e5); background: color-mix(in srgb, var(--primary, #4f46e5) 5%, var(--panel, #f8fafc)); }
.dropzone.drag-over { border-color: var(--primary, #4f46e5); background: color-mix(in srgb, var(--primary, #4f46e5) 10%, var(--panel, #f8fafc)); }
.dropzone:hover { border-color: var(--primary, #5b5bd6); background: color-mix(in srgb, var(--primary, #5b5bd6) 5%, var(--panel, #f8fafc)); }
.dropzone.drag-over { border-color: var(--primary, #5b5bd6); background: color-mix(in srgb, var(--primary, #5b5bd6) 10%, var(--panel, #f8fafc)); }
.dropzone-icon { font-size: 44px; }
.dropzone-text { display: flex; flex-direction: column; gap: 4px; }
.dropzone-text strong { font-size: 16px; }
@@ -454,7 +456,7 @@ function textPreview(data: string, maxLen = 80): string {
.filter-bar { display: flex; gap: 16px; padding: 4px 0; }
.filter-bar .chk { display: flex; align-items: center; gap: 6px; font-size: 14px; cursor: pointer; }
.ai-progress { padding: 6px 12px; background: rgba(79, 70, 229, 0.06); border-radius: 8px; font-size: 13px; color: var(--primary, #4f46e5); }
.ai-progress { padding: 6px 12px; background: rgba(79, 70, 229, 0.06); border-radius: 8px; font-size: 13px; color: var(--primary, #5b5bd6); }
.ai-error { padding: 6px 12px; background: rgba(239, 68, 68, 0.06); border-radius: 8px; font-size: 13px; color: #dc2626; display: flex; align-items: center; gap: 8px; }
.file-list { max-height: 200px; overflow-y: auto; border: 1px solid var(--border, #e2e8f0); border-radius: 8px; display: flex; flex-direction: column; }
+64 -10
View File
@@ -2,11 +2,12 @@
LibraryModal.vue 演示文库弹窗
===================================================================== -->
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import type { LibItem, Slide } from '../../core/types'
import { store, resolveBg } from '../../core/store'
import { appPrompt, appConfirm } from '../../core/dialog'
import ElementView from '../editor/ElementView.vue'
import Icon from '../common/Icon.vue'
const props = defineProps<{ visible: boolean }>()
const emit = defineEmits<{
@@ -18,6 +19,18 @@ const emit = defineEmits<{
const libVersion = ref(0)
const nameInput = ref('')
const opsOpenId = ref<string | null>(null)
function toggleOps(id: string) {
opsOpenId.value = opsOpenId.value === id ? null : id
}
function onDocClick(e: MouseEvent) {
const t = e.target as HTMLElement
if (!t.closest('.lib-ops')) opsOpenId.value = null
}
onMounted(() => document.addEventListener('mousedown', onDocClick))
onUnmounted(() => document.removeEventListener('mousedown', onDocClick))
/** 读文库列表(非响应式,靠 libVersion 触发重算) */
const library = computed<LibItem[]>(() => {
@@ -86,6 +99,7 @@ function onNewBlank() {
function onOpen(id: string) {
if (store.loadFromLibrary(id)) {
opsOpenId.value = null
toast('已打开')
emit('open-deck')
emit('close')
@@ -95,6 +109,7 @@ function onOpen(id: string) {
async function onRename(id: string) {
const cur = store.getLibrary().find(x => x.id === id)
const name = await appPrompt('重命名', { defaultValue: cur ? cur.name : '' })
opsOpenId.value = null
if (name != null && name.trim()) {
store.renameInLibrary(id, name.trim())
bump()
@@ -102,12 +117,14 @@ async function onRename(id: string) {
}
function onDuplicate(id: string) {
opsOpenId.value = null
store.duplicateInLibrary(id)
toast('已复制')
bump()
}
async function onDelete(id: string) {
opsOpenId.value = null
if (await appConfirm('删除这份演示?', '此操作不可撤销', { danger: true, okText: '删除' })) {
store.deleteFromLibrary(id)
toast('已删除')
@@ -119,13 +136,21 @@ async function onDelete(id: string) {
<template>
<div class="modal-mask lib-modal-mask" :class="{ hidden: !visible }" @click.self="emit('close')">
<div class="modal lib-modal">
<h3>演示文库</h3>
<div class="lib-head">
<h3>演示文库</h3>
<button class="btn ghost" @click="onNewBlank"> 新建空白</button>
</div>
<p class="modal-tip">保存多套演示文稿到本地随时切换</p>
<div class="lib-save-row">
<input type="text" v-model="nameInput" :placeholder="placeholder" @keydown="onNameKeydown" />
<button class="btn primary" @click="onSave">存入文库</button>
<button class="btn" @click="onNewBlank">新建空白</button>
<div class="lib-save-card">
<div class="lib-save-title">
<span><Icon name="save" :size="14" /> 保存当前演示</span>
<span v-if="activeId" class="lib-autosave-flag">改动已自动同步到文库</span>
</div>
<div class="lib-save-row">
<input type="text" v-model="nameInput" :placeholder="placeholder" @keydown="onNameKeydown" />
<button class="btn primary" @click="onSave">存入文库</button>
</div>
</div>
<div class="lib-hint"> {{ libCount }} </div>
@@ -142,10 +167,13 @@ async function onDelete(id: string) {
<div class="lib-meta">{{ (it.deck && it.deck.slides ? it.deck.slides.length : 0) + ' 页 · ' + formatTime(it.updatedAt || it.createdAt) }}</div>
</div>
<div class="lib-ops">
<button class="btn" @click="onOpen(it.id)">打开</button>
<button class="btn" @click="onRename(it.id)">重命名</button>
<button class="btn" @click="onDuplicate(it.id)">复制</button>
<button class="btn danger" @click="onDelete(it.id)">删除</button>
<button class="btn primary small" @click="onOpen(it.id)">打开</button>
<button class="btn" @click="toggleOps(it.id)"></button>
<div v-if="opsOpenId === it.id" class="ops-menu">
<button @click="onRename(it.id)">重命名</button>
<button @click="onDuplicate(it.id)">复制</button>
<button class="danger" @click="onDelete(it.id)">删除</button>
</div>
</div>
</div>
</div>
@@ -156,3 +184,29 @@ async function onDelete(id: string) {
</div>
</div>
</template>
<style scoped>
.lib-head { display: flex; align-items: center; justify-content: space-between; }
.lib-head h3 { margin: 0; }
.lib-save-card {
border: 1px solid color-mix(in srgb, var(--ui-primary, #5b5bd6) 22%, transparent);
background: color-mix(in srgb, var(--ui-primary, #5b5bd6) 5%, #fff);
border-radius: 10px;
padding: 12px 14px;
margin-bottom: 12px;
}
.lib-save-title { font-size: 13px; font-weight: 600; color: var(--ui-text, #1e293b); margin-bottom: 8px; display: flex; align-items: center; justify-content: space-between; }
.lib-autosave-flag { font-size: 11px; font-weight: 400; color: var(--ui-muted, #64748b); }
.lib-save-row { display: flex; gap: 8px; }
.lib-save-row input { flex: 1; }
.lib-ops { position: relative; }
.ops-menu {
position: absolute; right: 0; top: calc(100% + 4px);
background: #fff; border: 1px solid var(--ui-border, #e2e8f0);
border-radius: 8px; box-shadow: 0 4px 16px rgba(15,23,42,.12);
min-width: 96px; z-index: 10; overflow: hidden; padding: 4px 0;
}
.ops-menu button { display: block; width: 100%; text-align: left; padding: 6px 12px; border: none; background: none; font-size: 12px; cursor: pointer; }
.ops-menu button:hover { background: var(--ui-hover, #f1f5f9); }
.ops-menu button.danger { color: var(--ui-danger, #dc2626); }
</style>
+3 -2
View File
@@ -7,6 +7,7 @@
import { computed } from 'vue'
import { store, resolveBg } from '../../core/store'
import ElementView from '../editor/ElementView.vue'
import Icon from '../common/Icon.vue'
const emit = defineEmits<{ (e: 'close'): void }>()
@@ -20,10 +21,10 @@ function onPrint() { window.print() }
<div class="print-modal">
<!-- 操作栏打印时不显示 -->
<div class="print-bar no-print">
<button class="btn primary" @click="onPrint">🖨 打印 / 另存为 PDF</button>
<button class="btn primary" @click="onPrint"><Icon name="printer" :size="14" /> 打印 / 另存为 PDF</button>
<span class="muted">{{ slides.length }} · A4 横向</span>
<span class="sep"></span>
<button class="btn ghost" @click="emit('close')"> 关闭</button>
<button class="btn ghost" @click="emit('close')"><Icon name="x" :size="14" /> 关闭</button>
</div>
<!-- 所有幻灯片逐页渲染复用编辑器元素渲染几何与编辑画布同构 -->
+7 -6
View File
@@ -7,6 +7,7 @@ import { store, resolveBg } from '../../core/store'
import { appConfirm } from '../../core/dialog'
import type { PageTemplate } from '../../core/types'
import ElementView from '../editor/ElementView.vue'
import Icon from '../common/Icon.vue'
defineProps<{ visible: boolean }>()
const emit = defineEmits<{
@@ -78,12 +79,12 @@ async function onDelete(tpl: PageTemplate) {
<template>
<div class="modal-mask" :class="{ hidden: !visible }" @click.self="emit('close')">
<div class="modal tpl-modal">
<h3>📋 页面模板</h3>
<h3><Icon name="clipboard" :size="15" /> 页面模板</h3>
<!-- 存当前页 -->
<div class="tpl-save-bar">
<input v-model="nameInput" placeholder="存当前页为模板,输入名称…" @keydown.enter="onSave" />
<button class="btn primary" @click="onSave">💾 保存为模板</button>
<button class="btn primary" @click="onSave"><Icon name="save" :size="14" /> 保存为模板</button>
</div>
<!-- 模板网格按分组分节 -->
@@ -100,7 +101,7 @@ async function onDelete(tpl: PageTemplate) {
<div class="tpl-info">
<span class="tpl-name">{{ tpl.name }}</span>
<span class="tpl-badge" :class="tpl.category">{{ tpl.category === 'built-in' ? '内置' : '自存' }}</span>
<button v-if="tpl.category === 'user'" class="tpl-del" title="删除" @click.stop="onDelete(tpl)">🗑</button>
<button v-if="tpl.category === 'user'" class="tpl-del" title="删除" @click.stop="onDelete(tpl)"><Icon name="trash" :size="15" /></button>
</div>
</div>
</div>
@@ -179,7 +180,7 @@ async function onDelete(tpl: PageTemplate) {
}
.tpl-card:hover {
border-color: var(--ui-primary, #4f46e5);
border-color: var(--ui-primary, #5b5bd6);
box-shadow: 0 4px 12px rgba(15, 23, 42, .12);
transform: translateY(-2px);
}
@@ -227,8 +228,8 @@ async function onDelete(tpl: PageTemplate) {
}
.tpl-badge.user {
background: var(--ui-primary-soft, #eef2ff);
color: var(--ui-primary, #4f46e5);
background: var(--ui-primary-soft, #eeeefc);
color: var(--ui-primary, #5b5bd6);
}
.tpl-del {