优化: UI 去 AI 味——SVG 图标体系替代 emoji/字符图标,首页产品化重设计(文档网格+右侧新建栏),主色统一 #5b5bd6,补键盘焦点可见性
This commit is contained in:
+262
-215
@@ -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>
|
||||||
</div>
|
<span class="tb-name">u-ppt</span>
|
||||||
|
</div>
|
||||||
<div class="home-inner">
|
<div class="tb-actions">
|
||||||
<!-- 品牌区 -->
|
<button class="tb-btn ghost" @click="emit('open-library')">
|
||||||
<header class="home-brand">
|
<Icon name="book" :size="15" /> 文库
|
||||||
<span class="home-logo">▦</span>
|
|
||||||
<h1 class="home-title">u-ppt</h1>
|
|
||||||
<p class="home-desc">轻量在线演示工具 · 支持 AI 创作与本地资料导入</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- 未保存草稿恢复 -->
|
|
||||||
<button v-if="workDraft" class="draft-resume" @click="emit('open-deck')">
|
|
||||||
<span class="draft-icon">⏵</span>
|
|
||||||
<span class="draft-info">
|
|
||||||
<strong>继续编辑「{{ workDraft.title }}」</strong>
|
|
||||||
<span>{{ workDraft.pages }} 页 · 上次未保存的草稿已自动暂存</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- 快捷入口 -->
|
|
||||||
<div class="home-actions">
|
|
||||||
<button class="action-card" @click="emit('new-blank')">
|
|
||||||
<span class="action-icon">+</span>
|
|
||||||
<span class="action-label">新建空白</span>
|
|
||||||
<span class="action-hint">从空白页开始创作</span>
|
|
||||||
</button>
|
</button>
|
||||||
<button class="action-card" @click="emit('open-library')">
|
<button class="tb-btn ghost" @click="emit('open-settings')">
|
||||||
<span class="action-icon">📁</span>
|
<Icon name="settings" :size="15" /> 设置
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<!-- 最近文库 -->
|
<div class="home-body">
|
||||||
<div v-if="library.length > 0" class="home-recent">
|
<!-- 左主列:文档网格 -->
|
||||||
<h2 class="section-title">最近文档</h2>
|
<main class="home-main">
|
||||||
<div class="recent-list">
|
<!-- 草稿恢复横条 -->
|
||||||
|
<button v-if="workDraft" class="draft-resume" @click="emit('open-deck')">
|
||||||
|
<span class="draft-icon"><Icon name="play" :size="13" /></span>
|
||||||
|
<span class="draft-info">
|
||||||
|
<strong>继续编辑「{{ workDraft.title }}」</strong>
|
||||||
|
<span>{{ workDraft.pages }} 页 · 上次未保存的草稿已自动暂存</span>
|
||||||
|
</span>
|
||||||
|
<Icon name="chevron-right" :size="16" class="draft-arrow" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="section-head">
|
||||||
|
<h2 class="section-title">我的文档</h2>
|
||||||
|
<span class="section-count">{{ library.length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="library.length" class="doc-grid">
|
||||||
<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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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 } 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> = {
|
||||||
@@ -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 黑底▶),不加载视频 -->
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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">
|
||||||
<h3>演示文库</h3>
|
<div class="lib-head">
|
||||||
|
<h3>演示文库</h3>
|
||||||
|
<button class="btn ghost" @click="onNewBlank">+ 新建空白</button>
|
||||||
|
</div>
|
||||||
<p class="modal-tip">保存多套演示文稿到本地,随时切换。</p>
|
<p class="modal-tip">保存多套演示文稿到本地,随时切换。</p>
|
||||||
|
|
||||||
<div class="lib-save-row">
|
<div class="lib-save-card">
|
||||||
<input type="text" v-model="nameInput" :placeholder="placeholder" @keydown="onNameKeydown" />
|
<div class="lib-save-title">
|
||||||
<button class="btn primary" @click="onSave">存入文库</button>
|
<span><Icon name="save" :size="14" /> 保存当前演示</span>
|
||||||
<button class="btn" @click="onNewBlank">新建空白</button>
|
<span v-if="activeId" class="lib-autosave-flag">改动已自动同步到文库</span>
|
||||||
|
</div>
|
||||||
|
<div class="lib-save-row">
|
||||||
|
<input type="text" v-model="nameInput" :placeholder="placeholder" @keydown="onNameKeydown" />
|
||||||
|
<button class="btn primary" @click="onSave">存入文库</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
<!-- 所有幻灯片逐页渲染(复用编辑器元素渲染,几何与编辑画布同构) -->
|
<!-- 所有幻灯片逐页渲染(复用编辑器元素渲染,几何与编辑画布同构) -->
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+10
-4
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -252,7 +252,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 +324,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 +452,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; }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user