- Logo 渐变从粉紫改为橙/粉/蓝蜡笔色,快门圆点改橙色 - 批量替换 accent:violet→orange、fuchsia→rose、indigo→sky、purple→orange, 十六进制与 rgb 字面量同步换算为 Tailwind 对应色 - 修复类名合并冲突:管理员筛选 pill 改 fp-orange、会员号改 fp-pink(新增), style.css 中去重 fp-rose/fp-orange 并补 fp-pink 的浅色模式变体 - 选区色、标签输入框、筛选 pill、图表、滑块等所有紫色残留全部归位 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
442 lines
19 KiB
Vue
442 lines
19 KiB
Vue
<script setup>
|
||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { api, generatedUrl, thumbUrl } from '../api'
|
||
import { fmtTs } from '../utils/format'
|
||
import { copyText } from '../utils/clipboard'
|
||
import { zipSync } from 'fflate'
|
||
import Icon from '../components/Icon.vue'
|
||
import MediaLightbox from '../components/MediaLightbox.vue'
|
||
|
||
const router = useRouter()
|
||
|
||
const items = ref([])
|
||
const total = ref(0)
|
||
const loading = ref(false)
|
||
const kindFilter = ref('') // '', 'image', 'video'
|
||
const search = ref('')
|
||
const page = ref(1)
|
||
// 20 per page so a 4-col (lg) / 5-col (xl) grid lays out as clean rows,
|
||
// matching the admin 图片管理 (ImagesView) page.
|
||
const pageSize = 20
|
||
let timer = null
|
||
|
||
async function load() {
|
||
loading.value = true
|
||
// Server-side pagination over real media only: status=success + has_file=1
|
||
// makes the row count == displayable count, so the numbered pager is accurate.
|
||
// Failed/pending/file-pruned rows live in admin /admin/logs, never here.
|
||
const qs = new URLSearchParams({
|
||
limit: String(pageSize),
|
||
offset: String((page.value - 1) * pageSize),
|
||
status: 'success',
|
||
has_file: '1',
|
||
source: 'user', // 创作记录 = 画图台作品;排除 API(v1,无存储文件)+ 测试
|
||
})
|
||
if (kindFilter.value) qs.set('kind', kindFilter.value)
|
||
if (search.value.trim()) qs.set('q', search.value.trim())
|
||
const r = await api('/logs?' + qs.toString())
|
||
items.value = (r.data?.data || []).filter((e) => e.status === 'success' && e.file)
|
||
total.value = Number(r.data?.total ?? items.value.length)
|
||
loading.value = false
|
||
}
|
||
|
||
// 搜索走服务端(跨页),直接展示服务端返回的当页结果。
|
||
const filtered = computed(() => items.value)
|
||
function doSearch() { page.value = 1; load() }
|
||
|
||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
|
||
function setKind(v) { kindFilter.value = v; page.value = 1; load() }
|
||
function goPage(n) {
|
||
const target = Math.max(1, Math.min(totalPages.value, n))
|
||
if (target === page.value) return
|
||
page.value = target
|
||
load()
|
||
}
|
||
const pageNumbers = computed(() => {
|
||
const n = totalPages.value
|
||
const cur = page.value
|
||
if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1)
|
||
const want = new Set([1, n, cur - 1, cur, cur + 1])
|
||
if (cur <= 3) { want.add(2); want.add(3); want.add(4) }
|
||
if (cur >= n - 2) { want.add(n - 1); want.add(n - 2); want.add(n - 3) }
|
||
const list = [...want].filter((x) => x >= 1 && x <= n).sort((a, b) => a - b)
|
||
const out = []
|
||
for (let i = 0; i < list.length; i++) {
|
||
if (i > 0 && list[i] - list[i - 1] > 1) out.push(null)
|
||
out.push(list[i])
|
||
}
|
||
return out
|
||
})
|
||
|
||
function fmtMs(ms) {
|
||
if (!ms) return ''
|
||
if (ms < 1000) return ms + 'ms'
|
||
return (ms / 1000).toFixed(1) + 's'
|
||
}
|
||
|
||
|
||
async function copyLink(name) {
|
||
const u = generatedUrl(name)
|
||
const ok = await copyText(u.startsWith('http') ? u : location.origin + u)
|
||
toast.value = ok ? '链接已复制' : '复制失败'
|
||
setTimeout(() => (toast.value = ''), 1500)
|
||
}
|
||
|
||
async function copyImage(url) {
|
||
try {
|
||
const blob = await (await fetch(url)).blob()
|
||
const pngBlob = blob.type === 'image/png'
|
||
? blob
|
||
: await new Promise((resolve, reject) => {
|
||
createImageBitmap(blob).then((bitmap) => {
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = bitmap.width
|
||
canvas.height = bitmap.height
|
||
const ctx = canvas.getContext('2d')
|
||
if (!ctx) { reject(new Error('no canvas ctx')); return }
|
||
ctx.drawImage(bitmap, 0, 0)
|
||
canvas.toBlob((out) => {
|
||
if (out) resolve(out)
|
||
else reject(new Error('png convert failed'))
|
||
}, 'image/png')
|
||
}).catch(reject)
|
||
})
|
||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
|
||
toast.value = '图片已复制'
|
||
} catch {
|
||
toast.value = '复制失败'
|
||
}
|
||
setTimeout(() => (toast.value = ''), 1500)
|
||
}
|
||
|
||
async function copyPrompt(e) {
|
||
if (!e.prompt) return
|
||
const ok = await copyText(e.prompt)
|
||
toast.value = ok ? '指令已复制' : '复制失败'
|
||
setTimeout(() => (toast.value = ''), 1500)
|
||
}
|
||
|
||
const toast = ref('')
|
||
|
||
// Delete one of my works (file + thumb server-side), then reload the page so
|
||
// pagination stays accurate.
|
||
async function deleteEntry(e) {
|
||
if (!e || !e.file) return
|
||
if (!confirm('确定删除这个作品?删除后不可恢复')) return
|
||
const r = await api('/my-files?file=' + encodeURIComponent(e.file), { method: 'DELETE' })
|
||
toast.value = r.ok ? '已删除' : (r.data?.detail || '删除失败')
|
||
setTimeout(() => (toast.value = ''), 1500)
|
||
if (r.ok) load()
|
||
}
|
||
// multi-select (keyed by file path) — bulk delete/download from the filter bar.
|
||
const picked = ref(new Set())
|
||
function togglePick(e) {
|
||
if (!e.file) return
|
||
const s = new Set(picked.value)
|
||
s.has(e.file) ? s.delete(e.file) : s.add(e.file)
|
||
picked.value = s
|
||
}
|
||
const pageAllPicked = computed(() => {
|
||
const files = filtered.value.filter((e) => e.status === 'success' && e.file)
|
||
return files.length > 0 && files.every((e) => picked.value.has(e.file))
|
||
})
|
||
function togglePickAll() {
|
||
const s = new Set(picked.value)
|
||
const files = filtered.value.filter((e) => e.status === 'success' && e.file)
|
||
if (pageAllPicked.value) files.forEach((e) => s.delete(e.file))
|
||
else files.forEach((e) => s.add(e.file))
|
||
picked.value = s
|
||
}
|
||
async function deletePicked() {
|
||
const files = [...picked.value]
|
||
if (!files.length) return
|
||
if (!confirm(`确定删除选中的 ${files.length} 个作品?删除后不可恢复`)) return
|
||
let ok = 0
|
||
for (const f of files) {
|
||
const r = await api('/my-files?file=' + encodeURIComponent(f), { method: 'DELETE' })
|
||
if (r.ok) ok++
|
||
}
|
||
picked.value = new Set()
|
||
toast.value = `已删除 ${ok} 个`
|
||
setTimeout(() => (toast.value = ''), 1500)
|
||
load()
|
||
}
|
||
// Single pick → direct file download; multiple → bundle into one zip.
|
||
const zipping = ref(false)
|
||
async function downloadPicked() {
|
||
const files = [...picked.value]
|
||
if (!files.length) return
|
||
if (files.length === 1) {
|
||
const a = document.createElement('a')
|
||
a.href = generatedUrl(files[0])
|
||
a.download = files[0].split('/').pop()
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
a.remove()
|
||
return
|
||
}
|
||
zipping.value = true
|
||
toast.value = '打包中…'
|
||
try {
|
||
// Fetch concurrently (10 at a time) so large batches pack fast.
|
||
const bufs = []
|
||
let next = 0
|
||
await Promise.all(Array.from({ length: Math.min(10, files.length) }, async () => {
|
||
while (next < files.length) {
|
||
const i = next++
|
||
bufs[i] = await (await fetch(generatedUrl(files[i]))).arrayBuffer()
|
||
}
|
||
}))
|
||
const entries = {}
|
||
files.forEach((f, i) => {
|
||
let name = f.split('/').pop()
|
||
while (entries[name]) name = '_' + name
|
||
entries[name] = [new Uint8Array(bufs[i]), { level: 0 }]
|
||
})
|
||
const zipped = zipSync(entries)
|
||
const url = URL.createObjectURL(new Blob([zipped], { type: 'application/zip' }))
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `作品-${files.length}个-${Date.now()}.zip`
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
a.remove()
|
||
setTimeout(() => URL.revokeObjectURL(url), 30000)
|
||
toast.value = '已打包下载'
|
||
} catch {
|
||
toast.value = '打包失败'
|
||
}
|
||
zipping.value = false
|
||
setTimeout(() => (toast.value = ''), 1500)
|
||
}
|
||
|
||
const lightbox = ref(null)
|
||
// Videos whose first-frame thumbnail is missing (old videos) — fall back to
|
||
// the muted <video> preview for those cards.
|
||
const thumbFail = reactive({})
|
||
function onKey(e) { if (e.key === 'Escape') lightbox.value = null }
|
||
|
||
onMounted(() => {
|
||
load()
|
||
timer = setInterval(load, 3000)
|
||
window.addEventListener('keydown', onKey)
|
||
})
|
||
onUnmounted(() => {
|
||
clearInterval(timer)
|
||
window.removeEventListener('keydown', onKey)
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<section class="space-y-5">
|
||
<!-- Header -->
|
||
<div class="flex items-end justify-between flex-wrap gap-3">
|
||
<div>
|
||
<h1 class="text-2xl font-semibold tracking-tight text-slate-900">我的创作记录</h1>
|
||
<p class="text-sm text-slate-500 mt-1">
|
||
{{ total }} 条作品
|
||
</p>
|
||
</div>
|
||
<button @click="router.push('/user')" class="btn-primary">
|
||
<Icon name="spark" class="w-4 h-4" /> 去画图
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Filter bar -->
|
||
<div class="card p-3 flex items-center gap-3 flex-wrap">
|
||
<div class="flex items-center gap-1.5">
|
||
<button @click="setKind('')" class="text-xs rounded-lg px-2.5 py-1.5 transition-colors"
|
||
:class="kindFilter === '' ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">全部</button>
|
||
<button @click="setKind('image')" class="text-xs rounded-lg px-2.5 py-1.5 transition-colors"
|
||
:class="kindFilter === 'image' ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">图像</button>
|
||
<button @click="setKind('video')" class="text-xs rounded-lg px-2.5 py-1.5 transition-colors"
|
||
:class="kindFilter === 'video' ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">视频</button>
|
||
</div>
|
||
<div class="flex-1 min-w-[180px]">
|
||
<input v-model="search" @keyup.enter="doSearch" @change="doSearch"
|
||
class="field !py-1.5 text-xs" placeholder="搜索提示词或模型…" />
|
||
</div>
|
||
<button @click="togglePickAll" class="text-xs rounded-lg px-2.5 py-1.5 transition-colors inline-flex items-center gap-1"
|
||
:class="pageAllPicked ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">
|
||
<Icon name="check" class="w-3.5 h-3.5" /> 全选本页
|
||
</button>
|
||
<template v-if="picked.size">
|
||
<button @click="downloadPicked" :disabled="zipping" class="text-xs rounded-lg px-2.5 py-1.5 bg-slate-900 text-white hover:bg-slate-700 inline-flex items-center gap-1 disabled:opacity-50">
|
||
<Icon name="download" class="w-3.5 h-3.5" /> {{ zipping ? '打包中…' : `下载选中 (${picked.size})` }}
|
||
</button>
|
||
<button @click="deletePicked" class="text-xs rounded-lg px-2.5 py-1.5 bg-rose-600 text-white hover:bg-rose-500 inline-flex items-center gap-1">
|
||
<Icon name="trash" class="w-3.5 h-3.5" /> 删除选中 ({{ picked.size }})
|
||
</button>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Empty -->
|
||
<div v-if="loading && !items.length" class="card text-center text-sm text-slate-400 py-24">加载中…</div>
|
||
<div v-else-if="!filtered.length"
|
||
class="anime-card relative overflow-hidden flex flex-col items-center gap-3 text-[color:var(--fg-3)] py-20">
|
||
<div class="anime-stars"></div>
|
||
<span class="empty-badge">✦</span>
|
||
<span class="text-sm">还没有创作记录,画一张试试吧~</span>
|
||
<button @click="router.push('/user')" class="bubble-btn mt-1">开始第一张 ✦</button>
|
||
</div>
|
||
|
||
<!-- Cards — gallery layout, matching 图片管理 (ImagesView) -->
|
||
<div v-else class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
|
||
<div v-for="e in filtered" :key="e.id"
|
||
class="group relative rounded-xl overflow-hidden ring-1 ring-slate-200 bg-slate-100 aspect-[4/5]"
|
||
:class="(e.status === 'success' && e.file) && 'cursor-zoom-in'"
|
||
@click="(e.status === 'success' && e.file) && (lightbox = e)">
|
||
<!-- media -->
|
||
<template v-if="e.status === 'success' && e.file">
|
||
<!-- first-frame still as background-image (same as image cards); the
|
||
hidden probe img flips to the <video> fallback for old videos. -->
|
||
<div v-if="e.kind === 'video' && !thumbFail[e.id]" :style="{ backgroundImage: `url(${thumbUrl(e.file)})` }"
|
||
class="absolute inset-0 w-full h-full bg-cover bg-center transition-transform duration-300 group-hover:scale-105">
|
||
<img :src="thumbUrl(e.file)" class="hidden" @error="thumbFail[e.id] = true" />
|
||
</div>
|
||
<video v-else-if="e.kind === 'video'" :src="generatedUrl(e.file)" muted loop preload="metadata"
|
||
class="absolute inset-0 w-full h-full object-cover"
|
||
@mouseenter="$event.target.play && $event.target.play()"
|
||
@mouseleave="$event.target.pause && $event.target.pause()" />
|
||
<!-- background-image (not <img>) so Edge shows no 视觉搜索 overlay icon. -->
|
||
<div v-else :style="{ backgroundImage: `url(${thumbUrl(e.file)})` }"
|
||
class="absolute inset-0 w-full h-full bg-cover bg-center transition-transform duration-300 group-hover:scale-105"></div>
|
||
<div class="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/85 via-black/40 to-transparent pointer-events-none"></div>
|
||
</template>
|
||
<!-- pending / failed placeholders -->
|
||
<div v-else-if="e.status === 'pending'" class="absolute inset-0 grid place-items-center text-slate-400 text-xs">
|
||
<div class="flex flex-col items-center gap-2">
|
||
<span class="w-10 h-10 rounded-xl bg-white grid place-items-center animate-pulse"><Icon name="spark" class="w-4 h-4" /></span>
|
||
生成中…
|
||
</div>
|
||
</div>
|
||
<div v-else class="absolute inset-0 grid place-items-center text-rose-500 text-xs px-4 text-center">
|
||
<div>
|
||
<Icon name="close" class="w-6 h-6 mx-auto mb-2 opacity-60" />
|
||
<div>生成失败</div>
|
||
<div v-if="e.error" class="text-[10px] text-rose-400 line-clamp-2 mt-1">{{ e.error }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- select + kind chip -->
|
||
<div class="absolute top-3 left-3 flex items-center gap-1.5">
|
||
<button v-if="e.status === 'success' && e.file" @click.stop.prevent="togglePick(e)"
|
||
:title="picked.has(e.file) ? '取消选择' : '选择'"
|
||
class="pick" :class="picked.has(e.file) && 'pick-on'">
|
||
<Icon name="check" class="w-3 h-3" />
|
||
</button>
|
||
<span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ring-1"
|
||
:class="e.kind === 'video' ? 'bg-rose-500/20 text-rose-200 ring-rose-400/30' : 'bg-sky-500/20 text-sky-200 ring-sky-400/30'">
|
||
{{ e.kind === 'video' ? '视频' : '图像' }}
|
||
</span>
|
||
</div>
|
||
|
||
<!-- hover actions (only when there's a file) -->
|
||
<div v-if="e.status === 'success' && e.file"
|
||
class="absolute top-3 right-3 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||
<button v-if="e.kind !== 'video'" @click.stop.prevent="copyImage(generatedUrl(e.file))" title="复制图片"
|
||
class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-black/70 text-white grid place-items-center">
|
||
<Icon name="copy" class="w-3.5 h-3.5" />
|
||
</button>
|
||
<a :href="generatedUrl(e.file)" :download="e.file.split('/').pop()" @click.stop title="下载"
|
||
class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-black/70 text-white grid place-items-center">
|
||
<Icon name="download" class="w-3.5 h-3.5" />
|
||
</a>
|
||
<button @click.stop.prevent="deleteEntry(e)" title="删除"
|
||
class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-rose-600/80 text-white grid place-items-center">
|
||
<Icon name="trash" class="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
|
||
<!-- caption (over a real image) -->
|
||
<div v-if="e.status === 'success' && e.file" class="absolute inset-x-0 bottom-0 p-3 pointer-events-none">
|
||
<div class="text-[12px] leading-tight text-white font-medium line-clamp-2 mb-1 transition-colors"
|
||
:class="e.prompt ? 'pointer-events-auto cursor-pointer hover:text-white/75' : ''"
|
||
:title="e.prompt ? '点击复制提示词' : ''"
|
||
@click.stop="copyPrompt(e)">{{ e.prompt }}</div>
|
||
<div class="text-[10px] text-white/55 flex items-center justify-between gap-2 tabular-nums">
|
||
<span class="break-all" :title="e.model || ''">{{ e.model || '—' }}</span>
|
||
<span class="shrink-0 flex items-center gap-1">
|
||
<span v-if="e.resolution" class="text-emerald-300/90">{{ e.resolution }}</span>
|
||
<span v-if="e.ratio" class="text-white/40">{{ e.ratio }}</span>
|
||
<span v-if="e.kind === 'video' && e.duration" class="text-rose-300/80">{{ e.duration }}</span>
|
||
<span v-if="e.deai" class="text-[#ec5b13]">去AI</span>
|
||
</span>
|
||
</div>
|
||
<div class="text-[10px] text-white/35 mt-0.5 tabular-nums">{{ fmtTs(e.ts) }}<span v-if="e.elapsed_ms"> · {{ fmtMs(e.elapsed_ms) }}</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Pagination — its own card, exactly like 图片管理 (ImagesView) -->
|
||
<div v-if="total && totalPages > 1" class="card !p-3 flex items-center justify-between gap-3">
|
||
<div class="text-xs text-slate-500 tabular-nums px-2">
|
||
<span class="text-slate-700">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(total, page * pageSize) }}</span>
|
||
/ {{ total }} 张
|
||
</div>
|
||
<div class="flex items-center gap-1">
|
||
<template v-for="(n, i) in pageNumbers" :key="i">
|
||
<span v-if="n === null" class="px-1 text-slate-300">…</span>
|
||
<button v-else @click="goPage(n)" class="pg" :class="page === n && 'pg-on'">{{ n }}</button>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Lightbox (shared component) -->
|
||
<MediaLightbox
|
||
v-if="lightbox"
|
||
:src="generatedUrl(lightbox.file)"
|
||
:kind="lightbox.kind"
|
||
:prompt="lightbox.prompt"
|
||
:meta="[lightbox.model, lightbox.ratio, lightbox.resolution, lightbox.duration, (lightbox.deai ? '去AI特征' : ''), fmtMs(lightbox.elapsed_ms)].filter(Boolean).join(' · ')"
|
||
:download-name="lightbox.file"
|
||
@close="lightbox = null" />
|
||
|
||
<!-- Toast -->
|
||
<transition name="fade">
|
||
<div v-if="toast"
|
||
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-[60] bg-slate-900 text-white text-xs px-4 py-2 rounded-lg shadow-lg">
|
||
{{ toast }}
|
||
</div>
|
||
</transition>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; }
|
||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||
|
||
/* Numbered pagination buttons — light-theme twin of the admin .pg */
|
||
.pg {
|
||
min-width: 1.75rem;
|
||
padding: 0.3rem 0.55rem;
|
||
font-size: 0.72rem;
|
||
font-weight: 500;
|
||
text-align: center;
|
||
border-radius: 0.45rem;
|
||
color: rgb(71 85 105);
|
||
background: rgb(241 245 249);
|
||
box-shadow: inset 0 0 0 1px rgb(15 23 42 / 0.06);
|
||
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
|
||
}
|
||
.pg:hover:not(.pg-on) { background: rgb(226 232 240); color: rgb(15 23 42); }
|
||
.pg-on { background: rgb(15 23 42); color: white; box-shadow: none; }
|
||
|
||
/* card select toggle — always visible rounded-square check button */
|
||
.pick {
|
||
width: 1.4rem; height: 1.4rem; border-radius: 0.375rem;
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
color: rgb(255 255 255 / 0.85);
|
||
background: rgb(0 0 0 / 0.45);
|
||
box-shadow: inset 0 0 0 1.5px rgb(255 255 255 / 0.75);
|
||
transition: background 0.15s, box-shadow 0.15s;
|
||
}
|
||
.pick svg { opacity: 0; transition: opacity 0.15s; }
|
||
.pick:hover { background: rgb(0 0 0 / 0.65); }
|
||
.pick:hover svg { opacity: 0.6; }
|
||
.pick-on { background: rgb(244 63 94); box-shadow: inset 0 0 0 1.5px rgb(255 255 255 / 0.9); }
|
||
.pick-on svg { opacity: 1; }
|
||
</style>
|