新增: 分享链接闭环——OSS 静态发布与观众查看器

This commit is contained in:
lxy
2026-08-28 00:26:09 +08:00
parent daaccb8ad7
commit eca5b11ea4
12 changed files with 469 additions and 59 deletions
+21
View File
@@ -7,6 +7,8 @@ import { store } from './core/store'
import { readAsDataURL, fileToImageElement } from './core/importer'
import { checkImageQuota } from './core/ai'
import { putAsset, isOssEnabled } from './core/assets'
import { publishDeck } from './core/share'
import { appAlert } from './core/dialog'
import { isTauri, readLocalFiles } from './core/bridge'
import AppDialog from './components/common/AppDialog.vue'
import FileDock from './components/common/FileDock.vue'
@@ -167,6 +169,24 @@ function onHome() {
mode.value = 'home'
}
/* ---------- 分享:发布 deck 到 OSS,生成公开只读链接 ---------- */
async function onShare() {
try {
const { url } = await publishDeck()
try { await navigator.clipboard.writeText(url) } catch (e) { /* 剪贴板失败不阻断展示 */ }
await appAlert('分享链接已生成' + '(已复制到剪贴板)', url)
} catch (e: any) {
const msg = e?.message || String(e)
// 未配置云存储时引导到设置页
if (msg.includes('云存储') || msg.includes('OSS')) {
await appAlert('无法分享', msg)
ossVisible.value = true
} else {
await appAlert('分享失败', msg)
}
}
}
function onImportClose() {
importVisible.value = false
if (mode.value === 'home') {
@@ -469,6 +489,7 @@ onUnmounted(() => {
@export-json="onExportJson"
@import-materials="onImportMaterials"
@home="onHome"
@share="onShare"
@import-json="onImportJson"
@export-pdf="onExportPdf"
/>
+5
View File
@@ -20,6 +20,7 @@ const emit = defineEmits<{
(e: 'import-materials'): void
(e: 'export-pdf'): void
(e: 'home'): void
(e: 'share'): void
}>()
const themeKeys = Object.keys(themes)
@@ -71,6 +72,7 @@ function fileAction(a: string) {
case 'import-materials': emit('import-materials'); break
case 'import-json': emit('import-json'); break
case 'export-pdf': emit('export-pdf'); break
case 'share': emit('share'); break
case 'reset': action('reset'); break
}
}
@@ -148,6 +150,9 @@ defineProps<{ disabledActions?: string[] }>()
<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>
</button>
<button class="menu-item" role="menuitem" @click="fileAction('share')">
<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 danger" role="menuitem" :disabled="disabledActions?.includes('reset')" @click="fileAction('reset')">
<span class="mi-icon"></span><span class="mi-label">重置为示例</span>
+9 -2
View File
@@ -17,7 +17,8 @@ const emit = defineEmits<{
const form = ref<OssCfg>({
enabled: false, provider: 'aliyun', dir: 'u-ppt',
aliAccessKeyId: '', aliAccessKeySecret: '', aliEndpoint: '', aliBucket: '',
qiniuAccessKey: '', qiniuSecretKey: '', qiniuBucket: '', qiniuUpHost: '', qiniuDomain: ''
qiniuAccessKey: '', qiniuSecretKey: '', qiniuBucket: '', qiniuUpHost: '', qiniuDomain: '',
viewerBase: ''
})
function loadFromStore() {
@@ -42,7 +43,8 @@ function save() {
qiniuSecretKey: c.qiniuSecretKey.trim(),
qiniuBucket: c.qiniuBucket.trim(),
qiniuUpHost: c.qiniuUpHost.trim(),
qiniuDomain: c.qiniuDomain.trim()
qiniuDomain: c.qiniuDomain.trim(),
viewerBase: c.viewerBase.trim()
})
emit('toast', c.enabled ? '已保存云存储设置(已启用)' : '已保存云存储设置(未启用)')
emit('close')
@@ -87,6 +89,11 @@ async function manualSync() {
<input type="text" v-model="form.dir" placeholder="如 u-ppt/images,可留空" />
</div>
<div class="form-row">
<label>查看器地址</label>
<input type="text" v-model="form.viewerBase" placeholder="如 https://img.1216.top,生成分享链接必填" />
</div>
<!-- 阿里云 -->
<template v-if="form.provider === 'aliyun'">
<div class="section-title">阿里云 OSS</div>
+1 -1
View File
@@ -129,7 +129,7 @@ function blobToBase64(blob: Blob): Promise<string> {
}
/** 由 OSS 配置构造上传请求(不含 key/data) */
function buildUploadReq(cfg: OssCfg, key: string, dataBase64: string, contentType: string): OssUploadReq {
export function buildUploadReq(cfg: OssCfg, key: string, dataBase64: string, contentType: string): OssUploadReq {
if (cfg.provider === 'aliyun') {
return {
provider: 'aliyun', key, dataBase64, contentType,
+72
View File
@@ -0,0 +1,72 @@
/* =====================================================================
* bg.ts //
* viewer store
* ===================================================================== */
import { themes } from './sample'
/* ---------- 颜色工具 ---------- */
export function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const c = String(hex).replace('#', '')
const full = c.length === 3 ? c[0] + c[0] + c[1] + c[1] + c[2] + c[2] : c
const r = parseInt(full.substr(0, 2), 16)
const g = parseInt(full.substr(2, 2), 16)
const b = parseInt(full.substr(4, 2), 16)
return (isNaN(r) || isNaN(g) || isNaN(b)) ? null : { r, g, b }
}
export function isDarkHex(hex: string): boolean {
const rgb = hexToRgb(hex); if (!rgb) return false
return (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) < 145
}
export function shade(hex: string, pct: number): string {
const rgb = hexToRgb(hex); if (!rgb) return hex
const f = pct < 0 ? 0 : 255, p = Math.abs(pct) / 100
const r = Math.round((f - rgb.r) * p + rgb.r)
const g = Math.round((f - rgb.g) * p + rgb.g)
const b = Math.round((f - rgb.b) * p + rgb.b)
return '#' + [r, g, b].map(x => { const s = x.toString(16); return s.length < 2 ? '0' + s : s }).join('')
}
export function isValidHex(s: string): boolean {
return typeof s === 'string' && /^#[0-9a-f]{3,8}$/i.test(s)
}
/* ---------- 自定义主题读取(与 store 共用同一 localStorage 键) ---------- */
export const CUSTOM_THEME_KEY = 'u-ppt.themes.v1'
/** 内置 + 自定义主题全量返回(自定义优先在前) */
export function getAllThemes(): Record<string, import('./types').Theme> {
let custom: Record<string, import('./types').Theme> = {}
try { custom = JSON.parse(localStorage.getItem(CUSTOM_THEME_KEY) || '{}') || {} } catch (e) {}
return { ...custom, ...themes }
}
/* ---------- 当前主题注入:store 初始化时注册,viewer 无需引入 store ---------- */
let getTheme: () => string = () => 'indigo'
/** 由 store 注入当前主题 getter(避免 bg.ts 反向依赖 store 造成循环/viewer 拉入编辑器状态) */
export function registerBgThemeGetter(fn: () => string): void {
getTheme = fn
}
/** 背景是否深色:primary/accent/渐变键视为深;bg/panel 视为浅;hex 按亮度判断 */
export function isDarkBg(bg: string): boolean {
if (!bg) return false
if (bg.charAt(0) === '#') return isDarkHex(bg)
if (bg === 'primary' || bg === 'accent') return true
if (bg.indexOf('g-') === 0) return true
return false
}
/** 背景解析:渐变键 → CSS gradient;其余 → 主题色或合法 hex;未知键回退白 */
export function resolveBg(key: string): string {
const t = (getAllThemes()[getTheme()] || {}) as unknown as Record<string, string>
if (!key) return '#ffffff'
if (key.charAt(0) === '#') return isValidHex(key) ? key : '#ffffff'
if (key === 'g-primary') return 'linear-gradient(135deg, ' + t.primary + ' 0%, ' + t.accent + ' 100%)'
if (key === 'g-deep') return 'linear-gradient(160deg, ' + shade(t.primary, -28) + ' 0%, ' + t.primary + ' 100%)'
if (key === 'g-soft') return 'linear-gradient(135deg, ' + (t.panel || '#f8fafc') + ' 0%, ' + (t.bg || '#ffffff') + ' 100%)'
return t[key] || '#ffffff'
}
+107
View File
@@ -0,0 +1,107 @@
/* =====================================================================
* share.ts deck JSON OSS
* <qiniuDomain aliyun bucket 域名>#/p/<shareId> viewer
* URL<bucket>.<endpoint>/<dir>/share/<id>.json
* URL<domain>/<dir>/share/<id>.json
* ===================================================================== */
import { store } from './store'
import { ossUpload, isTauri } from './bridge'
import { canUploadNow, isOssEnabled, buildUploadReq } from './assets'
import type { OssCfg } from './types'
/** 分享发布前置校验:不满足时返回中文原因 */
export function shareReady(): { ok: boolean; reason?: string } {
if (!isOssEnabled()) return { ok: false, reason: '请先在 ☁ 云存储中配置并启用 OSS 后再分享' }
if (!canUploadNow()) {
if (!isTauri()) return { ok: false, reason: '分享发布仅支持桌面版,请在桌面应用中操作' }
return { ok: false, reason: '当前离线,请联网后再分享' }
}
const cfg = store.getOssCfg()
if (cfg.provider === 'qiniu' && !(cfg.qiniuDomain || '').trim()) {
return { ok: false, reason: '七牛云分享需配置绑定的公开访问域名(Bucket 绑定的 CDN 域名)' }
}
return { ok: true }
}
/** 生成分享 id'p' + 时间戳36进制 + 随机后缀 */
function genShareId(): string {
return 'p' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
}
/** 按 dir 规则(trim + 去首尾斜杠)拼分享对象 key */
function shareKey(cfg: OssCfg, shareId: string): string {
const dir = (cfg.dir || '').trim().replace(/^\/+|\/+$/g, '')
return [dir, 'share', `${shareId}.json`].filter(Boolean).join('/')
}
/** 由 OSS 配置 + key 反推公开访问 URL */
function jsonUrlFor(cfg: OssCfg, key: string): string {
if (cfg.provider === 'qiniu') {
return `${(cfg.qiniuDomain || '').trim().replace(/\/+$/, '')}/${key}`
}
// 阿里云:<bucket>.<endpoint host>/<key>endpoint 规整同 oss.rs:去协议、去尾斜杠)
const host = (cfg.aliEndpoint || '').trim().replace(/^https?:\/\//, '').replace(/\/+$/, '')
const bucket = (cfg.aliBucket || '').trim()
return `https://${bucket}.${host}/${key}`
}
/* ---------- hash JSON ----------
* OSS shareId
* JSON URLbase64url hash
* #/p/<shareId> 退
* #/p/<shareId>:<b64url>
*/
function b64urlEncode(s: string): string {
return btoa(unescape(encodeURIComponent(s))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
function b64urlDecode(s: string): string {
const std = s.replace(/-/g, '+').replace(/_/g, '/')
return decodeURIComponent(escape(atob(std + '='.repeat((4 - std.length % 4) % 4))))
}
/** 解析 location.hash,返回 { shareId, jsonUrl }jsonUrl 为 null 时由调用方回退反推。非法格式返回 null */
export function parseShareHash(): { shareId: string; jsonUrl: string | null } | null {
if (typeof location === 'undefined') return null
const m = /^#\/p\/([A-Za-z0-9]+)(?::([A-Za-z0-9_-]+))?$/.exec(location.hash || '')
if (!m) return null
return { shareId: m[1], jsonUrl: m[2] ? b64urlDecode(m[2]) : null }
}
/** 由分享 id 反推 JSON 地址(旧格式回退:仅当本机存有相同 OSS 配置时可用) */
export function shareJsonUrlOf(shareId: string): string {
return jsonUrlFor(store.getOssCfg(), shareKey(store.getOssCfg(), shareId))
}
export async function publishDeck(): Promise<{ url: string; jsonUrl: string }> {
const ready = shareReady()
if (!ready.ok) throw new Error(ready.reason)
// 资产检查:本地暂存未上云的图片/视频会让观众看不到
let unsynced = 0
for (const slide of store.getDeck().slides || []) {
for (const el of slide.elements || []) {
if ((el.type === 'image' || el.type === 'video') && (el.content || '').startsWith('asset:')) unsynced++
}
}
if (unsynced > 0) {
throw new Error(`${unsynced} 张图片/视频还未同步到云端,请先在 ☁ 云存储中「立即同步」后再分享`)
}
// viewerBase:查看器页的公开基地址(如 https://img.1216.top),
// 分享链接必须指向渲染页而非 JSON 文件(否则观众看到的是 JSON 源码)
const cfg = store.getOssCfg()
const base = (cfg.viewerBase || '').trim().replace(/\/+$/, '')
if (!base) {
throw new Error('未配置查看器地址:请在 ☁ 云存储设置的「查看器地址」填入 viewer 页的公开网址')
}
const shareId = genShareId()
const key = shareKey(cfg, shareId)
const dataBase64 = btoa(unescape(encodeURIComponent(JSON.stringify(store.getDeck()))))
const url = await ossUpload(buildUploadReq(cfg, key, dataBase64, 'application/json'))
if (!url) throw new Error('上传分享数据失败:ossUpload 未返回 URL')
const jsonUrl = url.split('#')[0]
// JSON 地址编码进 hash:观众端零配置拉取
const hash = `${shareId}:${b64urlEncode(jsonUrl)}`
return { url: `${base}/viewer.html#/p/${hash}`, jsonUrl }
}
+8 -56
View File
@@ -235,42 +235,12 @@ function syncActiveLibItem() {
setLibrary(lib)
}
/* ---------- 颜色/背景解析纯函数,供组件调用) ---------- */
export function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const c = String(hex).replace('#', '')
const full = c.length === 3 ? c[0] + c[0] + c[1] + c[1] + c[2] + c[2] : c
const r = parseInt(full.substr(0, 2), 16)
const g = parseInt(full.substr(2, 2), 16)
const b = parseInt(full.substr(4, 2), 16)
return (isNaN(r) || isNaN(g) || isNaN(b)) ? null : { r, g, b }
}
/* ---------- 颜色/背景解析纯函数已抽至 ./bg,此处再导出保持调用方 import 路径不变 ---------- */
export { isDarkBg, resolveBg, isDarkHex, isValidHex } from './bg'
import { isDarkHex, isValidHex, getAllThemes, registerBgThemeGetter, CUSTOM_THEME_KEY } from './bg'
export function isDarkHex(hex: string): boolean {
const rgb = hexToRgb(hex); if (!rgb) return false
return (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) < 145
}
export function shade(hex: string, pct: number): string {
const rgb = hexToRgb(hex); if (!rgb) return hex
const f = pct < 0 ? 0 : 255, p = Math.abs(pct) / 100
const r = Math.round((f - rgb.r) * p + rgb.r)
const g = Math.round((f - rgb.g) * p + rgb.g)
const b = Math.round((f - rgb.b) * p + rgb.b)
return '#' + [r, g, b].map(x => { const s = x.toString(16); return s.length < 2 ? '0' + s : s }).join('')
}
/** 背景是否深色:primary/accent/渐变键视为深;bg/panel 视为浅;hex 按亮度判断 */
export function isDarkBg(bg: string): boolean {
if (!bg) return false
if (bg.charAt(0) === '#') return isDarkHex(bg)
if (bg === 'primary' || bg === 'accent') return true
if (bg.indexOf('g-') === 0) return true
return false
}
export function isValidHex(s: string): boolean {
return typeof s === 'string' && /^#[0-9a-f]{3,8}$/i.test(s)
}
// 注入当前主题 getterbg.ts 解析背景时读取(viewer 侧无需引入本 store
registerBgThemeGetter(() => state.deck.theme)
/**
* dark=true
@@ -293,17 +263,6 @@ export function resolveColor(key: string | undefined, dark: boolean): string {
return t[key] || t.text || '#1e293b'
}
/** 背景解析:渐变键 → CSS gradient;其余 → 主题色或合法 hex;未知键回退白 */
export function resolveBg(key: string): string {
const t = (getAllThemes()[state.deck.theme] || {}) as unknown as Record<string, string>
if (!key) return '#ffffff'
if (key.charAt(0) === '#') return isValidHex(key) ? key : '#ffffff'
if (key === 'g-primary') return 'linear-gradient(135deg, ' + t.primary + ' 0%, ' + t.accent + ' 100%)'
if (key === 'g-deep') return 'linear-gradient(160deg, ' + shade(t.primary, -28) + ' 0%, ' + t.primary + ' 100%)'
if (key === 'g-soft') return 'linear-gradient(135deg, ' + (t.panel || '#f8fafc') + ' 0%, ' + (t.bg || '#ffffff') + ' 100%)'
return t[key] || '#ffffff'
}
/* ---------- 内部:执行 Op 并推入历史 ---------- */
/** 执行一个 Op,计算逆 Op,推入历史栈。返回是否执行成功 */
@@ -855,7 +814,8 @@ const LS_OSS = 'u-ppt.oss-cfg.v1'
const DEFAULT_OSS: OssCfg = {
enabled: false, provider: 'aliyun', dir: 'u-ppt',
aliAccessKeyId: '', aliAccessKeySecret: '', aliEndpoint: '', aliBucket: '',
qiniuAccessKey: '', qiniuSecretKey: '', qiniuBucket: '', qiniuUpHost: '', qiniuDomain: ''
qiniuAccessKey: '', qiniuSecretKey: '', qiniuBucket: '', qiniuUpHost: '', qiniuDomain: '',
viewerBase: ''
}
function getOssCfg(): OssCfg {
@@ -881,15 +841,7 @@ function rewriteAssetRef(oldRef: string, newUrl: string) {
return changed
}
/* ---------- 自定义主题:AI 配色建议应用后存储 ---------- */
const CUSTOM_THEME_KEY = 'u-ppt.themes.v1'
/** 内置 + 自定义主题全量返回(自定义优先在前) */
function getAllThemes(): Record<string, import('./types').Theme> {
let custom: Record<string, import('./types').Theme> = {}
try { custom = JSON.parse(localStorage.getItem(CUSTOM_THEME_KEY) || '{}') || {} } catch (e) {}
return { ...custom, ...themes }
}
/* ---------- 自定义主题:AI 配色建议应用后存储(全量读取复用 bg.ts ---------- */
/** 获取自定义主题列表(仅自定义的) */
function getCustomThemes(): Record<string, import('./types').Theme> {
+3
View File
@@ -213,6 +213,9 @@ export interface OssCfg {
qiniuUpHost: string
/** 绑定的公开访问域名,如 https://cdn.example.com */
qiniuDomain: string
/** 分享查看器页的公开基地址(如 https://img.1216.top),分享链接 = <viewerBase>/viewer.html#/p/<id> */
viewerBase: string
}
/** 文库条目 */
+217
View File
@@ -0,0 +1,217 @@
<!-- =====================================================================
ViewerApp.vue 分享查看器观众版独立入口
数据源分享 JSON不连 store仅静态渲染当前页
===================================================================== -->
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import type { Deck, Slide } from '../core/types'
import { parseShareHash, shareJsonUrlOf } from '../core/share'
import { resolveBg, registerBgThemeGetter } from '../core/bg'
import { CANVAS_W, CANVAS_H } from '../core/sample'
import ElementView from '../components/editor/ElementView.vue'
type Status = 'loading' | 'ready' | 'error' | 'invalid'
const status = ref<Status>('loading')
const errMsg = ref('')
const deck = ref<Deck | null>(null)
const index = ref(0)
const scale = ref(1)
const idle = ref(false)
const slides = computed<Slide[]>(() => deck.value?.slides ?? [])
const total = computed(() => slides.value.length)
const currentSlide = computed<Slide | null>(() => {
const arr = slides.value
if (!arr.length) return null
return arr[Math.max(0, Math.min(index.value, arr.length - 1))]
})
/* ---------- 加载分享 JSON ---------- */
onMounted(async () => {
const parsed = parseShareHash()
if (!parsed) {
status.value = 'invalid'
return
}
try {
// JSON 退
const jsonUrl = parsed.jsonUrl || shareJsonUrlOf(parsed.shareId)
const res = await fetch(jsonUrl)
if (!res.ok) throw new Error('HTTP ' + res.status)
deck.value = JSON.parse(await res.text()) as Deck
if (!deck.value?.slides?.length) throw new Error('分享内容为空')
// deck resolveBg /
registerBgThemeGetter(() => String(deck.value?.theme || 'indigo'))
status.value = 'ready'
fit()
} catch (e: any) {
errMsg.value = e?.message || String(e)
status.value = 'error'
}
})
/* ---------- 缩放适配 ---------- */
function fit() {
scale.value = Math.min(window.innerWidth / CANVAS_W, window.innerHeight / CANVAS_H)
}
/* ---------- 翻页 ---------- */
function next() {
if (index.value < total.value - 1) index.value++
wake()
}
function prev() {
if (index.value > 0) index.value--
wake()
}
/* ---------- 键盘 ---------- */
function onKey(e: KeyboardEvent) {
switch (e.key) {
case 'ArrowRight': case ' ': case 'PageDown':
e.preventDefault(); next(); break
case 'ArrowLeft': case 'PageUp':
e.preventDefault(); prev(); break
}
}
/* ---------- 点击:左半屏上一页 / 右半屏下一页 ---------- */
function onClick(e: MouseEvent) {
const stage = stageEl.value
if (!stage) return
const rect = stage.getBoundingClientRect()
if (e.clientX - rect.left > rect.width / 2) next()
else prev()
}
/* ---------- 触摸滑动 ---------- */
let touchX = 0
function onTouchStart(e: TouchEvent) {
touchX = e.changedTouches[0].clientX
}
function onTouchEnd(e: TouchEvent) {
const dx = e.changedTouches[0].clientX - touchX
if (dx > 40) prev()
else if (dx < -40) next()
}
/* ---------- 页码 HUD3 秒无操作淡出 ---------- */
let idleTimer: ReturnType<typeof setTimeout> | null = null
function wake() {
idle.value = false
if (idleTimer) clearTimeout(idleTimer)
idleTimer = setTimeout(() => { idle.value = true }, 3000)
}
function bindEvents() {
document.addEventListener('keydown', onKey)
window.addEventListener('resize', fit)
}
function unbindEvents() {
document.removeEventListener('keydown', onKey)
window.removeEventListener('resize', fit)
if (idleTimer) clearTimeout(idleTimer)
}
const stageEl = ref<HTMLElement | null>(null)
onMounted(bindEvents)
onUnmounted(unbindEvents)
</script>
<template>
<!-- 无效链接 -->
<div v-if="status === 'invalid'" class="viewer-state">
<div class="viewer-state-title">链接无效</div>
</div>
<!-- 加载失败 -->
<div v-else-if="status === 'error'" class="viewer-state">
<div class="viewer-state-title">加载失败链接可能已失效</div>
<div v-if="errMsg" class="viewer-state-detail">{{ errMsg }}</div>
</div>
<!-- 加载中 -->
<div v-else-if="status === 'loading'" class="viewer-state">
<div class="viewer-spinner"></div>
<div class="viewer-state-title">加载中</div>
</div>
<!-- 渲染 -->
<div v-else class="viewer-stage" :class="{ idle }" ref="stageEl" @click="onClick" @touchstart.passive="onTouchStart" @touchend.passive="onTouchEnd" @mousemove="wake">
<!-- 沉浸背景当前页主色放大铺满 + 模糊消除黑边手机竖屏尤甚 -->
<div class="viewer-backdrop" :style="{ background: resolveBg(currentSlide?.background || 'bg') }"></div>
<div class="slide-layer" :style="{ transform: 'scale(' + scale + ')' }">
<div v-if="currentSlide" class="slide-inner" :style="{ background: resolveBg(currentSlide.background) }">
<ElementView
v-for="el in currentSlide.elements"
:key="el.id"
:el="el"
:bg="currentSlide.background"
/>
</div>
</div>
<div class="viewer-hud">{{ (index + 1) + ' / ' + total }}</div>
</div>
</template>
<style scoped>
.viewer-state {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--text-secondary, #888);
font-size: 15px;
user-select: none;
}
.viewer-state-title { font-weight: 500; }
.viewer-state-detail { font-size: 12px; opacity: 0.6; }
.viewer-spinner {
width: 28px;
height: 28px;
border: 3px solid rgba(128, 128, 128, 0.25);
border-top-color: var(--accent, #5b8def);
border-radius: 50%;
animation: viewer-spin 0.9s linear infinite;
}
@keyframes viewer-spin { to { transform: rotate(360deg); } }
.viewer-stage {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: #000;
cursor: pointer;
}
/* 沉浸背景层:幻灯片背景色的同色柔和延伸,不做明暗/饱和处理,过渡平缓 */
.viewer-backdrop {
position: absolute;
inset: -40px;
filter: blur(48px);
}
.viewer-backdrop + .slide-layer { box-shadow: 0 12px 40px rgba(0, 0, 0, .25); }
.viewer-hud {
position: fixed;
bottom: 14px;
left: 50%;
transform: translateX(-50%);
padding: 4px 12px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.45);
color: #fff;
font-size: 12px;
user-select: none;
pointer-events: none;
transition: opacity 0.5s;
}
.viewer-stage.idle .viewer-hud { opacity: 0; }
</style>
+4
View File
@@ -0,0 +1,4 @@
import { createApp } from 'vue'
import ViewerApp from './ViewerApp.vue'
createApp(ViewerApp).mount('#app')
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>u-ppt · 演示</title>
<link rel="stylesheet" href="/src/styles/base.css" />
<!-- editor.css 含 .el 系列元素样式(定位/字体/形状/表格等),与编辑器/演示保持同一渲染 -->
<link rel="stylesheet" href="/src/styles/editor.css" />
<link rel="stylesheet" href="/src/styles/present.css" />
</head>
<body data-mode="present">
<div id="app"></div>
<script type="module" src="/src/viewer/main.ts"></script>
</body>
</html>
+6
View File
@@ -5,6 +5,12 @@ export default defineConfig(({ mode }) => ({
plugins: [vue()],
build: {
chunkSizeWarningLimit: 800,
rollupOptions: {
input: {
main: 'index.html',
viewer: 'viewer.html',
},
},
},
// npm run dev:webmode=web)→ 浏览器模式,端口 8080
// npm run tauri:dev → 默认 mode,端口 5173