Compare commits

..
6 Commits
31 changed files with 2018 additions and 1126 deletions
+24 -33
View File
@@ -11,6 +11,7 @@ import { publishDeck } from './core/share'
import { appAlert } from './core/dialog' import { appAlert } from './core/dialog'
import { isTauri, readLocalFiles } from './core/bridge' import { isTauri, readLocalFiles } from './core/bridge'
import AppDialog from './components/common/AppDialog.vue' import AppDialog from './components/common/AppDialog.vue'
import Icon from './components/common/Icon.vue'
import FileDock from './components/common/FileDock.vue' import FileDock from './components/common/FileDock.vue'
import FilePreviewDrawer from './components/common/FilePreviewDrawer.vue' import FilePreviewDrawer from './components/common/FilePreviewDrawer.vue'
import { addFiles } from './core/attachments' import { addFiles } from './core/attachments'
@@ -19,9 +20,7 @@ import ThumbBar from './components/editor/ThumbBar.vue'
import Canvas from './components/editor/Canvas.vue' import Canvas from './components/editor/Canvas.vue'
import PropsPanel from './components/editor/PropsPanel.vue' import PropsPanel from './components/editor/PropsPanel.vue'
import AiPanel from './components/ai/AiPanel.vue' import AiPanel from './components/ai/AiPanel.vue'
import AgentPanel from './components/ai/AgentPanel.vue' import UnifiedSettingsModal, { type SettingsTab } from './components/modals/UnifiedSettingsModal.vue'
import SettingsModal from './components/modals/SettingsModal.vue'
import OssSettingsModal from './components/modals/OssSettingsModal.vue'
import LibraryModal from './components/modals/LibraryModal.vue' import LibraryModal from './components/modals/LibraryModal.vue'
import TemplateModal from './components/modals/TemplateModal.vue' import TemplateModal from './components/modals/TemplateModal.vue'
import ImportModal from './components/modals/ImportModal.vue' import ImportModal from './components/modals/ImportModal.vue'
@@ -35,14 +34,18 @@ const presentVisible = ref(false)
const presentStartIndex = ref(0) const presentStartIndex = ref(0)
/* ---------- 活动面板 tab ---------- */ /* ---------- 活动面板 tab ---------- */
const activeTab = ref<'props' | 'ai' | 'agent'>('props') const activeTab = ref<'props' | 'ai'>('props')
function switchTab(name: 'props' | 'ai' | 'agent') { function switchTab(name: 'props' | 'ai') {
activeTab.value = name activeTab.value = name
} }
/* ---------- 弹窗 ---------- */ /* ---------- 弹窗 ---------- */
const settingsVisible = ref(false) const settingsVisible = ref(false)
const ossVisible = ref(false) const settingsTab = ref<SettingsTab>('ai')
function openSettings(tab: SettingsTab = 'ai') {
settingsTab.value = tab
settingsVisible.value = true
}
const libraryVisible = ref(false) const libraryVisible = ref(false)
const templateVisible = ref(false) const templateVisible = ref(false)
const importVisible = ref(false) const importVisible = ref(false)
@@ -181,7 +184,7 @@ async function onShare() {
// 未配置云存储时引导到设置页 // 未配置云存储时引导到设置页
if (msg.includes('云存储') || msg.includes('OSS')) { if (msg.includes('云存储') || msg.includes('OSS')) {
await appAlert('无法分享', msg) await appAlert('无法分享', msg)
ossVisible.value = true openSettings('oss')
} else { } else {
await appAlert('分享失败', msg) await appAlert('分享失败', msg)
} }
@@ -225,7 +228,7 @@ function onKey(e: KeyboardEvent) {
if (inField) return; e.preventDefault(); if (store.redo()) toast('已重做'); return if (inField) return; e.preventDefault(); if (store.redo()) toast('已重做'); return
} }
if ((e.ctrlKey || e.metaKey) && e.key === 's') { if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault(); if (!inField) toast('已自动保存到本地'); return e.preventDefault(); onSave(); return
} }
/* 元素剪贴:输入框内放行原生行为(复制文本等);多选时作用于整组 */ /* 元素剪贴:输入框内放行原生行为(复制文本等);多选时作用于整组 */
@@ -412,7 +415,7 @@ async function onGlobalDrop(e: DragEvent) {
e.preventDefault() e.preventDefault()
globalDragOver.value = false globalDragOver.value = false
// 单个 .json 文件 → 直接导入 deck(与工具栏 📥 导入同路径) // 单个 .json 文件 → 直接导入 deck(与工具栏导入同路径)
if (fl.length === 1 && /\.json$/i.test(fl[0].name)) { if (fl.length === 1 && /\.json$/i.test(fl[0].name)) {
await importJsonFile(fl[0]) await importJsonFile(fl[0])
return return
@@ -473,8 +476,7 @@ onUnmounted(() => {
@open-deck="deckLoaded = true; mode = 'editor'" @open-deck="deckLoaded = true; mode = 'editor'"
@ai-create="onNewBlank(); switchTab('ai')" @ai-create="onNewBlank(); switchTab('ai')"
@toast="toast" @toast="toast"
@open-settings="settingsVisible = true" @open-settings="openSettings('ai')"
@open-oss="ossVisible = true"
/> />
<!-- ===================== 编辑模式 ===================== --> <!-- ===================== 编辑模式 ===================== -->
@@ -483,8 +485,7 @@ onUnmounted(() => {
:disabled-actions="disabledActions" :disabled-actions="disabledActions"
@present="onPresent" @present="onPresent"
@open-library="libraryVisible = true" @open-library="libraryVisible = true"
@open-settings="settingsVisible = true" @open-settings="openSettings('ai')"
@open-oss="ossVisible = true"
@save="onSave" @save="onSave"
@open-templates="templateVisible = true" @open-templates="templateVisible = true"
@export-json="onExportJson" @export-json="onExportJson"
@@ -505,9 +506,8 @@ onUnmounted(() => {
<!-- Tabs属性 / AI --> <!-- Tabs属性 / AI -->
<aside class="side-panel"> <aside class="side-panel">
<div class="panel-tabs"> <div class="panel-tabs">
<button class="panel-tab" :class="{ active: activeTab === 'props' }" @click="switchTab('props')">🎨 属性</button> <button class="panel-tab" :class="{ active: activeTab === 'props' }" @click="switchTab('props')"><Icon name="sliders" :size="13" /> 属性</button>
<button class="panel-tab" :class="{ active: activeTab === 'ai' }" @click="switchTab('ai')">🤖 AI 助手</button> <button class="panel-tab" :class="{ active: activeTab === 'ai' }" @click="switchTab('ai')"><Icon name="sparkles" :size="13" /> AI 助手</button>
<button class="panel-tab" :class="{ active: activeTab === 'agent' }" @click="switchTab('agent')">📡 Agent</button>
</div> </div>
<PropsPanel v-show="activeTab === 'props'" /> <PropsPanel v-show="activeTab === 'props'" />
@@ -516,14 +516,9 @@ onUnmounted(() => {
v-show="activeTab === 'ai'" v-show="activeTab === 'ai'"
@busy-change="onBusyChange" @busy-change="onBusyChange"
@toast="toast" @toast="toast"
@open-settings="settingsVisible = true" @open-settings="openSettings('ai')"
@switch-tab="(t: string) => switchTab(t as 'props' | 'ai' | 'agent')" @open-relay-settings="openSettings('relay')"
/> @switch-tab="(t: string) => switchTab(t as 'props' | 'ai')"
<AgentPanel
v-show="activeTab === 'agent'"
@toast="toast"
@open-settings="settingsVisible = true"
/> />
</aside> </aside>
</div> </div>
@@ -537,21 +532,17 @@ onUnmounted(() => {
/> />
<!-- ===================== 弹窗 ===================== --> <!-- ===================== 弹窗 ===================== -->
<SettingsModal <UnifiedSettingsModal
:visible="settingsVisible" :visible="settingsVisible"
:initial-tab="settingsTab"
@close="settingsVisible = false" @close="settingsVisible = false"
@toast="toast" @toast="toast"
/> />
<OssSettingsModal
:visible="ossVisible"
@close="ossVisible = false"
@toast="toast"
/>
<LibraryModal <LibraryModal
:visible="libraryVisible" :visible="libraryVisible"
@close="libraryVisible = false" @close="libraryVisible = false"
@toast="toast" @toast="toast"
@switch-tab="(t: string) => switchTab(t as 'props' | 'ai' | 'agent')" @switch-tab="(t: string) => switchTab(t as 'props' | 'ai')"
@open-deck="mode = 'editor'" @open-deck="mode = 'editor'"
/> />
<TemplateModal <TemplateModal
@@ -564,7 +555,7 @@ onUnmounted(() => {
:visible="importVisible" :visible="importVisible"
@close="onImportClose" @close="onImportClose"
@toast="toast" @toast="toast"
@open-settings="settingsVisible = true" @open-settings="openSettings('ai')"
/> />
<PrintModal <PrintModal
v-if="printVisible" v-if="printVisible"
@@ -577,7 +568,7 @@ onUnmounted(() => {
<!-- 全局拖放提示遮罩 --> <!-- 全局拖放提示遮罩 -->
<div v-if="globalDragOver" class="global-drop-overlay"> <div v-if="globalDragOver" class="global-drop-overlay">
<div class="global-drop-card"> <div class="global-drop-card">
<span class="global-drop-icon">📥</span> <span class="global-drop-icon"><Icon name="download" :size="44" /></span>
<strong>松开导入文件</strong> <strong>松开导入文件</strong>
<span class="global-drop-hint">.json 直接导入 · 图片/文档进入资料导入</span> <span class="global-drop-hint">.json 直接导入 · 图片/文档进入资料导入</span>
</div> </div>
+254 -207
View File
@@ -1,18 +1,21 @@
<!-- ===================================================================== <!-- =====================================================================
HomePage.vue 首页品牌展示快捷入口最近文库 HomePage.vue 首页产品化文档网格
布局逻辑顶栏品牌+全局动作 工作区左主列=文档网格右侧栏=新建入口
去装饰化无光晕/无居中 hero内容即界面文档缩略图是主视觉
===================================================================== --> ===================================================================== -->
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { store } from '../core/store' import Icon from './common/Icon.vue'
import { store, resolveBg } from '../core/store'
import type { LibItem } from '../core/types' import type { LibItem } from '../core/types'
import ElementView from './editor/ElementView.vue'
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'new-blank'): void (e: 'new-blank'): void
(e: 'open-library'): void (e: 'open-library'): void
(e: 'import-materials'): void (e: 'import-materials'): void
(e: 'open-settings'): void (e: 'open-settings'): void
(e: 'open-oss'): void
(e: 'toast', msg: string): void (e: 'toast', msg: string): void
(e: 'open-deck'): void (e: 'open-deck'): void
(e: 'ai-create'): void (e: 'ai-create'): void
@@ -21,7 +24,7 @@ const emit = defineEmits<{
const libVersion = ref(0) const libVersion = ref(0)
const library = computed<LibItem[]>(() => { const library = computed<LibItem[]>(() => {
void libVersion.value void libVersion.value
return store.getLibrary().slice(0, 6) // 最近 6 个 return store.getLibrary()
}) })
function bump() { libVersion.value++ } function bump() { libVersion.value++ }
@@ -34,6 +37,13 @@ function loadItem(id: string) {
} }
} }
function removeItem(e: Event, id: string) {
e.stopPropagation()
const item = library.value.find(x => x.id === id)
store.deleteFromLibrary(id)
emit('toast', '已从文库移除:' + (item?.name || ''))
}
function formatTime(ts: number): string { function formatTime(ts: number): string {
if (!ts) return '' if (!ts) return ''
const diff = Date.now() - ts const diff = Date.now() - ts
@@ -45,8 +55,9 @@ function formatTime(ts: number): string {
} }
function firstSlideTitle(item: LibItem): string { function firstSlideTitle(item: LibItem): string {
const el = item?.deck?.slides?.[0]?.elements?.[0] const slide = item?.deck?.slides?.[0]
return el?.type === 'title' ? (el.content || '无标题') : '无标题' const el = slide?.elements?.find(e => e.type === 'title')
return el?.content?.trim() || '无标题'
} }
/* 上次未入库的工作区草稿(改了没保存就关/刷新),提示继续编辑 */ /* 上次未入库的工作区草稿(改了没保存就关/刷新),提示继续编辑 */
@@ -62,81 +73,97 @@ const workDraft = computed(() => {
<template> <template>
<div class="home"> <div class="home">
<!-- 背景装饰 --> <!-- 顶栏品牌 + 全局动作 -->
<div class="home-bg"> <header class="home-topbar">
<div class="home-glow top-right"></div> <div class="tb-brand">
<div class="home-glow bottom-left"></div> <span class="tb-logo"></span>
<span class="tb-name">u-ppt</span>
</div>
<div class="tb-actions">
<button class="tb-btn ghost" @click="emit('open-library')">
<Icon name="book" :size="15" /> 文库
</button>
<button class="tb-btn ghost" @click="emit('open-settings')">
<Icon name="settings" :size="15" /> 设置
</button>
</div> </div>
<div class="home-inner">
<!-- 品牌区 -->
<header class="home-brand">
<span class="home-logo"></span>
<h1 class="home-title">u-ppt</h1>
<p class="home-desc">轻量在线演示工具 · 支持 AI 创作与本地资料导入</p>
</header> </header>
<!-- 未保存草稿恢复 --> <div class="home-body">
<!-- 左主列文档网格 -->
<main class="home-main">
<!-- 草稿恢复横条 -->
<button v-if="workDraft" class="draft-resume" @click="emit('open-deck')"> <button v-if="workDraft" class="draft-resume" @click="emit('open-deck')">
<span class="draft-icon"></span> <span class="draft-icon"><Icon name="play" :size="13" /></span>
<span class="draft-info"> <span class="draft-info">
<strong>继续编辑{{ workDraft.title }}</strong> <strong>继续编辑{{ workDraft.title }}</strong>
<span>{{ workDraft.pages }} · 上次未保存的草稿已自动暂存</span> <span>{{ workDraft.pages }} · 上次未保存的草稿已自动暂存</span>
</span> </span>
<Icon name="chevron-right" :size="16" class="draft-arrow" />
</button> </button>
<!-- 快捷入口 --> <div class="section-head">
<div class="home-actions"> <h2 class="section-title">我的文档</h2>
<button class="action-card" @click="emit('new-blank')"> <span class="section-count">{{ library.length }}</span>
<span class="action-icon"></span>
<span class="action-label">新建空白</span>
<span class="action-hint">从空白页开始创作</span>
</button>
<button class="action-card" @click="emit('open-library')">
<span class="action-icon">📁</span>
<span class="action-label">从文库打开</span>
<span class="action-hint">继续已有的演示</span>
</button>
<button class="action-card" @click="emit('import-materials')">
<span class="action-icon">📂</span>
<span class="action-label">导入资料</span>
<span class="action-hint">图片/PDF/DOCX AI 生成</span>
</button>
<button class="action-card accent" @click="emit('ai-create')">
<span class="action-icon">🤖</span>
<span class="action-label">AI 创作</span>
<span class="action-hint">输入主题AI 生成全套演示</span>
</button>
</div> </div>
<!-- 最近文库 --> <div v-if="library.length" class="doc-grid">
<div v-if="library.length > 0" class="home-recent">
<h2 class="section-title">最近文档</h2>
<div class="recent-list">
<button <button
v-for="item in library" v-for="item in library"
:key="item.id" :key="item.id"
class="recent-item" class="doc-card"
@click="loadItem(item.id)"
:title="item.name" :title="item.name"
@click="loadItem(item.id)"
> >
<span class="recent-icon">📄</span> <span class="doc-thumb" :style="{ background: item.deck.slides[0] ? resolveBg(item.deck.slides[0].background) : '#fff' }">
<span class="recent-info"> <span v-if="item.deck.slides[0]?.elements?.length" class="doc-thumb-inner">
<span class="recent-name">{{ item.name }}</span> <ElementView v-for="el in item.deck.slides[0].elements" :key="el.id" :el="el" :bg="item.deck.slides[0].background" />
<span class="recent-meta"> </span>
{{ item.deck.slides.length }} · {{ formatTime(item.updatedAt || item.createdAt) }} <span v-else class="doc-thumb-empty"><Icon name="file" :size="20" /></span>
</span>
<span class="doc-meta">
<span class="doc-name">{{ item.name }}</span>
<span class="doc-sub">
<span class="doc-title">{{ firstSlideTitle(item) }}</span>
<span class="doc-time">{{ item.deck.slides.length }} · {{ formatTime(item.updatedAt || item.createdAt) }}</span>
</span> </span>
</span> </span>
<span class="recent-preview">{{ firstSlideTitle(item) }}</span> <span class="doc-remove" title="从文库移除" @click="removeItem($event, item.id)">
<Icon name="trash" :size="13" />
</span>
</button> </button>
</div> </div>
</div>
<!-- 底部 --> <div v-else class="doc-empty">
<footer class="home-footer"> <p class="doc-empty-title">还没有文档</p>
<button class="btn ghost" @click="emit('open-oss')"> 云存储</button> <p class="doc-empty-sub">从右侧开始你的第一份演示</p>
<button class="btn ghost" @click="emit('open-settings')"> 设置</button> </div>
</footer> </main>
<!-- 右侧栏新建入口 -->
<aside class="home-side">
<button class="side-card primary" @click="emit('ai-create')">
<span class="side-icon"><Icon name="sparkles" :size="18" /></span>
<span class="side-text">
<strong>AI 创作</strong>
<span>输入主题生成全套演示</span>
</span>
</button>
<button class="side-card" @click="emit('new-blank')">
<span class="side-icon"><Icon name="plus" :size="18" /></span>
<span class="side-text">
<strong>新建空白</strong>
<span>从空白页开始创作</span>
</span>
</button>
<button class="side-card" @click="emit('import-materials')">
<span class="side-icon"><Icon name="upload" :size="18" /></span>
<span class="side-text">
<strong>导入资料</strong>
<span>图片 / PDF / DOCX</span>
</span>
</button>
</aside>
</div> </div>
</div> </div>
</template> </template>
@@ -145,165 +172,185 @@ const workDraft = computed(() => {
.home { .home {
height: 100vh; height: 100vh;
display: flex; display: flex;
align-items: center; flex-direction: column;
justify-content: center;
position: relative;
overflow: hidden;
background: var(--ui-bg, #f1f5f9); background: var(--ui-bg, #f1f5f9);
overflow: hidden;
} }
/* 背景光晕 */ /* ===== 顶栏 ===== */
.home-bg { position: absolute; inset: 0; pointer-events: none; } .home-topbar {
.home-glow { height: 52px; flex-shrink: 0;
position: absolute; display: flex; align-items: center; justify-content: space-between;
width: 480px; height: 480px; padding: 0 20px;
border-radius: 50%;
filter: blur(120px);
opacity: .08;
}
.home-glow.top-right { top: -120px; right: -80px; background: var(--ui-primary, #4f46e5); }
.home-glow.bottom-left { bottom: -160px; left: -80px; background: var(--ui-primary, #4f46e5); }
.home-inner {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 40px;
padding: 40px 24px;
max-width: 680px;
width: 100%;
}
/* 品牌 */
.home-brand { text-align: center; }
.home-logo {
font-size: 56px; line-height: 1;
color: var(--ui-primary, #4f46e5);
display: block; margin-bottom: 8px;
}
.home-title {
font-size: 32px; font-weight: 700;
letter-spacing: -.02em;
color: var(--ui-text, #1e293b);
}
.home-desc {
margin-top: 6px;
font-size: 15px;
color: var(--ui-muted, #64748b);
}
/* 未保存草稿恢复卡片 */
.draft-resume {
display: flex; align-items: center; gap: 14px;
width: 100%; padding: 14px 18px;
border: 1px solid var(--ui-primary, #4f46e5);
border-radius: 12px;
background: color-mix(in srgb, var(--ui-primary, #4f46e5) 6%, var(--ui-panel, #fff));
cursor: pointer; text-align: left;
transition: box-shadow .15s, transform .15s;
}
.draft-resume:hover { box-shadow: var(--shadow-md, 0 4px 12px rgba(0,0,0,.08)); transform: translateY(-1px); }
.draft-icon {
width: 36px; height: 36px; border-radius: 50%;
background: var(--ui-primary, #4f46e5); color: #fff;
display: flex; align-items: center; justify-content: center;
font-size: 16px; flex-shrink: 0;
}
.draft-info { display: flex; flex-direction: column; gap: 2px; }
.draft-info strong { font-size: 14px; color: var(--ui-text, #1e293b); }
.draft-info span { font-size: 12px; color: var(--ui-muted, #64748b); }
/* 快捷入口网格 */
.home-actions {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
width: 100%;
}
.action-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
padding: 20px;
border: 1px solid var(--ui-border, #e2e8f0);
border-radius: 12px;
background: var(--ui-panel, #fff); background: var(--ui-panel, #fff);
cursor: pointer; border-bottom: 1px solid var(--ui-border, #e2e8f0);
transition: all .15s;
text-align: left;
} }
.action-card:hover { .tb-brand { display: flex; align-items: baseline; gap: 6px; }
border-color: var(--ui-primary, #4f46e5); .tb-logo { color: var(--ui-primary, #5b5bd6); font-size: 18px; font-weight: 800; }
box-shadow: var(--shadow-md, 0 4px 12px rgba(15,23,42,.08)); .tb-name { font-size: 16px; font-weight: 700; letter-spacing: .3px; color: var(--ui-text, #1e293b); }
transform: translateY(-2px); .tb-actions { display: flex; gap: 6px; }
.tb-btn {
display: inline-flex; align-items: center; gap: 5px;
height: 30px; padding: 0 12px;
border: 1px solid transparent; border-radius: var(--radius-sm, 6px);
background: transparent; font-size: 13px; color: var(--ui-muted, #64748b);
} }
.action-card:active { transform: translateY(0); } .tb-btn:hover { background: var(--ui-hover, #f1f5f9); color: var(--ui-text, #1e293b); }
.action-icon { font-size: 28px; line-height: 1; margin-bottom: 4px; }
.action-label { font-size: 16px; font-weight: 600; color: var(--ui-text, #1e293b); }
.action-hint { font-size: 13px; color: var(--ui-muted, #64748b); }
/* 最近文档 */ /* ===== 主体:左文档 + 右新建 ===== */
.home-recent { width: 100%; } .home-body {
flex: 1; min-height: 0;
display: grid;
grid-template-columns: 1fr 248px;
gap: 24px;
padding: 24px 28px 28px;
max-width: 1200px;
width: 100%;
margin: 0 auto;
overflow: hidden;
}
.home-main { min-width: 0; overflow-y: auto; padding-right: 4px; }
/* 草稿恢复横条 */
.draft-resume {
display: flex; align-items: center; gap: 12px;
width: 100%; padding: 12px 16px; margin-bottom: 20px;
border: 1px solid color-mix(in srgb, var(--ui-primary, #5b5bd6) 35%, var(--ui-border, #e2e8f0));
border-radius: var(--radius, 10px);
background: color-mix(in srgb, var(--ui-primary, #5b5bd6) 5%, var(--ui-panel, #fff));
cursor: pointer; text-align: left;
transition: box-shadow .15s;
}
.draft-resume:hover { box-shadow: var(--shadow-md); }
.draft-icon {
width: 30px; height: 30px; border-radius: 50%;
background: var(--ui-primary, #5b5bd6); color: #fff;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.draft-icon svg { margin-left: 2px; }
.draft-info { display: flex; flex-direction: column; gap: 1px; flex: 1; min-width: 0; }
.draft-info strong { font-size: 13.5px; color: var(--ui-text, #1e293b); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.draft-info span { font-size: 12px; color: var(--ui-muted, #64748b); }
.draft-arrow { color: var(--ui-muted, #64748b); flex-shrink: 0; }
/* 区块标题 */
.section-head { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
.section-title { .section-title {
font-size: 14px; font-weight: 600; font-size: 14px; font-weight: 600;
color: var(--ui-muted, #64748b);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: .05em;
}
.recent-list {
display: flex;
flex-direction: column;
border: 1px solid var(--ui-border, #e2e8f0);
border-radius: 10px;
overflow: hidden;
background: var(--ui-panel, #fff);
}
.recent-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border: none;
border-bottom: 1px solid var(--ui-border, #f1f5f9);
background: transparent;
cursor: pointer;
transition: background .1s;
text-align: left;
width: 100%;
}
.recent-item:last-child { border-bottom: none; }
.recent-item:hover { background: var(--ui-hover, #f1f5f9); }
.recent-icon { font-size: 20px; flex-shrink: 0; }
.recent-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.recent-name {
font-size: 14px; font-weight: 500;
color: var(--ui-text, #1e293b); color: var(--ui-text, #1e293b);
overflow: hidden; margin: 0;
text-overflow: ellipsis;
white-space: nowrap;
} }
.recent-meta { .section-count {
font-size: 12px; font-size: 11px; font-weight: 600; color: var(--ui-muted, #64748b);
color: var(--ui-muted, #64748b); background: var(--ui-hover, #f1f5f9);
} padding: 1px 7px; border-radius: 99px;
.recent-preview {
font-size: 13px;
color: var(--ui-muted, #64748b);
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
/* 底部 */ /* ===== 文档网格 ===== */
.home-footer { display: flex; gap: 8px; } .doc-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(196px, 1fr));
gap: 14px;
}
.doc-card {
position: relative;
display: flex; flex-direction: column; gap: 0;
border: 1px solid var(--ui-border, #e2e8f0);
border-radius: var(--radius, 10px);
background: var(--ui-panel, #fff);
overflow: hidden; cursor: pointer; text-align: left; padding: 0;
transition: box-shadow .15s, border-color .15s, transform .1s;
}
.doc-card:hover {
border-color: color-mix(in srgb, var(--ui-primary, #5b5bd6) 45%, var(--ui-border, #e2e8f0));
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.doc-thumb {
position: relative; width: 100%; aspect-ratio: 16 / 9;
border-bottom: 1px solid var(--ui-border, #e2e8f0);
overflow: hidden; background: #fff; display: block;
container-type: inline-size; /* 供 cqw 计算自适应缩放 */
}
.doc-thumb-inner {
position: absolute; top: 0; left: 0; width: 1280px; height: 720px;
/* 100cqw = 缩略图实际宽度,scale = 宽/1280,列宽变化时缩略图精确等比 */
transform: scale(calc(100cqw / 1280)); transform-origin: top left;
pointer-events: none; display: block;
}
.doc-thumb-empty {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
color: var(--ui-border, #cbd5e1);
}
.doc-meta {
display: flex; flex-direction: column; gap: 3px;
padding: 10px 12px 11px; min-width: 0;
}
.doc-name {
font-size: 13.5px; font-weight: 600; color: var(--ui-text, #1e293b);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.doc-sub { display: flex; align-items: center; gap: 6px; min-width: 0; }
.doc-title {
font-size: 12px; color: var(--ui-muted, #64748b);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0;
}
.doc-time { font-size: 11.5px; color: var(--ui-muted, #64748b); opacity: .8; flex-shrink: 0; }
.doc-remove {
position: absolute; top: 6px; right: 6px; z-index: 2;
width: 24px; height: 24px; border-radius: 6px;
display: flex; align-items: center; justify-content: center;
background: rgba(15, 23, 42, .55); color: #fff;
opacity: 0; transition: opacity .12s, background .12s;
}
.doc-card:hover .doc-remove { opacity: 1; }
.doc-remove:hover { background: var(--ui-danger, #e11d48); }
/* 空态 */
.doc-empty {
padding: 64px 0; text-align: center;
border: 1.5px dashed var(--ui-border, #e2e8f0);
border-radius: var(--radius, 10px);
}
.doc-empty-title { font-size: 14px; font-weight: 600; color: var(--ui-text, #1e293b); margin: 0 0 4px; }
.doc-empty-sub { font-size: 12.5px; color: var(--ui-muted, #64748b); margin: 0; }
/* ===== 右侧栏:新建入口 ===== */
.home-side {
display: flex; flex-direction: column; gap: 10px;
align-self: start;
position: sticky; top: 0;
}
.side-card {
display: flex; align-items: flex-start; gap: 12px;
padding: 14px 16px;
border: 1px solid var(--ui-border, #e2e8f0);
border-radius: var(--radius, 10px);
background: var(--ui-panel, #fff);
cursor: pointer; text-align: left;
transition: border-color .15s, box-shadow .15s;
}
.side-card:hover { border-color: var(--ui-border, #cbd5e1); box-shadow: var(--shadow-sm); }
.side-card.primary {
background: var(--ui-primary, #5b5bd6);
border-color: var(--ui-primary, #5b5bd6);
}
.side-card.primary:hover {
background: #4a4ac4;
box-shadow: 0 4px 14px color-mix(in srgb, var(--ui-primary, #5b5bd6) 35%, transparent);
}
.side-card.primary .side-text strong { color: #fff; }
.side-card.primary .side-text span { color: rgba(255, 255, 255, .78); }
.side-icon {
width: 32px; height: 32px; border-radius: 8px;
background: var(--ui-primary-soft, #eeeefc); color: var(--ui-primary, #5b5bd6);
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.side-card.primary .side-icon { background: rgba(255, 255, 255, .16); color: #fff; }
.side-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.side-text strong { font-size: 14px; font-weight: 600; color: var(--ui-text, #1e293b); }
.side-text span { font-size: 12px; color: var(--ui-muted, #64748b); }
</style> </style>
-223
View File
@@ -1,223 +0,0 @@
<!-- =====================================================================
AgentPanel.vue Agent 模式面板u-relay 中继接入远端 Agent
连接状态徽标 + 指令输入 + 进度流 + 结果按 SEP 协议应用到 deck
===================================================================== -->
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { store } from '../../core/store'
import { relay, type RelayStatus } from '../../core/relay'
import { buildAgentPrompt, parseChatReply } from '../../core/ai'
import { renderMd } from '../../core/markdown'
const emit = defineEmits<{
(e: 'toast', msg: string): void
(e: 'open-settings'): void
}>()
interface StreamMsg {
key: number
role: 'user' | 'assistant' | 'system'
content: string
streaming?: boolean
tag?: string
error?: boolean
}
const messagesEl = ref<HTMLElement | null>(null)
const inputEl = ref<HTMLTextAreaElement | null>(null)
const inputText = ref('')
const status = ref<RelayStatus>(relay.getStatus())
const statusDetail = ref('')
const busy = ref(false)
let keySeq = 0
const msgs = ref<StreamMsg[]>([])
const configured = computed(() => relay.isConfigured())
const STATUS_LABEL: Record<RelayStatus, string> = {
disabled: '未启用',
connecting: '连接中',
connected: '已连接',
reconnecting: '重连中',
error: '连接失败'
}
function scrollBottom() {
nextTick(() => {
const el = messagesEl.value
if (el) el.scrollTop = el.scrollHeight
})
}
function push(role: StreamMsg['role'], content: string, opts?: Partial<StreamMsg>): StreamMsg {
const m: StreamMsg = { key: ++keySeq, role, content, ...opts }
msgs.value.push(m)
scrollBottom()
return m
}
/** 进行中的请求:request_id → 流式气泡 */
const inflight = new Map<string, StreamMsg>()
function onStatus(s: RelayStatus, detail?: string) {
status.value = s
statusDetail.value = detail || ''
}
function onProgress(rid: string, text: string) {
const m = inflight.get(rid)
if (!m) return
m.content = text
m.streaming = true
scrollBottom()
}
function onResult(rid: string, text: string) {
let m = inflight.get(rid)
inflight.delete(rid)
relay.settle(rid)
if (m) {
m.content = text
m.streaming = false
} else {
m = push('assistant', text)
}
applyResult(m)
busy.value = false
}
function applyResult(m: StreamMsg) {
const { reply, op } = parseChatReply(m.content)
m.content = reply || '(无文字回复)'
if (op && op.action !== 'answer' && op.slides.length) {
const slides = op.slides
const idx = store.getCurrentIndex()
if (op.action === 'create_all') {
store.replaceDeck({ theme: store.theme.value, slides }, { newChat: true })
m.tag = '已替换为 ' + slides.length + ' 页新演示'
} else if (op.action === 'add_page') {
const at = (op.target != null ? op.target : idx) + 1
store.insertSlideAt(Math.min(at, store.getCount()), slides[0])
m.tag = '已新增 1 页'
} else if (op.action === 'update_page') {
const t = Math.max(0, Math.min(op.target != null ? op.target : idx, store.getCount() - 1))
store.replaceSlide(t, slides[0])
if (t !== idx) store.setCurrentIndex(t)
m.tag = '已更新第 ' + (t + 1) + ' 页'
}
}
scrollBottom()
}
async function onSend() {
if (busy.value || !inputText.value.trim()) return
if (!configured.value) { emit('toast', '请先在设置中配置 Agent 中继'); emit('open-settings'); return }
if (status.value !== 'connected') { emit('toast', '中继未连接,请稍候'); return }
const input = inputText.value.trim()
inputText.value = ''
push('user', input)
const prompt = buildAgentPrompt(input)
busy.value = true
try {
const rid = relay.request(prompt)
inflight.set(rid, push('assistant', '', { streaming: true }))
} catch (e: any) {
push('assistant', '⚠ ' + (e?.message || String(e)), { error: true })
busy.value = false
}
}
function onToggleConnect() {
if (status.value === 'connected' || status.value === 'connecting' || status.value === 'reconnecting') {
relay.disconnect()
} else {
relay.connect()
}
}
function onInputKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onSend() }
}
relay.setHandlers({ onStatus, onProgress, onResult })
onMounted(() => {
if (configured.value) relay.connect()
})
onUnmounted(() => {
// 面板卸载不断开共享连接(切 Tab 不掉线),仅清 handler 由单例保底
})
</script>
<template>
<div class="panel-pane agent-pane">
<div class="agent-status-bar">
<span class="status-badge" :class="status">
<i class="dot"></i>{{ STATUS_LABEL[status] }}
</span>
<button class="agent-action ghost" @click="onToggleConnect">
{{ (status === 'connected' || status === 'connecting' || status === 'reconnecting') ? '断开' : '连接' }}
</button>
<button class="agent-action ghost" @click="emit('open-settings')">设置</button>
</div>
<div v-if="!configured" class="agent-empty">
未配置 Agent 中继请点击右上角设置填写<br />中继 URL / Token / 设备 ID三项齐备即启用
</div>
<div v-show="configured" class="agent-messages" ref="messagesEl">
<div v-if="!msgs.length" class="agent-empty">
通过中继把指令发给远端 Agent例如<br />把当前页的标题改得更有冲击力
</div>
<div v-for="m in msgs" :key="m.key" class="msg" :class="m.role">
<div class="bubble" :class="{ error: m.error }">
<span v-if="m.tag" class="diff-tag"> {{ m.tag }}</span>
<div v-if="m.content" class="md-body" v-html="renderMd(m.content)"></div>
<span v-if="m.streaming" class="cursor"></span>
</div>
</div>
</div>
<div class="chat-input-bar">
<textarea
ref="inputEl"
v-model="inputText"
rows="3"
:placeholder="configured ? '输入 Agent 指令,回车发送(Shift+Enter 换行)' : '请先配置中继'"
:disabled="!configured"
@keydown="onInputKeydown"
></textarea>
<div class="btns">
<button class="btn primary" :disabled="busy || !configured" @click="onSend">发送</button>
</div>
</div>
</div>
</template>
<style scoped>
.agent-pane { display: flex; flex-direction: column; height: 100%; }
.agent-status-bar {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px; border-bottom: 1px solid var(--ui-border);
}
.status-badge {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12px; color: var(--ui-text-secondary, #888); flex: 1;
}
.status-badge .dot { width: 8px; height: 8px; border-radius: 50%; background: #9ca3af; }
.status-badge.connected .dot { background: #22c55e; }
.status-badge.connecting .dot, .status-badge.reconnecting .dot { background: #f59e0b; animation: pulse 1.2s infinite; }
.status-badge.error .dot { background: #ef4444; }
@keyframes pulse { 50% { opacity: 0.3; } }
.agent-action {
border: none; background: none; cursor: pointer; font-size: 12px;
color: var(--ui-text-secondary, #888); padding: 2px 6px;
}
.agent-action:hover { color: var(--ui-text, #333); }
.agent-messages { flex: 1; overflow-y: auto; padding: 10px; }
.agent-empty {
color: var(--ui-text-secondary, #999); font-size: 13px;
text-align: center; padding: 32px 12px; line-height: 1.8;
}
</style>
+403 -56
View File
@@ -1,30 +1,35 @@
<!-- ===================================================================== <!-- =====================================================================
AiPanel.vue AI 聊天面板 AiPanel.vue AI 聊天面板双通道直连 LLM / u-relay Agent
发送/停止/生成整套/润色本页/流式渲染/操作应用 通道切换/发送分流/流式渲染/操作应用direct chatLog 持久化
agent 走内存 msgs不持久化op 应用统一走 applyOp
===================================================================== --> ===================================================================== -->
<script setup lang="ts"> <script setup lang="ts">
import { ref, nextTick, computed, watch, onUnmounted } from 'vue' import { ref, nextTick, computed, watch, onMounted, onUnmounted } from 'vue'
import type { ChatMessage, AiOp, Slide } from '../../core/types' import type { ChatMessage, AiOp, Outline, Slide } from '../../core/types'
import { store } from '../../core/store' import { store } from '../../core/store'
import { generate, polish, chat, beautifyPage, isConfigured } from '../../core/ai' import { polish, chat, beautifyPage, isConfigured, buildAgentPrompt, parseChatReply, pageInstruction } from '../../core/ai'
import { relay, type RelayStatus } from '../../core/relay'
import { elementTypes } from '../../core/sample' import { elementTypes } from '../../core/sample'
import { renderMd } from '../../core/markdown' import { renderMd } from '../../core/markdown'
import { appPrompt, appConfirm } from '../../core/dialog' import { appConfirm } from '../../core/dialog'
import OutlinePanel from './OutlinePanel.vue' import OutlinePanel from './OutlinePanel.vue'
import Icon from '../common/Icon.vue'
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'busy-change', busy: boolean): void (e: 'busy-change', busy: boolean): void
(e: 'toast', msg: string): void (e: 'toast', msg: string): void
(e: 'open-settings'): void (e: 'open-settings'): void
(e: 'open-relay-settings'): void
(e: 'switch-tab', tab: string): void (e: 'switch-tab', tab: string): void
}>() }>()
const CHAT_KEY = 'u-ppt.chat.v1' // 旧 key,仅用于迁移 const CHAT_KEY = 'u-ppt.chat.v1' // 旧 key,仅用于迁移
const AGENT_CHAT_PREFIX = 'u-ppt.agentchat.v1.' // agent 会话持久化前缀(按 chatId 隔离)
/** 当前会话绑定的 chatId(跟随 deck.chatId */ /** 当前会话绑定的 chatId(跟随 deck.chatId */
const currentChatId = computed(() => store.getChatId()) const currentChatId = computed(() => store.getChatId())
/** 渲染用消息条目(带可选的流式/标签/error 状态) */ /** 渲染用消息条目(带可选的流式/标签/error 状态direct/agent 通道共用结构 */
interface RenderMsg { interface RenderMsg {
key: number key: number
role: 'user' | 'assistant' | 'system' role: 'user' | 'assistant' | 'system'
@@ -34,8 +39,29 @@ interface RenderMsg {
error?: boolean error?: boolean
} }
/* ---------- 通道 ---------- */
const channel = ref<'direct' | 'agent'>('direct')
const status = ref<RelayStatus>(relay.getStatus())
const statusDetail = ref('')
const agentConfigured = computed(() => relay.isConfigured())
const STATUS_LABEL: Record<RelayStatus, string> = {
disabled: '未启用',
connecting: '连接中',
connected: '已连接',
reconnecting: '重连中',
error: '连接失败'
}
const renderMsgs = ref<RenderMsg[]>([]) const renderMsgs = ref<RenderMsg[]>([])
/** agent 通道消息(按 chatId 持久化到 localStoragetag/error/streaming 状态不存) */
const agentMsgs = ref<RenderMsg[]>([])
/** 当前通道渲染的消息源 */
const viewMsgs = computed(() => (channel.value === 'direct' ? renderMsgs.value : agentMsgs.value))
const chatLog = ref<ChatMessage[]>(loadChat()) const chatLog = ref<ChatMessage[]>(loadChat())
/** agent 通道当前大纲(agent 返回 outline op 后填充,传给 OutlinePanel */
const agentOutline = ref<Outline | null>(null)
const messagesEl = ref<HTMLElement | null>(null) const messagesEl = ref<HTMLElement | null>(null)
const inputEl = ref<HTMLTextAreaElement | null>(null) const inputEl = ref<HTMLTextAreaElement | null>(null)
@@ -69,6 +95,43 @@ function persistChat() {
}, 400) }, 400)
} }
/* ---------- agent 会话持久化(独立前缀,结构与 ChatMessage 一致,tag/error 不存) ---------- */
let agentSaveTimer: ReturnType<typeof setTimeout> | null = null
/** 加载该 chatId 的 agent 会话(剥掉持久化字段) */
function loadAgentChat(): RenderMsg[] {
try {
const list: ChatMessage[] = JSON.parse(localStorage.getItem(AGENT_CHAT_PREFIX + currentChatId.value) || '[]') || []
return list.map(m => ({ key: ++keySeq, role: m.role as 'user' | 'assistant', content: m.content }))
} catch (e) { return [] }
}
/** 防抖持久化 agent 会话(仅 role/contentquota 溢出静默丢弃旧条目) */
function persistAgentChat() {
if (agentSaveTimer) clearTimeout(agentSaveTimer)
agentSaveTimer = setTimeout(() => {
const list: ChatMessage[] = agentMsgs.value.map(m => ({ role: m.role === 'system' ? 'assistant' : m.role, content: m.content }))
// 超 1.5MB 时成对丢弃最旧条目(参考 store.setChat
while (list.length > 2) {
if (JSON.stringify(list).length <= 1_500_000) break
list.splice(0, 2)
}
try { localStorage.setItem(AGENT_CHAT_PREFIX + currentChatId.value, JSON.stringify(list)) } catch (e) { /* quota 满静默丢弃 */ }
}, 400)
}
/** agent 通道追加一条消息(内存 + 防抖持久化) */
function addAgentMsg(m: RenderMsg) {
agentMsgs.value.push(m)
persistAgentChat()
}
/** 清空 agent 会话(内存 + localStorage */
function clearAgentChat() {
agentMsgs.value = []
try { localStorage.removeItem(AGENT_CHAT_PREFIX + currentChatId.value) } catch (e) {}
}
function setBusy(b: boolean) { function setBusy(b: boolean) {
busy.value = b busy.value = b
emit('busy-change', b) emit('busy-change', b)
@@ -192,14 +255,14 @@ function streamSetText(s: StreamCtrl, txt: string) {
s.flushPending = false s.flushPending = false
} }
function streamError(s: StreamCtrl, msg: string) { function streamError(s: StreamCtrl, msg: string) {
streamSetText(s, '⚠ ' + msg) streamSetText(s, msg)
s.msg.error = true s.msg.error = true
} }
function streamTag(s: StreamCtrl, txt: string) { function streamTag(s: StreamCtrl, txt: string) {
if (txt) s.msg.tag = txt if (txt) s.msg.tag = txt
} }
/** 应用 AI 返回的操作到 store */ /** 应用 AI 返回的操作到 storedirect/agent 通道统一入口;target 越界 clamp,跨页跳转) */
function applyOp(op: AiOp, lockedIdx: number): string { function applyOp(op: AiOp, lockedIdx: number): string {
const slides = op.slides const slides = op.slides
if (op.action === 'create_all' && slides.length) { if (op.action === 'create_all' && slides.length) {
@@ -231,11 +294,167 @@ function persistStream(s: StreamCtrl) {
persistChat() persistChat()
} }
/* ---------- Agent 通道:relay handler 挂接 ---------- */
/** 进行中的 agent 请求:request_id → 流式气泡 */
const inflight = new Map<string, RenderMsg>()
/** 单一忙碌请求的 rid(用于「停止」) */
let agentBusyRid: string | null = null
/** 请求级看门狗:agent 180s 无响应则按停止语义释放,防挂死 */
let agentWatchdog: ReturnType<typeof setTimeout> | null = null
const AGENT_TIMEOUT_MS = 180_000
/** 当前 inflight 单页生成对应的大纲条目下标(null=非单页请求),结果到达时按此对齐写入 */
let agentPendingPageIdx: number | null = null
function onRelayStatus(s: RelayStatus, detail?: string) {
status.value = s
statusDetail.value = detail || ''
}
function onRelayProgress(rid: string, text: string) {
const m = inflight.get(rid)
if (!m) return
m.content = text
m.streaming = true
scrollBottom()
}
function onRelayResult(rid: string, text: string) {
let m = inflight.get(rid)
inflight.delete(rid)
relay.settle(rid)
if (rid === agentBusyRid) {
agentBusyRid = null
if (agentWatchdog) { clearTimeout(agentWatchdog); agentWatchdog = null }
setBusy(false)
}
if (m) {
m.content = text
m.streaming = false
} else {
m = { key: ++keySeq, role: 'assistant', content: text }
agentMsgs.value.push(m)
}
// SEP 协议解析与 op 应用(与 direct 通道共用 applyOp,行为一致)
const { reply, op } = parseChatReply(m.content)
m.content = reply || '(无文字回复)'
if (op && op.action === 'outline' && op.outline) {
// agent 返回大纲:填充 topic 后交给 OutlinePanel(作为 initialOutline),自动展开面板
op.outline.topic = op.outline.topic || agentOutlineTopic.value
agentOutline.value = op.outline
showOutline.value = true
} else if (op && (op.action === 'gen_page' || op.action === 'add_page') && op.slides.length) {
// 大纲单页生成结果:有大纲则按请求时记录的条目下标对齐回收(面板关闭也照写画布),回收后驱动下一条(串行全部生成)
if (agentOutline.value) {
outlinePanelEl.value?.acceptAgentSlide(op.slides[0], agentPendingPageIdx != null ? agentPendingPageIdx : undefined)
agentPendingPageIdx = null
driveNextOutlinePage()
} else {
toast('收到页面结果但无活动大纲,已忽略')
agentPendingPageIdx = null
}
} else if (op && op.action !== 'answer' && op.slides.length) {
m.tag = applyOp(op, store.getCurrentIndex())
}
addAgentMsg(m)
scrollBottom()
}
relay.setHandlers({ onStatus: onRelayStatus, onProgress: onRelayProgress, onResult: onRelayResult })
onMounted(() => {
// 面板挂载即连(配置齐备才连);卸载不断开(切 Tab 不掉线,relay 单例保底)
if (relay.isConfigured()) relay.connect()
})
function onToggleConnect() {
if (status.value === 'connected' || status.value === 'connecting' || status.value === 'reconnecting') {
relay.disconnect()
} else {
relay.connect()
}
}
/** 切换通道 */
function switchChannel(c: 'direct' | 'agent') {
channel.value = c
nextTick(scrollBottom)
}
/** agent 通道发送(发送分流与快捷操作共用;带多轮历史与选中元素上下文) */
function sendAgent(input: string) {
if (!agentConfigured.value) { toast('请先在设置中配置 Agent 中继'); emit('open-relay-settings'); return }
if (status.value !== 'connected') {
toast('中继未连接,请稍候')
agentFailCurrent('')
return
}
// busy 守卫:同一时刻只允许一个 agent 请求,防止并发覆盖 agentBusyRid 打断串行链
if (agentBusyRid) { toast('当前有生成任务进行中'); return }
// 历史取最近 10 条(剥离 tag 后缀),不含本次输入
const history = agentMsgs.value.slice(-10).map(m => ({
role: m.role,
content: m.content.replace(/\n?\[✓[^\]]*\]$/g, '').replace(/\n?(已停止)$/g, '')
}))
addAgentMsg({ key: ++keySeq, role: 'user', content: input })
const prompt = buildAgentPrompt(input, selectedEl.value, history)
try {
const rid = relay.request(prompt)
agentBusyRid = rid
const m: RenderMsg = { key: ++keySeq, role: 'assistant', content: '', streaming: true }
addAgentMsg(m)
inflight.set(rid, m)
setBusy(true)
// 请求级看门狗:180s 无响应按停止语义释放,防请求挂死
if (agentWatchdog) clearTimeout(agentWatchdog)
agentWatchdog = setTimeout(() => {
agentWatchdog = null
if (rid !== agentBusyRid) return
const pageIdx = agentPendingPageIdx
stopAgent()
toast('agent 响应超时')
if (pageIdx != null) toast('第 ' + (pageIdx + 1) + ' 页生成失败,可手动重试')
}, AGENT_TIMEOUT_MS)
} catch (e: any) {
addAgentMsg({ key: ++keySeq, role: 'assistant', content: (e?.message || String(e)), error: true })
agentFailCurrent(e?.message || String(e))
}
scrollBottom()
}
/** agent 请求失败收尾:清看门狗/占位,复位 busy 允许手动续发;单页请求时提示带页码的失败信息 */
function agentFailCurrent(reason: string) {
if (agentWatchdog) { clearTimeout(agentWatchdog); agentWatchdog = null }
agentBusyRid = null
const pageIdx = agentPendingPageIdx
agentPendingPageIdx = null
if (busy.value) setBusy(false)
if (pageIdx != null) toast('第 ' + (pageIdx + 1) + ' 页生成失败' + (reason ? '' + reason : '') + ',可手动重试')
else if (reason) toast(reason)
}
/** agent 通道停止:放弃匹配槽,气泡标「(已停止)」 */
function stopAgent() {
if (!agentBusyRid) return
const rid = agentBusyRid
agentBusyRid = null
if (agentWatchdog) { clearTimeout(agentWatchdog); agentWatchdog = null }
const m = inflight.get(rid)
inflight.delete(rid)
relay.settle(rid)
if (m) {
m.streaming = false
if (!m.content) m.content = '(已停止)'
else m.content += '\n(已停止)'
}
setBusy(false)
}
/* ---------- 发送 ---------- */ /* ---------- 发送 ---------- */
function onSend() { function onSend() {
if (busy.value) return if (busy.value) return
const text = inputText.value.trim() const text = inputText.value.trim()
if (!text) return if (!text) return
if (channel.value === 'agent') { inputText.value = ''; sendAgent(text); return }
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return } if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
inputText.value = '' inputText.value = ''
runChat(text) runChat(text)
@@ -287,46 +506,31 @@ async function runChat(input: string) {
} }
function onStop() { function onStop() {
if (channel.value === 'agent') { stopAgent(); return }
if (abortCtrl) abortCtrl.abort() if (abortCtrl) abortCtrl.abort()
} }
/* ---------- 生成整套 ---------- */ /* ---------- 生成整套:统一入口,打开大纲面板并聚焦主题输入(大纲驱动创作) ---------- */
async function onGenerate() { function onGenerate() {
if (busy.value) return if (busy.value) return
const topic = inputText.value.trim() || await appPrompt('生成整套', { message: '请输入演示主题', placeholder: '例如「远程办公的兴起与未来」' }) // 输入框已有主题则预填进大纲面板(沿用旧版「输入框即主题」习惯),并清空聊天输入避免两处重复
if (!topic) return const prefill = inputText.value.trim()
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return } if (prefill) inputText.value = ''
inputText.value = '' showOutline.value = true
const stream = streamBubble('正在创作「' + topic + '」…') nextTick(() => outlinePanelEl.value?.focusTopic(prefill))
setBusy(true); abortCtrl = new AbortController()
try {
const r = await generate({ topic, signal: abortCtrl.signal })
streamDone(stream)
// 生成整套 → 开新会话,旧会话保留在 localStorage
store.replaceDeck({ theme: store.theme.value, slides: r.slides }, { newChat: true })
// watch(currentChatId) 会自动清空 renderMsgs 并加载新会话(空的)
// 在新会话里记录这次生成
addPersisted('user', '✨ 生成整套:' + topic)
addPersisted('assistant', '✅ 已生成 ' + r.slides.length + ' 页演示。可在画布查看与微调,Ctrl+Z 可撤销。')
} catch (e: any) {
if (e?.name === 'AbortError') streamSetText(stream, '(已停止)')
else streamError(stream, e?.message || String(e))
persistStream(stream)
} finally {
setBusy(false); abortCtrl = null
}
} }
/* ---------- 润色本页 ---------- */ /* ---------- 润色本页 ---------- */
async function onPolish() { async function onPolish() {
if (busy.value) return if (busy.value) return
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
const slide = store.currentSlide.value const slide = store.currentSlide.value
if (!slide) return if (!slide) return
const idx0 = store.getCurrentIndex() const idx0 = store.getCurrentIndex()
const idx = idx0 + 1 const idx = idx0 + 1
addPersisted('user', '🪄 润色第 ' + idx + ' 页') // agent 通道:转自然语言指令走中继
if (channel.value === 'agent') { sendAgent('润色第 ' + idx + ' 页:让内容更有吸引力、表达更精炼,保持布局合理'); return }
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
addPersisted('user', '润色第 ' + idx + ' 页')
const stream = streamBubble('正在润色第 ' + idx + ' 页…') const stream = streamBubble('正在润色第 ' + idx + ' 页…')
setBusy(true); abortCtrl = new AbortController() setBusy(true); abortCtrl = new AbortController()
@@ -334,7 +538,7 @@ async function onPolish() {
const r = await polish({ slide: slide as Slide, instruction: '让内容更有吸引力、表达更精炼,保持布局合理', signal: abortCtrl.signal }) const r = await polish({ slide: slide as Slide, instruction: '让内容更有吸引力、表达更精炼,保持布局合理', signal: abortCtrl.signal })
streamDone(stream) streamDone(stream)
if (idx0 < store.getCount()) store.replaceSlide(idx0, r.slide) if (idx0 < store.getCount()) store.replaceSlide(idx0, r.slide)
streamSetText(stream, '🪄 已润色第 ' + idx + ' 页' + (r.note ? '' + r.note : '') + '。Ctrl+Z 可撤销。') streamSetText(stream, '已润色第 ' + idx + ' 页' + (r.note ? '' + r.note : '') + '。Ctrl+Z 可撤销。')
persistStream(stream) persistStream(stream)
} catch (e: any) { } catch (e: any) {
if (e?.name === 'AbortError') streamSetText(stream, '(已停止)') if (e?.name === 'AbortError') streamSetText(stream, '(已停止)')
@@ -347,6 +551,12 @@ async function onPolish() {
/* ---------- 清空 ---------- */ /* ---------- 清空 ---------- */
async function onClear() { async function onClear() {
if (channel.value === 'agent') {
if (!agentMsgs.value.length) { toast('对话已是空的'); return }
if (!(await appConfirm('清空对话记录?', '将清空当前 PPT 的 Agent 对话记录', { danger: true, okText: '清空' }))) return
clearAgentChat()
return
}
if (!chatLog.value.length) { toast('对话已是空的'); return } if (!chatLog.value.length) { toast('对话已是空的'); return }
if (!(await appConfirm('清空对话记录?', '将清空当前 PPT 的对话记录', { danger: true, okText: '清空' }))) return if (!(await appConfirm('清空对话记录?', '将清空当前 PPT 的对话记录', { danger: true, okText: '清空' }))) return
chatLog.value = [] chatLog.value = []
@@ -354,19 +564,55 @@ async function onClear() {
renderMsgs.value = [] renderMsgs.value = []
} }
/* ---------- 大纲面板 ---------- */ /* ---------- 大纲面板(双通道通用;agent 通道走 outline/gen_page op ---------- */
const outlinePanelEl = ref<InstanceType<typeof OutlinePanel> | null>(null)
/** agent 通道大纲主题暂存(OutlinePanel 主题输入回传,用于填充 outline.topic */
const agentOutlineTopic = ref('')
function onToggleOutline() { function onToggleOutline() {
showOutline.value = !showOutline.value showOutline.value = !showOutline.value
} }
/** OutlinePanelagent 通道)转发指令:把生成大纲/单页的自然语言指令发给中继 Agent */
function onOutlineRequestAgent(instruction: string, pageIdx?: number) {
if (!instruction.startsWith('按大纲生成')) {
// 生成大纲指令:记住主题,agent 返回 outline 后回填 topic;期间面板进入生成中状态(agentBusyRid 驱动面板忙态)
const m = instruction.match(/主题「([^」]+)」/)
if (m) agentOutlineTopic.value = m[1]
agentOutline.value = null
agentPendingPageIdx = null
} else {
// 单页生成:记录目标条目下标,结果到达时按此对齐写入(避免扫首个未完成错位)
agentPendingPageIdx = pageIdx != null ? pageIdx : null
}
sendAgent(instruction)
}
/** agent 单页生成完成后的串行驱动:OutlinePanel 逐页回收后自动发下一条未完成条目 */
function driveNextOutlinePage() {
const panel = outlinePanelEl.value
if (!panel || !agentOutline.value) return
const next = agentOutline.value.items.findIndex(it => !it.done)
if (next < 0) { toast('大纲全部页已生成'); return }
panel.requestAgent(pageInstruction(agentOutline.value!.items[next], next, agentOutline.value!.items.length, curAgentPlan()), next)
agentPendingPageIdx = next
}
/** agent 通道当前大纲 → 整套规划摘要(注入单页指令,约束套内风格一致) */
function curAgentPlan() {
const o = agentOutline.value
return o ? { title: o.title, items: o.items.map(it => ({ kind: it.kind, title: it.title })) } : undefined
}
/* ---------- 一键美化 ---------- */ /* ---------- 一键美化 ---------- */
async function onBeautify() { async function onBeautify() {
if (busy.value) return if (busy.value) return
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
const deck = store.getDeck() const deck = store.getDeck()
const slides = deck.slides const slides = deck.slides
if (!slides || !slides.length) { toast('当前没有幻灯片可美化'); return } if (!slides || !slides.length) { toast('当前没有幻灯片可美化'); return }
addPersisted('user', '🎨 一键美化全部 (' + slides.length + ' 页)') // agent 通道:转自然语言指令走中继
if (channel.value === 'agent') { sendAgent('美化当前演示的全部 ' + slides.length + ' 页,让视觉与排版更精致'); return }
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
addPersisted('user', '一键美化全部 (' + slides.length + ' 页)')
const stream = streamBubble('正在美化第 1/' + slides.length + ' 页…') const stream = streamBubble('正在美化第 1/' + slides.length + ' 页…')
setBusy(true); abortCtrl = new AbortController() setBusy(true); abortCtrl = new AbortController()
@@ -381,7 +627,7 @@ async function onBeautify() {
done++ done++
} }
streamDone(stream) streamDone(stream)
streamSetText(stream, '已美化 ' + done + ' 页') streamSetText(stream, '已美化 ' + done + ' 页')
persistStream(stream) persistStream(stream)
} catch (e: any) { } catch (e: any) {
if (e?.name === 'AbortError') streamSetText(stream, '(已停止,已美化 ' + done + ' 页)') if (e?.name === 'AbortError') streamSetText(stream, '(已停止,已美化 ' + done + ' 页)')
@@ -401,10 +647,11 @@ function isBusy() { return busy.value }
function focus() { nextTick(() => inputEl.value?.focus()) } function focus() { nextTick(() => inputEl.value?.focus()) }
defineExpose({ isBusy, focus }) defineExpose({ isBusy, focus })
/* ---------- 初始化:从持久化记录重建 ---------- */ /* ---------- 初始化:从持久化记录重建direct + agent 双通道) ---------- */
rebuildFromChatLog() rebuildFromChatLog()
agentMsgs.value = loadAgentChat()
/* 组件卸载:停掉所有在跑的打字机 rAF */ /* 组件卸载:停掉所有在跑的打字机 rAF;不断开 relay(切 Tab 不掉线) */
onUnmounted(() => { onUnmounted(() => {
for (const s of activeStreams) { for (const s of activeStreams) {
if (s.rafId != null) cancelAnimationFrame(s.rafId) if (s.rafId != null) cancelAnimationFrame(s.rafId)
@@ -412,10 +659,15 @@ onUnmounted(() => {
activeStreams.clear() activeStreams.clear()
}) })
/* ---------- 会话切换:chatId 变化时重新加载对话 ---------- */ /* ---------- 会话切换:chatId 变化时重新加载对话direct + agent 双通道) ---------- */
watch(currentChatId, () => { watch(currentChatId, () => {
// 有 inflight agent 请求先按停止语义清理(含看门狗/busy 复位),避免旧会话结果串进新会话
if (agentBusyRid) stopAgent()
agentPendingPageIdx = null
chatLog.value = loadChat() chatLog.value = loadChat()
rebuildFromChatLog() rebuildFromChatLog()
agentMsgs.value = loadAgentChat()
agentOutline.value = null
scrollBottom() scrollBottom()
}) })
</script> </script>
@@ -423,16 +675,51 @@ watch(currentChatId, () => {
<template> <template>
<div class="panel-pane ai-pane"> <div class="panel-pane ai-pane">
<div class="ai-actions"> <div class="ai-actions">
<button class="ai-action" :disabled="busy" @click="onGenerate"> 生成整套</button> <button class="ai-action" :disabled="busy" @click="onGenerate"><Icon name="sparkles" :size="14" /> 生成整套</button>
<button class="ai-action" :disabled="busy" @click="onPolish">🪄 润色本页</button> <button class="ai-action" :disabled="busy" @click="onPolish"><Icon name="wand" :size="14" /> 润色本页</button>
<button class="ai-action" :disabled="busy" @click="onToggleOutline">📋 大纲</button> <button class="ai-action" :disabled="busy" @click="onToggleOutline"><Icon name="clipboard" :size="14" /> 大纲</button>
<button class="ai-action" :disabled="busy" @click="onBeautify">🎨 美化</button> <button class="ai-action" :disabled="busy" @click="onBeautify"><Icon name="palette" :size="14" /> 美化</button>
<button class="ai-action ghost" :disabled="busy" @click="onClear">🗑 清空</button> <button class="ai-action ghost" :disabled="busy" @click="onClear"><Icon name="trash" :size="14" /> 清空</button>
<!-- 通道切换segmentedagent 段带状态色点 -->
<div class="channel-switch">
<button class="seg" :class="{ active: channel === 'direct' }" @click="switchChannel('direct')"><Icon name="zap" :size="14" /> 直连</button>
<button
class="seg"
:class="{ active: channel === 'agent' }"
:title="statusDetail ? STATUS_LABEL[status] + ' · ' + statusDetail : STATUS_LABEL[status]"
@click="switchChannel('agent')"
>
<i class="seg-dot" :class="status"></i>Agent
</button>
</div>
</div> </div>
<OutlinePanel v-if="showOutline" :visible="showOutline" @busy-change="setBusy" @toast="toast" @open-settings="emit('open-settings')" /> <!-- agent 通道连接控制 + 未配置空态 -->
<div v-if="channel === 'agent' && agentConfigured" class="agent-status-bar">
<span class="status-badge" :class="status">
<i class="dot"></i>{{ STATUS_LABEL[status] }}{{ statusDetail ? ' · ' + statusDetail : '' }}
</span>
<button class="agent-action ghost" @click="onToggleConnect">
{{ (status === 'connected' || status === 'connecting' || status === 'reconnecting') ? '断开' : '连接' }}
</button>
<button class="agent-action ghost" @click="emit('open-relay-settings')">设置</button>
</div>
<!-- 选中元素提示条告诉用户 AI 会围绕这个元素对话 --> <OutlinePanel
v-if="showOutline"
ref="outlinePanelEl"
:visible="showOutline"
:initial-outline="channel === 'agent' ? agentOutline : null"
:agent-channel="channel === 'agent'"
:agent-busy="channel === 'agent' && busy"
@busy-change="setBusy"
@toast="toast"
@open-settings="emit('open-settings')"
@request-agent="onOutlineRequestAgent"
/>
<!-- 选中元素提示条两通道共用告诉 AI 会围绕这个元素对话 -->
<div v-if="selectedHint" class="selected-hint" title="AI 对话将基于此选中元素"> <div v-if="selectedHint" class="selected-hint" title="AI 对话将基于此选中元素">
<span class="selected-hint-icon"></span> <span class="selected-hint-icon"></span>
<span class="selected-hint-text">已选中{{ selectedHint }}</span> <span class="selected-hint-text">已选中{{ selectedHint }}</span>
@@ -440,18 +727,32 @@ watch(currentChatId, () => {
</div> </div>
<div class="chat-messages" ref="messagesEl"> <div class="chat-messages" ref="messagesEl">
<div v-if="!renderMsgs.length" class="chat-empty"> <!-- agent 未配置空态 -->
<div v-if="channel === 'agent' && !agentConfigured" class="chat-empty">
未配置 Agent 中继<br />
三项配置中继 URL / Token / 设备ID齐备即启用<br />
<button class="btn primary go-settings" @click="emit('open-relay-settings')">去设置</button>
</div>
<template v-else>
<div v-if="!viewMsgs.length && !showOutline" class="chat-empty">
<template v-if="channel === 'agent'">
通过中继把指令发给远端 Agent例如<br />
把当前页的标题<b>改得更有冲击力</b>
</template>
<template v-else>
告诉我你的主题例如<br /> 告诉我你的主题例如<br />
生成一份关于<b>远程办公趋势</b>的演示 生成一份关于<b>远程办公趋势</b>的演示
</template>
</div> </div>
<div v-for="m in renderMsgs" :key="m.key" class="msg" :class="m.role"> <div v-for="m in viewMsgs" :key="m.key" class="msg" :class="m.role">
<div class="bubble" :class="{ error: m.error }"> <div class="bubble" :class="{ error: m.error }">
<span v-if="m.tag" class="diff-tag"> {{ m.tag }}</span> <span v-if="m.tag" class="diff-tag"><Icon name="check" :size="12" /> {{ m.tag }}</span>
<!-- Markdown 整段渲染含代码块/列表/加粗等 DOMPurify 消毒 --> <!-- Markdown 整段渲染含代码块/列表/加粗等 DOMPurify 消毒 -->
<div v-if="m.content" class="md-body" v-html="renderMd(m.content)"></div> <div v-if="m.content" class="md-body" v-html="renderMd(m.content)"></div>
<span v-if="m.streaming" class="cursor"></span> <span v-if="m.streaming" class="cursor"></span>
</div> </div>
</div> </div>
</template>
</div> </div>
<div class="chat-input-bar"> <div class="chat-input-bar">
@@ -460,13 +761,59 @@ watch(currentChatId, () => {
v-model="inputText" v-model="inputText"
id="chatInput" id="chatInput"
rows="3" rows="3"
placeholder="输入指令,回车发送(Shift+Enter 换行)" :placeholder="channel === 'agent' && !agentConfigured ? '请先配置中继' : '输入指令,回车发送(Shift+Enter 换行)'"
:disabled="channel === 'agent' && !agentConfigured"
@keydown="onInputKeydown" @keydown="onInputKeydown"
></textarea> ></textarea>
<div class="btns"> <div class="btns">
<button v-if="!busy" class="btn primary" @click="onSend">发送</button> <button v-if="!busy" class="btn primary" :disabled="channel === 'agent' && !agentConfigured" @click="onSend">发送</button>
<button v-else class="btn danger" @click="onStop">停止</button> <button v-else class="btn danger" @click="onStop">停止</button>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<style scoped>
/* ---------- 通道切换(segmented,与 28px ai-action 协调) ---------- */
.channel-switch {
display: inline-flex;
margin-left: auto;
border: 1px solid var(--ui-border, #ddd);
border-radius: 6px;
overflow: hidden;
}
.seg {
display: inline-flex; align-items: center; gap: 5px;
height: 26px; padding: 0 9px;
border: none; background: none; cursor: pointer;
font-size: 12px; color: var(--ui-text-secondary, #888);
}
.seg + .seg { border-left: 1px solid var(--ui-border, #ddd); }
.seg.active { background: var(--ui-primary-soft, #eef); color: var(--ui-primary, #4f6ef7); font-weight: 600; }
.seg-dot { width: 7px; height: 7px; border-radius: 50%; background: #9ca3af; }
.seg-dot.connected { background: #22c55e; }
.seg-dot.connecting, .seg-dot.reconnecting { background: #f59e0b; animation: seg-pulse 1.2s infinite; }
.seg-dot.error { background: #ef4444; }
@keyframes seg-pulse { 50% { opacity: 0.3; } }
/* ---------- agent 状态条(自原 AgentPanel 迁入) ---------- */
.agent-status-bar {
display: flex; align-items: center; gap: 8px;
padding: 6px 10px; border-bottom: 1px solid var(--ui-border);
}
.status-badge {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12px; color: var(--ui-text-secondary, #888); flex: 1;
}
.status-badge .dot { width: 8px; height: 8px; border-radius: 50%; background: #9ca3af; }
.status-badge.connected .dot { background: #22c55e; }
.status-badge.connecting .dot, .status-badge.reconnecting .dot { background: #f59e0b; animation: seg-pulse 1.2s infinite; }
.status-badge.error .dot { background: #ef4444; }
.agent-action {
border: none; background: none; cursor: pointer; font-size: 12px;
color: var(--ui-text-secondary, #888); padding: 2px 6px;
}
.agent-action:hover { color: var(--ui-text, #333); }
.go-settings { margin-top: 10px; }
</style>
+198 -32
View File
@@ -3,34 +3,95 @@
生成大纲 逐条编辑 逐页/全部生成 应用到文稿 生成大纲 逐条编辑 逐页/全部生成 应用到文稿
===================================================================== --> ===================================================================== -->
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed, watch, onMounted, nextTick } from 'vue'
import type { Outline, OutlineItem, Slide } from '../../core/types' import type { Outline, OutlineItem, Slide } from '../../core/types'
import { store } from '../../core/store' import { store } from '../../core/store'
import { outline as genOutline, generatePage, isConfigured } from '../../core/ai' import { outline as genOutline, generatePage, pageInstruction, isConfigured } from '../../core/ai'
import Icon from '../common/Icon.vue'
defineProps<{ visible: boolean }>() const props = defineProps<{ visible: boolean; initialOutline?: Outline | null; agentChannel?: boolean; agentBusy?: boolean }>()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'busy-change', busy: boolean): void (e: 'busy-change', busy: boolean): void
(e: 'toast', msg: string): void (e: 'toast', msg: string): void
(e: 'open-settings'): void (e: 'open-settings'): void
/** agent 通道:把生成大纲/单页的指令交由 AiPanel 转发给中继 Agent(单页时携带目标条目下标) */
(e: 'request-agent', instruction: string, pageIdx?: number): void
}>() }>()
const outline = ref<Outline | null>(null) const outline = ref<Outline | null>(null)
const topicInput = ref('') const topicInput = ref('')
const topicEl = ref<HTMLInputElement | null>(null)
const titleInput = ref('') const titleInput = ref('')
const editingTitle = ref(false) const editingTitle = ref(false)
const busy = ref(false) const busy = ref(false)
/** agent 通道忙态(由 AiPanel 的 agentBusyRid 经 prop 下传):请求进行中禁用交互防并发打断 */
const agentBusy = computed(() => !!props.agentBusy)
/** 面板级总忙态:本地生成忙 or agent 请求忙 */
const locked = computed(() => busy.value || agentBusy.value)
const progress = ref({ cur: 0, total: 0 }) const progress = ref({ cur: 0, total: 0 })
const generatedSlides = ref<Slide[]>([]) const generatedSlides = ref<Slide[]>([])
const pageCount = ref<'auto' | number>('auto')
/** 页数分段选项:自动=AI 按主题信息量裁量,其余为指定页数 */
const pageCountOpts: { value: 'auto' | number; label: string; tip: string }[] = [
{ value: 'auto', label: '自动', tip: '按主题信息量由 AI 裁量(5-12 页)' },
{ value: 5, label: '5', tip: '固定 5 页' },
{ value: 7, label: '7', tip: '固定 7 页' },
{ value: 10, label: '10', tip: '固定 10 页' },
{ value: 12, label: '12', tip: '固定 12 页' }
]
const expanded = ref<Set<string>>(new Set())
let abortCtrl: AbortController | null = null let abortCtrl: AbortController | null = null
function toggleExpand(id: string) {
const s = expanded.value
s.has(id) ? s.delete(id) : s.add(id)
expanded.value = new Set(s)
}
/** 首个生成页:整体替换文稿(保留主题),后续页追加;返回写入的页索引 */
function commitSlide(slide: Slide, isFirst: boolean): number {
if (isFirst) {
store.replaceDeck({ theme: store.theme.value, slides: [slide] }, { newChat: true })
return 0
}
store.appendSlide(slide)
return store.getCount() - 1
}
/** 挂载/外部传入大纲变化时(agent 通道返回 outline op),进入编辑态 */
onMounted(() => {
if (props.initialOutline) applyInitialOutline(props.initialOutline)
})
watch(() => props.initialOutline, (o) => { if (o) applyInitialOutline(o) })
function applyInitialOutline(o: Outline) {
outline.value = o
titleInput.value = o.title
generatedSlides.value = []
expanded.value = new Set()
}
/** 接收 agent 通道生成的单页(由 AiPanel 在 op 返回后调用;pageIdx 为请求时记录的目标条目下标) */
function acceptAgentSlide(slide: Slide, pageIdx?: number) {
// 优先按请求时记录的条目下标对齐;无记录时退回扫首个未完成;generatedSlides 与 items 同索引(空位补 undefined 占位)
const scanned = outline.value ? outline.value.items.findIndex(it => !it.done) : -1
const idx = pageIdx != null ? pageIdx : scanned
const at = idx >= 0 ? idx : generatedSlides.value.length
while (generatedSlides.value.length < at) generatedSlides.value.push(undefined as unknown as Slide)
generatedSlides.value[at] = slide
const isFirst = doneCount.value === 0
if (idx >= 0) outline.value!.items[idx].done = true
const committed = commitSlide(slide, isFirst)
store.setCurrentIndex(committed)
}
/** kind → 徽标文本与颜色 */ /** kind → 徽标文本与颜色 */
const KIND_META: Record<OutlineItem['kind'], { label: string; color: string }> = { const KIND_META: Record<OutlineItem['kind'], { label: string; color: string }> = {
cover: { label: '封面', color: '#4f46e5' }, cover: { label: '封面', color: '#5b5bd6' },
toc: { label: '目录', color: '#06b6d4' }, toc: { label: '目录', color: '#06b6d4' },
content: { label: '内容', color: '#64748b' }, content: { label: '内容', color: '#64748b' },
quote: { label: '金句', color: '#f59e0b' }, quote: { label: '金句', color: '#f59e0b' },
end: { label: '结尾', color: '#4f46e5' } end: { label: '结尾', color: '#5b5bd6' }
} }
function setBusy(b: boolean) { function setBusy(b: boolean) {
@@ -44,17 +105,25 @@ const allDone = computed(() => total.value > 0 && doneCount.value === total.valu
/* ---------- 生成大纲 ---------- */ /* ---------- 生成大纲 ---------- */
async function onGenOutline() { async function onGenOutline() {
if (busy.value) return if (locked.value) return
const topic = topicInput.value.trim() const topic = topicInput.value.trim()
if (!topic) { emit('toast', '请先输入主题'); return } if (!topic) { emit('toast', '请先输入主题'); return }
// agent 通道:转自然语言指令走中继,大纲由 agent 返回(AiPanel 填充 initialOutline
if (props.agentChannel) {
emit('request-agent', pageCount.value === 'auto'
? '拟定大纲:主题「' + topic + '」,页数按主题信息量自动裁量(5-12页)'
: '拟定大纲:主题「' + topic + '」,约 ' + pageCount.value + ' 页')
return
}
if (!isConfigured()) { emit('open-settings'); return } if (!isConfigured()) { emit('open-settings'); return }
setBusy(true) setBusy(true)
abortCtrl = new AbortController() abortCtrl = new AbortController()
try { try {
const r = await genOutline({ topic, signal: abortCtrl.signal }) const r = await genOutline({ topic, count: pageCount.value, signal: abortCtrl.signal })
outline.value = r outline.value = r
titleInput.value = r.title titleInput.value = r.title
generatedSlides.value = [] generatedSlides.value = []
expanded.value = new Set()
emit('toast', '已生成 ' + r.items.length + ' 条大纲') emit('toast', '已生成 ' + r.items.length + ' 条大纲')
} catch (e: any) { } catch (e: any) {
if (e?.name === 'AbortError') emit('toast', '已停止') if (e?.name === 'AbortError') emit('toast', '已停止')
@@ -85,11 +154,18 @@ function removeItem(idx: number) {
outline.value.items.splice(idx, 1) outline.value.items.splice(idx, 1)
} }
/** 当前大纲 → 整套规划摘要(注入单页生成,约束套内风格一致) */
function curPlan() {
return outline.value ? { title: outline.value.title, items: outline.value.items.map(it => ({ kind: it.kind, title: it.title })) } : undefined
}
/* ---------- 生成单页 ---------- */ /* ---------- 生成单页 ---------- */
async function onGenOne(idx: number) { async function onGenOne(idx: number) {
if (busy.value || !outline.value) return if (locked.value || !outline.value) return
const item = outline.value.items[idx] const item = outline.value.items[idx]
if (!item) return if (!item) return
// agent 通道:指令交由 AiPanel 转发(携带目标条目下标用于结果对齐),结果经 acceptAgentSlide 回收
if (props.agentChannel) { emit('request-agent', pageInstruction(item, idx, outline.value.items.length, curPlan()), idx); return }
setBusy(true) setBusy(true)
abortCtrl = new AbortController() abortCtrl = new AbortController()
try { try {
@@ -97,11 +173,15 @@ async function onGenOne(idx: number) {
item, item,
index: idx, index: idx,
total: outline.value.items.length, total: outline.value.items.length,
plan: curPlan(),
signal: abortCtrl.signal signal: abortCtrl.signal
}) })
// 保持 generatedSlides 与 items 顺序对齐 // 保持 generatedSlides 与 items 顺序对齐
const isFirst = doneCount.value === 0
generatedSlides.value[idx] = slide generatedSlides.value[idx] = slide
item.done = true item.done = true
const committed = commitSlide(slide, isFirst)
store.setCurrentIndex(committed)
emit('toast', '已生成第 ' + (idx + 1) + ' 页') emit('toast', '已生成第 ' + (idx + 1) + ' 页')
} catch (e: any) { } catch (e: any) {
if (e?.name === 'AbortError') emit('toast', '已停止') if (e?.name === 'AbortError') emit('toast', '已停止')
@@ -113,11 +193,19 @@ async function onGenOne(idx: number) {
/* ---------- 全部生成(串行) ---------- */ /* ---------- 全部生成(串行) ---------- */
async function onGenAll() { async function onGenAll() {
if (busy.value || !outline.value) return if (locked.value || !outline.value) return
const items = outline.value.items const items = outline.value.items
// agent 通道:发首条指令即返回,AiPanel 在每页回收后驱动下一条(见 onOutlineRequestAgent),保证串行与页面顺序对齐
if (props.agentChannel) {
const next = items.findIndex(it => !it.done)
if (next < 0) { emit('toast', '全部条目已生成'); return }
emit('request-agent', pageInstruction(items[next], next, items.length, curPlan()), next)
return
}
setBusy(true) setBusy(true)
abortCtrl = new AbortController() abortCtrl = new AbortController()
progress.value = { cur: 0, total: items.length } progress.value = { cur: 0, total: items.length }
let committedCount = doneCount.value
try { try {
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
if (abortCtrl.signal.aborted) break if (abortCtrl.signal.aborted) break
@@ -126,10 +214,15 @@ async function onGenAll() {
item: items[i], item: items[i],
index: i, index: i,
total: items.length, total: items.length,
plan: curPlan(),
signal: abortCtrl.signal signal: abortCtrl.signal
}) })
const isFirst = committedCount === 0
generatedSlides.value[i] = slide generatedSlides.value[i] = slide
items[i].done = true items[i].done = true
committedCount++
const committed = commitSlide(slide, isFirst)
store.setCurrentIndex(committed)
} }
emit('toast', '全部生成完成(' + items.filter(it => it.done).length + '/' + items.length + '') emit('toast', '全部生成完成(' + items.filter(it => it.done).length + '/' + items.length + '')
} catch (e: any) { } catch (e: any) {
@@ -146,25 +239,42 @@ function onStop() {
if (abortCtrl) abortCtrl.abort() if (abortCtrl) abortCtrl.abort()
} }
/* ---------- 应用到文稿 ---------- */ defineExpose({
function onApply() { acceptAgentSlide,
if (!outline.value) return requestAgent: (s: string, idx?: number) => emit('request-agent', s, idx),
const slides = generatedSlides.value.filter(Boolean) /** 供「生成整套」入口聚焦主题输入框;prefill 为可选预填主题(来自聊天输入框) */
if (!slides.length) { emit('toast', '请先生成至少一页'); return } focusTopic: (prefill?: string) => {
store.replaceDeck({ theme: store.theme.value, slides }, { newChat: true }) if (prefill) topicInput.value = prefill
emit('toast', '已应用 ' + slides.length + ' 页到文稿') nextTick(() => topicEl.value?.focus())
} }
})
</script> </script>
<template> <template>
<div class="outline-panel"> <div class="outline-panel">
<!-- 主题输入 + 生成大纲 --> <!-- 主题输入 + 生成大纲 -->
<div class="outline-top"> <div class="outline-top">
<input type="text" v-model="topicInput" placeholder="输入主题,例如「人工智能的产业落地」" :disabled="busy" @keydown.enter="onGenOutline" /> <input ref="topicEl" type="text" v-model="topicInput" placeholder="输入主题,例如「人工智能的产业落地」" :disabled="locked" @keydown.enter="onGenOutline" />
<button class="btn primary" :disabled="busy" @click="onGenOutline"> 生成大纲</button> <button class="btn primary" :disabled="locked" @click="onGenOutline"><Icon name="sparkles" :size="14" /> 生成大纲</button>
<button v-if="busy" class="btn danger" @click="onStop">停止</button> <button v-if="busy" class="btn danger" @click="onStop">停止</button>
</div> </div>
<!-- 页数选择一体式分段单选与通道切换 .seg 同语言一键直达免下拉 -->
<div class="outline-count" role="radiogroup" aria-label="生成页数">
<span class="count-label">页数</span>
<div class="count-seg">
<button
v-for="opt in pageCountOpts" :key="String(opt.value)"
type="button" role="radio" :aria-checked="pageCount === opt.value"
class="count-pill" :class="{ active: pageCount === opt.value }"
:title="opt.tip" :disabled="locked"
@click="pageCount = opt.value"
>{{ opt.label }}</button>
</div>
<span class="count-hint" v-if="pageCount === 'auto'">AI 裁量</span>
<span class="count-hint" v-else>固定 {{ pageCount }} </span>
</div>
<!-- 无大纲时的空态 --> <!-- 无大纲时的空态 -->
<div v-if="!outline" class="outline-empty"> <div v-if="!outline" class="outline-empty">
输入主题后点击生成大纲<br />AI 会先拟定大纲再逐页生成 输入主题后点击生成大纲<br />AI 会先拟定大纲再逐页生成
@@ -175,32 +285,34 @@ function onApply() {
<!-- 标题 --> <!-- 标题 -->
<div class="outline-title"> <div class="outline-title">
<input v-if="editingTitle" type="text" v-model="titleInput" @blur="commitTitle" @keydown.enter="commitTitle" /> <input v-if="editingTitle" type="text" v-model="titleInput" @blur="commitTitle" @keydown.enter="commitTitle" />
<h4 v-else @click="startEditTitle" title="点击编辑">{{ outline.title || '(未命名)' }} <span class="edit-hint"></span></h4> <h4 v-else @click="startEditTitle" title="点击编辑">{{ outline.title || '(未命名)' }} <span class="edit-hint"><Icon name="edit" :size="12" /></span></h4>
</div> </div>
<!-- 条目列表 --> <!-- 条目列表 -->
<div class="outline-items"> <div class="outline-items">
<div v-for="(it, i) in outline.items" :key="it.id" class="outline-item"> <div v-for="(it, i) in outline.items" :key="it.id" class="outline-item">
<div class="item-head"> <div class="item-head" @click="toggleExpand(it.id)">
<span class="fold-arrow" :class="{ open: expanded.has(it.id) }"></span>
<span class="kind-badge" :style="{ background: KIND_META[it.kind].color }">{{ KIND_META[it.kind].label }}</span> <span class="kind-badge" :style="{ background: KIND_META[it.kind].color }">{{ KIND_META[it.kind].label }}</span>
<input class="item-title" type="text" v-model="it.title" :disabled="busy" /> <input class="item-title" type="text" v-model="it.title" :disabled="locked" @click.stop />
<span class="status" :class="{ done: it.done }">{{ it.done ? '已生成' : '待生成' }}</span> <span class="status" :class="{ done: it.done }">{{ it.done ? '已生成' : '待生成' }}</span>
<button class="btn small" :disabled="busy" @click="onGenOne(i)" :title="'生成第 ' + (i + 1) + ' 页'">生成此页</button> <button class="btn small" :disabled="locked" @click.stop="onGenOne(i)" :title="'生成第 ' + (i + 1) + ' 页'">生成此页</button>
<button class="btn small danger" :disabled="busy" @click="removeItem(i)" title="删除">🗑</button> <button class="btn small danger" :disabled="locked" @click.stop="removeItem(i)" title="删除"><Icon name="trash" :size="13" /></button>
</div> </div>
<div class="item-summary" v-if="!expanded.has(it.id) && it.points.length">{{ it.points[0] }}</div>
<!-- 要点列表 --> <!-- 要点列表 -->
<div class="item-points"> <div class="item-points" v-show="expanded.has(it.id)">
<div v-for="(p, pi) in it.points" :key="pi" class="point-row"> <div v-for="(p, pi) in it.points" :key="pi" class="point-row">
<input type="text" v-model="it.points[pi]" :disabled="busy" placeholder="要点内容" /> <input type="text" v-model="it.points[pi]" :disabled="locked" placeholder="要点内容" />
<button class="btn small ghost" :disabled="busy" @click="removePoint(it, pi)" title="删除要点"></button> <button class="btn small ghost" :disabled="locked" @click="removePoint(it, pi)" title="删除要点"><Icon name="x" :size="13" /></button>
</div> </div>
<button class="btn small ghost add-point" :disabled="busy" @click="addPoint(it)">+ 添加要点</button> <button class="btn small ghost add-point" :disabled="locked" @click="addPoint(it)">+ 添加要点</button>
</div> </div>
<!-- hint --> <!-- hint -->
<div class="item-hint"> <div class="item-hint" v-show="expanded.has(it.id)">
<input type="text" v-model="it.hint" :disabled="busy" placeholder="补充指令(可选,如「用对比卡片」「数据页」" /> <input type="text" v-model="it.hint" :disabled="locked" placeholder="补充指令(可选,如「用对比卡片」「数据页」" />
</div> </div>
</div> </div>
</div> </div>
@@ -209,8 +321,7 @@ function onApply() {
<div class="outline-footer"> <div class="outline-footer">
<span class="progress-text" v-if="progress.total">{{ progress.cur }}/{{ progress.total }}</span> <span class="progress-text" v-if="progress.total">{{ progress.cur }}/{{ progress.total }}</span>
<span class="done-count" v-else>{{ doneCount }}/{{ total }} 页已生成</span> <span class="done-count" v-else>{{ doneCount }}/{{ total }} 页已生成</span>
<button class="btn primary" :disabled="busy || allDone" @click="onGenAll">全部生成</button> <button class="btn primary" :disabled="locked || allDone" @click="onGenAll">全部生成</button>
<button class="btn" :disabled="busy || !doneCount" @click="onApply">应用到文稿</button>
</div> </div>
</div> </div>
</div> </div>
@@ -233,6 +344,61 @@ function onApply() {
} }
.outline-top input { flex: 1; } .outline-top input { flex: 1; }
/* 页数分段单选:一体式外壳+互斥 pill,与通道切换(.seg)同设计语言 */
.outline-count {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.count-label {
font-size: 11px;
color: var(--ui-muted);
flex-shrink: 0;
}
.count-seg {
display: inline-flex;
border: 1px solid var(--ui-border);
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--ui-panel);
}
.count-pill {
height: 24px;
padding: 0 10px;
border: none;
background: none;
cursor: pointer;
font-size: 12px;
color: var(--ui-muted);
transition: background .12s, color .12s;
}
.count-pill + .count-pill { border-left: 1px solid var(--ui-border); }
.count-pill:hover:not(:disabled):not(.active) { background: var(--ui-primary-soft); }
.count-pill.active {
background: var(--ui-primary-soft);
color: var(--ui-primary);
font-weight: 600;
}
.count-pill:disabled { cursor: not-allowed; opacity: .55; }
.count-hint {
font-size: 11px;
color: var(--ui-muted);
opacity: .85;
}
.fold-arrow {
display: inline-block; font-size: 10px; color: var(--ui-muted);
transition: transform .15s; flex-shrink: 0; cursor: pointer;
}
.fold-arrow.open { transform: rotate(90deg); }
.item-head { cursor: pointer; }
.item-head input, .item-head button { cursor: auto; }
.item-summary {
font-size: 11px; color: var(--ui-muted);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
margin: -2px 0 4px 22px;
}
.outline-empty { .outline-empty {
color: var(--ui-muted); color: var(--ui-muted);
font-size: 13px; font-size: 13px;
+6 -5
View File
@@ -5,11 +5,12 @@
===================================================================== --> ===================================================================== -->
<script setup lang="ts"> <script setup lang="ts">
import { attachmentState, openPreview, removeAttachment, clearAttachments, formatSize } from '../../core/attachments' import { attachmentState, openPreview, removeAttachment, clearAttachments, formatSize } from '../../core/attachments'
import Icon from './Icon.vue'
/* 类型 → 图标(纯 emoji,与工具栏风格一致,零依赖 */ /* 类型 → 图标名(内联 SVG 图标组件 */
const ICONS: Record<string, string> = { const ICONS: Record<string, string> = {
image: '🖼️', video: '🎬', pdf: '📕', image: 'image', video: 'video', pdf: 'book',
markdown: '📝', text: '📄', doc: '📘', meta: '📎' markdown: 'file-text', text: 'file-text', doc: 'book', meta: 'paperclip'
} }
</script> </script>
@@ -17,7 +18,7 @@ const ICONS: Record<string, string> = {
<!-- 有附件才显示 --> <!-- 有附件才显示 -->
<div v-if="attachmentState.list.value.length" class="file-dock"> <div v-if="attachmentState.list.value.length" class="file-dock">
<div class="file-dock-head"> <div class="file-dock-head">
<span class="file-dock-title">📎 附件 {{ attachmentState.list.value.length }}</span> <span class="file-dock-title"><Icon name="paperclip" :size="14" /> 附件 {{ attachmentState.list.value.length }}</span>
<button class="file-dock-clear" title="清空附件" @click="clearAttachments">清空</button> <button class="file-dock-clear" title="清空附件" @click="clearAttachments">清空</button>
</div> </div>
<div class="file-dock-list"> <div class="file-dock-list">
@@ -29,7 +30,7 @@ const ICONS: Record<string, string> = {
:title="`${att.name} · ${formatSize(att.size)}`" :title="`${att.name} · ${formatSize(att.size)}`"
@click="openPreview(att.id)" @click="openPreview(att.id)"
> >
<span class="file-chip-icon">{{ ICONS[att.kind] || '📎' }}</span> <span class="file-chip-icon"><Icon :name="ICONS[att.kind] || 'paperclip'" :size="14" /></span>
<span class="file-chip-name">{{ att.name }}</span> <span class="file-chip-name">{{ att.name }}</span>
<span class="file-chip-size">{{ formatSize(att.size) }}</span> <span class="file-chip-size">{{ formatSize(att.size) }}</span>
<button <button
+3 -2
View File
@@ -7,6 +7,7 @@
import { computed, ref, onMounted, onBeforeUnmount, watch } from 'vue' import { computed, ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { attachmentState, closePreview, downloadAttachment, getAttachment, loadPreviewText } from '../../core/attachments' import { attachmentState, closePreview, downloadAttachment, getAttachment, loadPreviewText } from '../../core/attachments'
import { renderMd } from '../../core/markdown' import { renderMd } from '../../core/markdown'
import Icon from './Icon.vue'
const activeAttachment = computed(() => getAttachment(attachmentState.activeId.value)) const activeAttachment = computed(() => getAttachment(attachmentState.activeId.value))
const renderedMarkdown = computed(() => renderMd(activeAttachment.value?.text || '')) const renderedMarkdown = computed(() => renderMd(activeAttachment.value?.text || ''))
@@ -51,7 +52,7 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeydown))
<aside class="file-preview-drawer" role="dialog" aria-modal="true" :aria-label="`${activeAttachment.name} 预览`"> <aside class="file-preview-drawer" role="dialog" aria-modal="true" :aria-label="`${activeAttachment.name} 预览`">
<header class="file-preview-head"> <header class="file-preview-head">
<div class="file-preview-title" :title="activeAttachment.name"> <div class="file-preview-title" :title="activeAttachment.name">
<span class="file-preview-title-icon">📎</span> <span class="file-preview-title-icon"><Icon name="paperclip" :size="14" /></span>
<span>{{ activeAttachment.name }}</span> <span>{{ activeAttachment.name }}</span>
</div> </div>
<div class="file-preview-actions"> <div class="file-preview-actions">
@@ -100,7 +101,7 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeydown))
<!-- 无结构化预览的文件 --> <!-- 无结构化预览的文件 -->
<div v-else class="file-preview-meta"> <div v-else class="file-preview-meta">
<span class="file-preview-meta-icon">📎</span> <span class="file-preview-meta-icon"><Icon name="paperclip" :size="12" /></span>
<strong>{{ activeAttachment.name }}</strong> <strong>{{ activeAttachment.name }}</strong>
<span>{{ activeAttachment.file.type || '未知文件类型' }}</span> <span>{{ activeAttachment.file.type || '未知文件类型' }}</span>
<span>{{ activeAttachment.size.toLocaleString() }} B</span> <span>{{ activeAttachment.size.toLocaleString() }} B</span>
+92
View File
@@ -0,0 +1,92 @@
<!-- =====================================================================
Icon.vue 内联 SVG 线性图标Tabler/Feather 风格零依赖
用法<Icon name="save" :size="16" /> 颜色随 currentColor替代 emoji 图标
===================================================================== -->
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{
name: string
size?: number | string
strokeWidth?: number | string
}>(), { size: 16, strokeWidth: 1.8 })
const ICONS: Record<string, string> = {
/* 文件/目录 */
'folder': '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>',
'folder-open': '<path d="M3 6a1 1 0 0 1 1-1h5l2 2h8a1 1 0 0 1 1 1v2"/><path d="M3 10h18l-1.6 8.2a1 1 0 0 1-1 .8H5.6a1 1 0 0 1-1-.8z"/>',
'file': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/>',
'file-text': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/>',
'book': '<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>',
'image': '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/>',
'video': '<path d="m23 7-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2"/>',
'cloud': '<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"/>',
/* 存取/流转 */
'download': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/>',
'upload': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m17 8-5-5-5 5"/><path d="M12 3v12"/>',
'printer': '<path d="M6 9V3h12v6"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="7" rx="1"/>',
'link': '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
'save': '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/>',
'clipboard': '<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1"/>',
'trash': '<path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="m19 6-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/>',
'rotate': '<path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>',
/* 操作/状态 */
'plus': '<path d="M12 5v14"/><path d="M5 12h14"/>',
'play': '<path d="m6 4 14 8-14 8z"/>',
'check': '<path d="M20 6 9 17l-5-5"/>',
'alert': '<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
'x': '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
'settings': '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
'sliders': '<path d="M4 21v-7"/><path d="M4 10V3"/><path d="M12 21v-9"/><path d="M12 8V3"/><path d="M20 21v-5"/><path d="M20 12V3"/><path d="M2 14h4"/><path d="M10 8h4"/><path d="M18 16h4"/>',
'pointer': '<path d="m3 3 7.07 16.97 2.51-7.39 7.39-2.51z"/>',
'paperclip': '<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
/* AI 特征 */
'sparkles': '<path d="M12 3l1.9 5.6a2 2 0 0 0 1.5 1.5L21 12l-5.6 1.9a2 2 0 0 0-1.5 1.5L12 21l-1.9-5.6a2 2 0 0 0-1.5-1.5L3 12l5.6-1.9a2 2 0 0 0 1.5-1.5z"/><path d="M19 3v2"/><path d="M18 4h2"/>',
'wand': '<path d="m3 21 9-9"/><path d="M15 4V2"/><path d="M15 16v-2"/><path d="M8 9h2"/><path d="M20 9h2"/><path d="m17.8 11.8 1.4 1.4"/><path d="m17.8 6.2 1.4-1.4"/><path d="m12.2 6.2-1.4-1.4"/>',
'zap': '<path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/>',
'radio': '<circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49"/><path d="M7.76 16.25a6 6 0 0 1 0-8.49"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M4.93 19.07a10 10 0 0 1 0-14.14"/>',
'palette': '<path d="M12 22a10 10 0 1 1 10-10c0 1.66-1.34 3-3 3h-2.2a2 2 0 0 0-1.5 3.32c.4.45.7 1.05.7 1.68a2 2 0 0 1-2 2z"/><circle cx="7.5" cy="11.5" r="1"/><circle cx="11" cy="7.5" r="1"/><circle cx="16" cy="8.5" r="1"/>',
/* 图表 */
'bar-chart': '<path d="M12 20V10"/><path d="M18 20V4"/><path d="M6 20v-4"/>',
'bar-chart-h': '<path d="M4 6h16"/><path d="M4 12h10"/><path d="M4 18h13"/>',
'line-chart': '<path d="M3 3v18h18"/><path d="m19 9-5 5-4-4-3 3"/>',
'area-chart': '<path d="M3 3v18h18"/><path d="M7 14l4-4 3 3 5-6"/>',
'pie': '<path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/>',
'doughnut': '<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3.5"/><path d="M12 3v5.5"/><path d="M21 12h-5.5"/>',
'radar': '<path d="M12 3l7.8 4.5v9L12 21l-7.8-4.5v-9z"/><path d="M12 8l3.9 2.25v4.5L12 17l-3.9-2.25v-4.5z"/>',
'progress': '<circle cx="12" cy="12" r="9" opacity=".25"/><path d="M12 3a9 9 0 0 1 9 9"/>',
/* 文本/编辑 */
'edit': '<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5z"/>',
'align-left': '<path d="M21 6H3"/><path d="M15 12H3"/><path d="M17 18H3"/>',
'align-center': '<path d="M21 6H3"/><path d="M19 12H5"/><path d="M17 18H7"/>',
'align-right': '<path d="M21 6H3"/><path d="M21 12H9"/><path d="M21 18H7"/>',
'list': '<path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/>',
'layout': '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/>',
'table': '<path d="M12 3v18"/><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/>',
'code': '<path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/>',
'quote': '<path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/>',
'chevron-right': '<path d="m9 18 6-6-6-6"/>'
}
const html = computed(() => ICONS[props.name] || '')
</script>
<template>
<svg
class="ui-icon"
:width="size"
:height="size"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
:stroke-width="strokeWidth"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
v-html="html"
/>
</template>
<style scoped>
.ui-icon { flex-shrink: 0; vertical-align: -0.15em; }
</style>
+6 -4
View File
@@ -4,12 +4,14 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ElementType } from '../../core/types' import type { ElementType } from '../../core/types'
import { elementTypes } from '../../core/sample' import { elementTypes } from '../../core/sample'
import Icon from '../common/Icon.vue'
const TYPES: ElementType[] = ['title', 'text', 'list', 'stat', 'quote', 'image', 'video', 'shape', 'chart', 'card', 'table', 'code', 'formula'] const TYPES: ElementType[] = ['title', 'text', 'list', 'stat', 'quote', 'image', 'video', 'shape', 'chart', 'card', 'table', 'code', 'formula']
/* 全部走 Icon 图标体系(纯文字伪图标是 UI 杂音来源) */
const ICONS: Record<string, string> = { const ICONS: Record<string, string> = {
title: 'T', text: '', list: '', stat: '#', quote: '“”', title: 'layout', text: 'file-text', list: 'list', stat: 'bar-chart-h', quote: 'quote',
image: '🖼', video: '🎬', shape: '', chart: '📊', card: '', image: 'image', video: 'video', shape: 'palette', chart: 'line-chart', card: 'book',
table: '', code: '</>', formula: '' table: 'table', code: 'code', formula: 'file'
} }
const emit = defineEmits<{ (e: 'add', type: ElementType): void }>() const emit = defineEmits<{ (e: 'add', type: ElementType): void }>()
@@ -18,7 +20,7 @@ const emit = defineEmits<{ (e: 'add', type: ElementType): void }>()
<template> <template>
<div class="add-grid"> <div class="add-grid">
<button v-for="t in TYPES" :key="t" :data-add="t" @click="emit('add', t)"> <button v-for="t in TYPES" :key="t" :data-add="t" @click="emit('add', t)">
<span class="ic">{{ ICONS[t] || '·' }}</span>{{ elementTypes[t].label }} <span class="ic"><Icon :name="ICONS[t]" :size="16" /></span>{{ elementTypes[t].label }}
</button> </button>
</div> </div>
</template> </template>
+2 -2
View File
@@ -391,7 +391,7 @@ onMounted(() => {
overflow: hidden; overflow: hidden;
} }
.anno-bubble.selected { .anno-bubble.selected {
border-color: var(--ui-primary, #4f46e5); border-color: var(--ui-primary, #5b5bd6);
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.25), 0 2px 8px rgba(15, 23, 42, 0.1); box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.25), 0 2px 8px rgba(15, 23, 42, 0.1);
} }
.anno-bubble-text { .anno-bubble-text {
@@ -427,7 +427,7 @@ onMounted(() => {
height: 14px; height: 14px;
cursor: nwse-resize; cursor: nwse-resize;
background: background:
linear-gradient(135deg, transparent 50%, var(--ui-primary, #4f46e5) 50%); linear-gradient(135deg, transparent 50%, var(--ui-primary, #5b5bd6) 50%);
border-bottom-right-radius: 8px; border-bottom-right-radius: 8px;
opacity: 0; opacity: 0;
transition: opacity .12s; transition: opacity .12s;
+2 -1
View File
@@ -8,6 +8,7 @@ import { CANVAS_W } from '../../core/sample'
import { useEditor } from '../../composables/useEditor' import { useEditor } from '../../composables/useEditor'
import ElementView from './ElementView.vue' import ElementView from './ElementView.vue'
import AnnotationLayer from './AnnotationLayer.vue' import AnnotationLayer from './AnnotationLayer.vue'
import Icon from '../common/Icon.vue'
const canvasRef = ref<HTMLElement>() const canvasRef = ref<HTMLElement>()
const canvasFrameRef = ref<HTMLElement>() // 注意:绑定 .canvas-frame(不含 stage 的 padding),scale 以它为基准 const canvasFrameRef = ref<HTMLElement>() // 注意:绑定 .canvas-frame(不含 stage 的 padding),scale 以它为基准
@@ -159,7 +160,7 @@ onUnmounted(() => {
:title="noteOpen ? '收起备注' : '演讲者备注'" :title="noteOpen ? '收起备注' : '演讲者备注'"
@click="noteOpen = !noteOpen" @click="noteOpen = !noteOpen"
> >
<span class="note-toggle-icon"></span> <Icon name="edit" :size="13" />
<span>备注</span> <span>备注</span>
<span v-if="slide?.note && !noteOpen" class="note-dot"></span> <span v-if="slide?.note && !noteOpen" class="note-dot"></span>
</button> </button>
+1 -1
View File
@@ -21,7 +21,7 @@ const props = defineProps<{
/* ---------- 颜色调色板(最多 6 个系列,交替主题色与补色) ---------- */ /* ---------- 颜色调色板(最多 6 个系列,交替主题色与补色) ---------- */
const PALETTE = computed(() => [ const PALETTE = computed(() => [
resolveColor(props.style.color, props.dark) || '#4f46e5', resolveColor(props.style.color, props.dark) || '#5b5bd6',
resolveColor('accent', props.dark) || '#06b6d4', resolveColor('accent', props.dark) || '#06b6d4',
'#f59e0b', '#10b981', '#ef4444', '#8b5cf6' '#f59e0b', '#10b981', '#ef4444', '#8b5cf6'
]) ])
+7 -6
View File
@@ -7,8 +7,9 @@ import { computed } from 'vue'
import type { SlideElement, BgKey } from '../../core/types' import type { SlideElement, BgKey } from '../../core/types'
import { store, resolveColor, isDarkBg } from '../../core/store' import { store, resolveColor, isDarkBg } from '../../core/store'
import { resolveRef } from '../../core/assets' import { resolveRef } from '../../core/assets'
import { segmentsToHtml, markdownToSegments, hasFormatting } from '../../core/richtext' import { segmentsToHtml, markdownToSegments, hasFormatting, hasLineMarker } from '../../core/richtext'
import ChartView from './ChartView.vue' import ChartView from './ChartView.vue'
import Icon from '../common/Icon.vue'
/* ---------- LaTeX 子集渲染(公式元素) ---------- */ /* ---------- LaTeX 子集渲染(公式元素) ---------- */
const LATEX_SYMBOLS: Record<string, string> = { const LATEX_SYMBOLS: Record<string, string> = {
@@ -239,15 +240,15 @@ function onBlur(e: Event, field: string) {
<template v-else-if="el.type === 'list'"> <template v-else-if="el.type === 'list'">
<!-- 编辑态 --> <!-- 编辑态 -->
<div v-if="edit" class="el-list" contenteditable="true" data-edit="content" @blur="onBlur($event, 'content')"> <div v-if="edit" class="el-list" contenteditable="true" data-edit="content" @blur="onBlur($event, 'content')">
<div v-for="(line, i) in dataList" :key="i" class="li">{{ line }}</div> <div v-for="(line, i) in dataList" :key="i" class="li" :class="{ 'no-marker': hasLineMarker(line) }">{{ line }}</div>
</div> </div>
<!-- 非编辑态 + segments --> <!-- 非编辑态 + segments -->
<div v-else-if="renderedListItems" class="el-list"> <div v-else-if="renderedListItems" class="el-list">
<div v-for="(html, i) in renderedListItems" :key="i" class="li" v-html="html"></div> <div v-for="(html, i) in renderedListItems" :key="i" class="li" :class="{ 'no-marker': hasLineMarker(dataList[i] || '') }" v-html="html"></div>
</div> </div>
<!-- 非编辑态 + 纯文本 --> <!-- 非编辑态 + 纯文本 -->
<div v-else class="el-list"> <div v-else class="el-list">
<div v-for="(line, i) in dataList" :key="i" class="li">{{ line }}</div> <div v-for="(line, i) in dataList" :key="i" class="li" :class="{ 'no-marker': hasLineMarker(line) }">{{ line }}</div>
</div> </div>
</template> </template>
@@ -273,7 +274,7 @@ function onBlur(e: Event, field: string) {
<!-- 图片无内容时显示占位提示不渲染空 src 的裂图 --> <!-- 图片无内容时显示占位提示不渲染空 src 的裂图 -->
<template v-else-if="el.type === 'image'"> <template v-else-if="el.type === 'image'">
<div v-if="!el.content" class="el-image-empty"> <div v-if="!el.content" class="el-image-empty">
<span class="el-image-empty-icon">🖼</span> <span class="el-image-empty-icon"><Icon name="image" :size="28" /></span>
<span class="el-image-empty-text">拖入图片 · 属性面板本地图片 AI 配图</span> <span class="el-image-empty-text">拖入图片 · 属性面板本地图片 AI 配图</span>
</div> </div>
<img v-else class="el-image" :src="mediaSrc" draggable="false" /> <img v-else class="el-image" :src="mediaSrc" draggable="false" />
@@ -282,7 +283,7 @@ function onBlur(e: Event, field: string) {
<!-- 视频缩略图端降级静态preload=metadata 控制加载开销 --> <!-- 视频缩略图端降级静态preload=metadata 控制加载开销 -->
<template v-else-if="el.type === 'video'"> <template v-else-if="el.type === 'video'">
<div v-if="!el.content" class="el-image-empty"> <div v-if="!el.content" class="el-image-empty">
<span class="el-image-empty-icon">🎬</span> <span class="el-image-empty-icon"><Icon name="video" :size="28" /></span>
<span class="el-image-empty-text">属性面板选择本地视频或填入 URL</span> <span class="el-image-empty-text">属性面板选择本地视频或填入 URL</span>
</div> </div>
<!-- 缩略图poster 静态图 poster 黑底不加载视频 --> <!-- 缩略图poster 静态图 poster 黑底不加载视频 -->
+26 -25
View File
@@ -11,17 +11,18 @@ import { putAsset, isOssEnabled } from '../../core/assets'
import { appAlert, appConfirm, appPrompt } from '../../core/dialog' import { appAlert, appConfirm, appPrompt } from '../../core/dialog'
import { markdownToSegments, segmentsToPlain, hasFormatting } from '../../core/richtext' import { markdownToSegments, segmentsToPlain, hasFormatting } from '../../core/richtext'
import AddGrid from './AddGrid.vue' import AddGrid from './AddGrid.vue'
import Icon from '../common/Icon.vue'
import type { ElementType, ChartType, ShapeType } from '../../core/types' import type { ElementType, ChartType, ShapeType } from '../../core/types'
const CHART_TYPES: Array<{ k: ChartType; label: string; icon: string }> = [ const CHART_TYPES: Array<{ k: ChartType; label: string; icon: string }> = [
{ k: 'bar', label: '柱状图', icon: '📊' }, { k: 'bar', label: '柱状图', icon: 'bar-chart' },
{ k: 'hbar', label: '条形图', icon: '📋' }, { k: 'hbar', label: '条形图', icon: 'bar-chart-h' },
{ k: 'line', label: '折线图', icon: '📈' }, { k: 'line', label: '折线图', icon: 'line-chart' },
{ k: 'area', label: '面积图', icon: '🌄' }, { k: 'area', label: '面积图', icon: 'area-chart' },
{ k: 'pie', label: '饼图', icon: '🥧' }, { k: 'pie', label: '饼图', icon: 'pie' },
{ k: 'doughnut', label: '环形图', icon: '🍩' }, { k: 'doughnut', label: '环形图', icon: 'doughnut' },
{ k: 'radar', label: '雷达图', icon: '🕸' }, { k: 'radar', label: '雷达图', icon: 'radar' },
{ k: 'progress', label: '进度图', icon: '' } { k: 'progress', label: '进度图', icon: 'progress' }
] ]
const selected = computed(() => store.getSelected()) const selected = computed(() => store.getSelected())
@@ -344,7 +345,7 @@ function toggleVideoOpt(key: 'autoplay' | 'loop' | 'muted') {
<label>批量操作</label> <label>批量操作</label>
<div class="seg"> <div class="seg">
<button @click="onMultiCopy" title="Ctrl+C"> 复制</button> <button @click="onMultiCopy" title="Ctrl+C"> 复制</button>
<button class="danger" @click="onMultiDel" title="Delete">🗑 删除</button> <button class="danger" @click="onMultiDel" title="Delete"><Icon name="trash" :size="13" /> 删除</button>
</div> </div>
</div> </div>
<div class="prop-row"> <div class="prop-row">
@@ -424,9 +425,9 @@ function toggleVideoOpt(key: 'autoplay' | 'loop' | 'muted') {
<div v-if="canAlign" class="prop-row"> <div v-if="canAlign" class="prop-row">
<label>对齐</label> <label>对齐</label>
<div class="seg"> <div class="seg">
<button :class="{ active: selected.style.align === 'left' }" @click="onAlign('left')"></button> <button :class="{ active: selected.style.align === 'left' }" @click="onAlign('left')" title="左对齐"><Icon name="align-left" :size="14" /></button>
<button :class="{ active: selected.style.align === 'center' || !selected.style.align }" @click="onAlign('center')"></button> <button :class="{ active: selected.style.align === 'center' || !selected.style.align }" @click="onAlign('center')" title="居中"><Icon name="align-center" :size="14" /></button>
<button :class="{ active: selected.style.align === 'right' }" @click="onAlign('right')"></button> <button :class="{ active: selected.style.align === 'right' }" @click="onAlign('right')" title="右对齐"><Icon name="align-right" :size="14" /></button>
</div> </div>
</div> </div>
@@ -462,7 +463,7 @@ function toggleVideoOpt(key: 'autoplay' | 'loop' | 'muted') {
<div class="prop-row"> <div class="prop-row">
<label>图表类型</label> <label>图表类型</label>
<select v-model="chartType" @change="onChartTypeChange"> <select v-model="chartType" @change="onChartTypeChange">
<option v-for="t in CHART_TYPES" :key="t.k" :value="t.k">{{ t.icon }} {{ t.label }}</option> <option v-for="t in CHART_TYPES" :key="t.k" :value="t.k">{{ t.label }}</option>
</select> </select>
</div> </div>
<div class="prop-row"> <div class="prop-row">
@@ -510,8 +511,8 @@ function toggleVideoOpt(key: 'autoplay' | 'loop' | 'muted') {
<div v-if="selected.type === 'image'" class="prop-row"> <div v-if="selected.type === 'image'" class="prop-row">
<label>图片来源</label> <label>图片来源</label>
<div class="seg"> <div class="seg">
<button @click="onLocalImage" title="从本地选择图片">📁 本地图片</button> <button @click="onLocalImage" title="从本地选择图片"><Icon name="folder" :size="13" /> 本地图片</button>
<button :disabled="imgBusy" @click="onAiImage">{{ imgBusy ? '生成中' : '🎨 AI 配图' }}</button> <button :disabled="imgBusy" @click="onAiImage"><template v-if="imgBusy">生成中…</template><template v-else><Icon name="palette" :size="13" /> AI 配图</template></button>
</div> </div>
</div> </div>
@@ -519,7 +520,7 @@ function toggleVideoOpt(key: 'autoplay' | 'loop' | 'muted') {
<div v-if="selected.type === 'video'" class="prop-row"> <div v-if="selected.type === 'video'" class="prop-row">
<label>视频来源</label> <label>视频来源</label>
<div class="seg"> <div class="seg">
<button @click="onLocalVideo" title="从本地选择视频文件">📁 本地视频</button> <button @click="onLocalVideo" title="从本地选择视频文件"><Icon name="folder" :size="13" /> 本地视频</button>
</div> </div>
<input <input
type="text" type="text"
@@ -546,7 +547,7 @@ function toggleVideoOpt(key: 'autoplay' | 'loop' | 'muted') {
<div class="seg"> <div class="seg">
<button @click="onZ(1)" title="上移"></button> <button @click="onZ(1)" title="上移"></button>
<button @click="onZ(-1)" title="下移"></button> <button @click="onZ(-1)" title="下移"></button>
<button class="danger" @click="onDel" title="删除">🗑</button> <button class="danger" @click="onDel" title="删除"><Icon name="trash" :size="13" /></button>
</div> </div>
</div> </div>
@@ -598,9 +599,9 @@ function toggleVideoOpt(key: 'autoplay' | 'loop' | 'muted') {
</div> </div>
<div class="anno-line"> <div class="anno-line">
<div class="seg"> <div class="seg">
<button :class="{ active: anno.align === 'left' }" @click="patchAnno(anno.id, { align: 'left' })"></button> <button :class="{ active: anno.align === 'left' }" @click="patchAnno(anno.id, { align: 'left' })" title="左对齐"><Icon name="align-left" :size="14" /></button>
<button :class="{ active: anno.align === 'center' || !anno.align }" @click="patchAnno(anno.id, { align: 'center' })"></button> <button :class="{ active: anno.align === 'center' || !anno.align }" @click="patchAnno(anno.id, { align: 'center' })" title="居中"><Icon name="align-center" :size="14" /></button>
<button :class="{ active: anno.align === 'right' }" @click="patchAnno(anno.id, { align: 'right' })"></button> <button :class="{ active: anno.align === 'right' }" @click="patchAnno(anno.id, { align: 'right' })" title="右对齐"><Icon name="align-right" :size="14" /></button>
</div> </div>
</div> </div>
@@ -685,9 +686,9 @@ function toggleVideoOpt(key: 'autoplay' | 'loop' | 'muted') {
transition: background .15s, border-color .15s; transition: background .15s, border-color .15s;
} }
.anno-head button:hover { .anno-head button:hover {
background: var(--ui-primary-soft, #eef2ff); background: var(--ui-primary-soft, #eeeefc);
border-color: var(--ui-primary, #4f46e5); border-color: var(--ui-primary, #5b5bd6);
color: var(--ui-primary, #4f46e5); color: var(--ui-primary, #5b5bd6);
} }
.anno-list { .anno-list {
display: flex; display: flex;
@@ -704,7 +705,7 @@ function toggleVideoOpt(key: 'autoplay' | 'loop' | 'muted') {
background: var(--ui-panel, #f8fafc); background: var(--ui-panel, #f8fafc);
} }
.anno-card.open { .anno-card.open {
border-color: var(--ui-primary, #4f46e5); border-color: var(--ui-primary, #5b5bd6);
} }
.anno-card-head { .anno-card-head {
display: flex; display: flex;
@@ -715,7 +716,7 @@ function toggleVideoOpt(key: 'autoplay' | 'loop' | 'muted') {
user-select: none; user-select: none;
} }
.anno-card-head:hover { .anno-card-head:hover {
background: var(--ui-primary-soft, #eef2ff); background: var(--ui-primary-soft, #eeeefc);
} }
.anno-caret { .anno-caret {
font-size: 10px; font-size: 10px;
+18 -21
View File
@@ -7,12 +7,12 @@ import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { store } from '../../core/store' import { store } from '../../core/store'
import { themes } from '../../core/sample' import { themes } from '../../core/sample'
import { appConfirm } from '../../core/dialog' import { appConfirm } from '../../core/dialog'
import Icon from '../common/Icon.vue'
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'present'): void (e: 'present'): void
(e: 'open-library'): void (e: 'open-library'): void
(e: 'open-settings'): void (e: 'open-settings'): void
(e: 'open-oss'): void
(e: 'save'): void (e: 'save'): void
(e: 'open-templates'): void (e: 'open-templates'): void
(e: 'export-json'): void (e: 'export-json'): void
@@ -51,7 +51,6 @@ async function action(a: string) {
case 'library': emit('open-library'); break case 'library': emit('open-library'); break
case 'save': emit('save'); break case 'save': emit('save'); break
case 'settings': emit('open-settings'); break case 'settings': emit('open-settings'); break
case 'oss': emit('open-oss'); break
} }
} }
@@ -108,11 +107,11 @@ defineProps<{ disabledActions?: string[] }>()
<div class="tools"> <div class="tools">
<!-- 高频页面操作 --> <!-- 高频页面操作 -->
<button class="btn tool-add" data-action="add-slide" title="新建幻灯片" :disabled="disabledActions?.includes('add-slide')" @click="action('add-slide')"> 幻灯片</button> <button class="btn tool-add" data-action="add-slide" title="新建幻灯片" :disabled="disabledActions?.includes('add-slide')" @click="action('add-slide')"><Icon name="plus" :size="14" /> 幻灯片</button>
<button class="btn" data-action="templates" title="从模板新建页" @click="emit('open-templates')">📋 模板</button> <button class="btn" data-action="templates" title="从模板新建页" @click="emit('open-templates')"><Icon name="clipboard" :size="14" /> 模板</button>
<span class="sep"></span> <span class="sep"></span>
<button class="btn icon-only" data-action="dup-slide" title="复制当前页" :disabled="disabledActions?.includes('dup-slide')" @click="action('dup-slide')"></button> <button class="btn icon-only" data-action="dup-slide" title="复制当前页" :disabled="disabledActions?.includes('dup-slide')" @click="action('dup-slide')"><Icon name="clipboard" :size="15" /></button>
<button class="btn icon-only danger-hover" data-action="del-slide" title="删除当前页" :disabled="disabledActions?.includes('del-slide')" @click="action('del-slide')">🗑</button> <button class="btn icon-only danger-hover" data-action="del-slide" title="删除当前页" :disabled="disabledActions?.includes('del-slide')" @click="action('del-slide')"><Icon name="trash" :size="15" /></button>
<span class="sep"></span> <span class="sep"></span>
<!-- 主题 --> <!-- 主题 -->
@@ -124,47 +123,45 @@ defineProps<{ disabledActions?: string[] }>()
</label> </label>
<span class="sep"></span> <span class="sep"></span>
<!-- 保存高频动作常驻工具栏 -->
<button class="btn" data-action="save" title="保存到文库 (Ctrl+S)" :disabled="disabledActions?.includes('save')" @click="action('save')"><Icon name="save" :size="14" /> 保存</button>
<!-- 低频文件域收进菜单 --> <!-- 低频文件域收进菜单 -->
<div class="file-menu" ref="fileMenuBtn"> <div class="file-menu" ref="fileMenuBtn">
<button class="btn" data-action="file-menu" :class="{ active: fileMenuOpen }" @click="toggleFileMenu"> <button class="btn" data-action="file-menu" :class="{ active: fileMenuOpen }" @click="toggleFileMenu">
🗀 文件 <span class="chevron" :class="{ open: fileMenuOpen }"></span> <Icon name="folder" :size="14" /> 文件 <span class="chevron" :class="{ open: fileMenuOpen }"></span>
</button> </button>
<Transition name="menu-pop"> <Transition name="menu-pop">
<div v-if="fileMenuOpen" class="file-dropdown" role="menu"> <div v-if="fileMenuOpen" class="file-dropdown" role="menu">
<button class="menu-item" role="menuitem" @click="fileAction('library')"> <button class="menu-item" role="menuitem" @click="fileAction('library')">
<span class="mi-icon">📁</span><span class="mi-label">文库</span><span class="mi-hint">我的演示</span> <span class="mi-icon"><Icon name="folder" :size="15" /></span><span class="mi-label">文库</span><span class="mi-hint">我的演示</span>
</button> </button>
<button class="menu-item" role="menuitem" :disabled="disabledActions?.includes('save')" @click="fileAction('save')">
<span class="mi-icon">💾</span><span class="mi-label">保存到文库</span><span class="mi-hint">已自动暂存</span>
</button>
<div class="menu-sep"></div>
<button class="menu-item" role="menuitem" @click="fileAction('import-materials')"> <button class="menu-item" role="menuitem" @click="fileAction('import-materials')">
<span class="mi-icon">📂</span><span class="mi-label">导入资料</span><span class="mi-hint">图片 / 文档 / 目录</span> <span class="mi-icon"><Icon name="folder-open" :size="15" /></span><span class="mi-label">导入资料</span><span class="mi-hint">图片 / 文档 / 目录</span>
</button> </button>
<button class="menu-item" role="menuitem" @click="fileAction('import-json')"> <button class="menu-item" role="menuitem" @click="fileAction('import-json')">
<span class="mi-icon">📥</span><span class="mi-label">导入 JSON</span> <span class="mi-icon"><Icon name="download" :size="15" /></span><span class="mi-label">导入 JSON</span>
</button> </button>
<button class="menu-item" role="menuitem" @click="fileAction('export-json')"> <button class="menu-item" role="menuitem" @click="fileAction('export-json')">
<span class="mi-icon">📤</span><span class="mi-label">导出 JSON</span> <span class="mi-icon"><Icon name="upload" :size="15" /></span><span class="mi-label">导出 JSON</span>
</button> </button>
<button class="menu-item" role="menuitem" @click="fileAction('export-pdf')"> <button class="menu-item" role="menuitem" @click="fileAction('export-pdf')">
<span class="mi-icon">🖨</span><span class="mi-label">导出 PDF</span><span class="mi-hint">打印 / 另存</span> <span class="mi-icon"><Icon name="printer" :size="15" /></span><span class="mi-label">导出 PDF</span><span class="mi-hint">打印 / 另存</span>
</button> </button>
<button class="menu-item" role="menuitem" @click="fileAction('share')"> <button class="menu-item" role="menuitem" @click="fileAction('share')">
<span class="mi-icon">🔗</span><span class="mi-label">生成分享链接</span><span class="mi-hint">公开只读 · 云端</span> <span class="mi-icon"><Icon name="link" :size="15" /></span><span class="mi-label">生成分享链接</span><span class="mi-hint">公开只读 · 云端</span>
</button> </button>
<div class="menu-sep"></div> <div class="menu-sep"></div>
<button class="menu-item danger" role="menuitem" :disabled="disabledActions?.includes('reset')" @click="fileAction('reset')"> <button class="menu-item danger" role="menuitem" :disabled="disabledActions?.includes('reset')" @click="fileAction('reset')">
<span class="mi-icon"></span><span class="mi-label">重置为示例</span> <span class="mi-icon"><Icon name="rotate" :size="15" /></span><span class="mi-label">重置为示例</span>
</button> </button>
</div> </div>
</Transition> </Transition>
</div> </div>
<!-- 终端动作 --> <!-- 终端动作 -->
<button class="btn primary" data-action="present" title="开始演示 (F5)" :disabled="disabledActions?.includes('present')" @click="action('present')"> 演示</button> <button class="btn primary" data-action="present" title="开始演示 (F5)" :disabled="disabledActions?.includes('present')" @click="action('present')"><Icon name="play" :size="13" /> 演示</button>
<button class="btn ghost icon-only" data-action="oss" title="云存储 OSS 设置" @click="action('oss')"></button> <button class="btn ghost icon-only" data-action="settings" title="设置AI / 云存储 / 中继)" @click="action('settings')"><Icon name="settings" :size="16" /></button>
<button class="btn ghost icon-only" data-action="settings" title="AI 设置" @click="action('settings')"></button>
</div> </div>
</header> </header>
</template> </template>
+26 -24
View File
@@ -15,6 +15,7 @@ import {
describeReport describeReport
} from '../../core/importer' } from '../../core/importer'
import { putAsset, isOssEnabled } from '../../core/assets' import { putAsset, isOssEnabled } from '../../core/assets'
import Icon from '../common/Icon.vue'
const props = withDefaults(defineProps<{ visible: boolean }>(), { visible: false }) const props = withDefaults(defineProps<{ visible: boolean }>(), { visible: false })
const emit = defineEmits<{ const emit = defineEmits<{
@@ -137,7 +138,7 @@ async function runAiAnalysis() {
if (docs.length === 0) return if (docs.length === 0) return
if (!aiReady.value) { if (!aiReady.value) {
analyzeError.value = '请先在设置中配置 AI API Key' analyzeError.value = '请先在设置中配置 AI API Key'
return return
} }
@@ -196,7 +197,7 @@ async function doImport() {
try { deckChars = JSON.stringify(store.getDeck()).length } catch (e) { /* ignore */ } try { deckChars = JSON.stringify(store.getDeck()).length } catch (e) { /* ignore */ }
const QUOTA_CHARS = 4_500_000 const QUOTA_CHARS = 4_500_000
if (deckChars + imgChars > QUOTA_CHARS) { 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 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 v-if="props.visible" class="modal-mask" @click.self="emit('close')">
<div class="modal import-modal"> <div class="modal import-modal">
<header class="modal-header"> <header class="modal-header">
<h2>📂 导入本地资料</h2> <h2>导入本地资料</h2>
<button class="close" @click="emit('close')"></button> <button class="close" @click="emit('close')" title="关闭"><Icon name="x" :size="16" /></button>
</header> </header>
<!-- ======== 选择区域 ======== --> <!-- ======== 选择区域 ======== -->
<div v-if="!loaded" class="import-select"> <div v-if="!loaded" class="import-select">
<div class="import-tabs"> <div class="import-tabs">
<button class="tab" :class="{ active: activeTab === 'files' }" @click="activeTab = 'files'">📄 选择文件</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'">📁 读取目录</button> <button class="tab" :class="{ active: activeTab === 'dir' }" @click="activeTab = 'dir'"><Icon name="folder-open" :size="14" /> 读取目录</button>
</div> </div>
<div <div
@@ -314,7 +315,7 @@ function textPreview(data: string, maxLen = 80): string {
@dragleave="onDropzoneDragLeave" @dragleave="onDropzoneDragLeave"
@drop="onDropzoneDrop" @drop="onDropzoneDrop"
> >
<div class="dropzone-icon">📄</div> <div class="dropzone-icon"><Icon name="file" :size="34" /></div>
<div class="dropzone-text"> <div class="dropzone-text">
<strong>点击选择或拖入文件</strong> <strong>点击选择或拖入文件</strong>
<span class="hint">图片直接插入 · 文档PDF/DOCX/MD/TXT AI 分析生成幻灯片</span> <span class="hint">图片直接插入 · 文档PDF/DOCX/MD/TXT AI 分析生成幻灯片</span>
@@ -330,7 +331,7 @@ function textPreview(data: string, maxLen = 80): string {
@dragleave="onDropzoneDragLeave" @dragleave="onDropzoneDragLeave"
@drop="onDropzoneDrop" @drop="onDropzoneDrop"
> >
<div class="dropzone-icon">📁</div> <div class="dropzone-icon"><Icon name="folder-open" :size="34" /></div>
<div class="dropzone-text"> <div class="dropzone-text">
<strong>点击选择目录</strong> <strong>点击选择目录</strong>
<span class="hint">读取目录下所有支持的图片和文档AI 自动分析生成</span> <span class="hint">读取目录下所有支持的图片和文档AI 自动分析生成</span>
@@ -354,9 +355,9 @@ function textPreview(data: string, maxLen = 80): string {
<!-- 过滤 --> <!-- 过滤 -->
<div class="filter-bar"> <div class="filter-bar">
<label class="chk"><input type="checkbox" v-model="showImages" /> 🖼 图片 ({{ imageEntries.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" /> 🎬 视频 ({{ videoEntries.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" /> 📄 文档 ({{ docEntries.length }})</label> <label class="chk"><input type="checkbox" v-model="showDocs" /> <Icon name="file-text" :size="13" /> 文档 ({{ docEntries.length }})</label>
</div> </div>
<!-- AI 分析进度 --> <!-- AI 分析进度 -->
@@ -366,7 +367,7 @@ function textPreview(data: string, maxLen = 80): string {
<!-- AI 分析错误 --> <!-- AI 分析错误 -->
<div v-if="analyzeError && !analyzing" class="ai-error"> <div v-if="analyzeError && !analyzing" class="ai-error">
{{ analyzeError }} <Icon name="alert" :size="13" /> {{ analyzeError }}
<template v-if="!aiReady"> <template v-if="!aiReady">
<button class="btn-sm" @click="emit('open-settings')">去配置</button> <button class="btn-sm" @click="emit('open-settings')">去配置</button>
</template> </template>
@@ -375,20 +376,20 @@ function textPreview(data: string, maxLen = 80): string {
<!-- 文件列表 --> <!-- 文件列表 -->
<div class="file-list"> <div class="file-list">
<div v-for="(entry, i) in filteredEntries" :key="i" class="file-item" :class="entry.kind"> <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-name" :title="entry.name">{{ entry.name }}</span>
<span class="file-size">{{ fmtSize(entry.file.size) }}</span> <span class="file-size">{{ fmtSize(entry.file.size) }}</span>
<span class="file-status"> <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 分析结果 --> <!-- 文档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 && analyzeDone"> 待分析</template>
<template v-else-if="entry.kind === 'document' && entry.data && analyzing"> AI 分析中</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.kind === 'document' && entry.data">{{ textPreview(entry.data) }}</template>
<template v-else-if="entry.error"> {{ entry.error }}</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-if="entry.kind === 'unsupported'"> 跳过</template>
<template v-else> 读取中</template> <template v-else> 读取中</template>
</span> </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"> <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 class="preview-scroll">
<div v-for="(entry, i) in docEntries.filter(e => e.slides)" :key="'p' + i" class="preview-item"> <div v-for="(entry, i) in docEntries.filter(e => e.slides)" :key="'p' + i" class="preview-item">
<strong>{{ entry.name }}</strong> <strong>{{ entry.name }}</strong>
@@ -415,7 +416,8 @@ function textPreview(data: string, maxLen = 80): string {
<!-- 操作按钮 --> <!-- 操作按钮 -->
<div class="import-actions"> <div class="import-actions">
<button class="btn primary" @click="doImport" :disabled="filteredEntries.length === 0 || analyzing"> <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>
<button class="btn" @click="emit('close')">取消</button> <button class="btn" @click="emit('close')">取消</button>
</div> </div>
@@ -433,14 +435,14 @@ function textPreview(data: string, maxLen = 80): string {
background: var(--bg, #fff); border-radius: 8px; cursor: pointer; background: var(--bg, #fff); border-radius: 8px; cursor: pointer;
font-size: 14px; transition: all .15s; 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 { .dropzone {
border: 2px dashed var(--border, #cbd5e1); border-radius: 12px; padding: 36px 24px; border: 2px dashed var(--border, #cbd5e1); border-radius: 12px; padding: 36px 24px;
display: flex; align-items: center; gap: 20px; cursor: pointer; transition: all .2s; display: flex; align-items: center; gap: 20px; cursor: pointer; transition: all .2s;
background: var(--panel, #f8fafc); background: var(--panel, #f8fafc);
} }
.dropzone:hover { border-color: var(--primary, #4f46e5); background: color-mix(in srgb, var(--primary, #4f46e5) 5%, 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, #4f46e5); background: color-mix(in srgb, var(--primary, #4f46e5) 10%, 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-icon { font-size: 44px; }
.dropzone-text { display: flex; flex-direction: column; gap: 4px; } .dropzone-text { display: flex; flex-direction: column; gap: 4px; }
.dropzone-text strong { font-size: 16px; } .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 { display: flex; gap: 16px; padding: 4px 0; }
.filter-bar .chk { display: flex; align-items: center; gap: 6px; font-size: 14px; cursor: pointer; } .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; } .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; } .file-list { max-height: 200px; overflow-y: auto; border: 1px solid var(--border, #e2e8f0); border-radius: 8px; display: flex; flex-direction: column; }
+60 -6
View File
@@ -2,11 +2,12 @@
LibraryModal.vue 演示文库弹窗 LibraryModal.vue 演示文库弹窗
===================================================================== --> ===================================================================== -->
<script setup lang="ts"> <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 type { LibItem, Slide } from '../../core/types'
import { store, resolveBg } from '../../core/store' import { store, resolveBg } from '../../core/store'
import { appPrompt, appConfirm } from '../../core/dialog' import { appPrompt, appConfirm } from '../../core/dialog'
import ElementView from '../editor/ElementView.vue' import ElementView from '../editor/ElementView.vue'
import Icon from '../common/Icon.vue'
const props = defineProps<{ visible: boolean }>() const props = defineProps<{ visible: boolean }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -18,6 +19,18 @@ const emit = defineEmits<{
const libVersion = ref(0) const libVersion = ref(0)
const nameInput = ref('') 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 触发重算) */ /** 读文库列表(非响应式,靠 libVersion 触发重算) */
const library = computed<LibItem[]>(() => { const library = computed<LibItem[]>(() => {
@@ -86,6 +99,7 @@ function onNewBlank() {
function onOpen(id: string) { function onOpen(id: string) {
if (store.loadFromLibrary(id)) { if (store.loadFromLibrary(id)) {
opsOpenId.value = null
toast('已打开') toast('已打开')
emit('open-deck') emit('open-deck')
emit('close') emit('close')
@@ -95,6 +109,7 @@ function onOpen(id: string) {
async function onRename(id: string) { async function onRename(id: string) {
const cur = store.getLibrary().find(x => x.id === id) const cur = store.getLibrary().find(x => x.id === id)
const name = await appPrompt('重命名', { defaultValue: cur ? cur.name : '' }) const name = await appPrompt('重命名', { defaultValue: cur ? cur.name : '' })
opsOpenId.value = null
if (name != null && name.trim()) { if (name != null && name.trim()) {
store.renameInLibrary(id, name.trim()) store.renameInLibrary(id, name.trim())
bump() bump()
@@ -102,12 +117,14 @@ async function onRename(id: string) {
} }
function onDuplicate(id: string) { function onDuplicate(id: string) {
opsOpenId.value = null
store.duplicateInLibrary(id) store.duplicateInLibrary(id)
toast('已复制') toast('已复制')
bump() bump()
} }
async function onDelete(id: string) { async function onDelete(id: string) {
opsOpenId.value = null
if (await appConfirm('删除这份演示?', '此操作不可撤销', { danger: true, okText: '删除' })) { if (await appConfirm('删除这份演示?', '此操作不可撤销', { danger: true, okText: '删除' })) {
store.deleteFromLibrary(id) store.deleteFromLibrary(id)
toast('已删除') toast('已删除')
@@ -119,13 +136,21 @@ async function onDelete(id: string) {
<template> <template>
<div class="modal-mask lib-modal-mask" :class="{ hidden: !visible }" @click.self="emit('close')"> <div class="modal-mask lib-modal-mask" :class="{ hidden: !visible }" @click.self="emit('close')">
<div class="modal lib-modal"> <div class="modal lib-modal">
<div class="lib-head">
<h3>演示文库</h3> <h3>演示文库</h3>
<button class="btn ghost" @click="onNewBlank"> 新建空白</button>
</div>
<p class="modal-tip">保存多套演示文稿到本地随时切换</p> <p class="modal-tip">保存多套演示文稿到本地随时切换</p>
<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"> <div class="lib-save-row">
<input type="text" v-model="nameInput" :placeholder="placeholder" @keydown="onNameKeydown" /> <input type="text" v-model="nameInput" :placeholder="placeholder" @keydown="onNameKeydown" />
<button class="btn primary" @click="onSave">存入文库</button> <button class="btn primary" @click="onSave">存入文库</button>
<button class="btn" @click="onNewBlank">新建空白</button> </div>
</div> </div>
<div class="lib-hint"> {{ libCount }} </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 class="lib-meta">{{ (it.deck && it.deck.slides ? it.deck.slides.length : 0) + ' 页 · ' + formatTime(it.updatedAt || it.createdAt) }}</div>
</div> </div>
<div class="lib-ops"> <div class="lib-ops">
<button class="btn" @click="onOpen(it.id)">打开</button> <button class="btn primary small" @click="onOpen(it.id)">打开</button>
<button class="btn" @click="onRename(it.id)">重命名</button> <button class="btn" @click="toggleOps(it.id)"></button>
<button class="btn" @click="onDuplicate(it.id)">复制</button> <div v-if="opsOpenId === it.id" class="ops-menu">
<button class="btn danger" @click="onDelete(it.id)">删除</button> <button @click="onRename(it.id)">重命名</button>
<button @click="onDuplicate(it.id)">复制</button>
<button class="danger" @click="onDelete(it.id)">删除</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -156,3 +184,29 @@ async function onDelete(id: string) {
</div> </div>
</div> </div>
</template> </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>
-197
View File
@@ -1,197 +0,0 @@
<!-- =====================================================================
OssSettingsModal.vue 云存储OSS设置弹窗
所有媒体资产上云避免撑爆本地存储离线暂存本地联网自动同步
===================================================================== -->
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { OssCfg } from '../../core/types'
import { store } from '../../core/store'
import { syncState, syncPending, canUploadNow } from '../../core/assets'
const props = defineProps<{ visible: boolean }>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'toast', msg: string): void
}>()
const form = ref<OssCfg>({
enabled: false, provider: 'aliyun', dir: 'u-ppt',
aliAccessKeyId: '', aliAccessKeySecret: '', aliEndpoint: '', aliBucket: '',
qiniuAccessKey: '', qiniuSecretKey: '', qiniuBucket: '', qiniuUpHost: '', qiniuDomain: '',
viewerBase: ''
})
function loadFromStore() {
form.value = { ...form.value, ...store.getOssCfg() }
}
watch(() => props.visible, (v) => {
if (v) loadFromStore()
}, { immediate: true })
function save() {
const c = form.value
store.setOssCfg({
enabled: c.enabled,
provider: c.provider,
dir: c.dir.trim(),
aliAccessKeyId: c.aliAccessKeyId.trim(),
aliAccessKeySecret: c.aliAccessKeySecret.trim(),
aliEndpoint: c.aliEndpoint.trim(),
aliBucket: c.aliBucket.trim(),
qiniuAccessKey: c.qiniuAccessKey.trim(),
qiniuSecretKey: c.qiniuSecretKey.trim(),
qiniuBucket: c.qiniuBucket.trim(),
qiniuUpHost: c.qiniuUpHost.trim(),
qiniuDomain: c.qiniuDomain.trim(),
viewerBase: c.viewerBase.trim()
})
emit('toast', c.enabled ? '已保存云存储设置(已启用)' : '已保存云存储设置(未启用)')
emit('close')
}
async function manualSync() {
if (!canUploadNow()) {
emit('toast', '当前不可同步:需桌面版 + 已联网 + 已启用并保存配置')
return
}
const r = await syncPending()
emit('toast', `同步完成:成功 ${r.done},失败 ${r.fail}`)
}
</script>
<template>
<div class="modal-mask" :class="{ hidden: !visible }" @click.self="emit('close')">
<div class="modal">
<h3>云存储OSS设置</h3>
<p class="modal-tip">
开启后图片/视频等媒体自动上传对象存储避免撑爆本地离线时先暂存本地联网后自动同步云端密钥仅保存在本地
</p>
<div class="form-row">
<label>启用云存储</label>
<label class="switch-line">
<input type="checkbox" v-model="form.enabled" />
<span>{{ form.enabled ? '开' : '关(维持本地内嵌)' }}</span>
</label>
</div>
<div class="form-row">
<label>服务商</label>
<select v-model="form.provider">
<option value="aliyun">阿里云 OSS</option>
<option value="qiniu">七牛云 Kodo</option>
</select>
</div>
<div class="form-row">
<label>所属目录</label>
<input type="text" v-model="form.dir" placeholder="如 u-ppt/images,可留空" />
</div>
<div class="form-row">
<label>查看器地址</label>
<input type="text" v-model="form.viewerBase" placeholder="如 https://img.1216.top,生成分享链接必填" />
</div>
<!-- 阿里云 -->
<template v-if="form.provider === 'aliyun'">
<div class="section-title">阿里云 OSS</div>
<div class="form-row">
<label>AccessKeyId</label>
<input type="text" v-model="form.aliAccessKeyId" placeholder="LTAI..." autocomplete="off" />
</div>
<div class="form-row">
<label>AccessKeySecret</label>
<input type="password" v-model="form.aliAccessKeySecret" placeholder="密钥" autocomplete="off" />
</div>
<div class="form-row">
<label>Endpoint</label>
<input type="text" v-model="form.aliEndpoint" placeholder="oss-cn-hangzhou.aliyuncs.com" />
</div>
<div class="form-row">
<label>Bucket</label>
<input type="text" v-model="form.aliBucket" placeholder="bucket 名称" />
</div>
</template>
<!-- 七牛云 -->
<template v-else>
<div class="section-title">七牛云 Kodo</div>
<div class="form-row">
<label>AccessKey</label>
<input type="text" v-model="form.qiniuAccessKey" placeholder="AK" autocomplete="off" />
</div>
<div class="form-row">
<label>SecretKey</label>
<input type="password" v-model="form.qiniuSecretKey" placeholder="SK" autocomplete="off" />
</div>
<div class="form-row">
<label>Bucket</label>
<input type="text" v-model="form.qiniuBucket" placeholder="空间名称" />
</div>
<div class="form-row">
<label>加速域名</label>
<input type="text" v-model="form.qiniuDomain" placeholder="https://cdn.example.com" />
</div>
<div class="form-row">
<label>上传域名可选</label>
<input type="text" v-model="form.qiniuUpHost" placeholder="留空自动探测区域" />
</div>
</template>
<div class="sync-bar">
<div class="sync-info">
<div>
待同步 {{ syncState.pending }} <template v-if="syncState.syncing">同步中</template>
<span v-if="syncState.lastError" class="sync-err">· 有错误</span>
</div>
<div v-if="syncState.lastError" class="sync-err-detail">{{ syncState.lastError }}</div>
</div>
<button class="btn" @click="manualSync" :disabled="syncState.syncing">立即同步</button>
</div>
<div class="modal-actions">
<button class="btn" @click="emit('close')">取消</button>
<button class="btn primary" @click="save">保存</button>
</div>
</div>
</div>
</template>
<style scoped>
.section-title {
margin: 18px 0 10px;
padding-top: 14px;
border-top: 1px solid var(--ui-border);
font-size: 13px;
font-weight: 600;
color: var(--ui-text);
}
.switch-line {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--ui-muted);
}
.switch-line input { width: auto; }
.sync-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--ui-border);
}
.sync-info { font-size: 12px; color: var(--ui-muted); }
.sync-err { color: #e5484d; }
.sync-err-detail {
margin-top: 4px;
max-width: 420px;
word-break: break-all;
font-size: 12px;
line-height: 1.5;
color: #e5484d;
}
</style>
+3 -2
View File
@@ -7,6 +7,7 @@
import { computed } from 'vue' import { computed } from 'vue'
import { store, resolveBg } from '../../core/store' import { store, resolveBg } from '../../core/store'
import ElementView from '../editor/ElementView.vue' import ElementView from '../editor/ElementView.vue'
import Icon from '../common/Icon.vue'
const emit = defineEmits<{ (e: 'close'): void }>() const emit = defineEmits<{ (e: 'close'): void }>()
@@ -20,10 +21,10 @@ function onPrint() { window.print() }
<div class="print-modal"> <div class="print-modal">
<!-- 操作栏打印时不显示 --> <!-- 操作栏打印时不显示 -->
<div class="print-bar no-print"> <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="muted">{{ slides.length }} · A4 横向</span>
<span class="sep"></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> </div>
<!-- 所有幻灯片逐页渲染复用编辑器元素渲染几何与编辑画布同构 --> <!-- 所有幻灯片逐页渲染复用编辑器元素渲染几何与编辑画布同构 -->
-216
View File
@@ -1,216 +0,0 @@
<!-- =====================================================================
SettingsModal.vue AI 设置弹窗
===================================================================== -->
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { AiCfg } from '../../core/types'
import { store } from '../../core/store'
const props = defineProps<{ visible: boolean }>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'toast', msg: string): void
}>()
const PRESETS: Record<string, { protocol: 'openai' | 'anthropic'; base: string; model: string; label: string }> = {
zhipu: { protocol: 'openai', base: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4.6', label: '智谱 GLM (OpenAI 协议)' },
zhipu_anth: { protocol: 'anthropic', base: 'https://open.bigmodel.cn/api/anthropic', model: 'glm-4.6', label: '智谱 GLM (Anthropic 协议)' },
deepseek: { protocol: 'openai', base: 'https://api.deepseek.com', model: 'deepseek-chat', label: 'DeepSeek' },
qwen: { protocol: 'openai', base: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus', label: '通义千问' },
kimi: { protocol: 'openai', base: 'https://api.moonshot.cn/v1', model: 'moonshot-v1-8k', label: 'Kimi' },
doubao: { protocol: 'openai', base: 'https://ark.cn-beijing.volces.com/api/v3', model: 'doubao-pro-32k', label: '豆包' },
openai: { protocol: 'openai', base: 'https://api.openai.com/v1', model: 'gpt-4o-mini', label: 'OpenAI' },
anthropic: { protocol: 'anthropic', base: 'https://api.anthropic.com', model: 'claude-sonnet-5', label: 'Anthropic' },
gemini: { protocol: 'openai', base: 'https://generativelanguage.googleapis.com/v1beta/openai', model: 'gemini-2.0-flash', label: 'Gemini' },
groq: { protocol: 'openai', base: 'https://api.groq.com/openai/v1', model: 'llama-3.3-70b-versatile', label: 'Groq' },
ollama: { protocol: 'openai', base: 'http://localhost:11434/v1', model: 'llama3.1', label: 'Ollama (本地)' }
}
const PROTO_DEFAULTS: Record<string, { base: string; model: string }> = {
openai: { base: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4.6' },
anthropic: { base: 'https://open.bigmodel.cn/api/anthropic', model: 'glm-4.6' }
}
const form = ref<AiCfg>({
preset: 'zhipu', protocol: 'openai',
base: '', key: '', model: '', proxy: '',
imgBase: '', imgKey: '', imgModel: '',
relayUrl: '', relayToken: '', relayDeviceId: ''
})
/** 从 store 读取并填充表单 */
function loadFromStore() {
const c = store.getCfg()
form.value = {
preset: PRESETS[c.preset] ? c.preset : 'custom',
protocol: c.protocol || 'openai',
base: c.base, key: c.key, model: c.model, proxy: c.proxy,
imgBase: c.imgBase || '',
imgKey: c.imgKey || '',
imgModel: c.imgModel || '',
relayUrl: c.relayUrl || '',
relayToken: c.relayToken || '',
relayDeviceId: c.relayDeviceId || ''
}
}
watch(() => props.visible, (v) => {
if (v) loadFromStore()
}, { immediate: true })
function applyPreset(key: string) {
const p = PRESETS[key]
if (!p) return
form.value.protocol = p.protocol
form.value.base = p.base
form.value.model = p.model
}
function onProviderChange() {
if (form.value.preset !== 'custom') applyPreset(form.value.preset)
}
function applyProtoDefaults(p: string) {
const d = PROTO_DEFAULTS[p] || PROTO_DEFAULTS.openai
const other = PROTO_DEFAULTS[p === 'openai' ? 'anthropic' : 'openai']
if (!form.value.base.trim() || form.value.base.trim() === other.base) form.value.base = d.base
if (!form.value.model.trim() || form.value.model.trim() === other.model) form.value.model = d.model
}
function onProtocolChange() {
applyProtoDefaults(form.value.protocol)
}
function save() {
store.setCfg({
preset: form.value.preset,
protocol: form.value.protocol,
base: form.value.base.trim(),
key: form.value.key.trim(),
model: form.value.model.trim(),
proxy: form.value.proxy.trim(),
imgBase: (form.value.imgBase || '').trim(),
imgKey: (form.value.imgKey || '').trim(),
imgModel: (form.value.imgModel || '').trim(),
relayUrl: (form.value.relayUrl || '').trim(),
relayToken: (form.value.relayToken || '').trim(),
relayDeviceId: (form.value.relayDeviceId || '').trim()
})
const label = form.value.preset === 'custom'
? (form.value.protocol === 'anthropic' ? 'Anthropic' : 'OpenAI') + ' 自定义'
: (PRESETS[form.value.preset]?.label || form.value.preset)
emit('toast', '已保存 AI 设置(' + label + '')
emit('close')
}
</script>
<template>
<div class="modal-mask" :class="{ hidden: !visible }" @click.self="emit('close')">
<div class="modal">
<h3>AI 设置</h3>
<p class="modal-tip">配置大模型服务商数据仅保存在本地浏览器</p>
<div class="form-row">
<label>服务商</label>
<select v-model="form.preset" @change="onProviderChange">
<optgroup label="国内">
<option value="zhipu">智谱 GLM (OpenAI 协议)</option>
<option value="zhipu_anth">智谱 GLM (Anthropic 协议)</option>
<option value="deepseek">DeepSeek</option>
<option value="qwen">通义千问</option>
<option value="kimi">Kimi</option>
<option value="doubao">豆包</option>
</optgroup>
<optgroup label="海外">
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="gemini">Gemini</option>
<option value="groq">Groq</option>
</optgroup>
<optgroup label="本地">
<option value="ollama">Ollama</option>
</optgroup>
<option value="custom">自定义</option>
</select>
</div>
<div class="form-row">
<label>API 协议</label>
<select v-model="form.protocol" @change="onProtocolChange">
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
</select>
</div>
<div class="form-row">
<label>Base URL</label>
<input type="text" v-model="form.base" placeholder="https://..." />
</div>
<div class="form-row">
<label>API Key</label>
<input type="password" v-model="form.key" placeholder="sk-..." autocomplete="off" />
</div>
<div class="form-row">
<label>模型</label>
<input type="text" v-model="form.model" placeholder="模型名称" />
</div>
<div class="form-row">
<label>代理 URL可选</label>
<input type="text" v-model="form.proxy" placeholder="留空则直连" />
</div>
<div class="section-title">图像模型可选用于 AI 配图</div>
<div class="form-row">
<label>图像 Base URL</label>
<input type="text" v-model="form.imgBase" placeholder="留空则复用上方 Base URL" />
</div>
<div class="form-row">
<label>图像 API Key</label>
<input type="password" v-model="form.imgKey" placeholder="留空则复用上方 Key" autocomplete="off" />
</div>
<div class="form-row">
<label>图像模型</label>
<input type="text" v-model="form.imgModel" placeholder="dall-e-3" />
</div>
<div class="section-title">Agent 中继可选三项齐备即启用 Agent 模式</div>
<div class="form-row">
<label>中继 URL</label>
<input type="text" v-model="form.relayUrl" placeholder="wss://..." />
</div>
<div class="form-row">
<label>中继 Token</label>
<input type="password" v-model="form.relayToken" placeholder="设备配对 Token" autocomplete="off" />
</div>
<div class="form-row">
<label>设备 ID</label>
<input type="text" v-model="form.relayDeviceId" placeholder="device_id" />
</div>
<div class="modal-actions">
<button class="btn" @click="emit('close')">取消</button>
<button class="btn primary" @click="save">保存</button>
</div>
</div>
</div>
</template>
<style scoped>
.section-title {
margin: 18px 0 10px;
padding-top: 14px;
border-top: 1px solid var(--ui-border);
font-size: 13px;
font-weight: 600;
color: var(--ui-text);
}
</style>
+7 -6
View File
@@ -7,6 +7,7 @@ import { store, resolveBg } from '../../core/store'
import { appConfirm } from '../../core/dialog' import { appConfirm } from '../../core/dialog'
import type { PageTemplate } from '../../core/types' import type { PageTemplate } from '../../core/types'
import ElementView from '../editor/ElementView.vue' import ElementView from '../editor/ElementView.vue'
import Icon from '../common/Icon.vue'
defineProps<{ visible: boolean }>() defineProps<{ visible: boolean }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -78,12 +79,12 @@ async function onDelete(tpl: PageTemplate) {
<template> <template>
<div class="modal-mask" :class="{ hidden: !visible }" @click.self="emit('close')"> <div class="modal-mask" :class="{ hidden: !visible }" @click.self="emit('close')">
<div class="modal tpl-modal"> <div class="modal tpl-modal">
<h3>📋 页面模板</h3> <h3><Icon name="clipboard" :size="15" /> 页面模板</h3>
<!-- 存当前页 --> <!-- 存当前页 -->
<div class="tpl-save-bar"> <div class="tpl-save-bar">
<input v-model="nameInput" placeholder="存当前页为模板,输入名称…" @keydown.enter="onSave" /> <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> </div>
<!-- 模板网格按分组分节 --> <!-- 模板网格按分组分节 -->
@@ -100,7 +101,7 @@ async function onDelete(tpl: PageTemplate) {
<div class="tpl-info"> <div class="tpl-info">
<span class="tpl-name">{{ tpl.name }}</span> <span class="tpl-name">{{ tpl.name }}</span>
<span class="tpl-badge" :class="tpl.category">{{ tpl.category === 'built-in' ? '内置' : '自存' }}</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> </div>
</div> </div>
@@ -179,7 +180,7 @@ async function onDelete(tpl: PageTemplate) {
} }
.tpl-card:hover { .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); box-shadow: 0 4px 12px rgba(15, 23, 42, .12);
transform: translateY(-2px); transform: translateY(-2px);
} }
@@ -227,8 +228,8 @@ async function onDelete(tpl: PageTemplate) {
} }
.tpl-badge.user { .tpl-badge.user {
background: var(--ui-primary-soft, #eef2ff); background: var(--ui-primary-soft, #eeeefc);
color: var(--ui-primary, #4f46e5); color: var(--ui-primary, #5b5bd6);
} }
.tpl-del { .tpl-del {
@@ -0,0 +1,466 @@
<!-- =====================================================================
UnifiedSettingsModal.vue 统一设置弹窗
合并原 SettingsModalAI/中继 OssSettingsModal云存储tab 分区切换
===================================================================== -->
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { AiCfg, OssCfg } from '../../core/types'
import { store } from '../../core/store'
import { syncState, syncPending, canUploadNow } from '../../core/assets'
export type SettingsTab = 'ai' | 'oss' | 'relay'
const props = defineProps<{ visible: boolean; initialTab?: SettingsTab }>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'toast', msg: string): void
}>()
const tab = ref<SettingsTab>('ai')
watch(() => props.visible, (v) => {
if (v) { tab.value = props.initialTab || 'ai'; loadFromStore() }
}, { immediate: true })
/* ==================== AI 模型 ==================== */
const PRESETS: Record<string, { protocol: 'openai' | 'anthropic'; base: string; model: string; label: string }> = {
zhipu: { protocol: 'openai', base: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4.6', label: '智谱 GLM (OpenAI 协议)' },
zhipu_anth: { protocol: 'anthropic', base: 'https://open.bigmodel.cn/api/anthropic', model: 'glm-4.6', label: '智谱 GLM (Anthropic 协议)' },
deepseek: { protocol: 'openai', base: 'https://api.deepseek.com', model: 'deepseek-chat', label: 'DeepSeek' },
qwen: { protocol: 'openai', base: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus', label: '通义千问' },
kimi: { protocol: 'openai', base: 'https://api.moonshot.cn/v1', model: 'moonshot-v1-8k', label: 'Kimi' },
doubao: { protocol: 'openai', base: 'https://ark.cn-beijing.volces.com/api/v3', model: 'doubao-pro-32k', label: '豆包' },
openai: { protocol: 'openai', base: 'https://api.openai.com/v1', model: 'gpt-4o-mini', label: 'OpenAI' },
anthropic: { protocol: 'anthropic', base: 'https://api.anthropic.com', model: 'claude-sonnet-5', label: 'Anthropic' },
gemini: { protocol: 'openai', base: 'https://generativelanguage.googleapis.com/v1beta/openai', model: 'gemini-2.0-flash', label: 'Gemini' },
groq: { protocol: 'openai', base: 'https://api.groq.com/openai/v1', model: 'llama-3.3-70b-versatile', label: 'Groq' },
ollama: { protocol: 'openai', base: 'http://localhost:11434/v1', model: 'llama3.1', label: 'Ollama (本地)' }
}
const PROTO_DEFAULTS: Record<string, { base: string; model: string }> = {
openai: { base: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4.6' },
anthropic: { base: 'https://open.bigmodel.cn/api/anthropic', model: 'glm-4.6' }
}
const aiForm = ref<AiCfg>({
preset: 'zhipu', protocol: 'openai',
base: '', key: '', model: '', proxy: '',
imgBase: '', imgKey: '', imgModel: '',
relayUrl: '', relayToken: '', relayDeviceId: ''
})
/* ==================== 云存储(OSS ==================== */
const ossForm = ref<OssCfg>({
enabled: false, provider: 'aliyun', dir: 'u-ppt',
aliAccessKeyId: '', aliAccessKeySecret: '', aliEndpoint: '', aliBucket: '',
qiniuAccessKey: '', qiniuSecretKey: '', qiniuBucket: '', qiniuUpHost: '', qiniuDomain: '',
viewerBase: ''
})
/** 从 store 读取并填充两份表单 */
function loadFromStore() {
const c = store.getCfg()
aiForm.value = {
preset: PRESETS[c.preset] ? c.preset : 'custom',
protocol: c.protocol || 'openai',
base: c.base, key: c.key, model: c.model, proxy: c.proxy,
imgBase: c.imgBase || '',
imgKey: c.imgKey || '',
imgModel: c.imgModel || '',
relayUrl: c.relayUrl || '',
relayToken: c.relayToken || '',
relayDeviceId: c.relayDeviceId || ''
}
ossForm.value = { ...ossForm.value, ...store.getOssCfg() }
}
function applyPreset(key: string) {
const p = PRESETS[key]
if (!p) return
aiForm.value.protocol = p.protocol
aiForm.value.base = p.base
aiForm.value.model = p.model
}
function onProviderChange() {
if (aiForm.value.preset !== 'custom') applyPreset(aiForm.value.preset)
}
function applyProtoDefaults(p: string) {
const d = PROTO_DEFAULTS[p] || PROTO_DEFAULTS.openai
const other = PROTO_DEFAULTS[p === 'openai' ? 'anthropic' : 'openai']
if (!aiForm.value.base.trim() || aiForm.value.base.trim() === other.base) aiForm.value.base = d.base
if (!aiForm.value.model.trim() || aiForm.value.model.trim() === other.model) aiForm.value.model = d.model
}
function onProtocolChange() {
applyProtoDefaults(aiForm.value.protocol)
}
/* ==================== 保存 ==================== */
function save() {
if (tab.value === 'ai') {
const f = aiForm.value
store.setCfg({
preset: f.preset,
protocol: f.protocol,
base: f.base.trim(),
key: f.key.trim(),
model: f.model.trim(),
proxy: f.proxy.trim(),
imgBase: (f.imgBase || '').trim(),
imgKey: (f.imgKey || '').trim(),
imgModel: (f.imgModel || '').trim(),
relayUrl: (f.relayUrl || '').trim(),
relayToken: (f.relayToken || '').trim(),
relayDeviceId: (f.relayDeviceId || '').trim()
})
const label = f.preset === 'custom'
? (f.protocol === 'anthropic' ? 'Anthropic' : 'OpenAI') + ' 自定义'
: (PRESETS[f.preset]?.label || f.preset)
emit('toast', '已保存 AI 设置(' + label + '')
} else if (tab.value === 'oss') {
saveOss()
} else {
// tabAI
const f = aiForm.value
const c = store.getCfg()
store.setCfg({
...c,
preset: f.preset,
protocol: f.protocol,
base: f.base.trim(),
key: f.key.trim(),
model: f.model.trim(),
proxy: f.proxy.trim(),
imgBase: (f.imgBase || '').trim(),
imgKey: (f.imgKey || '').trim(),
imgModel: (f.imgModel || '').trim(),
relayUrl: (f.relayUrl || '').trim(),
relayToken: (f.relayToken || '').trim(),
relayDeviceId: (f.relayDeviceId || '').trim()
})
emit('toast', '已保存 Agent 中继设置')
}
emit('close')
}
function saveOss() {
const c = ossForm.value
store.setOssCfg({
enabled: c.enabled,
provider: c.provider,
dir: c.dir.trim(),
aliAccessKeyId: c.aliAccessKeyId.trim(),
aliAccessKeySecret: c.aliAccessKeySecret.trim(),
aliEndpoint: c.aliEndpoint.trim(),
aliBucket: c.aliBucket.trim(),
qiniuAccessKey: c.qiniuAccessKey.trim(),
qiniuSecretKey: c.qiniuSecretKey.trim(),
qiniuBucket: c.qiniuBucket.trim(),
qiniuUpHost: c.qiniuUpHost.trim(),
qiniuDomain: c.qiniuDomain.trim(),
viewerBase: c.viewerBase.trim()
})
emit('toast', c.enabled ? '已保存云存储设置(已启用)' : '已保存云存储设置(未启用)')
}
async function manualSync() {
if (!canUploadNow()) {
emit('toast', '当前不可同步:需桌面版 + 已联网 + 已启用并保存配置')
return
}
const r = await syncPending()
emit('toast', `同步完成:成功 ${r.done},失败 ${r.fail}`)
}
</script>
<template>
<div class="modal-mask" :class="{ hidden: !visible }" @click.self="emit('close')">
<div class="modal unified-modal">
<h3>设置</h3>
<!-- tab -->
<div class="tab-bar">
<button class="tab-btn" :class="{ active: tab === 'ai' }" @click="tab = 'ai'">AI 模型</button>
<button class="tab-btn" :class="{ active: tab === 'oss' }" @click="tab = 'oss'">云存储</button>
<button class="tab-btn" :class="{ active: tab === 'relay' }" @click="tab = 'relay'">Agent 中继</button>
</div>
<!-- 内容区统一 min-height避免切 tab 时弹窗高度跳动 -->
<div class="settings-body">
<!-- ==================== AI 模型 ==================== -->
<template v-if="tab === 'ai'">
<p class="modal-tip">配置大模型服务商数据仅保存在本地浏览器</p>
<div class="form-row">
<label>服务商</label>
<select v-model="aiForm.preset" @change="onProviderChange">
<optgroup label="国内">
<option value="zhipu">智谱 GLM (OpenAI 协议)</option>
<option value="zhipu_anth">智谱 GLM (Anthropic 协议)</option>
<option value="deepseek">DeepSeek</option>
<option value="qwen">通义千问</option>
<option value="kimi">Kimi</option>
<option value="doubao">豆包</option>
</optgroup>
<optgroup label="海外">
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="gemini">Gemini</option>
<option value="groq">Groq</option>
</optgroup>
<optgroup label="本地">
<option value="ollama">Ollama</option>
</optgroup>
<option value="custom">自定义</option>
</select>
</div>
<div class="form-row">
<label>API 协议</label>
<select v-model="aiForm.protocol" @change="onProtocolChange">
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
</select>
</div>
<div class="form-row">
<label>Base URL</label>
<input type="text" v-model="aiForm.base" placeholder="https://..." />
</div>
<div class="form-row">
<label>API Key</label>
<input type="password" v-model="aiForm.key" placeholder="sk-..." autocomplete="off" />
</div>
<div class="form-row">
<label>模型</label>
<input type="text" v-model="aiForm.model" placeholder="模型名称" />
</div>
<div class="form-row">
<label>代理 URL可选</label>
<input type="text" v-model="aiForm.proxy" placeholder="留空则直连" />
</div>
<div class="section-title">图像模型可选用于 AI 配图</div>
<div class="form-row">
<label>图像 Base URL</label>
<input type="text" v-model="aiForm.imgBase" placeholder="留空则复用上方 Base URL" />
</div>
<div class="form-row">
<label>图像 API Key</label>
<input type="password" v-model="aiForm.imgKey" placeholder="留空则复用上方 Key" autocomplete="off" />
</div>
<div class="form-row">
<label>图像模型</label>
<input type="text" v-model="aiForm.imgModel" placeholder="dall-e-3" />
</div>
</template>
<!-- ==================== 云存储 ==================== -->
<template v-else-if="tab === 'oss'">
<p class="modal-tip">
开启后图片/视频等媒体自动上传对象存储避免撑爆本地离线时先暂存本地联网后自动同步云端密钥仅保存在本地
</p>
<div class="form-row">
<label>启用云存储</label>
<label class="switch-line">
<input type="checkbox" v-model="ossForm.enabled" />
<span>{{ ossForm.enabled ? '开' : '关(维持本地内嵌)' }}</span>
</label>
</div>
<div class="form-row">
<label>服务商</label>
<select v-model="ossForm.provider">
<option value="aliyun">阿里云 OSS</option>
<option value="qiniu">七牛云 Kodo</option>
</select>
</div>
<div class="form-row">
<label>所属目录</label>
<input type="text" v-model="ossForm.dir" placeholder="如 u-ppt/images,可留空" />
</div>
<div class="form-row">
<label>查看器地址</label>
<input type="text" v-model="ossForm.viewerBase" placeholder="如 https://img.1216.top,生成分享链接必填" />
</div>
<!-- 阿里云 -->
<template v-if="ossForm.provider === 'aliyun'">
<div class="section-title">阿里云 OSS</div>
<div class="form-row">
<label>AccessKeyId</label>
<input type="text" v-model="ossForm.aliAccessKeyId" placeholder="LTAI..." autocomplete="off" />
</div>
<div class="form-row">
<label>AccessKeySecret</label>
<input type="password" v-model="ossForm.aliAccessKeySecret" placeholder="密钥" autocomplete="off" />
</div>
<div class="form-row">
<label>Endpoint</label>
<input type="text" v-model="ossForm.aliEndpoint" placeholder="oss-cn-hangzhou.aliyuncs.com" />
</div>
<div class="form-row">
<label>Bucket</label>
<input type="text" v-model="ossForm.aliBucket" placeholder="bucket 名称" />
</div>
</template>
<!-- 七牛云 -->
<template v-else>
<div class="section-title">七牛云 Kodo</div>
<div class="form-row">
<label>AccessKey</label>
<input type="text" v-model="ossForm.qiniuAccessKey" placeholder="AK" autocomplete="off" />
</div>
<div class="form-row">
<label>SecretKey</label>
<input type="password" v-model="ossForm.qiniuSecretKey" placeholder="SK" autocomplete="off" />
</div>
<div class="form-row">
<label>Bucket</label>
<input type="text" v-model="ossForm.qiniuBucket" placeholder="空间名称" />
</div>
<div class="form-row">
<label>加速域名</label>
<input type="text" v-model="ossForm.qiniuDomain" placeholder="https://cdn.example.com" />
</div>
<div class="form-row">
<label>上传域名可选</label>
<input type="text" v-model="ossForm.qiniuUpHost" placeholder="留空自动探测区域" />
</div>
</template>
<div class="sync-bar">
<div class="sync-info">
<div>
待同步 {{ syncState.pending }} <template v-if="syncState.syncing">同步中</template>
<span v-if="syncState.lastError" class="sync-err">· 有错误</span>
</div>
<div v-if="syncState.lastError" class="sync-err-detail">{{ syncState.lastError }}</div>
</div>
<button class="btn" @click="manualSync" :disabled="syncState.syncing">立即同步</button>
</div>
</template>
<!-- ==================== Agent 中继 ==================== -->
<template v-else>
<p class="modal-tip">通过 u-relay 中继接收远端 Agent 指令可选功能三项配置齐备即自动启用</p>
<div class="form-row">
<label>中继 URL</label>
<input type="text" v-model="aiForm.relayUrl" placeholder="wss://u-work.1216.top(可省略 /ws/miniapp" />
</div>
<div class="form-row">
<label>中继 Token</label>
<input type="password" v-model="aiForm.relayToken" placeholder="设备配对 Token" autocomplete="off" />
</div>
<div class="form-row">
<label>设备 ID</label>
<input type="text" v-model="aiForm.relayDeviceId" placeholder="device_id" />
</div>
</template>
</div>
<div class="modal-actions">
<button class="btn" @click="emit('close')">取消</button>
<button class="btn primary" @click="save">保存</button>
</div>
</div>
</div>
</template>
<style scoped>
/* 弹窗加宽(仅本组件) */
.unified-modal { width: 520px; display: flex; flex-direction: column; }
/* 内容区统一最小高度:以最高 tab(云存储)为基准,切 tab 不再跳动 */
.settings-body { min-height: 440px; }
/* tab 栏:下划线高亮 */
.tab-bar {
display: flex;
gap: 18px;
margin-bottom: 16px;
border-bottom: 1px solid var(--ui-border);
}
.tab-btn {
border: none;
background: transparent;
padding: 8px 2px;
font-size: 13.5px;
color: var(--ui-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.tab-btn.active { color: var(--ui-primary, #5b5bd6); border-bottom-color: var(--ui-primary, #5b5bd6); font-weight: 600; }
/* 紧凑化:label 与输入同行 */
.form-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.form-row > label {
display: block;
width: 110px;
flex-shrink: 0;
text-align: right;
white-space: nowrap;
margin-bottom: 0;
}
.form-row > select,
.form-row > input { flex: 1; min-width: 0; }
.section-title {
margin: 18px 0 10px;
padding-top: 14px;
border-top: 1px solid var(--ui-border);
font-size: 13px;
font-weight: 600;
color: var(--ui-text);
}
.section-title.first { margin-top: 0; padding-top: 0; border-top: none; }
.switch-line {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--ui-muted);
flex: 1;
}
.switch-line input { width: auto; }
.sync-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--ui-border);
}
.sync-info { font-size: 12px; color: var(--ui-muted); }
.sync-err { color: #e5484d; }
.sync-err-detail {
margin-top: 4px;
max-width: 420px;
word-break: break-all;
font-size: 12px;
line-height: 1.5;
color: #e5484d;
}
</style>
+162 -19
View File
@@ -34,8 +34,13 @@ const ERROR_HINTS_BY_STATUS: Record<number, string> = {
/* ============================================================ /* ============================================================
* Prompt * Prompt
* ============================================================ */ * ============================================================ */
/** 时效基准:本地日期 y-m-dtoISOString 走 UTC,跨时区会偏一天,故本地拼装) */
const now = new Date()
const TODAY = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0') + '-' + String(now.getDate()).padStart(2, '0')
const SYS_BASE = const SYS_BASE =
'你是「u-ppt」的内容创作助手,擅长把主题变成结构清晰、视觉现代、带入场动效的中文演示稿。\n' + '你是「u-ppt」的内容创作助手,擅长把主题变成结构清晰、视觉现代、带入场动效的中文演示稿。\n' +
'时效基准:当前日期是 ' + TODAY + '。内容中的年份、日期、季度必须与此一致,禁止臆造过时或未来的年份。\n' +
'输出必须严格遵循下面的数据模型,坐标用百分比(0-100),字号为数字。\n\n' + '输出必须严格遵循下面的数据模型,坐标用百分比(0-100),字号为数字。\n\n' +
'幻灯片模型:\n' + '幻灯片模型:\n' +
'{ "slides": [ { "background": "bg|panel|primary|accent|g-primary|g-deep|g-soft", "elements": [ 元素, ... ] } ] }\n' + '{ "slides": [ { "background": "bg|panel|primary|accent|g-primary|g-deep|g-soft", "elements": [ 元素, ... ] } ] }\n' +
@@ -72,6 +77,12 @@ const SYS_BASE =
'- 字号:title 44-66、text 22-28、list 24-30、stat 数字 64-80、quote 40-52。\n' + '- 字号:title 44-66、text 22-28、list 24-30、stat 数字 64-80、quote 40-52。\n' +
'- 一页一个观点,留白充足,列表不超过 5 条。\n' + '- 一页一个观点,留白充足,列表不超过 5 条。\n' +
'- 现代版式:多用 card 分组;封面/金句/结尾用 g-primary;目录用 3-4 张卡片网格;数据页 stat+chart。\n' + '- 现代版式:多用 card 分组;封面/金句/结尾用 g-primary;目录用 3-4 张卡片网格;数据页 stat+chart。\n' +
'- 形状纪律:每页装饰形状 ≤3 个;circle/star/triangle/diamond/pentagon/hexagon 框取正方形(w=h)arrow/chevron/bubble 可扁宽;装饰形状完整放在画布内,不得压在 title/text/list 文字上,胶囊条放在标题块正下方。\n' +
'- 装饰克制:禁止用多个形状拼组合图案(房子/人物/山丘/图标等);不要用形状当分隔线、进度条、底座;没有明确版式作用就不放形状,宁缺毋滥。\n' +
'- 纵向骨架:内容页标题 y=8 h=10,正文/列表/表格/卡片组从 y≈22-26 开始,按内容量给 h——列表 h≈6+条数×8,表格 h≈12+行数×9,卡片组下缘到 y≈85 收底。\n' +
'- 内容少时缩小 h 并整体上移,空白留在页面底部;标题与正文间不留大空档。\n' +
'- list 渲染层每行自带圆点,content 行首不要再写「•」「-」「①」等编号或符号前缀。\n' +
'- 深底页(g-primary/g-deep/primary/accent 背景)上,正文/脚注/小字不要用 accent(与渐变背景混同),用默认 muted;accent 仅用于大号元素(大数字/大标题)。\n' +
'- emoji 极克制:默认不给 card.icon,除非确有助于理解,多数卡片留空。\n\n' + '- emoji 极克制:默认不给 card.icon,除非确有助于理解,多数卡片留空。\n\n' +
'内容准则(重要——避免「AI 味」,写得像该领域的真人):\n' + '内容准则(重要——避免「AI 味」,写得像该领域的真人):\n' +
'- 标题写具体事实而非口号:「华东 Q3 增长 23%」而非「业绩腾飞」;「同屏字+口述记忆降 50%」而非「效率革命」。\n' + '- 标题写具体事实而非口号:「华东 Q3 增长 23%」而非「业绩腾飞」;「同屏字+口述记忆降 50%」而非「效率革命」。\n' +
@@ -451,15 +462,90 @@ function normElement(e: any): SlideElement | null {
const VALID_BGS = ['bg', 'panel', 'primary', 'accent', 'g-primary', 'g-deep', 'g-soft'] const VALID_BGS = ['bg', 'panel', 'primary', 'accent', 'g-primary', 'g-deep', 'g-soft']
/** 形状校正:多边形/圆形等比化(防 clip-path 拉伸变形)、形状收进画布、空装饰不压正文 */
function sanitizeShapes(slide: Slide): Slide {
// 需要正方形框的形状(w=h 取小者,中心不变):circle 与这些 clip-path 多边形
// arrow/chevron/bubble 天然扁宽、ellipse 本就扁圆,不做等比
const SQUARE_TYPES = new Set(['circle', 'star', 'triangle', 'diamond', 'pentagon', 'hexagon'])
const els = slide.elements
for (const el of els) {
if (el.type !== 'shape') continue
const shapeType = (el.style.shapeType as string) || 'rect'
if (SQUARE_TYPES.has(shapeType)) {
const m = Math.min(el.w, el.h)
if (el.w > m) el.x += (el.w - m) / 2
if (el.h > m) el.y += (el.h - m) / 2
el.w = m
el.h = m
}
// 完整收进画布,防边缘裁切怪片
if (el.x + el.w > 100) el.x = Math.max(0, 100 - el.w)
if (el.y + el.h > 100) el.y = Math.max(0, 100 - el.h)
if (el.x < 0) el.x = 0
if (el.y < 0) el.y = 0
}
// 空内容装饰形状压在正文文字行上:压 1 个 → 移到该元素正下方(保留「标题下胶囊条」设计);压 ≥2 个 → 丢弃
// 判定「真压字」:与元素相交高度 > 形状高 50% 且相交宽度 > 形状宽 30%(避免误伤贴标题下缘的合法胶囊)
const PROTECTED = new Set(['title', 'text', 'list', 'quote', 'stat'])
const hits = (sh: SlideElement, t: SlideElement) => {
const iw = Math.min(sh.x + sh.w, t.x + t.w) - Math.max(sh.x, t.x)
const ih = Math.min(sh.y + sh.h, t.y + t.h) - Math.max(sh.y, t.y)
return iw > sh.w * 0.3 && ih > sh.h * 0.5
}
const keep: SlideElement[] = []
for (const el of els) {
if (el.type !== 'shape' || el.content.trim()) { keep.push(el); continue }
const targets = els.filter(o => o !== el && PROTECTED.has(o.type) && hits(el, o))
if (targets.length === 0) { keep.push(el); continue }
if (targets.length >= 2) continue // 丢弃
el.y = Math.min(96, targets[0].y + targets[0].h + 1) // 移到正下方
keep.push(el)
}
// AI 拿形状当分隔线/进度条(横贯页面的灰色细条),渲染效果差,直接丢弃
const isDivider = (sh: SlideElement) =>
(((sh.style.shapeType as string) || 'rect') === 'rect' && sh.w >= 60 && sh.h <= 6)
// 多个空装饰形状叠放拼图案(三角+矩形+圆拼「房子」这类),保留先出现的、丢弃叠在其后的
const decoArea = (sh: SlideElement) => sh.w * sh.h
const overlaps = (a: SlideElement, b: SlideElement) => {
const iw = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)
const ih = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y)
if (iw <= 0 || ih <= 0) return false
return iw * ih > decoArea(b) * 0.2 // b 是较小者面积按较小者算,这里调用时保证 b 更小
}
const afterOverlap = keep.filter(el => {
if (el.type !== 'shape' || el.content.trim()) return true
if (isDivider(el)) return false
// 与任意已在保留集里的空装饰形状叠放 → 丢弃当前(较后)这个
for (const prev of keep) {
if (prev === el || prev.type !== 'shape' || prev.content.trim()) continue
const small = decoArea(prev) <= decoArea(el) ? prev : el
const big = small === prev ? el : prev
if (overlaps(big, small)) return false
}
return true
})
// 装饰形状数量兜底:prompt 要求 ≤3,机械上限放宽到 5,超出部分丢弃
let decoCount = 0
const final = afterOverlap.filter(el => {
if (el.type === 'shape' && !el.content.trim()) {
decoCount++
return decoCount <= 5
}
return true
})
return { ...slide, elements: final }
}
function normSlide(s: any): Slide | null { function normSlide(s: any): Slide | null {
if (!s || typeof s !== 'object') return null if (!s || typeof s !== 'object') return null
const bg = VALID_BGS.indexOf(s.background) >= 0 ? s.background const bg = VALID_BGS.indexOf(s.background) >= 0 ? s.background
: (typeof s.background === 'string' && s.background.charAt(0) === '#' ? s.background : 'bg') : (typeof s.background === 'string' && s.background.charAt(0) === '#' ? s.background : 'bg')
const els = (Array.isArray(s.elements) ? s.elements : []).map(normElement).filter(Boolean) as SlideElement[] const els = (Array.isArray(s.elements) ? s.elements : []).map(normElement).filter(Boolean) as SlideElement[]
return { id: uid('s'), background: bg as Slide['background'], elements: els } return sanitizeShapes({ id: uid('s'), background: bg as Slide['background'], elements: els })
} }
function normSlides(arr: any[]): Slide[] { /** AI 返回 → 有效幻灯片数组(元素归一化 + 形状校正) */
export function normSlides(arr: any[]): Slide[] {
return (Array.isArray(arr) ? arr : []).map(normSlide).filter((s): s is Slide => s !== null) return (Array.isArray(arr) ? arr : []).map(normSlide).filter((s): s is Slide => s !== null)
} }
@@ -566,13 +652,18 @@ export function parseChatReply(text: string): { reply: string; op: AiOp | null }
* Agent prompt + chat + deck * Agent prompt + chat + deck
* deck >700KB JSON + deck大纲摘要 1MiB * deck >700KB JSON + deck大纲摘要 1MiB
*/ */
export function buildAgentPrompt(input: string): string { export function buildAgentPrompt(input: string, selectedElement?: SlideElement | null, history?: { role: string; content: string }[]): string {
const SYS_AGENT = const SYS_AGENT =
SYS_BASE + SYS_BASE +
'\n任务:你是通过中继接入的远程 Agent。根据用户指令编辑当前演示。\n' + '\n任务:你是通过中继接入的远程 Agent。根据用户指令编辑当前演示。\n' +
'回复格式:先用中文说明你将做什么,如需修改 PPT,在回复最后另起一行输出分隔标记 ' + SEP + ',紧随其后输出 JSON 操作。\n' + '回复格式:先用中文说明你将做什么,如需修改 PPT,在回复最后另起一行输出分隔标记 ' + SEP + ',紧随其后输出 JSON 操作。\n' +
'JSON 操作格式:{"action":"add_page|update_page|create_all|answer","slides":[...],"target":页码(从1开始,可选)}\n' + 'JSON 操作格式:{"action":"add_page|update_page|create_all|answer|outline|gen_page","slides":[...],"target":页码(从1开始,可选)}\n' +
'- add_page:在 target 页后插入新页;- update_page:替换 target 页;- create_all:整体替换;- answer:仅回答不改稿。\n' + '- add_page:在 target 页后插入新页;- update_page:替换 target 页;- create_all:整体替换;- answer:仅回答不改稿。\n' +
'- outline:用户要求「拟定大纲/先出大纲」时返回,格式 {"action":"outline","title":"整份标题","items":[...]}\n' +
' items 结构:{ "kind":"cover|toc|content|quote|end", "title":"页标题", "points":["要点 1","要点 2"], "hint":"可选补充指令" }\n' +
' kind 取值:cover(封面)/toc(目录)/content(内容)/quote(金句)/end(结尾),封面与结尾各 1 页,目录可选;\n' +
' 页数遵循用户指令(指定具体页数照办;未指定/「自动」则按主题信息量在 5-12 页裁量:概念介绍 5-7、常规 7-9、复杂多维度 9-12);hint 给后续生成幻灯片的补充指令。\n' +
'- gen_page:用户给出大纲条目要求生成该页时返回,格式 {"action":"gen_page","slides":[单个slide对象]}。\n' +
'没有改动时不要输出分隔标记。不要使用 markdown 代码块。' '没有改动时不要输出分隔标记。不要使用 markdown 代码块。'
const deck = store.getDeck() const deck = store.getDeck()
let body: string let body: string
@@ -583,7 +674,34 @@ export function buildAgentPrompt(input: string): string {
} else { } else {
body = '完整 deck JSON\n' + full + '\n当前页码:第 ' + (store.getCurrentIndex() + 1) + ' 页' body = '完整 deck JSON\n' + full + '\n当前页码:第 ' + (store.getCurrentIndex() + 1) + ' 页'
} }
return SYS_AGENT + '\n\n' + body + '\n\n用户指令:' + input // 选中元素上下文:与 direct 通道对话行为对齐
if (selectedElement) {
const typeLabel = selectedElement.type
const preview = (selectedElement.content || '').replace(/\n/g, ' ').slice(0, 100)
body += '\n\n【用户当前选中的元素】(第' + (store.getCurrentIndex() + 1) + '页)'
body += '\n类型: ' + typeLabel + ',内容预览: "' + preview + '"'
body += '\n用户接下来的指令默认针对此元素,除非明确说整页/整套。'
}
// 多轮对话历史(不含本次输入);单条截断防长回复撑爆 1MiB 帧上限
let hist = ''
if (history && history.length) {
hist = '\n\n对话历史:\n' + history
.map(m => (m.role === 'user' ? '用户: ' : '助手: ') + m.content.slice(0, 1500))
.join('\n')
}
return SYS_AGENT + '\n\n' + hist + '\n\n' + body + '\n\n用户指令:' + input
}
/** 大纲条目规范化(outline() 与 normalizeOp 的 outline 分支共用) */
function normOutlineItems(arr: any[]): OutlineItem[] {
return (Array.isArray(arr) ? arr : []).map((it: any, i: number) => ({
id: 'ol-' + Date.now() + '-' + i,
kind: ['cover', 'toc', 'content', 'quote', 'end'].includes(it.kind) ? it.kind : 'content',
title: String(it.title || '未命名').slice(0, 80),
points: Array.isArray(it.points) ? it.points.map((p: any) => String(p).slice(0, 200)).filter(Boolean).slice(0, 6) : [],
hint: it.hint ? String(it.hint).slice(0, 120) : undefined,
done: false
}))
} }
function normalizeOp(json: any): AiOp | null { function normalizeOp(json: any): AiOp | null {
@@ -591,6 +709,14 @@ function normalizeOp(json: any): AiOp | null {
let action: AiOp['action'] = json.action || 'answer' let action: AiOp['action'] = json.action || 'answer'
const slides = normSlides(json.slides) const slides = normSlides(json.slides)
const target = json.target != null ? (Number(json.target) - 1) : null const target = json.target != null ? (Number(json.target) - 1) : null
// outline:agent 返回整份大纲,条目规范化(复用 outline() 的逻辑)
if (action === 'outline') {
const items = normOutlineItems(json.items)
if (!items.length) action = 'answer'
else return { action, slides, target: null, note: json.note || '', outline: { title: String(json.title || '').slice(0, 80) || '未命名大纲', topic: '', items } }
}
// gen_page:按大纲条目生成的单页,空则降级 answer
if (action === 'gen_page' && !slides.length) action = 'answer'
if (action === 'update_page' && slides.length) action = 'update_page' if (action === 'update_page' && slides.length) action = 'update_page'
if (action === 'add_page' && slides.length) action = 'add_page' if (action === 'add_page' && slides.length) action = 'add_page'
if ((action === 'update_page' || action === 'add_page') && !slides.length) action = 'answer' if ((action === 'update_page' || action === 'add_page') && !slides.length) action = 'answer'
@@ -610,7 +736,8 @@ import type { Outline, OutlineItem, ThemeSuggestion, ImageGenResult } from './ty
const SYS_OUTLINE = const SYS_OUTLINE =
'你是「u-ppt」的演示策划助手。用户给你一个主题,你先制定大纲,不要直接写完整幻灯片。\n\n' + '你是「u-ppt」的演示策划助手。用户给你一个主题,你先制定大纲,不要直接写完整幻灯片。\n\n' +
'大纲要素:先拟定一个具体、有信息量的整份标题(不要「关于 X 的分享」这类空泛标题);\n' + '大纲要素:先拟定一个具体、有信息量的整份标题(不要「关于 X 的分享」这类空泛标题);\n' +
'然后拆成 5-8 页,每页给出:页类型 kind、标题 title、3-5 条要点 points、可选 hint。\n' + '然后拆成页(页数遵循用户指令:指定了具体页数就照办;「自动」则按主题的信息量在 5-12 页间裁量,' +
'概念介绍 5-7 页、常规主题 7-9 页、多维度复杂主题 9-12 页),每页给出:页类型 kind、标题 title、3-5 条要点 points、可选 hint。\n' +
'kind 取值:cover(封面) / toc(目录) / content(内容) / quote(金句) / end(结尾)。\n' + 'kind 取值:cover(封面) / toc(目录) / content(内容) / quote(金句) / end(结尾)。\n' +
'封面与结尾各 1 页,目录可选。\n' + '封面与结尾各 1 页,目录可选。\n' +
'hint 用来给后续生成幻灯片的 AI 补充指令,例如「数据页:用 stat+chart」「对比页:双 card 并置」「引言页:深色背景金句」。\n\n' + 'hint 用来给后续生成幻灯片的 AI 补充指令,例如「数据页:用 stat+chart」「对比页:双 card 并置」「引言页:深色背景金句」。\n\n' +
@@ -626,9 +753,28 @@ const SYS_OUTLINE =
const SYS_GEN_PAGE = const SYS_GEN_PAGE =
SYS_BASE + SYS_BASE +
'\n任务:根据大纲中的一条,生成「一页」幻灯片。严格遵循要点与 hint,不要偏离主题。\n' + '\n任务:根据大纲中的一条,生成「一页」幻灯片。严格遵循要点与 hint,不要偏离主题。\n' +
'若提供整套规划,标题字号与装饰密度须与同套其他页一致。\n' +
'严格输出:{ "background":"...", "elements":[ ... ] }(单个 slide 对象,不要数组)。\n' + '严格输出:{ "background":"...", "elements":[ ... ] }(单个 slide 对象,不要数组)。\n' +
'kind=cover 用 g-primary 背景;kind=quote 用 g-deepkind=end 用 g-primarykind=content 数据页用 stat+chart。' 'kind=cover 用 g-primary 背景;kind=quote 用 g-deepkind=end 用 g-primarykind=content 数据页用 stat+chart。'
/** 整套规划摘要:注入每次单页生成的提示词,约束套内风格一致 */
export function planDigest(plan: { title: string; items: { kind: string; title: string }[] }): string {
const list = plan.items.map((it, i) => (i + 1) + '.[' + it.kind + '] ' + it.title).join(' / ')
return '整套规划「' + plan.title + '」共 ' + plan.items.length + ' 页:' + list +
'(一致性要求:内容页标题 fontSize 统一 44,正文 20-24;背景惯例 cover/end=g-primary、quote=g-deep、content=bg/panel'
}
/** agent generatePage user
* planDigest */
export function pageInstruction(item: OutlineItem, idx: number, total: number, plan?: { title: string; items: { kind: string; title: string }[] }): string {
return '按大纲生成第 ' + (idx + 1) + '/' + total + ' 页幻灯片:\n' +
'类型:' + item.kind + '\n' +
'标题:' + item.title + '\n' +
'要点:' + item.points.map((p, i) => (i + 1) + '. ' + p).join('\n') +
(item.hint ? '\n补充指令:' + item.hint : '') +
(plan ? '\n' + planDigest(plan) : '')
}
const SYS_THEME = const SYS_THEME =
'你是配色设计师。根据主题关键词,推荐一套现代、专业的 6 色配色(primary 主色、accent 强调色、bg 背景、panel 面板、text 正文、muted 次要文字)。\n\n' + '你是配色设计师。根据主题关键词,推荐一套现代、专业的 6 色配色(primary 主色、accent 强调色、bg 背景、panel 面板、text 正文、muted 次要文字)。\n\n' +
'要求:\n' + '要求:\n' +
@@ -651,35 +797,32 @@ const SYS_BEAUTIFY =
/* ---------- 1. 大纲生成 ---------- */ /* ---------- 1. 大纲生成 ---------- */
export async function outline(opts: { topic: string; count?: number; signal?: AbortSignal }): Promise<Outline> { export async function outline(opts: { topic: string; count?: number | 'auto'; signal?: AbortSignal }): Promise<Outline> {
const count = opts.count || 7 const count = opts.count || 'auto'
const countHint = count === 'auto'
? '页数自动裁量(结合主题信息量在 5-12 页之间取舍)'
: '请拟定约 ' + count + ' 页'
const messages: Message[] = [ const messages: Message[] = [
{ role: 'system', content: SYS_OUTLINE }, { role: 'system', content: SYS_OUTLINE },
{ role: 'user', content: '主题:' + opts.topic + '\n请拟定约 ' + count + '的大纲(含封面与结尾),中文。' } { role: 'user', content: '主题:' + opts.topic + '\n' + countHint + '的大纲(含封面与结尾),中文。' }
] ]
const r = await streamChat(messages, { jsonMode: true, signal: opts.signal }) const r = await streamChat(messages, { jsonMode: true, signal: opts.signal })
if (!r.json) throw new Error('AI 未返回有效大纲,请重试。') if (!r.json) throw new Error('AI 未返回有效大纲,请重试。')
const items: OutlineItem[] = (Array.isArray(r.json.items) ? r.json.items : []).map((it: any, i: number) => ({ const items = normOutlineItems(r.json.items)
id: 'ol-' + Date.now() + '-' + i,
kind: ['cover', 'toc', 'content', 'quote', 'end'].includes(it.kind) ? it.kind : 'content',
title: String(it.title || '未命名').slice(0, 80),
points: Array.isArray(it.points) ? it.points.map((p: any) => String(p).slice(0, 200)).filter(Boolean).slice(0, 6) : [],
hint: it.hint ? String(it.hint).slice(0, 120) : undefined,
done: false
}))
if (!items.length) throw new Error('大纲为空,请重试或换一个主题。') if (!items.length) throw new Error('大纲为空,请重试或换一个主题。')
return { title: String(r.json.title || opts.topic).slice(0, 80), topic: opts.topic, items } return { title: String(r.json.title || opts.topic).slice(0, 80), topic: opts.topic, items }
} }
/* ---------- 2. 按大纲条目生成单页 ---------- */ /* ---------- 2. 按大纲条目生成单页 ---------- */
export async function generatePage(opts: { item: OutlineItem; index: number; total: number; signal?: AbortSignal }): Promise<Slide> { export async function generatePage(opts: { item: OutlineItem; index: number; total: number; plan?: { title: string; items: { kind: string; title: string }[] }; signal?: AbortSignal }): Promise<Slide> {
const user = const user =
'大纲第 ' + (opts.index + 1) + '/' + opts.total + ' 页:\n' + '大纲第 ' + (opts.index + 1) + '/' + opts.total + ' 页:\n' +
'类型:' + opts.item.kind + '\n' + '类型:' + opts.item.kind + '\n' +
'标题:' + opts.item.title + '\n' + '标题:' + opts.item.title + '\n' +
'要点:' + opts.item.points.map((p, i) => (i + 1) + '. ' + p).join('\n') + '要点:' + opts.item.points.map((p, i) => (i + 1) + '. ' + p).join('\n') +
(opts.item.hint ? '\n补充指令:' + opts.item.hint : '') (opts.item.hint ? '\n补充指令:' + opts.item.hint : '') +
(opts.plan ? '\n' + planDigest(opts.plan) : '')
const messages: Message[] = [ const messages: Message[] = [
{ role: 'system', content: SYS_GEN_PAGE }, { role: 'system', content: SYS_GEN_PAGE },
{ role: 'user', content: user } { role: 'user', content: user }
+12 -1
View File
@@ -36,6 +36,17 @@ const MAX_FRAME = 1024 * 1024 // 服务端帧上限 1MiB
const RECONNECT_BASE = 1_000 // 重连退避基数 const RECONNECT_BASE = 1_000 // 重连退避基数
const RECONNECT_MAX = 30_000 // 重连退避上限 const RECONNECT_MAX = 30_000 // 重连退避上限
/** 补全中继地址:无路径(或仅"/")时自动追加 /ws/miniapp 端点;已含路径则原样 */
export function normalizeRelayUrl(url: string): string {
const u = url.trim().replace(/\/+$/, '')
try {
const parsed = new URL(u)
return (parsed.pathname === '/' || parsed.pathname === '') ? u + '/ws/miniapp' : u
} catch {
return u
}
}
function genId(): string { function genId(): string {
return 'req-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10) return 'req-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10)
} }
@@ -108,7 +119,7 @@ export class RelayClient {
const c = store.getCfg() const c = store.getCfg()
this.setStatus(this.attempts ? 'reconnecting' : 'connecting') this.setStatus(this.attempts ? 'reconnecting' : 'connecting')
let ws: WebSocket let ws: WebSocket
try { ws = new WebSocket(c.relayUrl!) } catch (e: any) { try { ws = new WebSocket(normalizeRelayUrl(c.relayUrl!)) } catch (e: any) {
this.setStatus('error', 'URL 无效:' + (e?.message || e)); return this.setStatus('error', 'URL 无效:' + (e?.message || e)); return
} }
this.ws = ws this.ws = ws
+8
View File
@@ -170,6 +170,14 @@ export function hasFormatting(lines: RichLine[]): boolean {
return false return false
} }
/* ---------- 工具:行首是否自带列表标记(圆点/编号,用于避免渲染层双重标记) ---------- */
/** 检测行首(允许空白)是否自带列表标记:圈号①-⑳ / 阿拉伯数字+点顿 / 圆点符号 / 中文数字+点顿 */
export function hasLineMarker(line: string): boolean {
if (!line) return false
return /^[\s]*(?:[①-⑳]|[(]?\d{1,2}[).、.]|[-•·▪●○*]|[一二三四五六七八九十]+[、..])/.test(line)
}
/* ---------- 工具:安全化(AI 输出或外部数据 → 合法 segments ---------- */ /* ---------- 工具:安全化(AI 输出或外部数据 → 合法 segments ---------- */
export function normSegments(input: any): RichLine[] | undefined { export function normSegments(input: any): RichLine[] | undefined {
+3 -1
View File
@@ -233,10 +233,12 @@ export interface LibItem {
/** AI 返回的操作 */ /** AI 返回的操作 */
export interface AiOp { export interface AiOp {
action: 'create_all' | 'add_page' | 'update_page' | 'answer' action: 'create_all' | 'add_page' | 'update_page' | 'answer' | 'outline' | 'gen_page'
slides: Slide[] slides: Slide[]
target: number | null // 1-based 页码 target: number | null // 1-based 页码
note: string note: string
/** action=outline 时携带的大纲 */
outline?: Outline
} }
/** 聊天消息 */ /** 聊天消息 */
+10 -4
View File
@@ -10,8 +10,8 @@
--ui-hover: #f1f5f9; /* 悬停 */ --ui-hover: #f1f5f9; /* 悬停 */
--ui-text: #1e293b; /* 主文字 */ --ui-text: #1e293b; /* 主文字 */
--ui-muted: #64748b; /* 次要文字 */ --ui-muted: #64748b; /* 次要文字 */
--ui-primary: #4f46e5; /* 主操作 */ --ui-primary: #5b5bd6; /* 主操作(与 app-site 品牌色一致) */
--ui-primary-soft: #eef2ff; --ui-primary-soft: #eeeefc;
--ui-danger: #e11d48; --ui-danger: #e11d48;
--ui-success: #059669; --ui-success: #059669;
--radius: 10px; --radius: 10px;
@@ -46,6 +46,12 @@ h1, h2, h3, h4, h5, h6, p { margin: 0; }
button { font-family: inherit; cursor: pointer; } button { font-family: inherit; cursor: pointer; }
input, select, textarea { font-family: inherit; font-size: inherit; color: inherit; } input, select, textarea { font-family: inherit; font-size: inherit; color: inherit; }
/* 键盘导航可见性:Tab 聚焦统一主色描边(鼠标点击不触发) */
:focus-visible {
outline: 2px solid var(--ui-primary);
outline-offset: 1px;
}
.hidden { display: none !important; } .hidden { display: none !important; }
.muted { color: var(--ui-muted); } .muted { color: var(--ui-muted); }
@@ -63,7 +69,7 @@ input, select, textarea { font-family: inherit; font-size: inherit; color: inher
.btn:hover { background: var(--ui-hover); } .btn:hover { background: var(--ui-hover); }
.btn:active { transform: translateY(1px); } .btn:active { transform: translateY(1px); }
.btn.primary { background: var(--ui-primary); border-color: var(--ui-primary); color: #fff; } .btn.primary { background: var(--ui-primary); border-color: var(--ui-primary); color: #fff; }
.btn.primary:hover { background: #4338ca; } .btn.primary:hover { background: #4a4ac4; }
.btn.ghost { background: transparent; border-color: transparent; color: var(--ui-muted); } .btn.ghost { background: transparent; border-color: transparent; color: var(--ui-muted); }
.btn.ghost:hover { background: var(--ui-hover); color: var(--ui-text); } .btn.ghost:hover { background: var(--ui-hover); color: var(--ui-text); }
.btn.danger { background: var(--ui-danger); border-color: var(--ui-danger); color: #fff; } .btn.danger { background: var(--ui-danger); border-color: var(--ui-danger); color: #fff; }
@@ -119,7 +125,7 @@ input[type="range"]::-webkit-slider-thumb {
background: rgba(255, 255, 255, .08); background: rgba(255, 255, 255, .08);
color: #fff; text-align: center; color: #fff; text-align: center;
} }
.global-drop-card .global-drop-icon { font-size: 48px; } .global-drop-card .global-drop-icon { display: inline-flex; color: rgba(255, 255, 255, .9); }
.global-drop-card strong { font-size: 18px; } .global-drop-card strong { font-size: 18px; }
.global-drop-card .global-drop-hint { font-size: 13px; opacity: .75; } .global-drop-card .global-drop-hint { font-size: 13px; opacity: .75; }
+9 -9
View File
@@ -19,9 +19,9 @@
white-space: nowrap; white-space: nowrap;
} }
.ai-action:hover { .ai-action:hover {
border-color: var(--ui-primary, #4f46e5); border-color: var(--ui-primary, #5b5bd6);
color: var(--ui-primary, #4f46e5); color: var(--ui-primary, #5b5bd6);
background: var(--ui-primary-soft, rgba(79,70,229,.06)); background: var(--ui-primary-soft, rgba(91,91,214,.06));
} }
.ai-action:disabled { opacity: .45; cursor: not-allowed; } .ai-action:disabled { opacity: .45; cursor: not-allowed; }
.ai-action:disabled:hover { border-color: var(--ui-border, #e2e8f0); color: var(--ui-text, #1e293b); background: #fff; } .ai-action:disabled:hover { border-color: var(--ui-border, #e2e8f0); color: var(--ui-text, #1e293b); background: #fff; }
@@ -39,14 +39,14 @@
.selected-hint { .selected-hint {
display: flex; align-items: center; gap: .45em; display: flex; align-items: center; gap: .45em;
padding: 7px 10px; margin: 0 2px 8px; padding: 7px 10px; margin: 0 2px 8px;
background: var(--ui-primary-soft, rgba(79,70,229,.08)); background: var(--ui-primary-soft, rgba(91,91,214,.08));
border: 1px solid var(--ui-primary, #4f46e5); border-radius: 6px; border: 1px solid var(--ui-primary, #5b5bd6); border-radius: 6px;
font-size: 12px; color: var(--ui-text, #1e293b); font-size: 12px; color: var(--ui-text, #1e293b);
flex-shrink: 0; flex-shrink: 0;
} }
.selected-hint-icon { color: var(--ui-primary, #4f46e5); font-weight: 700; } .selected-hint-icon { color: var(--ui-primary, #5b5bd6); font-weight: 700; }
.selected-hint-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; } .selected-hint-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
.selected-hint-flag { font-size: 11px; color: var(--ui-primary, #4f46e5); flex-shrink: 0; } .selected-hint-flag { font-size: 11px; color: var(--ui-primary, #5b5bd6); flex-shrink: 0; }
/* 消息列表 */ /* 消息列表 */
.chat-messages { .chat-messages {
@@ -109,7 +109,7 @@
.msg.user .msg-code { background: rgba(255,255,255,.15); } .msg.user .msg-code { background: rgba(255,255,255,.15); }
.msg .msg-code code { font-family: inherit; white-space: pre; } .msg .msg-code code { font-family: inherit; white-space: pre; }
/* JSON 语法着色 */ /* JSON 语法着色 */
.msg .msg-code .jk { color: #4f46e5; font-weight: 600; } /* key */ .msg .msg-code .jk { color: #5b5bd6; font-weight: 600; } /* key */
.msg .msg-code .js { color: #059669; } /* string value */ .msg .msg-code .js { color: #059669; } /* string value */
.msg .msg-code .jn { color: #d97706; } /* number */ .msg .msg-code .jn { color: #d97706; } /* number */
.msg .msg-code .jb { color: #e11d48; font-weight: 600; } /* boolean/null */ .msg .msg-code .jb { color: #e11d48; font-weight: 600; } /* boolean/null */
@@ -118,7 +118,7 @@
.cursor { .cursor {
display: inline-block; display: inline-block;
width: 2px; height: 14px; width: 2px; height: 14px;
background: var(--ui-primary, #4f46e5); background: var(--ui-primary, #5b5bd6);
margin-left: 2px; vertical-align: text-bottom; margin-left: 2px; vertical-align: text-bottom;
animation: blink 0.8s step-end infinite; animation: blink 0.8s step-end infinite;
} }
+6 -3
View File
@@ -174,6 +174,9 @@
content: ""; position: absolute; left: 0; top: .55em; content: ""; position: absolute; left: 0; top: .55em;
width: .35em; height: .35em; border-radius: 50%; background: currentColor; width: .35em; height: .35em; border-radius: 50%; background: currentColor;
} }
/* 行首自带编号/符号(①、1.、- 等)时不再画圆点,避免双重标记 */
.el-list .li.no-marker::before { display: none; }
.el-list .li.no-marker { padding-left: 0; }
.el-stat { width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; } .el-stat { width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.el-stat .num { font-weight: 800; line-height: 1; } .el-stat .num { font-weight: 800; line-height: 1; }
.el-stat .label { margin-top: .35em; text-align: center; } .el-stat .label { margin-top: .35em; text-align: center; }
@@ -252,7 +255,7 @@
overflow: hidden; word-break: break-word; overflow: hidden; word-break: break-word;
} }
.el-table th { .el-table th {
font-weight: 700; color: var(--ui-primary, #4f46e5); font-weight: 700; color: var(--ui-primary, #5b5bd6);
border-bottom: 2px solid rgba(100,116,139,.35); border-bottom: 2px solid rgba(100,116,139,.35);
background: rgba(100,116,139,.06); background: rgba(100,116,139,.06);
white-space: nowrap; white-space: nowrap;
@@ -324,7 +327,7 @@
/* 元素文字可直接双击编辑 */ /* 元素文字可直接双击编辑 */
.el [contenteditable="true"] { outline: none; cursor: text; } .el [contenteditable="true"] { outline: none; cursor: text; }
.el [contenteditable="true"]:focus { background: rgba(79,70,229,.06); border-radius: 2px; } .el [contenteditable="true"]:focus { background: rgba(91,91,214,.06); border-radius: 2px; }
/* ---------- 右:侧栏 Tabs ---------- */ /* ---------- 右:侧栏 Tabs ---------- */
.side-panel { border-left: 1px solid var(--ui-border); background: var(--ui-panel); display: flex; flex-direction: column; min-height: 0; } .side-panel { border-left: 1px solid var(--ui-border); background: var(--ui-panel); display: flex; flex-direction: column; min-height: 0; }
@@ -452,7 +455,7 @@
border: 1px solid var(--ui-border, #e2e8f0); background: #fff; border: 1px solid var(--ui-border, #e2e8f0); background: #fff;
color: var(--ui-text, #1e293b); border-radius: 4px; cursor: pointer; color: var(--ui-text, #1e293b); border-radius: 4px; cursor: pointer;
} }
.rich-btn:hover { border-color: var(--ui-primary, #4f46e5); color: var(--ui-primary, #4f46e5); } .rich-btn:hover { border-color: var(--ui-primary, #5b5bd6); color: var(--ui-primary, #5b5bd6); }
.rich-btn.danger { color: var(--ui-danger, #e11d48); border-color: transparent; } .rich-btn.danger { color: var(--ui-danger, #e11d48); border-color: transparent; }
.rich-btn.danger:hover { color: var(--ui-danger, #e11d48); background: #fff1f2; } .rich-btn.danger:hover { color: var(--ui-danger, #e11d48); background: #fff1f2; }
+47
View File
@@ -0,0 +1,47 @@
/* list-marker.test.ts — hasLineMarker 行首列表标记检测验证 */
import { describe, it, expect } from 'vitest'
import { hasLineMarker } from '../src/core/richtext'
describe('hasLineMarker 行首列表标记检测', () => {
it('圈号 ①-⑳ 识别为标记', () => {
expect(hasLineMarker('① 第一点')).toBe(true)
expect(hasLineMarker('⑳ 最后一点')).toBe(true)
})
it('阿拉伯数字+点/顿/括号 识别为标记', () => {
expect(hasLineMarker('1. 标题')).toBe(true)
expect(hasLineMarker('12、要点')).toBe(true)
expect(hasLineMarker('3)要点')).toBe(true)
expect(hasLineMarker('(4). 要点')).toBe(true)
expect(hasLineMarker('5.全角点')).toBe(true)
})
it('圆点类符号识别为标记', () => {
expect(hasLineMarker('- 要点')).toBe(true)
expect(hasLineMarker('• 要点')).toBe(true)
expect(hasLineMarker('· 要点')).toBe(true)
expect(hasLineMarker('▪ 要点')).toBe(true)
expect(hasLineMarker('● 要点')).toBe(true)
expect(hasLineMarker('○ 要点')).toBe(true)
expect(hasLineMarker('* 强调')).toBe(true)
})
it('中文数字+点顿 识别为标记', () => {
expect(hasLineMarker('一、概述')).toBe(true)
expect(hasLineMarker('十二. 展开')).toBe(true)
expect(hasLineMarker('十.收尾')).toBe(true)
})
it('行首空白不影响检测', () => {
expect(hasLineMarker(' ① 缩进的圈号')).toBe(true)
expect(hasLineMarker('\t- 制表符开头的圆点')).toBe(true)
})
it('普通文本返回 false', () => {
expect(hasLineMarker('华东 Q3 增长 23%')).toBe(false)
expect(hasLineMarker('第一点(无标点编号)')).toBe(false)
expect(hasLineMarker('')).toBe(false)
expect(hasLineMarker('2026 年趋势')).toBe(false)
expect(hasLineMarker('1 比 2 更好')).toBe(false)
})
})
+137
View File
@@ -0,0 +1,137 @@
/* =====================================================================
* sanitize-shapes.test.ts AI
* normSlides sanitizeShapes//
* AI clip-path
* ===================================================================== */
import { describe, it, expect } from 'vitest'
import { normSlides } from '../src/core/ai'
import type { SlideElement } from '../src/core/types'
/* ---------- 测试数据工厂 ---------- */
function shape(id: string, over: Partial<SlideElement> = {}): SlideElement {
return {
id, type: 'shape', x: 10, y: 10, w: 20, h: 8,
content: '', style: { shapeType: 'rect', fill: 'accent' },
...over
}
}
function norm(elements: SlideElement[]): SlideElement[] {
return normSlides([{ background: 'bg', elements }])[0].elements
}
describe('sanitizeShapes 等比化', () => {
it('star 非正方形框 → 取小者,中心不变', () => {
const els = norm([shape('a', { style: { shapeType: 'star' }, x: 40, y: 20, w: 20, h: 8 })])
expect(els[0].w).toBe(8)
expect(els[0].h).toBe(8)
expect(els[0].x).toBe(46) // 40 + (20-8)/2
expect(els[0].y).toBe(20)
})
it('circle 扁宽框 → 正方形', () => {
const els = norm([shape('a', { style: { shapeType: 'circle' }, x: 10, y: 30, w: 30, h: 10 })])
expect(els[0].w).toBe(10)
expect(els[0].h).toBe(10)
expect(els[0].x).toBe(20) // 10 + 10
expect(els[0].y).toBe(30)
})
it('arrow 天然扁宽 → 不等比', () => {
const els = norm([shape('a', { style: { shapeType: 'arrow' }, x: 10, y: 30, w: 30, h: 10 })])
expect(els[0].w).toBe(30)
expect(els[0].h).toBe(10)
})
})
describe('sanitizeShapes 收进画布', () => {
it('形状超出下缘 → 收进', () => {
const els = norm([shape('a', { x: 10, y: 95, w: 30, h: 15 })])
expect(els[0].y).toBe(85)
expect(els[0].y + els[0].h).toBeLessThanOrEqual(100)
})
it('形状超出右缘 → 收进', () => {
const els = norm([shape('a', { x: 90, y: 10, w: 30, h: 8 })])
expect(els[0].x).toBe(70)
})
})
describe('sanitizeShapes 防压字', () => {
const title: SlideElement = {
id: 't', type: 'title', x: 10, y: 8, w: 80, h: 10, content: '标题', style: {}
}
it('空胶囊压在 title 中部 → 移到 title 正下方', () => {
const els = norm([title, shape('cap', { x: 10, y: 12, w: 40, h: 3 })])
const cap = els.find(e => e.id === 'cap')!
expect(cap.y).toBe(19) // title.y + title.h + 1
})
it('空胶囊同时压 title 与 list 两个元素 → 丢弃', () => {
const list: SlideElement = { id: 'l', type: 'list', x: 10, y: 14, w: 80, h: 40, content: '条目', style: {} }
// cap y=10 h=10:与 title 相交高 10 > 5,与 list(y14起) 相交高 6 > 5 → 命中两个
const els = norm([title, list, shape('cap', { x: 10, y: 10, w: 40, h: 10 })])
expect(els.find(e => e.id === 'cap')).toBeUndefined()
})
it('有文字的形状压字 → 不动', () => {
const els = norm([title, shape('bad', { x: 10, y: 12, w: 40, h: 3, content: '标签' })])
const bad = els.find(e => e.id === 'bad')!
expect(bad.y).toBe(12)
})
it('贴标题下缘的合法胶囊(相交浅)→ 不误伤', () => {
// cap y=16 h=4(至20),title 至 y=18,相交高 2 = h*0.5,不满足「>」判定 → 保持原位
const els = norm([title, shape('cap', { x: 10, y: 16, w: 40, h: 4 })])
const cap = els.find(e => e.id === 'cap')!
expect(cap.y).toBe(16)
})
})
describe('sanitizeShapes 怪异装饰兜底', () => {
it('横贯页面的细矩形(分隔线/进度条)→ 丢弃', () => {
const els = norm([shape('div', { x: 10, y: 50, w: 80, h: 3 })])
expect(els.find(e => e.id === 'div')).toBeUndefined()
})
it('扁宽但不到横贯程度 → 保留', () => {
const els = norm([shape('wide', { x: 10, y: 50, w: 50, h: 3 })])
expect(els.find(e => e.id === 'wide')).toBeDefined()
})
it('多形状叠放拼图案(三角+矩形+圆拼「房子」)→ 只保留先出现的', () => {
// 三角在上方,矩形/圆与其叠放 → 后两者丢弃
const els = norm([
shape('roof', { style: { shapeType: 'triangle' }, x: 40, y: 10, w: 20, h: 12 }),
shape('body', { x: 42, y: 20, w: 16, h: 15 }),
shape('dot', { style: { shapeType: 'circle' }, x: 48, y: 24, w: 5, h: 5 })
])
expect(els.find(e => e.id === 'roof')).toBeDefined()
expect(els.find(e => e.id === 'body')).toBeUndefined()
expect(els.find(e => e.id === 'dot')).toBeUndefined()
})
it('不相交的独立装饰 → 互不影响', () => {
const els = norm([
shape('a', { x: 5, y: 10, w: 10, h: 8 }),
shape('b', { x: 60, y: 60, w: 10, h: 8 })
])
expect(els.find(e => e.id === 'a')).toBeDefined()
expect(els.find(e => e.id === 'b')).toBeDefined()
})
it('空装饰超过 5 个 → 只保留前 5 个', () => {
// 间距拉开互不叠放(w/h 最小钳制为 3,间隔须 >3),纯验数量兜底
const els = norm(Array.from({ length: 7 }, (_, i) => shape('d' + i, { x: i * 5, y: 50, w: 3, h: 3 })))
expect(els.filter(e => e.type === 'shape').length).toBe(5)
})
it('有文字的形状(bubble 标签)→ 不参与丢弃', () => {
const els = norm([
shape('tag1', { x: 10, y: 30, w: 15, h: 6, content: '要点' }),
shape('tag2', { x: 12, y: 32, w: 15, h: 6, content: '标签' })
])
expect(els.find(e => e.id === 'tag1')).toBeDefined()
expect(els.find(e => e.id === 'tag2')).toBeDefined()
})
})