新增: u-ppt Vue 3 在线演示工具初始版本
- 核心架构: Vue 3 + Vite + TypeScript,零运行时依赖(仅 Vue) - 编辑器: 幻灯片增删排序、元素拖拽八向缩放、双击编辑、12 种元素类型 (标题/正文/列表/数据/金句/图片/形状/图表/卡片/表格/代码/公式) - 图表: 8 种 SVG 自绘(柱状/条形/折线/面积/饼图/环形/雷达/进度) - 富文本: 结构化 segments(加粗/斜体/颜色/高亮/上下标/代码/链接) - AI 能力: 对话编辑、生成整套、润色本页、大纲→逐页生成、一键美化、AI 配图 - 会话绑定: 每份 PPT 独立会话,切换 PPT 自动切换对话历史 - 模板系统: 7 个内置版式 + 用户自存模板 - 演示模式: 全屏播放、键盘/鼠标/滚轮导航、入场动画 - 持久化: localStorage 存 deck/文库/会话/模板/配置 - 支持多服务商: 智谱/DeepSeek/通义/Kimi/豆包/OpenAI/Anthropic/Gemini/Groq/Ollama
This commit is contained in:
154
src/components/modals/LibraryModal.vue
Normal file
154
src/components/modals/LibraryModal.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<!-- =====================================================================
|
||||
LibraryModal.vue — 演示文库弹窗
|
||||
===================================================================== -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { LibItem, Slide } from '../../core/types'
|
||||
import { store, resolveBg } from '../../core/store'
|
||||
import ElementView from '../editor/ElementView.vue'
|
||||
|
||||
const props = defineProps<{ visible: boolean }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'toast', msg: string): void
|
||||
(e: 'switch-tab', tab: string): void
|
||||
}>()
|
||||
|
||||
const libVersion = ref(0)
|
||||
const nameInput = ref('')
|
||||
|
||||
/** 读文库列表(非响应式,靠 libVersion 触发重算) */
|
||||
const library = computed<LibItem[]>(() => {
|
||||
void libVersion.value
|
||||
return store.getLibrary()
|
||||
})
|
||||
const activeId = computed(() => {
|
||||
void libVersion.value
|
||||
return store.getActiveLibId()
|
||||
})
|
||||
|
||||
const libCount = computed(() => library.value.length)
|
||||
const placeholder = computed(() => {
|
||||
const cur = library.value.find(x => x.id === activeId.value)
|
||||
return cur
|
||||
? '当前:「' + cur.name + '」,留空同名覆盖,输入新名另存为新份'
|
||||
: '为当前演示命名后存入文库…'
|
||||
})
|
||||
|
||||
function bump() { libVersion.value++ }
|
||||
|
||||
watch(() => props.visible, (v) => {
|
||||
if (v) { bump(); nameInput.value = '' }
|
||||
})
|
||||
|
||||
function toast(msg: string) { emit('toast', msg) }
|
||||
|
||||
function firstSlide(item: LibItem): Slide | null {
|
||||
const s = item?.deck?.slides
|
||||
return s && s.length ? s[0] : null
|
||||
}
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
if (!ts) return ''
|
||||
const diff = Date.now() - ts
|
||||
if (diff < 60000) return '刚刚'
|
||||
if (diff < 3600000) return Math.floor(diff / 60000) + ' 分钟前'
|
||||
if (diff < 86400000) return Math.floor(diff / 3600000) + ' 小时前'
|
||||
const d = new Date(ts)
|
||||
return (d.getMonth() + 1) + '月' + d.getDate() + '日'
|
||||
}
|
||||
|
||||
function onNameKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); onSave() }
|
||||
}
|
||||
|
||||
function onSave() {
|
||||
const name = nameInput.value.trim()
|
||||
if (!name && !store.getActiveLibId()) {
|
||||
toast('请输入名称')
|
||||
return
|
||||
}
|
||||
store.saveToLibrary(name || null)
|
||||
toast(name ? ('已存入文库:' + name) : '已更新到文库')
|
||||
nameInput.value = ''
|
||||
bump()
|
||||
}
|
||||
|
||||
function onNewBlank() {
|
||||
store.newBlankDeck()
|
||||
emit('switch-tab', 'props')
|
||||
toast('已新建空白演示,在右侧添加元素')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function onOpen(id: string) {
|
||||
if (store.loadFromLibrary(id)) {
|
||||
toast('已打开')
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
function onRename(id: string) {
|
||||
const cur = store.getLibrary().find(x => x.id === id)
|
||||
const name = prompt('重命名为', cur ? cur.name : '')
|
||||
if (name != null && name.trim()) {
|
||||
store.renameInLibrary(id, name.trim())
|
||||
bump()
|
||||
}
|
||||
}
|
||||
|
||||
function onDuplicate(id: string) {
|
||||
store.duplicateInLibrary(id)
|
||||
toast('已复制')
|
||||
bump()
|
||||
}
|
||||
|
||||
function onDelete(id: string) {
|
||||
if (confirm('删除这份演示?此操作不可撤销。')) {
|
||||
store.deleteFromLibrary(id)
|
||||
toast('已删除')
|
||||
bump()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal-mask lib-modal-mask" :class="{ hidden: !visible }" @click.self="emit('close')">
|
||||
<div class="modal lib-modal">
|
||||
<h3>演示文库</h3>
|
||||
<p class="modal-tip">保存多套演示文稿到本地,随时切换。</p>
|
||||
|
||||
<div class="lib-save-row">
|
||||
<input type="text" v-model="nameInput" :placeholder="placeholder" @keydown="onNameKeydown" />
|
||||
<button class="btn primary" @click="onSave">存入文库</button>
|
||||
<button class="btn" @click="onNewBlank">新建空白</button>
|
||||
</div>
|
||||
|
||||
<div class="lib-hint">共 {{ libCount }} 份</div>
|
||||
|
||||
<div class="lib-list">
|
||||
<div v-for="it in library" :key="it.id" class="lib-item" :class="{ active: it.id === activeId }" :data-id="it.id">
|
||||
<div class="lib-thumb" :style="{ background: firstSlide(it) ? resolveBg(firstSlide(it)!.background) : '#fff' }">
|
||||
<div v-if="firstSlide(it)" style="position:absolute;top:0;left:0;width:1280px;height:720px;transform:scale(0.05625);transform-origin:top left;pointer-events:none">
|
||||
<ElementView v-for="el in firstSlide(it)!.elements" :key="el.id" :el="el" :bg="firstSlide(it)!.background" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="lib-info">
|
||||
<div class="lib-name">{{ it.name }}<span v-if="it.id === activeId"> (当前)</span></div>
|
||||
<div class="lib-meta">{{ (it.deck && it.deck.slides ? it.deck.slides.length : 0) + ' 页 · ' + formatTime(it.updatedAt || it.createdAt) }}</div>
|
||||
</div>
|
||||
<div class="lib-ops">
|
||||
<button class="btn" @click="onOpen(it.id)">打开</button>
|
||||
<button class="btn" @click="onRename(it.id)">重命名</button>
|
||||
<button class="btn" @click="onDuplicate(it.id)">复制</button>
|
||||
<button class="btn danger" @click="onDelete(it.id)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn" @click="emit('close')">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
192
src/components/modals/SettingsModal.vue
Normal file
192
src/components/modals/SettingsModal.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<!-- =====================================================================
|
||||
SettingsModal.vue — AI 设置弹窗
|
||||
===================================================================== -->
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import type { AiCfg } from '../../core/types'
|
||||
import { store } from '../../core/store'
|
||||
|
||||
const props = defineProps<{ visible: boolean }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'toast', msg: string): void
|
||||
}>()
|
||||
|
||||
const PRESETS: Record<string, { protocol: 'openai' | 'anthropic'; base: string; model: string; label: string }> = {
|
||||
zhipu: { protocol: 'openai', base: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4.6', label: '智谱 GLM (OpenAI 协议)' },
|
||||
zhipu_anth: { protocol: 'anthropic', base: 'https://open.bigmodel.cn/api/anthropic', model: 'glm-4.6', label: '智谱 GLM (Anthropic 协议)' },
|
||||
deepseek: { protocol: 'openai', base: 'https://api.deepseek.com', model: 'deepseek-chat', label: 'DeepSeek' },
|
||||
qwen: { protocol: 'openai', base: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus', label: '通义千问' },
|
||||
kimi: { protocol: 'openai', base: 'https://api.moonshot.cn/v1', model: 'moonshot-v1-8k', label: 'Kimi' },
|
||||
doubao: { protocol: 'openai', base: 'https://ark.cn-beijing.volces.com/api/v3', model: 'doubao-pro-32k', label: '豆包' },
|
||||
openai: { protocol: 'openai', base: 'https://api.openai.com/v1', model: 'gpt-4o-mini', label: 'OpenAI' },
|
||||
anthropic: { protocol: 'anthropic', base: 'https://api.anthropic.com', model: 'claude-sonnet-5', label: 'Anthropic' },
|
||||
gemini: { protocol: 'openai', base: 'https://generativelanguage.googleapis.com/v1beta/openai', model: 'gemini-2.0-flash', label: 'Gemini' },
|
||||
groq: { protocol: 'openai', base: 'https://api.groq.com/openai/v1', model: 'llama-3.3-70b-versatile', label: 'Groq' },
|
||||
ollama: { protocol: 'openai', base: 'http://localhost:11434/v1', model: 'llama3.1', label: 'Ollama (本地)' }
|
||||
}
|
||||
|
||||
const PROTO_DEFAULTS: Record<string, { base: string; model: string }> = {
|
||||
openai: { base: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4.6' },
|
||||
anthropic: { base: 'https://open.bigmodel.cn/api/anthropic', model: 'glm-4.6' }
|
||||
}
|
||||
|
||||
const form = ref<AiCfg>({
|
||||
preset: 'zhipu', protocol: 'openai',
|
||||
base: '', key: '', model: '', proxy: '',
|
||||
imgBase: '', imgKey: '', imgModel: ''
|
||||
})
|
||||
|
||||
/** 从 store 读取并填充表单 */
|
||||
function loadFromStore() {
|
||||
const c = store.getCfg()
|
||||
form.value = {
|
||||
preset: PRESETS[c.preset] ? c.preset : 'custom',
|
||||
protocol: c.protocol || 'openai',
|
||||
base: c.base, key: c.key, model: c.model, proxy: c.proxy,
|
||||
imgBase: c.imgBase || '',
|
||||
imgKey: c.imgKey || '',
|
||||
imgModel: c.imgModel || ''
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.visible, (v) => {
|
||||
if (v) loadFromStore()
|
||||
}, { immediate: true })
|
||||
|
||||
function applyPreset(key: string) {
|
||||
const p = PRESETS[key]
|
||||
if (!p) return
|
||||
form.value.protocol = p.protocol
|
||||
form.value.base = p.base
|
||||
form.value.model = p.model
|
||||
}
|
||||
|
||||
function onProviderChange() {
|
||||
if (form.value.preset !== 'custom') applyPreset(form.value.preset)
|
||||
}
|
||||
|
||||
function applyProtoDefaults(p: string) {
|
||||
const d = PROTO_DEFAULTS[p] || PROTO_DEFAULTS.openai
|
||||
const other = PROTO_DEFAULTS[p === 'openai' ? 'anthropic' : 'openai']
|
||||
if (!form.value.base.trim() || form.value.base.trim() === other.base) form.value.base = d.base
|
||||
if (!form.value.model.trim() || form.value.model.trim() === other.model) form.value.model = d.model
|
||||
}
|
||||
|
||||
function onProtocolChange() {
|
||||
applyProtoDefaults(form.value.protocol)
|
||||
}
|
||||
|
||||
function save() {
|
||||
store.setCfg({
|
||||
preset: form.value.preset,
|
||||
protocol: form.value.protocol,
|
||||
base: form.value.base.trim(),
|
||||
key: form.value.key.trim(),
|
||||
model: form.value.model.trim(),
|
||||
proxy: form.value.proxy.trim(),
|
||||
imgBase: (form.value.imgBase || '').trim(),
|
||||
imgKey: (form.value.imgKey || '').trim(),
|
||||
imgModel: (form.value.imgModel || '').trim()
|
||||
})
|
||||
const label = form.value.preset === 'custom'
|
||||
? (form.value.protocol === 'anthropic' ? 'Anthropic' : 'OpenAI') + ' 自定义'
|
||||
: (PRESETS[form.value.preset]?.label || form.value.preset)
|
||||
emit('toast', '已保存 AI 设置(' + label + ')')
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal-mask" :class="{ hidden: !visible }" @click.self="emit('close')">
|
||||
<div class="modal">
|
||||
<h3>AI 设置</h3>
|
||||
<p class="modal-tip">配置大模型服务商。数据仅保存在本地浏览器。</p>
|
||||
|
||||
<div class="form-row">
|
||||
<label>服务商</label>
|
||||
<select v-model="form.preset" @change="onProviderChange">
|
||||
<optgroup label="国内">
|
||||
<option value="zhipu">智谱 GLM (OpenAI 协议)</option>
|
||||
<option value="zhipu_anth">智谱 GLM (Anthropic 协议)</option>
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
<option value="qwen">通义千问</option>
|
||||
<option value="kimi">Kimi</option>
|
||||
<option value="doubao">豆包</option>
|
||||
</optgroup>
|
||||
<optgroup label="海外">
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="gemini">Gemini</option>
|
||||
<option value="groq">Groq</option>
|
||||
</optgroup>
|
||||
<optgroup label="本地">
|
||||
<option value="ollama">Ollama</option>
|
||||
</optgroup>
|
||||
<option value="custom">自定义</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>API 协议</label>
|
||||
<select v-model="form.protocol" @change="onProtocolChange">
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>Base URL</label>
|
||||
<input type="text" v-model="form.base" placeholder="https://..." />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>API Key</label>
|
||||
<input type="password" v-model="form.key" placeholder="sk-..." autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>模型</label>
|
||||
<input type="text" v-model="form.model" placeholder="模型名称" />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>代理 URL(可选)</label>
|
||||
<input type="text" v-model="form.proxy" placeholder="留空则直连" />
|
||||
</div>
|
||||
|
||||
<div class="section-title">图像模型(可选,用于 AI 配图)</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>图像 Base URL</label>
|
||||
<input type="text" v-model="form.imgBase" placeholder="留空则复用上方 Base URL" />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>图像 API Key</label>
|
||||
<input type="password" v-model="form.imgKey" placeholder="留空则复用上方 Key" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>图像模型</label>
|
||||
<input type="text" v-model="form.imgModel" placeholder="dall-e-3" />
|
||||
</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);
|
||||
}
|
||||
</style>
|
||||
189
src/components/modals/TemplateModal.vue
Normal file
189
src/components/modals/TemplateModal.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<!-- =====================================================================
|
||||
TemplateModal.vue — 页面模板:基于模板新建页 + 管理自存模板
|
||||
===================================================================== -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { store, resolveBg } from '../../core/store'
|
||||
import type { PageTemplate } from '../../core/types'
|
||||
import ElementView from '../editor/ElementView.vue'
|
||||
|
||||
defineProps<{ visible: boolean }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'toast', msg: string): void
|
||||
}>()
|
||||
|
||||
/* ---------- 存当前页为模板 ---------- */
|
||||
const nameInput = ref('')
|
||||
|
||||
/* ---------- 非响应式列表的刷新机制(getTemplates 不返回响应式数据) ---------- */
|
||||
const tick = ref(0)
|
||||
const templates = computed(() => { tick.value; return store.getTemplates() })
|
||||
function bump() { tick.value++ }
|
||||
|
||||
function onSave() {
|
||||
const v = nameInput.value.trim()
|
||||
if (!v) { emit('toast', '请输入模板名'); return }
|
||||
// 空页存模板没意义
|
||||
const cur = store.currentSlide.value
|
||||
if (cur && cur.elements.length === 0) {
|
||||
emit('toast', '当前页为空,无法存为模板')
|
||||
return
|
||||
}
|
||||
store.saveCurrentAsTemplate(v)
|
||||
bump()
|
||||
nameInput.value = ''
|
||||
emit('toast', '已存为模板:' + v)
|
||||
}
|
||||
|
||||
/* ---------- 基于模板新建页 ---------- */
|
||||
function onPick(tpl: PageTemplate) {
|
||||
const ok = store.addSlideFromTemplate(tpl.id)
|
||||
if (ok) {
|
||||
emit('toast', '已基于「' + tpl.name + '」新建一页')
|
||||
emit('close')
|
||||
} else {
|
||||
emit('toast', '新建失败:找不到模板')
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 删除自存模板 ---------- */
|
||||
function onDelete(tpl: PageTemplate) {
|
||||
if (confirm('删除模板「' + tpl.name + '」?')) {
|
||||
store.deleteTemplate(tpl.id)
|
||||
bump()
|
||||
emit('toast', '已删除')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal-mask" :class="{ hidden: !visible }" @click.self="emit('close')">
|
||||
<div class="modal tpl-modal">
|
||||
<h3>📋 页面模板</h3>
|
||||
|
||||
<!-- 存当前页 -->
|
||||
<div class="tpl-save-bar">
|
||||
<input v-model="nameInput" placeholder="存当前页为模板,输入名称…" @keydown.enter="onSave" />
|
||||
<button class="btn primary" @click="onSave">💾 保存为模板</button>
|
||||
</div>
|
||||
|
||||
<!-- 模板网格 -->
|
||||
<div class="tpl-grid">
|
||||
<div v-for="tpl in templates" :key="tpl.id" class="tpl-card" :class="tpl.category" @click="onPick(tpl)">
|
||||
<div class="tpl-thumb" :style="{ background: resolveBg(tpl.background) }">
|
||||
<div class="tpl-thumb-layer">
|
||||
<ElementView v-for="el in tpl.elements" :key="el.id" :el="el" :bg="tpl.background" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="tpl-info">
|
||||
<span class="tpl-name">{{ tpl.name }}</span>
|
||||
<span class="tpl-badge" :class="tpl.category">{{ tpl.category === 'built-in' ? '内置' : '自存' }}</span>
|
||||
<button v-if="tpl.category === 'user'" class="tpl-del" title="删除" @click.stop="onDelete(tpl)">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn" @click="emit('close')">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tpl-modal {
|
||||
width: 720px;
|
||||
max-width: 92vw;
|
||||
max-height: 86vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tpl-save-bar {
|
||||
display: flex;
|
||||
gap: .5em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.tpl-save-bar input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tpl-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1em;
|
||||
overflow-y: auto;
|
||||
padding: .2em;
|
||||
}
|
||||
|
||||
.tpl-card {
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border: 2px solid rgba(100, 116, 139, .2);
|
||||
transition: border-color .15s, transform .15s;
|
||||
}
|
||||
|
||||
.tpl-card:hover {
|
||||
border-color: var(--primary, #4f46e5);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.tpl-thumb {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-top: 56.25%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tpl-thumb-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1280px;
|
||||
height: 720px;
|
||||
transform: scale(0.094); /* 约 120px 宽 */
|
||||
transform-origin: top left;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tpl-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .4em;
|
||||
padding: .5em .6em;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tpl-name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tpl-badge {
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(100, 116, 139, .15);
|
||||
color: var(--muted, #64748b);
|
||||
}
|
||||
|
||||
.tpl-badge.user {
|
||||
background: rgba(79, 70, 229, .12);
|
||||
color: var(--primary, #4f46e5);
|
||||
}
|
||||
|
||||
.tpl-del {
|
||||
font-size: 13px;
|
||||
opacity: .5;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.tpl-del:hover {
|
||||
opacity: 1;
|
||||
color: #e11d48;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user