新增: 分享链接闭环——OSS 静态发布与观众查看器
This commit is contained in:
+21
@@ -7,6 +7,8 @@ import { store } from './core/store'
|
|||||||
import { readAsDataURL, fileToImageElement } from './core/importer'
|
import { readAsDataURL, fileToImageElement } from './core/importer'
|
||||||
import { checkImageQuota } from './core/ai'
|
import { checkImageQuota } from './core/ai'
|
||||||
import { putAsset, isOssEnabled } from './core/assets'
|
import { putAsset, isOssEnabled } from './core/assets'
|
||||||
|
import { publishDeck } from './core/share'
|
||||||
|
import { appAlert } from './core/dialog'
|
||||||
import { isTauri, readLocalFiles } from './core/bridge'
|
import { isTauri, readLocalFiles } from './core/bridge'
|
||||||
import AppDialog from './components/common/AppDialog.vue'
|
import AppDialog from './components/common/AppDialog.vue'
|
||||||
import FileDock from './components/common/FileDock.vue'
|
import FileDock from './components/common/FileDock.vue'
|
||||||
@@ -167,6 +169,24 @@ function onHome() {
|
|||||||
mode.value = 'home'
|
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() {
|
function onImportClose() {
|
||||||
importVisible.value = false
|
importVisible.value = false
|
||||||
if (mode.value === 'home') {
|
if (mode.value === 'home') {
|
||||||
@@ -469,6 +489,7 @@ onUnmounted(() => {
|
|||||||
@export-json="onExportJson"
|
@export-json="onExportJson"
|
||||||
@import-materials="onImportMaterials"
|
@import-materials="onImportMaterials"
|
||||||
@home="onHome"
|
@home="onHome"
|
||||||
|
@share="onShare"
|
||||||
@import-json="onImportJson"
|
@import-json="onImportJson"
|
||||||
@export-pdf="onExportPdf"
|
@export-pdf="onExportPdf"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const emit = defineEmits<{
|
|||||||
(e: 'import-materials'): void
|
(e: 'import-materials'): void
|
||||||
(e: 'export-pdf'): void
|
(e: 'export-pdf'): void
|
||||||
(e: 'home'): void
|
(e: 'home'): void
|
||||||
|
(e: 'share'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const themeKeys = Object.keys(themes)
|
const themeKeys = Object.keys(themes)
|
||||||
@@ -71,6 +72,7 @@ function fileAction(a: string) {
|
|||||||
case 'import-materials': emit('import-materials'); break
|
case 'import-materials': emit('import-materials'); break
|
||||||
case 'import-json': emit('import-json'); break
|
case 'import-json': emit('import-json'); break
|
||||||
case 'export-pdf': emit('export-pdf'); break
|
case 'export-pdf': emit('export-pdf'); break
|
||||||
|
case 'share': emit('share'); break
|
||||||
case 'reset': action('reset'); break
|
case 'reset': action('reset'); break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,6 +150,9 @@ defineProps<{ disabledActions?: string[] }>()
|
|||||||
<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">🖨</span><span class="mi-label">导出 PDF</span><span class="mi-hint">打印 / 另存</span>
|
||||||
</button>
|
</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>
|
<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">⟲</span><span class="mi-label">重置为示例</span>
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ const emit = defineEmits<{
|
|||||||
const form = ref<OssCfg>({
|
const form = ref<OssCfg>({
|
||||||
enabled: false, provider: 'aliyun', dir: 'u-ppt',
|
enabled: false, provider: 'aliyun', dir: 'u-ppt',
|
||||||
aliAccessKeyId: '', aliAccessKeySecret: '', aliEndpoint: '', aliBucket: '',
|
aliAccessKeyId: '', aliAccessKeySecret: '', aliEndpoint: '', aliBucket: '',
|
||||||
qiniuAccessKey: '', qiniuSecretKey: '', qiniuBucket: '', qiniuUpHost: '', qiniuDomain: ''
|
qiniuAccessKey: '', qiniuSecretKey: '', qiniuBucket: '', qiniuUpHost: '', qiniuDomain: '',
|
||||||
|
viewerBase: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
function loadFromStore() {
|
function loadFromStore() {
|
||||||
@@ -42,7 +43,8 @@ function save() {
|
|||||||
qiniuSecretKey: c.qiniuSecretKey.trim(),
|
qiniuSecretKey: c.qiniuSecretKey.trim(),
|
||||||
qiniuBucket: c.qiniuBucket.trim(),
|
qiniuBucket: c.qiniuBucket.trim(),
|
||||||
qiniuUpHost: c.qiniuUpHost.trim(),
|
qiniuUpHost: c.qiniuUpHost.trim(),
|
||||||
qiniuDomain: c.qiniuDomain.trim()
|
qiniuDomain: c.qiniuDomain.trim(),
|
||||||
|
viewerBase: c.viewerBase.trim()
|
||||||
})
|
})
|
||||||
emit('toast', c.enabled ? '已保存云存储设置(已启用)' : '已保存云存储设置(未启用)')
|
emit('toast', c.enabled ? '已保存云存储设置(已启用)' : '已保存云存储设置(未启用)')
|
||||||
emit('close')
|
emit('close')
|
||||||
@@ -87,6 +89,11 @@ async function manualSync() {
|
|||||||
<input type="text" v-model="form.dir" placeholder="如 u-ppt/images,可留空" />
|
<input type="text" v-model="form.dir" placeholder="如 u-ppt/images,可留空" />
|
||||||
</div>
|
</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'">
|
<template v-if="form.provider === 'aliyun'">
|
||||||
<div class="section-title">阿里云 OSS</div>
|
<div class="section-title">阿里云 OSS</div>
|
||||||
|
|||||||
+1
-1
@@ -129,7 +129,7 @@ function blobToBase64(blob: Blob): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 由 OSS 配置构造上传请求(不含 key/data) */
|
/** 由 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') {
|
if (cfg.provider === 'aliyun') {
|
||||||
return {
|
return {
|
||||||
provider: 'aliyun', key, dataBase64, contentType,
|
provider: 'aliyun', key, dataBase64, contentType,
|
||||||
|
|||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -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 URL(base64url 编码)放进 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
@@ -235,42 +235,12 @@ function syncActiveLibItem() {
|
|||||||
setLibrary(lib)
|
setLibrary(lib)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 颜色/背景解析(纯函数,供组件调用) ---------- */
|
/* ---------- 颜色/背景解析:纯函数已抽至 ./bg,此处再导出保持调用方 import 路径不变 ---------- */
|
||||||
export function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
export { isDarkBg, resolveBg, isDarkHex, isValidHex } from './bg'
|
||||||
const c = String(hex).replace('#', '')
|
import { isDarkHex, isValidHex, getAllThemes, registerBgThemeGetter, CUSTOM_THEME_KEY } from './bg'
|
||||||
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 {
|
// 注入当前主题 getter,bg.ts 解析背景时读取(viewer 侧无需引入本 store)
|
||||||
const rgb = hexToRgb(hex); if (!rgb) return false
|
registerBgThemeGetter(() => state.deck.theme)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 颜色解析:dark=true 时把深色文字键反相为浅色,确保深底可见。
|
* 颜色解析:dark=true 时把深色文字键反相为浅色,确保深底可见。
|
||||||
@@ -293,17 +263,6 @@ export function resolveColor(key: string | undefined, dark: boolean): string {
|
|||||||
return t[key] || t.text || '#1e293b'
|
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,计算逆 Op,推入历史栈。返回是否执行成功 */
|
/** 执行一个 Op,计算逆 Op,推入历史栈。返回是否执行成功 */
|
||||||
@@ -855,7 +814,8 @@ const LS_OSS = 'u-ppt.oss-cfg.v1'
|
|||||||
const DEFAULT_OSS: OssCfg = {
|
const DEFAULT_OSS: OssCfg = {
|
||||||
enabled: false, provider: 'aliyun', dir: 'u-ppt',
|
enabled: false, provider: 'aliyun', dir: 'u-ppt',
|
||||||
aliAccessKeyId: '', aliAccessKeySecret: '', aliEndpoint: '', aliBucket: '',
|
aliAccessKeyId: '', aliAccessKeySecret: '', aliEndpoint: '', aliBucket: '',
|
||||||
qiniuAccessKey: '', qiniuSecretKey: '', qiniuBucket: '', qiniuUpHost: '', qiniuDomain: ''
|
qiniuAccessKey: '', qiniuSecretKey: '', qiniuBucket: '', qiniuUpHost: '', qiniuDomain: '',
|
||||||
|
viewerBase: ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOssCfg(): OssCfg {
|
function getOssCfg(): OssCfg {
|
||||||
@@ -881,15 +841,7 @@ function rewriteAssetRef(oldRef: string, newUrl: string) {
|
|||||||
return changed
|
return changed
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 自定义主题:AI 配色建议应用后存储 ---------- */
|
/* ---------- 自定义主题:AI 配色建议应用后存储(全量读取复用 bg.ts) ---------- */
|
||||||
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 }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取自定义主题列表(仅自定义的) */
|
/** 获取自定义主题列表(仅自定义的) */
|
||||||
function getCustomThemes(): Record<string, import('./types').Theme> {
|
function getCustomThemes(): Record<string, import('./types').Theme> {
|
||||||
|
|||||||
@@ -213,6 +213,9 @@ export interface OssCfg {
|
|||||||
qiniuUpHost: string
|
qiniuUpHost: string
|
||||||
/** 绑定的公开访问域名,如 https://cdn.example.com */
|
/** 绑定的公开访问域名,如 https://cdn.example.com */
|
||||||
qiniuDomain: string
|
qiniuDomain: string
|
||||||
|
|
||||||
|
/** 分享查看器页的公开基地址(如 https://img.1216.top),分享链接 = <viewerBase>/viewer.html#/p/<id> */
|
||||||
|
viewerBase: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 文库条目 */
|
/** 文库条目 */
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 页码 HUD:3 秒无操作淡出 ---------- */
|
||||||
|
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>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import ViewerApp from './ViewerApp.vue'
|
||||||
|
|
||||||
|
createApp(ViewerApp).mount('#app')
|
||||||
+16
@@ -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>
|
||||||
@@ -5,6 +5,12 @@ export default defineConfig(({ mode }) => ({
|
|||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
build: {
|
build: {
|
||||||
chunkSizeWarningLimit: 800,
|
chunkSizeWarningLimit: 800,
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
main: 'index.html',
|
||||||
|
viewer: 'viewer.html',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
// npm run dev:web(mode=web)→ 浏览器模式,端口 8080
|
// npm run dev:web(mode=web)→ 浏览器模式,端口 8080
|
||||||
// npm run tauri:dev → 默认 mode,端口 5173
|
// npm run tauri:dev → 默认 mode,端口 5173
|
||||||
|
|||||||
Reference in New Issue
Block a user