新增: OSS 资产库与桌面本地文件读取,交互与云同步增强
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
type FileEntry, type ImportReport,
|
||||
describeReport
|
||||
} from '../../core/importer'
|
||||
import { putAsset, isOssEnabled } from '../../core/assets'
|
||||
|
||||
const props = withDefaults(defineProps<{ visible: boolean }>(), { visible: false })
|
||||
const emit = defineEmits<{
|
||||
@@ -65,7 +66,7 @@ function openFilePicker() {
|
||||
fileInput.type = 'file'
|
||||
fileInput.multiple = true
|
||||
fileInput.accept = ACCEPT_ATTR
|
||||
fileInput.onchange = () => handleFiles(fileInput!.files)
|
||||
fileInput.onchange = () => handleFiles(fileInput!.files ? Array.from(fileInput!.files) : null)
|
||||
}
|
||||
fileInput.value = ''
|
||||
fileInput.click()
|
||||
@@ -76,7 +77,7 @@ function openDirPicker() {
|
||||
dirInput = document.createElement('input')
|
||||
dirInput.type = 'file'
|
||||
;(dirInput as any).webkitdirectory = true
|
||||
dirInput.onchange = () => handleFiles(dirInput!.files)
|
||||
dirInput.onchange = () => handleFiles(dirInput!.files ? Array.from(dirInput!.files) : null)
|
||||
}
|
||||
dirInput.value = ''
|
||||
dirInput.click()
|
||||
@@ -98,17 +99,17 @@ function onDropzoneDrop(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
dragOver.value = false
|
||||
const fl = e.dataTransfer?.files
|
||||
if (fl && fl.length > 0) void handleFiles(fl)
|
||||
if (fl && fl.length > 0) void handleFiles(Array.from(fl))
|
||||
}
|
||||
|
||||
/** 供父组件预填拖入的文件(全局拖放 → 打开弹窗并直接进入预览态) */
|
||||
function acceptDroppedFiles(fl: FileList) {
|
||||
function acceptDroppedFiles(fl: File[]) {
|
||||
void handleFiles(fl)
|
||||
}
|
||||
|
||||
defineExpose({ acceptDroppedFiles })
|
||||
|
||||
async function handleFiles(fl: FileList | null) {
|
||||
async function handleFiles(fl: File[] | null) {
|
||||
if (!fl || fl.length === 0) return
|
||||
loading.value = true
|
||||
loaded.value = false
|
||||
@@ -167,7 +168,7 @@ async function runAiAnalysis() {
|
||||
}
|
||||
|
||||
/* ---------- 执行导入 ---------- */
|
||||
function doImport() {
|
||||
async function doImport() {
|
||||
const images = imageEntries.value.filter(e => e.data)
|
||||
const videos = videoEntries.value.filter(e => e.data)
|
||||
const docs = docEntries.value.filter(e => e.slides && e.slides.length > 0)
|
||||
@@ -188,20 +189,40 @@ function doImport() {
|
||||
const batch = store.beginBatch()
|
||||
|
||||
// 图片体积闸门:localStorage 约 5MB 字符上限,超限导入后刷新会丢图
|
||||
if (images.length > 0) {
|
||||
// (OSS 启用时走资产库引用,不占 localStorage,跳过闸门)
|
||||
if (images.length > 0 && !isOssEnabled()) {
|
||||
const imgChars = images.reduce((s, e) => s + (e.data?.length || 0), 0)
|
||||
let deckChars = 0
|
||||
try { deckChars = JSON.stringify(store.getDeck()).length } catch (e) { /* ignore */ }
|
||||
const QUOTA_CHARS = 4_500_000
|
||||
if (deckChars + imgChars > QUOTA_CHARS) {
|
||||
emit('toast', '图片过大:导入后约 ' + ((deckChars + imgChars) / 1048576).toFixed(1) + 'MB,超本地存储上限(约 5MB),刷新可能丢失。请压缩图片、减少数量,或先清理文库/旧图')
|
||||
emit('toast', '图片过大:导入后约 ' + ((deckChars + imgChars) / 1048576).toFixed(1) + 'MB,超本地存储上限(约 5MB),刷新可能丢失。请压缩图片、减少数量,或在 ☁ 云存储中启用 OSS')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 图片 → 当前幻灯片
|
||||
// 1. 图片 → 当前幻灯片(OSS 启用时 content 存资产引用,绕过 5MB 墙)
|
||||
if (images.length > 0) {
|
||||
if (images.length === 1) {
|
||||
if (isOssEnabled()) {
|
||||
if (images.length === 1) {
|
||||
const ref = await putAsset(images[0].file)
|
||||
const el = fileToImageElement(images[0].file, ref)
|
||||
store.addElement('image', { content: el.content, x: el.x, y: el.y, w: el.w, h: el.h, style: el.style }, batch)
|
||||
insertedImages = 1
|
||||
} else {
|
||||
const els = await Promise.all(images.map(async f => {
|
||||
const ref = await putAsset(f.file)
|
||||
return fileToImageElement(f.file, ref)
|
||||
}))
|
||||
// 复用网格布局(dataUrl 传空串,只取行列坐标/尺寸)
|
||||
const grid = imagesToSlideElements(images.map(f => ({ file: f.file, dataUrl: '' })))
|
||||
for (let i = 0; i < els.length; i++) {
|
||||
els[i].x = grid[i].x; els[i].y = grid[i].y; els[i].w = grid[i].w; els[i].h = grid[i].h
|
||||
store.addElement('image', { content: els[i].content, x: els[i].x, y: els[i].y, w: els[i].w, h: els[i].h, style: els[i].style }, batch)
|
||||
insertedImages++
|
||||
}
|
||||
}
|
||||
} else if (images.length === 1) {
|
||||
const el = fileToImageElement(images[0].file, images[0].data!)
|
||||
store.addElement('image', { content: el.content, x: el.x, y: el.y, w: el.w, h: el.h, style: el.style }, batch)
|
||||
insertedImages = 1
|
||||
@@ -214,10 +235,11 @@ function doImport() {
|
||||
}
|
||||
}
|
||||
|
||||
// 1.5 视频 → 当前幻灯片(每个视频一页,元素铺满合理区域)
|
||||
// 1.5 视频 → 当前幻灯片(OSS 启用时走资产库;每个视频一页,元素铺满合理区域)
|
||||
if (videos.length > 0) {
|
||||
for (const v of videos) {
|
||||
const el = fileToVideoElement(v.file, v.data!)
|
||||
const content = isOssEnabled() ? await putAsset(v.file) : v.data!
|
||||
const el = fileToVideoElement(v.file, content)
|
||||
if (videos.length === 1) {
|
||||
store.addElement('video', { content: el.content, x: el.x, y: el.y, w: el.w, h: el.h, style: el.style }, batch)
|
||||
insertedImages++
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
<!-- =====================================================================
|
||||
OssSettingsModal.vue — 云存储(OSS)设置弹窗
|
||||
所有媒体资产上云,避免撑爆本地存储;离线暂存本地,联网自动同步
|
||||
===================================================================== -->
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import type { OssCfg } from '../../core/types'
|
||||
import { store } from '../../core/store'
|
||||
import { syncState, syncPending, canUploadNow } from '../../core/assets'
|
||||
|
||||
const props = defineProps<{ visible: boolean }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'toast', msg: string): void
|
||||
}>()
|
||||
|
||||
const form = ref<OssCfg>({
|
||||
enabled: false, provider: 'aliyun', dir: 'u-ppt',
|
||||
aliAccessKeyId: '', aliAccessKeySecret: '', aliEndpoint: '', aliBucket: '',
|
||||
qiniuAccessKey: '', qiniuSecretKey: '', qiniuBucket: '', qiniuUpHost: '', qiniuDomain: ''
|
||||
})
|
||||
|
||||
function loadFromStore() {
|
||||
form.value = { ...form.value, ...store.getOssCfg() }
|
||||
}
|
||||
|
||||
watch(() => props.visible, (v) => {
|
||||
if (v) loadFromStore()
|
||||
}, { immediate: true })
|
||||
|
||||
function save() {
|
||||
const c = form.value
|
||||
store.setOssCfg({
|
||||
enabled: c.enabled,
|
||||
provider: c.provider,
|
||||
dir: c.dir.trim(),
|
||||
aliAccessKeyId: c.aliAccessKeyId.trim(),
|
||||
aliAccessKeySecret: c.aliAccessKeySecret.trim(),
|
||||
aliEndpoint: c.aliEndpoint.trim(),
|
||||
aliBucket: c.aliBucket.trim(),
|
||||
qiniuAccessKey: c.qiniuAccessKey.trim(),
|
||||
qiniuSecretKey: c.qiniuSecretKey.trim(),
|
||||
qiniuBucket: c.qiniuBucket.trim(),
|
||||
qiniuUpHost: c.qiniuUpHost.trim(),
|
||||
qiniuDomain: c.qiniuDomain.trim()
|
||||
})
|
||||
emit('toast', c.enabled ? '已保存云存储设置(已启用)' : '已保存云存储设置(未启用)')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
async function manualSync() {
|
||||
if (!canUploadNow()) {
|
||||
emit('toast', '当前不可同步:需桌面版 + 已联网 + 已启用并保存配置')
|
||||
return
|
||||
}
|
||||
const r = await syncPending()
|
||||
emit('toast', `同步完成:成功 ${r.done},失败 ${r.fail}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal-mask" :class="{ hidden: !visible }" @click.self="emit('close')">
|
||||
<div class="modal">
|
||||
<h3>云存储(OSS)设置</h3>
|
||||
<p class="modal-tip">
|
||||
开启后,图片/视频等媒体自动上传对象存储,避免撑爆本地。离线时先暂存本地,联网后自动同步云端。密钥仅保存在本地。
|
||||
</p>
|
||||
|
||||
<div class="form-row">
|
||||
<label>启用云存储</label>
|
||||
<label class="switch-line">
|
||||
<input type="checkbox" v-model="form.enabled" />
|
||||
<span>{{ form.enabled ? '开' : '关(维持本地内嵌)' }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>服务商</label>
|
||||
<select v-model="form.provider">
|
||||
<option value="aliyun">阿里云 OSS</option>
|
||||
<option value="qiniu">七牛云 Kodo</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>所属目录</label>
|
||||
<input type="text" v-model="form.dir" placeholder="如 u-ppt/images,可留空" />
|
||||
</div>
|
||||
|
||||
<!-- 阿里云 -->
|
||||
<template v-if="form.provider === 'aliyun'">
|
||||
<div class="section-title">阿里云 OSS</div>
|
||||
<div class="form-row">
|
||||
<label>AccessKeyId</label>
|
||||
<input type="text" v-model="form.aliAccessKeyId" placeholder="LTAI..." autocomplete="off" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>AccessKeySecret</label>
|
||||
<input type="password" v-model="form.aliAccessKeySecret" placeholder="密钥" autocomplete="off" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Endpoint</label>
|
||||
<input type="text" v-model="form.aliEndpoint" placeholder="oss-cn-hangzhou.aliyuncs.com" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Bucket</label>
|
||||
<input type="text" v-model="form.aliBucket" placeholder="bucket 名称" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 七牛云 -->
|
||||
<template v-else>
|
||||
<div class="section-title">七牛云 Kodo</div>
|
||||
<div class="form-row">
|
||||
<label>AccessKey</label>
|
||||
<input type="text" v-model="form.qiniuAccessKey" placeholder="AK" autocomplete="off" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>SecretKey</label>
|
||||
<input type="password" v-model="form.qiniuSecretKey" placeholder="SK" autocomplete="off" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Bucket</label>
|
||||
<input type="text" v-model="form.qiniuBucket" placeholder="空间名称" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>加速域名</label>
|
||||
<input type="text" v-model="form.qiniuDomain" placeholder="https://cdn.example.com" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>上传域名(可选)</label>
|
||||
<input type="text" v-model="form.qiniuUpHost" placeholder="留空自动探测区域" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="sync-bar">
|
||||
<div class="sync-info">
|
||||
<div>
|
||||
待同步 {{ syncState.pending }} 项<template v-if="syncState.syncing">(同步中…)</template>
|
||||
<span v-if="syncState.lastError" class="sync-err">· 有错误</span>
|
||||
</div>
|
||||
<div v-if="syncState.lastError" class="sync-err-detail">{{ syncState.lastError }}</div>
|
||||
</div>
|
||||
<button class="btn" @click="manualSync" :disabled="syncState.syncing">立即同步</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn" @click="emit('close')">取消</button>
|
||||
<button class="btn primary" @click="save">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.section-title {
|
||||
margin: 18px 0 10px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--ui-border);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--ui-text);
|
||||
}
|
||||
.switch-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--ui-muted);
|
||||
}
|
||||
.switch-line input { width: auto; }
|
||||
.sync-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--ui-border);
|
||||
}
|
||||
.sync-info { font-size: 12px; color: var(--ui-muted); }
|
||||
.sync-err { color: #e5484d; }
|
||||
.sync-err-detail {
|
||||
margin-top: 4px;
|
||||
max-width: 420px;
|
||||
word-break: break-all;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #e5484d;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user