794 lines
30 KiB
Vue
794 lines
30 KiB
Vue
<!-- =====================================================================
|
||
PropsPanel.vue — 右侧属性面板(元素样式 + 页面背景)
|
||
===================================================================== -->
|
||
<script setup lang="ts">
|
||
import { computed, watch, ref } from 'vue'
|
||
import { store } from '../../core/store'
|
||
import { elementTypes } from '../../core/sample'
|
||
import { generateImage, isImageConfigured, checkImageQuota } from '../../core/ai'
|
||
import { readAsDataURL } from '../../core/importer'
|
||
import { putAsset, isOssEnabled } from '../../core/assets'
|
||
import { appAlert, appConfirm, appPrompt } from '../../core/dialog'
|
||
import { markdownToSegments, segmentsToPlain, hasFormatting } from '../../core/richtext'
|
||
import AddGrid from './AddGrid.vue'
|
||
import Icon from '../common/Icon.vue'
|
||
import type { ElementType, ChartType, ShapeType } from '../../core/types'
|
||
|
||
const CHART_TYPES: Array<{ k: ChartType; label: string; icon: string }> = [
|
||
{ k: 'bar', label: '柱状图', icon: 'bar-chart' },
|
||
{ k: 'hbar', label: '条形图', icon: 'bar-chart-h' },
|
||
{ k: 'line', label: '折线图', icon: 'line-chart' },
|
||
{ k: 'area', label: '面积图', icon: 'area-chart' },
|
||
{ k: 'pie', label: '饼图', icon: 'pie' },
|
||
{ k: 'doughnut', label: '环形图', icon: 'doughnut' },
|
||
{ k: 'radar', label: '雷达图', icon: 'radar' },
|
||
{ k: 'progress', label: '进度图', icon: 'progress' }
|
||
]
|
||
|
||
const selected = computed(() => store.getSelected())
|
||
const slide = computed(() => store.currentSlide.value)
|
||
const multiCount = computed(() => store.getSelection().length)
|
||
|
||
/** 临时输入态(v-model 绑定) */
|
||
const content = ref('')
|
||
const fontSize = ref(24)
|
||
const colorSel = ref('primary')
|
||
const colorPicker = ref('#000000')
|
||
const shape = ref<ShapeType>('rect')
|
||
const chartType = ref<ChartType>('bar')
|
||
const codeLang = ref('')
|
||
const imgBusy = ref(false)
|
||
let imgAbort: AbortController | null = null
|
||
|
||
const BG_OPTIONS = [
|
||
{ k: 'bg', label: '白底' },
|
||
{ k: 'panel', label: '浅底' },
|
||
{ k: 'primary', label: '主色' },
|
||
{ k: 'accent', label: '强调' }
|
||
]
|
||
|
||
/** 选中变化时同步输入控件 */
|
||
watch(selected, (el) => {
|
||
if (!el) return
|
||
const s = el.style || {}
|
||
if (el.type !== 'stat') content.value = el.content || ''
|
||
fontSize.value = s.fontSize || 24
|
||
shape.value = (s.shapeType as any) || 'rect'
|
||
chartType.value = (s.chartType as ChartType) || 'bar'
|
||
codeLang.value = s.lang || ''
|
||
// 颜色下拉/picker 同步
|
||
const presets = ['primary', 'accent', 'text', 'muted', '#ffffff']
|
||
if (presets.includes(s.color || '')) {
|
||
colorSel.value = s.color!
|
||
colorPicker.value = '#000000'
|
||
} else if (s.color && s.color.charAt(0) === '#') {
|
||
colorSel.value = 'custom'
|
||
colorPicker.value = /^#[0-9a-f]{6}$/i.test(s.color) ? s.color : '#000000'
|
||
} else {
|
||
colorSel.value = 'primary'
|
||
}
|
||
}, { immediate: true })
|
||
|
||
const hasText = computed(() => selected.value && ['title', 'text', 'quote', 'list', 'stat', 'table', 'code', 'formula', 'shape', 'card'].includes(selected.value.type))
|
||
const canFont = computed(() => selected.value && ['title', 'text', 'quote', 'list', 'stat', 'table', 'code', 'formula', 'shape', 'card'].includes(selected.value.type))
|
||
const canAlign = computed(() => selected.value && ['title', 'text', 'quote', 'stat', 'table', 'formula', 'shape'].includes(selected.value.type))
|
||
const canBI = computed(() => selected.value && ['title', 'text', 'quote', 'list', 'shape'].includes(selected.value.type))
|
||
const canColor = computed(() => selected.value && ['title', 'text', 'quote', 'list', 'stat', 'table', 'code', 'formula', 'shape'].includes(selected.value.type))
|
||
const isShape = computed(() => selected.value?.type === 'shape')
|
||
|
||
const typeLabel = computed(() => {
|
||
const el = selected.value
|
||
return el ? (elementTypes[el.type]?.label || el.type) : ''
|
||
})
|
||
|
||
function onAdd(type: ElementType) {
|
||
store.addElement(type)
|
||
}
|
||
|
||
/* 多选批量操作 */
|
||
function onMultiCopy() {
|
||
const sel = store.getSelection()
|
||
if (sel.length) store.copyElements(sel)
|
||
}
|
||
async function onMultiDel() {
|
||
const sel = store.getSelection()
|
||
if (!sel.length) return
|
||
if (await appConfirm('删除选中的 ' + sel.length + ' 个元素?', '可用 Ctrl+Z 撤销', { danger: true, okText: '删除' })) {
|
||
store.delElements(sel)
|
||
}
|
||
}
|
||
function onAlignOp(edge: 'left' | 'hcenter' | 'right' | 'top' | 'vmiddle' | 'bottom') {
|
||
const sel = store.getSelection()
|
||
if (sel.length > 1) store.alignElements(sel, edge)
|
||
}
|
||
function onDistribute(axis: 'h' | 'v') {
|
||
const sel = store.getSelection()
|
||
if (sel.length > 2) store.distributeElements(sel, axis)
|
||
}
|
||
|
||
function onContentInput() {
|
||
if (!selected.value) return
|
||
store.updateElement(selected.value.id, { content: content.value })
|
||
}
|
||
function onFontSizeInput() {
|
||
if (!selected.value) return
|
||
store.updateElement(selected.value.id, { style: { fontSize: Number(fontSize.value) } })
|
||
}
|
||
function onColorChange() {
|
||
if (!selected.value) return
|
||
if (colorSel.value === 'custom') return
|
||
store.updateElement(selected.value.id, { style: { color: colorSel.value } })
|
||
}
|
||
function onColorPickerInput() {
|
||
if (!selected.value) return
|
||
store.updateElement(selected.value.id, { style: { color: colorPicker.value } })
|
||
}
|
||
function onShapeChange() {
|
||
if (!selected.value) return
|
||
store.updateElement(selected.value.id, { style: { shapeType: shape.value } })
|
||
}
|
||
function onChartTypeChange() {
|
||
if (!selected.value) return
|
||
store.updateElement(selected.value.id, { style: { chartType: chartType.value } })
|
||
}
|
||
function toggleLegend() {
|
||
if (!selected.value) return
|
||
const cur = selected.value.style.legend !== false
|
||
store.updateElement(selected.value.id, { style: { legend: !cur } })
|
||
}
|
||
function toggleGrid() {
|
||
if (!selected.value) return
|
||
const cur = selected.value.style.grid !== false
|
||
store.updateElement(selected.value.id, { style: { grid: !cur } })
|
||
}
|
||
function toggleHeader() {
|
||
if (!selected.value) return
|
||
const cur = selected.value.style.header !== false
|
||
store.updateElement(selected.value.id, { style: { header: !cur } })
|
||
}
|
||
function onCodeLangInput() {
|
||
if (!selected.value) return
|
||
store.updateElement(selected.value.id, { style: { lang: codeLang.value } })
|
||
}
|
||
function onAlign(a: 'left' | 'center' | 'right') {
|
||
if (!selected.value) return
|
||
store.updateElement(selected.value.id, { style: { align: a } })
|
||
}
|
||
function toggleBold() {
|
||
if (!selected.value) return
|
||
store.updateElement(selected.value.id, { style: { bold: !selected.value.style.bold } })
|
||
}
|
||
function toggleItalic() {
|
||
if (!selected.value) return
|
||
store.updateElement(selected.value.id, { style: { italic: !selected.value.style.italic } })
|
||
}
|
||
function onZ(dir: number) {
|
||
if (!selected.value) return
|
||
store.moveElementZ(selected.value.id, dir)
|
||
}
|
||
async function onDel() {
|
||
if (!selected.value) return
|
||
const label = elementTypes[selected.value.type]?.label || '元素'
|
||
if (await appConfirm('删除这个' + label + '?', '可用 Ctrl+Z 撤销', { danger: true, okText: '删除' })) {
|
||
store.delElement(selected.value.id)
|
||
}
|
||
}
|
||
function onBg(k: string) {
|
||
store.setSlideBackground(k)
|
||
}
|
||
|
||
/* ---------- 富文本格式 ---------- */
|
||
/** 当前选中元素是否有 segments */
|
||
const hasRich = computed(() => {
|
||
const el = selected.value
|
||
return !!(el?.segments && el.segments.length && hasFormatting(el.segments))
|
||
})
|
||
|
||
/** 从 Markdown 语法生成 segments */
|
||
function applyMarkdown() {
|
||
const el = selected.value
|
||
if (!el) return
|
||
const segs = markdownToSegments(content.value)
|
||
if (segs.length && hasFormatting(segs)) {
|
||
store.updateElement(el.id, { segments: segs })
|
||
}
|
||
}
|
||
|
||
/** 清除 segments(降级为纯文本) */
|
||
function clearRich() {
|
||
const el = selected.value
|
||
if (!el) return
|
||
store.updateElement(el.id, { segments: undefined })
|
||
}
|
||
|
||
/* ---------- AI 配图 / 本地换图 ---------- */
|
||
async function onAiImage() {
|
||
if (imgBusy.value) return
|
||
const el = selected.value
|
||
if (!el || el.type !== 'image') return
|
||
if (!isImageConfigured()) { appAlert('需要 API Key', '请先在「AI 设置」中配置'); return }
|
||
const promptText = await appPrompt('AI 配图', { placeholder: '描述你想要的图片,如「现代办公室协作场景,俯拍,柔和光线」' })
|
||
if (!promptText) return
|
||
imgBusy.value = true
|
||
imgAbort = new AbortController()
|
||
try {
|
||
const r = await generateImage({ prompt: promptText, signal: imgAbort.signal })
|
||
const quotaErr = checkImageQuota(r.url)
|
||
if (quotaErr) { appAlert('配图受限', quotaErr); return }
|
||
store.updateElement(el.id, { content: r.url })
|
||
} catch (e: any) {
|
||
if (e?.name !== 'AbortError') appAlert('配图失败', e?.message || String(e))
|
||
} finally {
|
||
imgBusy.value = false; imgAbort = null
|
||
}
|
||
}
|
||
|
||
/* ---------- 批注(仅图片元素) ---------- */
|
||
/** 复用文件内颜色选项集合(与颜色 select 的 option values 同款) */
|
||
const ANNO_COLORS: Array<{ k: string; label: string }> = [
|
||
{ k: 'primary', label: '主色' },
|
||
{ k: 'accent', label: '强调色' },
|
||
{ k: 'text', label: '正文色' },
|
||
{ k: 'muted', label: '次要色' },
|
||
{ k: '#ffffff', label: '白色' }
|
||
]
|
||
|
||
/** 当前图片的批注列表 */
|
||
const annotations = computed<any[]>(() => selected.value?.style?.annotations || [])
|
||
|
||
/** 折叠状态:记录「已展开」的批注 id(默认折叠,仅展开正在编辑的一条) */
|
||
const expandedAnno = ref<string | null>(null)
|
||
function toggleAnno(annoId: string) {
|
||
expandedAnno.value = expandedAnno.value === annoId ? null : annoId
|
||
}
|
||
|
||
function addAnno() {
|
||
if (!selected.value) return
|
||
const id = store.addAnnotation(selected.value.id)
|
||
if (id) expandedAnno.value = id // 新增自动展开
|
||
}
|
||
function delAnno(annoId: string) {
|
||
if (selected.value) store.delAnnotation(selected.value.id, annoId)
|
||
if (expandedAnno.value === annoId) expandedAnno.value = null
|
||
}
|
||
function patchAnno(annoId: string, patch: Record<string, any>) {
|
||
if (!selected.value) return
|
||
store.updateAnnotation(selected.value.id, annoId, patch)
|
||
}
|
||
|
||
/** 批注文字摘要(折叠态卡片头显示) */
|
||
function annoSummary(anno: any): string {
|
||
const t = (anno.text || '').trim()
|
||
return t ? (t.length > 12 ? t.slice(0, 12) + '…' : t) : '(空批注)'
|
||
}
|
||
|
||
/** 本地上传图片替换当前图片元素内容 */
|
||
function onLocalImage() {
|
||
const el = selected.value
|
||
if (!el || el.type !== 'image') return
|
||
const input = document.createElement('input')
|
||
input.type = 'file'
|
||
input.accept = 'image/*'
|
||
input.onchange = async () => {
|
||
const file = input.files?.[0]
|
||
if (!file) return
|
||
try {
|
||
// OSS 启用:走资产库(上云或离线暂存),content 存引用/URL,绕过 5MB 墙
|
||
if (isOssEnabled()) {
|
||
const ref = await putAsset(file)
|
||
store.updateElement(el.id, { content: ref })
|
||
return
|
||
}
|
||
const dataUrl = await readAsDataURL(file)
|
||
const quotaErr = checkImageQuota(dataUrl)
|
||
if (quotaErr) { appAlert('图片受限', quotaErr); return }
|
||
store.updateElement(el.id, { content: dataUrl })
|
||
} catch (e: any) {
|
||
appAlert('读取图片失败', e?.message || String(e))
|
||
}
|
||
}
|
||
input.click()
|
||
}
|
||
|
||
/* ---------- 视频元素 ---------- */
|
||
function onLocalVideo() {
|
||
const el = selected.value
|
||
if (!el) return
|
||
const input = document.createElement('input')
|
||
input.type = 'file'
|
||
input.accept = 'video/*'
|
||
input.onchange = async () => {
|
||
const f = input.files?.[0]
|
||
if (!f) return
|
||
// OSS 启用:视频走资产库(大文件尤其受益,避免撑爆本地存储)
|
||
if (isOssEnabled()) {
|
||
try {
|
||
const ref = await putAsset(f)
|
||
store.updateElement(el.id, { content: ref })
|
||
} catch (e: any) {
|
||
appAlert('读取视频失败', e?.message || String(e))
|
||
}
|
||
return
|
||
}
|
||
// 注意:大视频会整体读入内存(dataURL),本地工具首版接受
|
||
const dataUrl = await readAsDataURL(f)
|
||
store.updateElement(el.id, { content: dataUrl })
|
||
}
|
||
input.click()
|
||
}
|
||
function onVideoUrl(e: Event) {
|
||
const el = selected.value
|
||
if (!el) return
|
||
const v = (e.target as HTMLInputElement).value.trim()
|
||
if (v) store.updateElement(el.id, { content: v })
|
||
}
|
||
function toggleVideoOpt(key: 'autoplay' | 'loop' | 'muted') {
|
||
const el = selected.value
|
||
if (!el) return
|
||
const cur = (el.style as any)[key]
|
||
store.updateElement(el.id, { style: { [key]: !cur } })
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="panel-pane props-pane" id="panelProps">
|
||
<!-- 空态 -->
|
||
<section v-if="!selected" class="prop-section" id="propEmpty">
|
||
<p class="prop-hint">点击画布元素以编辑样式<br />或在下方添加新元素</p>
|
||
<AddGrid @add="onAdd" />
|
||
</section>
|
||
|
||
<!-- 多选态:批量操作 -->
|
||
<section v-else-if="multiCount > 1" class="prop-section" id="propMulti">
|
||
<h4 class="prop-title">多选 · <span>{{ multiCount }} 个元素</span></h4>
|
||
<div class="prop-row">
|
||
<label>批量操作</label>
|
||
<div class="seg">
|
||
<button @click="onMultiCopy" title="Ctrl+C">⧉ 复制</button>
|
||
<button class="danger" @click="onMultiDel" title="Delete"><Icon name="trash" :size="13" /> 删除</button>
|
||
</div>
|
||
</div>
|
||
<div class="prop-row">
|
||
<label>对齐</label>
|
||
<div class="seg">
|
||
<button @click="onAlignOp('left')" title="左对齐">⇤</button>
|
||
<button @click="onAlignOp('hcenter')" title="水平居中">↔</button>
|
||
<button @click="onAlignOp('right')" title="右对齐">⇥</button>
|
||
<button @click="onAlignOp('top')" title="顶对齐">⇧</button>
|
||
<button @click="onAlignOp('vmiddle')" title="垂直居中">↕</button>
|
||
<button @click="onAlignOp('bottom')" title="底对齐">⇩</button>
|
||
</div>
|
||
</div>
|
||
<div class="prop-row">
|
||
<label>分布</label>
|
||
<div class="seg">
|
||
<button :disabled="multiCount < 3" @click="onDistribute('h')" :title="multiCount < 3 ? '需要选中 3 个以上' : '水平等间距'">⋯</button>
|
||
<button :disabled="multiCount < 3" @click="onDistribute('v')" :title="multiCount < 3 ? '需要选中 3 个以上' : '垂直等间距'">⋮</button>
|
||
</div>
|
||
</div>
|
||
<div class="prop-row">
|
||
<label>提示</label>
|
||
<div style="font-size:12px;color:var(--ui-muted);line-height:1.6">
|
||
拖动整组移动 · Shift+点击加选/减选<br />空白处拖动框选 · Esc 取消多选
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 选中元素 -->
|
||
<section v-else class="prop-section" id="propElement">
|
||
<h4 class="prop-title">元素 · <span>{{ typeLabel }}</span></h4>
|
||
|
||
<!-- 内容 -->
|
||
<div v-if="hasText && selected.type !== 'stat'" class="prop-row">
|
||
<label>内容</label>
|
||
<textarea
|
||
rows="3"
|
||
:placeholder="selected.type === 'card' ? '第一行=标题,其余行=正文' : selected.type === 'shape' ? '形状内文字(支持 **加粗** 等 md 语法)' : '输入文字(列表用换行分隔)'"
|
||
v-model="content"
|
||
@input="onContentInput"
|
||
></textarea>
|
||
<!-- 富文本格式提示 -->
|
||
<div v-if="['title','text','quote','list'].includes(selected.type)" class="rich-hint">
|
||
<div class="rich-syntax">
|
||
**加粗** · *斜体* · ==高亮== · ~~删除线~~ · `代码` · ^上标^ · ~下标~
|
||
</div>
|
||
<div class="rich-actions">
|
||
<button v-if="hasRich" class="rich-btn danger" @click="clearRich">清除格式</button>
|
||
<button class="rich-btn" @click="applyMarkdown" title="把上面的 Markdown 语法转为富文本">转换格式</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 字号 -->
|
||
<div v-if="canFont" class="prop-row">
|
||
<label>字号 <span>{{ fontSize }}px</span></label>
|
||
<input type="range" min="12" max="120" v-model.number="fontSize" @input="onFontSizeInput" />
|
||
</div>
|
||
|
||
<!-- 颜色 -->
|
||
<div v-if="canColor" class="prop-row">
|
||
<label>颜色</label>
|
||
<div class="color-row">
|
||
<select v-model="colorSel" @change="onColorChange">
|
||
<option value="primary">主色</option>
|
||
<option value="accent">强调色</option>
|
||
<option value="text">正文色</option>
|
||
<option value="muted">次要色</option>
|
||
<option value="#ffffff">白色</option>
|
||
<option value="custom">自定义…</option>
|
||
</select>
|
||
<input type="color" v-model="colorPicker" v-show="colorSel === 'custom'" @input="onColorPickerInput" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 对齐 -->
|
||
<div v-if="canAlign" class="prop-row">
|
||
<label>对齐</label>
|
||
<div class="seg">
|
||
<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')" title="居中"><Icon name="align-center" :size="14" /></button>
|
||
<button :class="{ active: selected.style.align === 'right' }" @click="onAlign('right')" title="右对齐"><Icon name="align-right" :size="14" /></button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 加粗/斜体 -->
|
||
<div v-if="canBI" class="prop-row">
|
||
<label>样式</label>
|
||
<div class="seg">
|
||
<button :class="{ active: !!selected.style.bold }" @click="toggleBold" title="加粗"><b>B</b></button>
|
||
<button :class="{ active: !!selected.style.italic }" @click="toggleItalic" title="斜体"><i>I</i></button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 形状 -->
|
||
<div v-if="isShape" class="prop-row">
|
||
<label>形状</label>
|
||
<select v-model="shape" @change="onShapeChange">
|
||
<option value="rect">矩形</option>
|
||
<option value="circle">圆形</option>
|
||
<option value="ellipse">椭圆</option>
|
||
<option value="triangle">三角</option>
|
||
<option value="diamond">菱形</option>
|
||
<option value="pentagon">五边形</option>
|
||
<option value="hexagon">六边形</option>
|
||
<option value="star">五角星</option>
|
||
<option value="arrow">箭头</option>
|
||
<option value="chevron">折角</option>
|
||
<option value="bubble">气泡</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- 图表配置 -->
|
||
<template v-if="selected.type === 'chart'">
|
||
<div class="prop-row">
|
||
<label>图表类型</label>
|
||
<select v-model="chartType" @change="onChartTypeChange">
|
||
<option v-for="t in CHART_TYPES" :key="t.k" :value="t.k">{{ t.label }}</option>
|
||
</select>
|
||
</div>
|
||
<div class="prop-row">
|
||
<label>显示选项</label>
|
||
<div class="seg">
|
||
<button :class="{ active: selected.style.legend !== false }" @click="toggleLegend" title="图例">图例</button>
|
||
<button :class="{ active: selected.style.grid !== false }" @click="toggleGrid" title="网格线">网格</button>
|
||
</div>
|
||
</div>
|
||
<div class="prop-row">
|
||
<label>数据格式</label>
|
||
<div style="font-size:11px;color:var(--ui-muted,#64748b);line-height:1.6;padding:.3em 0">
|
||
单系列:[{"label":"A","value":65}]<br />
|
||
多系列:{"series":["Q1","Q2"],"items":[{"label":"华东","values":[120,150]}]}
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 表格选项 -->
|
||
<div v-if="selected.type === 'table'" class="prop-row">
|
||
<label>首行表头</label>
|
||
<div class="seg">
|
||
<button :class="{ active: selected.style.header !== false }" @click="toggleHeader">表头</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 代码语言 -->
|
||
<div v-if="selected.type === 'code'" class="prop-row">
|
||
<label>语言</label>
|
||
<input type="text" v-model="codeLang" @input="onCodeLangInput" placeholder="js / python / ..." />
|
||
</div>
|
||
|
||
<!-- 公式提示 -->
|
||
<div v-if="selected.type === 'formula'" class="prop-row">
|
||
<label>提示</label>
|
||
<div style="font-size:12px;color:var(--ui-muted);line-height:1.6">
|
||
LaTeX 语法。示例:<br />
|
||
E = mc^2<br />
|
||
\frac{a}{b}<br />
|
||
\sum_{i=1}^n x_i
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 图片来源(仅图片元素) -->
|
||
<div v-if="selected.type === 'image'" class="prop-row">
|
||
<label>图片来源</label>
|
||
<div class="seg">
|
||
<button @click="onLocalImage" title="从本地选择图片"><Icon name="folder" :size="13" /> 本地图片</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 v-if="selected.type === 'video'" class="prop-row">
|
||
<label>视频来源</label>
|
||
<div class="seg">
|
||
<button @click="onLocalVideo" title="从本地选择视频文件"><Icon name="folder" :size="13" /> 本地视频</button>
|
||
</div>
|
||
<input
|
||
type="text"
|
||
class="video-url"
|
||
placeholder="或粘贴视频 URL (https://…)"
|
||
style="margin-top:6px;width:100%;box-sizing:border-box"
|
||
:value="selected.content"
|
||
@change="onVideoUrl($event)"
|
||
/>
|
||
</div>
|
||
<!-- 视频播放选项 -->
|
||
<div v-if="selected.type === 'video'" class="prop-row">
|
||
<label>播放选项</label>
|
||
<div class="seg">
|
||
<button :class="{ active: !!selected.style.autoplay }" @click="toggleVideoOpt('autoplay')" title="自动播放">自动</button>
|
||
<button :class="{ active: !!selected.style.loop }" @click="toggleVideoOpt('loop')" title="循环">循环</button>
|
||
<button :class="{ active: !!selected.style.muted }" @click="toggleVideoOpt('muted')" title="静音">静音</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 层级 -->
|
||
<div class="prop-row">
|
||
<label>层级</label>
|
||
<div class="seg">
|
||
<button @click="onZ(1)" title="上移">↑</button>
|
||
<button @click="onZ(-1)" title="下移">↓</button>
|
||
<button class="danger" @click="onDel" title="删除"><Icon name="trash" :size="13" /></button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 批注(仅图片元素) -->
|
||
<div v-if="selected.type === 'image'" class="prop-row">
|
||
<label>批注</label>
|
||
<div class="anno-head">
|
||
<button @click="addAnno" title="添加一条批注">+ 添加批注</button>
|
||
</div>
|
||
<div class="anno-list">
|
||
<div v-for="(anno, i) in annotations" :key="anno.id" class="anno-card" :class="{ open: expandedAnno === anno.id }">
|
||
<div class="anno-card-head" @click="toggleAnno(anno.id)">
|
||
<span class="anno-caret">{{ expandedAnno === anno.id ? '▾' : '▸' }}</span>
|
||
<span class="anno-card-title">批注 {{ i + 1 }}</span>
|
||
<span v-if="expandedAnno !== anno.id" class="anno-card-summary">{{ annoSummary(anno) }}</span>
|
||
<button class="anno-del" @click.stop="delAnno(anno.id)" title="删除批注">×</button>
|
||
</div>
|
||
|
||
<div v-show="expandedAnno === anno.id" class="anno-body">
|
||
<textarea
|
||
class="anno-text"
|
||
rows="2"
|
||
placeholder="批注文字"
|
||
:value="anno.text"
|
||
@input="patchAnno(anno.id, { text: ($event.target as HTMLTextAreaElement).value })"
|
||
></textarea>
|
||
|
||
<!-- 气泡文字样式 -->
|
||
<div class="anno-sub">气泡文字</div>
|
||
<div class="anno-line">
|
||
<label class="anno-lbl">字号 {{ anno.fontSize || 16 }}px</label>
|
||
<input
|
||
type="range" min="12" max="48"
|
||
:value="anno.fontSize || 16"
|
||
@input="patchAnno(anno.id, { fontSize: Number(($event.target as HTMLInputElement).value) })"
|
||
/>
|
||
</div>
|
||
<div class="anno-line">
|
||
<select
|
||
:value="anno.color || 'text'"
|
||
@change="patchAnno(anno.id, { color: ($event.target as HTMLSelectElement).value })"
|
||
>
|
||
<option v-for="c in ANNO_COLORS" :key="c.k" :value="c.k">{{ c.label }}</option>
|
||
</select>
|
||
<div class="seg">
|
||
<button :class="{ active: !!anno.bold }" @click="patchAnno(anno.id, { bold: !anno.bold })" title="加粗"><b>B</b></button>
|
||
<button :class="{ active: !!anno.italic }" @click="patchAnno(anno.id, { italic: !anno.italic })" title="斜体"><i>I</i></button>
|
||
</div>
|
||
</div>
|
||
<div class="anno-line">
|
||
<div class="seg">
|
||
<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' })" title="居中"><Icon name="align-center" :size="14" /></button>
|
||
<button :class="{ active: anno.align === 'right' }" @click="patchAnno(anno.id, { align: 'right' })" title="右对齐"><Icon name="align-right" :size="14" /></button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 连线样式 -->
|
||
<div class="anno-sub">连线</div>
|
||
<div class="anno-line">
|
||
<select
|
||
:value="anno.line?.style || 'solid'"
|
||
@change="patchAnno(anno.id, { line: { style: ($event.target as HTMLSelectElement).value } })"
|
||
>
|
||
<option value="solid">实线</option>
|
||
<option value="dashed">虚线</option>
|
||
</select>
|
||
<select
|
||
:value="anno.line?.color || 'muted'"
|
||
@change="patchAnno(anno.id, { line: { color: ($event.target as HTMLSelectElement).value } })"
|
||
>
|
||
<option v-for="c in ANNO_COLORS" :key="c.k" :value="c.k">{{ c.label }}</option>
|
||
</select>
|
||
</div>
|
||
<div class="anno-line">
|
||
<label class="anno-lbl">线宽 {{ anno.line?.width ?? 0.5 }}px</label>
|
||
<input
|
||
type="range" min="0.5" max="8" step="0.5"
|
||
:value="anno.line?.width ?? 0.5"
|
||
@input="patchAnno(anno.id, { line: { width: Number(($event.target as HTMLInputElement).value) } })"
|
||
/>
|
||
</div>
|
||
<div class="anno-line">
|
||
<select
|
||
:value="anno.line?.startCap || 'none'"
|
||
@change="patchAnno(anno.id, { line: { startCap: ($event.target as HTMLSelectElement).value } })"
|
||
>
|
||
<option value="none">起点·无</option>
|
||
<option value="arrow">起点·箭头</option>
|
||
<option value="dot">起点·圆点</option>
|
||
</select>
|
||
<select
|
||
:value="anno.line?.endCap || 'arrow'"
|
||
@change="patchAnno(anno.id, { line: { endCap: ($event.target as HTMLSelectElement).value } })"
|
||
>
|
||
<option value="none">终点·无</option>
|
||
<option value="arrow">终点·箭头</option>
|
||
<option value="dot">终点·圆点</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 当前页背景 -->
|
||
<section class="prop-section">
|
||
<h4 class="prop-title">当前页背景</h4>
|
||
<div class="bg-grid">
|
||
<button
|
||
v-for="b in BG_OPTIONS"
|
||
:key="b.k"
|
||
:class="{ active: slide.background === b.k }"
|
||
@click="onBg(b.k)"
|
||
>{{ b.label }}</button>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.anno-head {
|
||
display: flex;
|
||
margin-bottom: 6px;
|
||
}
|
||
.anno-head button {
|
||
flex: 1;
|
||
height: 32px;
|
||
border: 1px solid var(--ui-border, #e2e8f0);
|
||
border-radius: var(--radius-sm, 6px);
|
||
background: #fff;
|
||
color: var(--ui-text, #1e293b);
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
transition: background .15s, border-color .15s;
|
||
}
|
||
.anno-head button:hover {
|
||
background: var(--ui-primary-soft, #eeeefc);
|
||
border-color: var(--ui-primary, #5b5bd6);
|
||
color: var(--ui-primary, #5b5bd6);
|
||
}
|
||
.anno-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
.anno-card {
|
||
border: 0.5px solid var(--ui-border, #e2e8f0);
|
||
border-radius: 8px;
|
||
padding: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
background: var(--ui-panel, #f8fafc);
|
||
}
|
||
.anno-card.open {
|
||
border-color: var(--ui-primary, #5b5bd6);
|
||
}
|
||
.anno-card-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 7px 8px;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
.anno-card-head:hover {
|
||
background: var(--ui-primary-soft, #eeeefc);
|
||
}
|
||
.anno-caret {
|
||
font-size: 10px;
|
||
color: var(--ui-muted, #64748b);
|
||
width: 12px;
|
||
flex: none;
|
||
}
|
||
.anno-card-title {
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
color: var(--ui-text, #1e293b);
|
||
flex: none;
|
||
}
|
||
.anno-card-summary {
|
||
font-size: 12px;
|
||
color: var(--ui-muted, #64748b);
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.anno-del {
|
||
margin-left: auto;
|
||
width: 20px;
|
||
height: 20px;
|
||
line-height: 1;
|
||
padding: 0;
|
||
border-radius: 6px;
|
||
border: 1px solid var(--ui-border, #e2e8f0);
|
||
background: #fff;
|
||
color: var(--ui-muted, #64748b);
|
||
cursor: pointer;
|
||
flex: none;
|
||
}
|
||
.anno-del:hover {
|
||
background: #fff1f2;
|
||
border-color: var(--ui-danger, #ef4444);
|
||
color: var(--ui-danger, #ef4444);
|
||
}
|
||
.anno-body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
padding: 0 8px 8px;
|
||
}
|
||
.anno-text {
|
||
width: 100%;
|
||
resize: vertical;
|
||
}
|
||
.anno-sub {
|
||
font-size: 11px;
|
||
color: var(--ui-muted, #64748b);
|
||
margin-top: 2px;
|
||
}
|
||
.anno-line {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.anno-line > select {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.anno-lbl {
|
||
font-size: 12px;
|
||
color: var(--ui-muted, #64748b);
|
||
white-space: nowrap;
|
||
}
|
||
.anno-line > input[type="range"] {
|
||
flex: 1;
|
||
}
|
||
</style>
|
||
|