feat: 画图台并发重构 + 品牌定制 + 用户备注 + provider/UI 多项修复

画图台(并发出图):
- 不再锁定 UI:点「生成」开独立任务,可连续多次并发
- 结果网格一行5个、最多10张,进行中/成功/失败状态回显,刷新保留进行中
- 生图张数 1/2/3/4,各自独立计费出卡
- 点图=参考图(单张替换/多张替换末位);首尾帧模型点视频=抓末帧设为首帧,否则放大
- /logs 新增 statuses=pending,success 服务端过滤(status IN 专用 SQL)

品牌定制(设置→网站):
- 自定义 Logo 图片 + 子标题(公开页头部 + 管理侧栏)
- 邮件验证码标题改用站点名:{title} 邮箱验证码

提示词复制:
- 去掉复制按钮,点提示词文字即复制(预览/后台日志/图片管理/画图记录),统一弹「指令已复制」
- 新增 utils/clipboard.js:execCommand 回退,非安全上下文(http/IP)也能复制

用户管理:列表加「备注」列,新建/编辑可填改备注(默认空)

provider 修复:
- grok 401 正确判死封号(markTokenFailure 漏了 grok 池)
- grok 视频支持 15s
- custom 上游报错去敏感(抹掉上游 URL/IP,改英文短描述)
- custom 去掉额度耗尽锁定:429/欠费当临时错误,账号保持 active

UI/其它:
- 展示位弹窗浅色主题适配(tab 选中高亮、输入框边框)— 主题变量 + 中心补丁
- 自定义模型:时长可填任意秒数 + 15s 预设
- 首页设置/卡密弹窗去固定高度与滚动条
- 顶部菜单「记录」→「图片」
- 下线 Flow provider(代码移除)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 01:11:22 +08:00
co-authored by Claude Opus 4.8
parent 3ef84bb9b3
commit 1137d0bd1d
35 changed files with 810 additions and 347 deletions
+1 -1
View File
@@ -182,7 +182,7 @@ const pageNumbers = computed(() => {
<span class="text-xs font-medium text-white/75">刚生成 {{ lastBatch.length }} 请复制保存</span>
<button @click="copyBatch" class="text-xs btn-soft"><Icon name="copy" class="w-3.5 h-3.5" /> 全部复制</button>
</div>
<div class="font-mono text-xs text-white/85 space-y-0.5 max-h-40 overflow-auto">
<div class="font-mono text-xs text-white/85 space-y-0.5">
<div v-for="code in lastBatch" :key="code">{{ code }}</div>
</div>
</div>
+15 -1
View File
@@ -37,12 +37,14 @@ async function saveMedia() {
}
// ---- site (branding shown across the app) ----
const siteForm = reactive({ title: '', qq: '', qq_link: '', qq_group: '', qq_group_link: '', email: '', shop: '' })
const siteForm = reactive({ title: '', logo: '', subtitle: '', qq: '', qq_link: '', qq_group: '', qq_group_link: '', email: '', shop: '' })
const siteBusy = ref(false); const siteSaved = ref(false)
async function loadSite() {
const r = await api('/settings/site')
if (r.ok && r.data) {
siteForm.title = r.data.title || ''
siteForm.logo = r.data.logo || ''
siteForm.subtitle = r.data.subtitle || ''
const c = r.data.contact || {}
siteForm.qq = c.qq || ''; siteForm.qq_link = c.qq_link || ''
siteForm.qq_group = c.qq_group || ''
@@ -54,10 +56,14 @@ async function saveSite() {
siteBusy.value = true; siteSaved.value = false
const r = await api('/settings/site', jsonBody('PUT', {
title: siteForm.title,
logo: siteForm.logo,
subtitle: siteForm.subtitle,
contact: { qq: siteForm.qq, qq_link: siteForm.qq_link, qq_group: siteForm.qq_group, qq_group_link: siteForm.qq_group_link, email: siteForm.email, shop: siteForm.shop },
}))
siteBusy.value = false
if (r.ok && r.data) {
site.logo = r.data.data?.logo ?? siteForm.logo.trim()
site.subtitle = r.data.data?.subtitle ?? siteForm.subtitle.trim()
// Mirror the change into the shared `site` store so every header /
// wordmark / tab title updates without a reload. The PUT response is
// nested ({ ok, data: { title } }) unlike the flat GET, so read the
@@ -212,6 +218,14 @@ onMounted(() => { loadSite(); loadReg(); loadSmtp(); loadCredits(); loadProxy();
<span><span class="lbl">网页主标题</span><span class="hint">显示在浏览器标签首页 Logo侧栏和登录卡上未设置时默认显示 "Vivid"</span></span>
<input v-model="siteForm.title" placeholder="Vivid" class="txt" />
</label>
<label class="row">
<span><span class="lbl">Logo 图片地址</span><span class="hint">侧栏 / 公开页头部显示的 Logo 图片 URL留空则用文字主标题</span></span>
<input v-model="siteForm.logo" placeholder="https://.../logo.png" class="txt" />
</label>
<label class="row">
<span><span class="lbl">子标题</span><span class="hint">主标题下方的副标题 / slogan,公开页展示留空则不显示</span></span>
<input v-model="siteForm.subtitle" placeholder="如:聚合顶级 AI 模型的生图生视频平台" class="txt" />
</label>
<label class="row">
<span><span class="lbl">联系 QQ</span><span class="hint">QQ (显示用)留空则不显示该项</span></span>
<input v-model="siteForm.qq" placeholder="1114639355" class="txt" />
+11 -8
View File
@@ -2,6 +2,7 @@
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { api, generatedUrl } from '../api'
import { fmtTs, fmtSize } from '../utils/format'
import { copyText } from '../utils/clipboard'
import Icon from '../components/Icon.vue'
import MediaLightbox from '../components/MediaLightbox.vue'
@@ -40,12 +41,12 @@ function absUrl(name) {
}
async function copyLink(name) {
try {
await navigator.clipboard.writeText(absUrl(name))
flash('链接已复制')
} catch {
flash('复制失败')
}
flash(await copyText(absUrl(name)) ? '链接已复制' : '复制失败')
}
async function copyPrompt(f) {
if (!f.prompt) return
flash(await copyText(f.prompt) ? '指令已复制' : '复制失败')
}
let toastTimer = null
@@ -173,8 +174,10 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
<!-- caption: prompt (truncated 2 lines) + meta line -->
<div 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"
:title="f.prompt || f.name">
<div class="text-[12px] leading-tight text-white font-medium line-clamp-2 mb-1 transition-colors"
:class="f.prompt ? 'pointer-events-auto cursor-pointer hover:text-white/75' : ''"
:title="f.prompt ? '点击复制提示词' : f.name"
@click.stop="copyPrompt(f)">
{{ f.prompt || f.name.split('/').pop() }}
</div>
<div class="text-[10px] text-white/55 flex items-center justify-between gap-2 tabular-nums">
+18 -1
View File
@@ -2,6 +2,7 @@
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { api } from '../api'
import { fmtTs, fmtDate, fmtClock } from '../utils/format'
import { copyText } from '../utils/clipboard'
import { generatedUrl } from '../api'
import Icon from '../components/Icon.vue'
import MediaLightbox from '../components/MediaLightbox.vue'
@@ -16,6 +17,14 @@ const search = ref('')
const page = ref(1)
const pageSize = ref(15)
const total = ref(0)
const toast = ref('')
let toastTimer = null
async function copyPrompt(e) {
if (!e.prompt) return
toast.value = (await copyText(e.prompt)) ? '指令已复制' : '复制失败'
clearTimeout(toastTimer)
toastTimer = setTimeout(() => (toast.value = ''), 1800)
}
async function load() {
loading.value = true
@@ -281,7 +290,10 @@ const sourcePill = (s) => ({
<!-- Prompt with error inline; the error reads as a follow-up rather
than wasting a whole column when there's nothing to show. -->
<td class="px-3 py-3.5 align-middle min-w-0">
<div class="text-xs text-white/80 truncate" :title="e.prompt">{{ e.prompt || '—' }}</div>
<div class="text-xs text-white/80 truncate transition-colors"
:class="e.prompt ? 'cursor-pointer hover:text-white' : ''"
:title="e.prompt ? '点击复制提示词' : ''"
@click="e.prompt && copyPrompt(e)">{{ e.prompt || '' }}</div>
<div v-if="e.error" class="mt-1 text-[11px] text-rose-300/85 truncate flex items-center gap-1.5" :title="e.error">
<Icon name="close" class="w-3 h-3 shrink-0" />
{{ e.error }}
@@ -337,6 +349,11 @@ const sourcePill = (s) => ({
:meta="[previewing.model, previewing.ratio, previewing.resolution, previewing.duration, fmtMs(previewing.elapsed_ms)].filter(Boolean).join(' · ')"
:download-name="previewing.file"
@close="closePreview" />
<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>
</section>
</template>
+28 -8
View File
@@ -37,6 +37,8 @@ async function refreshAll() {
const EMPTY_WINDOW = { total: 0, success: 0, failed: 0, pending: 0, image: 0, video: 0, api: 0, web: 0, spent: 0 }
const day = computed(() => dash.value?.day || EMPTY_WINDOW)
const week = computed(() => dash.value?.week || EMPTY_WINDOW)
// All-time persistent counters (stat_counters) — independent of log retention.
const lifetime = computed(() => dash.value?.lifetime || {})
const successRate = computed(() => (day.value.total ? Math.round((day.value.success / day.value.total) * 100) : 0))
// Direction vs the previous 24h (2448h ago) — a quiet day after a busy week is
@@ -54,7 +56,7 @@ const avg24hMs = computed(() => stats.value?.avg_elapsed_ms_24h ?? null)
// ---- range-toggled top-N analytics (both windows ship in the payload, so the
// 24h/7d switch is instant — no re-fetch) ----
const rangeLabel = computed(() => (range.value === 'week' ? '近 7 天' : '近 24h'))
const rangeLabel = computed(() => (range.value === 'week' ? '近 3 天' : '近 24h'))
const analytics = computed(() => dash.value?.analytics?.[range.value] || { models: [], failures: [], top_users: [] })
const modelUsage = computed(() => analytics.value.models || [])
const usageMax = computed(() => Math.max(1, ...modelUsage.value.map((m) => m.count)))
@@ -153,7 +155,7 @@ onUnmounted(() => clearInterval(timer))
</div>
<!-- ===== KPI strip ===== -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div class="grid grid-cols-2 lg:grid-cols-5 gap-3">
<!-- 用户 -->
<div class="card p-4">
<div class="flex items-center justify-between">
@@ -196,6 +198,24 @@ onUnmounted(() => clearInterval(timer))
</div>
</div>
<!-- 累计生成(全部 · 持久计数,不随日志清理变化) -->
<div class="card p-4">
<div class="flex items-center justify-between">
<span class="text-xs text-white/55">累计生成</span>
<span class="w-7 h-7 rounded-lg bg-indigo-500/15 text-indigo-300 grid place-items-center ring-1 ring-indigo-400/20">
<Icon name="overview" class="w-3.5 h-3.5" />
</span>
</div>
<div class="text-2xl font-semibold tabular-nums mt-2">{{ fmtInt(lifetime.total || 0) }}</div>
<div class="text-[11px] mt-1 flex flex-wrap gap-x-2">
<span class="text-emerald-300 tabular-nums">{{ lifetime.success || 0 }} 成功</span>
<span v-if="lifetime.failed" class="text-rose-300 tabular-nums">{{ lifetime.failed }} 失败</span>
</div>
<div class="text-[10px] text-white/40 mt-1 tabular-nums">
API {{ lifetime.api || 0 }} · {{ lifetime.image || 0 }} · {{ lifetime.video || 0 }}
</div>
</div>
<!-- 平均耗时 -->
<div class="card p-4">
<div class="flex items-center justify-between">
@@ -247,7 +267,7 @@ onUnmounted(() => clearInterval(timer))
<div class="card p-4">
<div class="text-xs text-white/55">活跃用户 · 24h</div>
<div class="text-xl font-semibold tabular-nums mt-2">{{ fmtInt(dau) }}</div>
<div class="text-[11px] text-white/45 mt-1"> 7 天累计生成 {{ fmtInt(week.total) }}</div>
<div class="text-[11px] text-white/45 mt-1"> 3 天累计生成 {{ fmtInt(week.total) }}</div>
</div>
</div>
@@ -342,7 +362,7 @@ onUnmounted(() => clearInterval(timer))
</div>
</div>
<div class="card">
<div class="card flex flex-col">
<div class="px-5 py-3 border-b border-white/[0.06] flex items-baseline justify-between">
<h2 class="text-sm font-semibold">24 小时生成趋势</h2>
<div class="text-[11px] text-white/45 flex items-center gap-3">
@@ -351,8 +371,8 @@ onUnmounted(() => clearInterval(timer))
<span class="tabular-nums">峰值 {{ hourMax }}/h</span>
</div>
</div>
<div class="p-5">
<div class="flex items-end gap-[3px] h-32">
<div class="p-5 flex-1 flex flex-col">
<div class="flex items-end gap-[3px] flex-1 min-h-[8rem]">
<div v-for="(b, i) in hourBuckets" :key="i"
class="group/bar relative flex-1 flex flex-col justify-end rounded-t overflow-visible"
:style="{ height: Math.max(4, ((b.image + b.video) / hourMax) * 100) + '%' }">
@@ -385,7 +405,7 @@ onUnmounted(() => clearInterval(timer))
<button @click="range = 'week'"
class="px-3 py-1 rounded-md transition-colors"
:class="range === 'week' ? 'bg-white/10 text-white font-medium' : 'text-white/50 hover:text-white/80'">
7d
3d
</button>
</div>
</div>
@@ -472,7 +492,7 @@ onUnmounted(() => clearInterval(timer))
<div class="text-[11px] text-white/40 mt-1">{{ day.success }} 次成功生成</div>
</div>
<div>
<div class="text-xs text-white/55"> 7 </div>
<div class="text-xs text-white/55"> 3 </div>
<div class="text-2xl font-semibold tabular-nums mt-1 text-amber-300">{{ fmtCredits(week.spent) }}</div>
<div class="text-[11px] text-white/40 mt-1">{{ week.success }} 次成功生成</div>
</div>
+267 -207
View File
@@ -1,13 +1,13 @@
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { api, jsonBody } from '../api'
import { api, jsonBody, generatedUrl } from '../api'
import { auth, refreshMe } from '../auth'
import { draft, applyJobToDraft } from '../playground'
import { draft } from '../playground'
import Icon from '../components/Icon.vue'
import SelectMenu from '../components/SelectMenu.vue'
import MediaLightbox from '../components/MediaLightbox.vue'
import { points, pointsLabel } from '../credits'
import { pointsLabel } from '../credits'
import { sortResolutions } from '../utils/format'
const route = useRoute()
@@ -38,29 +38,28 @@ watch(duration, (v) => { draft.duration = v })
const refImages = ref([]) // [{ name, dataUrl }]
const fileInput = ref(null)
const busy = ref(false)
// submitting = run() owns the busy/current state end-to-end while a /generate
// call is in flight. The 2s poll() must NOT touch busy or current during this
// window, or it races run() and the controls flicker unlocked mid-generation.
const submitting = ref(false)
// Gateway-timeout statuses our BACKEND never emits — they mean a CDN/proxy
// (e.g. EdgeOne 524) gave up waiting while the synchronous /generate is STILL
// rendering server-side. Treat these as "still running", NOT a failure: keep the
// controls locked and let poll() follow the live job to completion (出图).
// Concurrent generation: each 生成 click fires an INDEPENDENT /generate and adds
// a card — the UI never locks, so several can run at once. `tasks` holds the
// in-session cards (newest first); `history` fills the grid up to 10 with the
// user's recent finished results from the server.
const tasks = ref([])
const history = ref([])
// Gateway-timeout statuses our BACKEND never emits — a CDN/proxy (e.g. EdgeOne
// 524) gave up waiting while the synchronous /generate is STILL rendering. The
// task stays "running" and loadHistory() claims it once the result lands.
const GATEWAY_TIMEOUT = new Set([0, 408, 504, 520, 521, 522, 523, 524, 525])
const error = ref('')
const statusText = ref('')
// Only ever show the latest generation on the right side. Each new run
// replaces it; the persistent history lives at /logs (UserLogsView).
// `current` is restored from the server on mount and refreshed via /jobs/mine
// polling, so a reload, a parallel tab, or a different browser sees the same
// in-flight job and the same final result without re-running anything.
const current = ref(null)
const lightbox = ref(null)
const toast = ref('')
let pollTimer = null
const fileKey = (u) => (u || '').split('?')[0].split('/').pop()
const taskKey = (x) => [x.model, x.kind, (x.prompt || '').trim()].join('|')
// Up to 10 cards (一行五个 × 2): in-session tasks first, then the server's recent
// rows (进行中 + 成功) so the grid stays filled; loadHistory() prunes an optimistic
// task once the server tracks it → never a duplicate. 新的顶掉老的.
const displayItems = computed(() => [...tasks.value, ...history.value].slice(0, 10))
// ---- derived ----
const models = computed(() =>
allModels.value.filter((m) => m.enabled !== false && m.type === mode.value),
@@ -183,7 +182,13 @@ function openPicker() { fileInput.value && fileInput.value.click() }
// of charging + failing upstream after the upload.
const MAX_REF_BYTES = 8 * 1024 * 1024
function onFiles(ev) {
const files = Array.from(ev.target.files || [])
addFiles(Array.from(ev.target.files || []))
if (ev.target) ev.target.value = ''
}
// Shared by the file picker AND drag-and-drop. Filters to images, honors the
// per-model max + 8MB cap, reads each to a data URL.
function addFiles(files) {
files = files.filter((f) => f && f.type && f.type.startsWith('image/'))
const room = Math.max(0, maxRefs.value - refImages.value.length)
const tooBig = []
let added = 0
@@ -198,7 +203,23 @@ function onFiles(ev) {
error.value = tooBig.length
? `图片超过 8MB 已跳过:${tooBig.join('、')}(请压缩后再传)`
: ''
if (ev.target) ev.target.value = ''
}
// Drag-and-drop onto the reference area.
const dragOver = ref(false)
function onDrop(ev) {
ev.preventDefault()
dragOver.value = false
if (maxRefs.value <= 0) return
addFiles(Array.from(ev.dataTransfer?.files || []))
}
function onDragOver(ev) {
ev.preventDefault()
if (maxRefs.value > 0) dragOver.value = true
}
function onDragLeave(ev) {
// ignore leave events bubbling from children
if (ev.currentTarget.contains(ev.relatedTarget)) return
dragOver.value = false
}
function removeRef(i) { refImages.value.splice(i, 1) }
@@ -241,17 +262,11 @@ function flash(msg) {
toastTimer = setTimeout(() => (toast.value = ''), 1800)
}
async function copyLink(url) {
try {
const abs = url.startsWith('http') ? url : location.origin + url
await navigator.clipboard.writeText(abs)
flash('链接已复制')
} catch {
flash('复制失败')
}
}
// ---- generate (concurrent — no lock) ----
// 生图 can request 14 images at once: each is an independent task/charge.
const count = ref(1)
const batchCount = computed(() => (mode.value === 'image' ? Math.max(1, Math.min(4, count.value)) : 1))
// ---- generate ----
async function run() {
if (!modelId.value) { error.value = '请选择模型'; return }
if (!prompt.value.trim()) { error.value = '请输入提示词'; return }
@@ -263,11 +278,22 @@ async function run() {
error.value = '该参数组合未定价 (留空 = 不支持)'
return
}
if (!canAfford.value) {
error.value = `积分不足 — 需要 ${pointsLabel(price.value)},余额 ${pointsLabel(credits.value)}`
const n = batchCount.value
if (price.value != null && credits.value < price.value * n) {
error.value = `积分不足 — 需要 ${pointsLabel(price.value * n)},余额 ${pointsLabel(credits.value)}`
return
}
const job = {
error.value = ''
// A new generation clears any lingering real-time error cards.
tasks.value = tasks.value.filter((t) => t.status !== 'failed')
// Fire N independent tasks (no await between them → all run concurrently).
for (let i = 0; i < n; i++) fireOne()
}
async function fireOne() {
// Snapshot the form NOW — concurrent tasks keep their own params even if the
// user edits the form (or fires another batch) while this one runs.
const task = {
id: Math.random().toString(36).slice(2, 10),
model: modelId.value,
kind: mode.value,
@@ -275,7 +301,6 @@ async function run() {
ratio: ratio.value,
resolution: resolution.value,
duration: mode.value === 'video' ? duration.value : '',
refs: refImages.value.length,
status: 'pending',
url: '',
error: '',
@@ -283,118 +308,144 @@ async function run() {
charged: price.value,
ts: Date.now(),
}
current.value = job
const refsSnapshot = refImages.value.slice()
const chargedPrice = price.value
tasks.value.unshift(task)
if (tasks.value.length > 10) tasks.value = tasks.value.slice(0, 10)
busy.value = true
submitting.value = true
error.value = ''
statusText.value = mode.value === 'video' ? '生成视频中 (约 13 分钟)…' : '生成中…'
// Optimistically deduct the price (server debits before generating; a failure
// refunds + refreshMe reconciles).
if (auth.user && chargedPrice != null) {
auth.user.credits = Math.max(0, Number(auth.user.credits || 0) - chargedPrice)
}
const payload = {
model: task.model, prompt: task.prompt, ratio: task.ratio, resolution: task.resolution,
}
if (task.kind === 'video') payload.duration = task.duration
if (refsSnapshot.length) {
const refs = await Promise.all(refsSnapshot.map(refToBase64))
payload.reference_images = refs.filter(Boolean)
}
try {
// Optimistically deduct the price from the displayed balance right away. The
// server debits BEFORE generating (which can take minutes for video), so
// otherwise 余额 looks unchanged the whole time. The success response carries
// the authoritative balance (reconciled below); a failure refunds + refreshMe.
if (auth.user && price.value != null) {
auth.user.credits = Math.max(0, Number(auth.user.credits || 0) - price.value)
}
const payload = {
model: modelId.value,
prompt: prompt.value,
ratio: ratio.value,
resolution: resolution.value,
}
if (mode.value === 'video') payload.duration = duration.value
if (refImages.value.length) {
// Backend accepts raw base64 only — convert each ref (uploaded dataUrl or
// restored /images URL) to base64 at submit time.
const refs = await Promise.all(refImages.value.map(refToBase64))
payload.reference_images = refs.filter(Boolean)
}
// Single charged call: the server debits the price atomically BEFORE
// generating and refunds on failure, so the client can't skip the charge.
const r = await api('/generate', jsonBody('POST', payload))
if (r.ok && r.data?.url) {
job.status = 'done'
job.url = r.data.url
job.elapsed_ms = r.data.elapsed_ms
job.charged = r.data.charged ?? price.value
task.status = 'done'
task.url = r.data.url
task.elapsed_ms = r.data.elapsed_ms
task.charged = r.data.charged ?? chargedPrice
if (auth.user && r.data.credits != null) auth.user.credits = r.data.credits
statusText.value = `完成 · 扣费 ${pointsLabel(job.charged)} · ${(r.data.elapsed_ms / 1000).toFixed(1)}s · 余额 ${pointsLabel(credits.value)}`
busy.value = false // 出图 → 解锁
} else if (GATEWAY_TIMEOUT.has(r.status)) {
// CDN/代理回源超时(如 EdgeOne 524)—— 后端仍在生成。不当失败、不解锁:
// 保持 busy=true,交给下面的 poll() + 2s 轮询跟到出图("不出图就不闪")
statusText.value = mode.value === 'video' ? '生成视频中 (约 13 分钟)…' : '生成中…'
// CDN/代理回源超时(如 EdgeOne 524)—— 后端仍在生成。保持 running,
// loadHistory() 在结果落库后认领它
task.status = 'running'
} else {
// 真失败:服务端已退款,resync 余额并解锁。
await refreshMe()
job.status = 'failed'
job.error = r.data?.detail || `失败 (${r.status})`
statusText.value = ''
busy.value = false // 真失败 → 解锁
task.status = 'failed'
task.error = r.data?.detail || `失败 (${r.status})`
}
} finally {
// Hand control back to poll(); busy is left as set above (locked when the
// job is still rendering after a gateway timeout).
submitting.value = false
} catch (e) {
await refreshMe()
task.status = 'failed'
task.error = String(e)
}
// Sync real server state: poll() picks up the live pending job (replacing our
// optimistic one with the real id) and will unlock + show the result the
// moment it finishes — so a 524 mid-flight never leaves the UI unlocked.
poll()
loadHistory()
}
// Recover the current generation from the server: any pending job for this
// user lives in event_log, so reload / parallel tab / parallel browser can
// all see the same in-flight state and the same final result.
async function poll() {
// While run() is mid-submit it fully owns busy/current — don't race it.
if (submitting.value) return
const r = await api('/jobs/mine')
// Fill the grid up to 10 with the user's recent rows (进行中 + 成功). Past
// FAILURES are never shown from history — an error is only relevant for the live
// generation the user just ran. Prune optimistic tasks the server now tracks.
let prevPending = 0
async function loadHistory() {
// Server-side filter: status IN (pending, success), newest 12 — exactly the
// rows the grid shows, in one query (no client over-fetch).
const r = await api('/logs?limit=10&statuses=pending,success')
if (!r.ok) return
const { pending, latest } = r.data || {}
if (pending) {
busy.value = true
if (!statusText.value) {
statusText.value = pending.kind === 'video' ? '生成视频中 (约 13 分钟)…' : '生成中…'
}
if (!current.value || current.value.id !== pending.id) {
current.value = { ...pending }
// Replay the pending job's params onto the form so a fresh tab shows
// what's cooking — and writes them into the cross-component draft.
applyJobToDraft(pending)
mode.value = pending.kind === 'video' ? 'video' : 'image'
modelId.value = pending.model || modelId.value
prompt.value = pending.prompt || prompt.value
ratio.value = pending.ratio || ratio.value
resolution.value = pending.resolution || resolution.value
duration.value = pending.duration || duration.value
// Re-display the uploaded reference image(s) after a reload. They're
// served (cookie-authed) from /images; re-fetch into data URLs so the
// thumbnails show AND the refs stay re-submittable if the user regenerates.
restoreRefs(pending.reference_urls)
}
return
history.value = (r.data?.data || [])
.filter((e) => e.status === 'pending' || e.file)
.map((e) => ({
id: 'srv-' + e.id,
prompt: e.prompt, model: e.model, kind: e.kind,
ratio: e.ratio, resolution: e.resolution, duration: e.duration,
status: e.status === 'success' ? 'done' : 'running',
url: e.file ? generatedUrl(e.file) : '',
error: '',
elapsed_ms: e.elapsed_ms,
}))
// Hand each optimistic task over to the server once it's tracked there: drop a
// pending task when a matching server pending row exists, a done task once its
// file is in the server's rows. A FAILED task is a live error — keep it.
const serverPending = new Set(history.value.filter((h) => h.status === 'running').map(taskKey))
const serverFiles = new Set(history.value.filter((h) => h.url).map((h) => fileKey(h.url)))
tasks.value = tasks.value.filter((t) => {
if (t.status === 'failed') return true
if (t.status === 'done') return !serverFiles.has(fileKey(t.url))
return !serverPending.has(taskKey(t))
})
if (serverPending.size < prevPending) refreshMe()
prevPending = serverPending.size
}
// Click a generated IMAGE → use it as a reference. Single-ref model: replace the
// existing ref. Multi-ref: append if there's room, else replace the last one.
function useAsRef(item) {
if (!item || !item.url || item.status !== 'done') return
if (item.kind === 'video') return
const cap = maxRefs.value
if (cap <= 0) { flash('当前模型不支持参考图'); return }
const ref = { name: 'ref', url: item.url }
if (cap === 1) {
refImages.value = [ref]
} else if (refImages.value.length >= cap) {
refImages.value.splice(cap - 1, 1, ref)
} else {
refImages.value.push(ref)
}
// No pending. If our locally-shown job just finished on the server (same id,
// status flipped to success/failed), promote it to the result view — this is
// the live "I'm watching my own generation finish" case and stays.
if (current.value && current.value.status === 'pending' && latest && latest.id === current.value.id) {
current.value = { ...latest, status: latest.status === 'success' ? 'done' : latest.status }
if (latest.url) current.value.url = latest.url
busy.value = false
statusText.value = ''
refreshMe()
return
flash('已加入参考图')
}
// Grab the LAST frame of a video as a PNG data URL (same-origin → canvas isn't
// tainted). Used to continue a video from where it ended (首尾帧 models).
function lastFrameDataUrl(url) {
return new Promise((resolve) => {
const v = document.createElement('video')
v.crossOrigin = 'anonymous'
v.muted = true
v.preload = 'auto'
v.src = url
const grab = () => {
try {
const c = document.createElement('canvas')
c.width = v.videoWidth; c.height = v.videoHeight
c.getContext('2d').drawImage(v, 0, 0)
resolve(c.toDataURL('image/png'))
} catch { resolve('') }
}
v.addEventListener('loadeddata', () => {
const t = Math.max(0, (v.duration || 0) - 0.05)
if (isFinite(t) && t > 0) v.currentTime = t
else grab()
})
v.addEventListener('seeked', grab)
v.addEventListener('error', () => resolve(''))
})
}
// Click a generated VIDEO. For a 首尾帧 (frame) model, set the video's LAST frame
// as the 首帧 (first reference) — to continue the scene. Otherwise just zoom.
async function onVideoClick(item) {
if (refMode.value === 'frame' && maxRefs.value > 0 && item.url) {
const dataUrl = await lastFrameDataUrl(item.url)
if (dataUrl) {
const ref = { name: 'frame', dataUrl }
if (refImages.value.length === 0) refImages.value = [ref]
else refImages.value.splice(0, 1, ref) // replace the 首帧 slot
flash('已把视频末帧设为首帧')
return
}
}
// Intentionally NO restore of an already-finished result on first paint /
// navigation: the playground only ever shows an in-progress job (or the one
// that just completed while watched). Past results live in /记录 (logs), not
// re-echoed onto a freshly opened workspace.
lightbox.value = item
}
function onKey(e) { if (e.key === 'Escape') lightbox.value = null }
@@ -434,10 +485,10 @@ onMounted(async () => {
applyModelDefaults()
}
window.addEventListener('keydown', onKey)
// Restore any in-flight or recently-finished job for this user, then poll
// every 2s so a parallel tab / device sees changes within one tick.
poll()
pollTimer = setInterval(poll, 2000)
// Fill the grid with the user's recent results, then refresh every 3s so
// finished tasks (incl. gateway-timed-out ones) land without a reload.
loadHistory()
pollTimer = setInterval(loadHistory, 3000)
})
onUnmounted(() => {
window.removeEventListener('keydown', onKey)
@@ -447,18 +498,17 @@ onUnmounted(() => {
<template>
<section class="theme-text grid lg:grid-cols-[420px_1fr] gap-6">
<!-- LEFT: controls every interactive element accepts :disabled="busy"
so the form locks the moment a generation kicks off. Reload, parallel
tab and tab-switch all see the same locked state via poll(). -->
<!-- LEFT: controls never locked. 生成 fires an independent task each click,
so several generations can run at once (concurrent). -->
<div class="card p-5 space-y-5 lg:sticky lg:top-24 self-start">
<!-- mode switch -->
<div class="grid grid-cols-2 gap-2 p-1 bg-slate-100 rounded-xl">
<button @click="setMode('image')" type="button" :disabled="busy"
<button @click="setMode('image')" type="button"
class="rounded-lg py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed"
:class="mode === 'image' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'">
<Icon name="files" class="w-4 h-4 inline -mt-0.5" /> 生图
</button>
<button @click="setMode('video')" type="button" :disabled="busy"
<button @click="setMode('video')" type="button"
class="rounded-lg py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed"
:class="mode === 'video' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'">
<Icon name="video" class="w-4 h-4 inline -mt-0.5" /> 生视频
@@ -469,7 +519,7 @@ onUnmounted(() => {
<div>
<label class="block text-xs font-medium text-slate-500 mb-1.5">模型</label>
<SelectMenu v-if="models.length" :model-value="modelId" @update:model-value="selectModel"
:options="modelOptions" placeholder="选择模型" mono :disabled="busy" />
:options="modelOptions" placeholder="选择模型" mono />
<div v-else class="rounded-lg border border-dashed border-slate-200 px-3 py-4 text-xs text-slate-400 text-center">
还没有可用的{{ mode === 'video' ? '视频' : '图像' }}模型 ·
<router-link to="/admin/models" class="text-slate-700 underline">去添加</router-link>
@@ -479,7 +529,7 @@ onUnmounted(() => {
<!-- prompt -->
<div>
<label class="block text-xs font-medium text-slate-500 mb-1.5">提示词</label>
<textarea v-model="prompt" rows="4" :disabled="busy" class="field resize-none disabled:opacity-60 disabled:cursor-not-allowed"
<textarea v-model="prompt" rows="4" class="field resize-none disabled:opacity-60 disabled:cursor-not-allowed"
placeholder="描述想要的画面…如:黄昏时分,金色麦田里奔跑的金毛猎犬,电影感"></textarea>
</div>
@@ -489,7 +539,7 @@ onUnmounted(() => {
<div v-if="ratios.length > 0 && showRatio">
<label class="block text-xs font-medium text-slate-500 mb-1.5">比例</label>
<div class="flex flex-wrap gap-1.5">
<button v-for="r in ratios" :key="r" type="button" @click="ratio = r" :disabled="busy"
<button v-for="r in ratios" :key="r" type="button" @click="ratio = r"
class="rounded-lg px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:class="ratio === r ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">
{{ r }}
@@ -500,7 +550,7 @@ onUnmounted(() => {
<div v-if="resolutions.length > 0">
<label class="block text-xs font-medium text-slate-500 mb-1.5">{{ mode === 'video' ? '分辨率' : '画质' }}</label>
<div class="flex flex-wrap gap-1.5">
<button v-for="r in resolutions" :key="r" type="button" @click="resolution = r" :disabled="busy"
<button v-for="r in resolutions" :key="r" type="button" @click="resolution = r"
class="rounded-lg px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:class="resolution === r ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">
{{ r }}
@@ -511,7 +561,7 @@ onUnmounted(() => {
<div v-if="mode === 'video' && durations.length > 0">
<label class="block text-xs font-medium text-slate-500 mb-1.5">时长</label>
<div class="flex flex-wrap gap-1.5">
<button v-for="d in durations" :key="d" type="button" @click="duration = d" :disabled="busy"
<button v-for="d in durations" :key="d" type="button" @click="duration = d"
class="rounded-lg px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:class="duration === d ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">
{{ d }}
@@ -528,12 +578,13 @@ onUnmounted(() => {
</span>
<span v-if="refsRequired" class="text-rose-500">*</span>
</label>
<div class="flex gap-2 flex-wrap items-start">
<div class="flex gap-2 flex-wrap items-start rounded-lg transition-colors"
:class="dragOver ? 'ring-2 ring-indigo-400 ring-offset-2 bg-indigo-50/40' : ''"
@drop="onDrop" @dragover="onDragOver" @dragleave="onDragLeave">
<div v-for="(img, i) in refImages" :key="i"
class="relative w-20 h-20 rounded-lg overflow-hidden border border-slate-200 bg-slate-50 transition-all"
:class="busy ? 'opacity-60 grayscale pointer-events-none' : ''">
class="relative w-20 h-20 rounded-lg overflow-hidden border border-slate-200 bg-slate-50 transition-all">
<img :src="img.dataUrl || img.url" class="w-full h-full object-cover" />
<button type="button" @click="removeRef(i)" :disabled="busy"
<button type="button" @click="removeRef(i)"
class="absolute top-1 right-1 w-5 h-5 rounded-full bg-slate-900/70 text-white hover:bg-rose-500 grid place-items-center disabled:opacity-40 disabled:cursor-not-allowed">
<Icon name="close" class="w-3 h-3" />
</button>
@@ -542,20 +593,33 @@ onUnmounted(() => {
{{ i === 0 ? '首帧' : (i === 1 ? '末帧' : '') }}
</div>
</div>
<button v-if="refImages.length < maxRefs" type="button" @click="openPicker" :disabled="busy"
class="w-20 h-20 rounded-lg border-2 border-dashed border-slate-200 text-slate-400 hover:bg-slate-50 hover:border-slate-300 grid place-items-center disabled:opacity-40 disabled:cursor-not-allowed">
<Icon name="plus" class="w-5 h-5" />
<button v-if="refImages.length < maxRefs" type="button" @click="openPicker"
class="w-20 h-20 rounded-lg border-2 border-dashed border-slate-200 text-slate-400 hover:bg-slate-50 hover:border-slate-300 grid place-items-center disabled:opacity-40 disabled:cursor-not-allowed"
:title="dragOver ? '松开以添加' : '点击或拖拽图片到此'">
<Icon :name="dragOver ? 'download' : 'plus'" class="w-5 h-5" />
</button>
</div>
<input ref="fileInput" type="file" accept="image/*" multiple class="hidden" @change="onFiles" />
</div>
<button @click="run" :disabled="busy || !models.length || price == null || !canAfford"
<!-- 生图张数 14 (image only) — each is a separate concurrent generation. -->
<div v-if="mode === 'image'">
<label class="block text-xs font-medium text-slate-500 mb-1.5">张数</label>
<div class="flex gap-1.5">
<button v-for="n in [1, 2, 3, 4]" :key="n" type="button" @click="count = n"
class="flex-1 rounded-lg py-1.5 text-xs font-medium transition-colors"
:class="count === n ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">
{{ n }}
</button>
</div>
</div>
<button @click="run" :disabled="!models.length || price == null || !canAfford"
class="btn-primary w-full !py-3 flex items-center justify-center gap-2 leading-none">
<Icon name="spark" class="w-4 h-4 shrink-0" />
<span class="leading-none">{{ busy ? (mode === 'video' ? '生成中请耐心等待' : '生成中') : '生成' }}</span>
<span v-if="!busy && price != null" class="text-xs opacity-70 tabular-nums leading-none">· {{ priceLabel }}</span>
<span v-if="!busy && price != null && !canAfford" class="text-xs text-rose-200 leading-none">积分不足</span>
<span class="leading-none">生成<span v-if="batchCount > 1"> {{ batchCount }} 张</span></span>
<span v-if="price != null" class="text-xs opacity-70 tabular-nums leading-none">· {{ batchCount > 1 ? pointsLabel(price * batchCount) : priceLabel }}</span>
<span v-if="price != null && !canAfford" class="text-xs text-rose-200 leading-none">积分不足</span>
</button>
<!-- Validation / upload errors (model/prompt/ref/price/credits/oversized
@@ -565,60 +629,56 @@ onUnmounted(() => {
</div>
<!-- RIGHT: single latest result (replaces on each new generation).
min-w-0: the 1fr grid track defaults to min-width:auto, so a long
unbroken prompt would otherwise blow the column wider than the page
(truncate can't shrink a track that won't shrink). -->
<div class="space-y-4 min-w-0">
<div v-if="!current && !busy"
class="card p-14 grid place-items-center text-slate-400 text-center">
<span class="w-16 h-16 rounded-2xl bg-slate-100 grid place-items-center mb-4">
<Icon name="spark" class="w-7 h-7 text-slate-400" />
</span>
<p class="text-sm">还没有生成过 — 在左侧写提示词,点击「生成」</p>
<router-link to="/logs" class="text-xs text-slate-500 hover:text-white mt-3 transition-colors">查看历史记录 →</router-link>
</div>
<div v-else-if="current" class="card overflow-hidden">
<div class="px-5 py-3 border-b border-slate-100 flex items-center justify-between gap-3">
<div class="min-w-0">
<div class="text-sm font-medium line-clamp-2 break-words">{{ current.prompt }}</div>
<div class="text-[11px] text-slate-400 mt-0.5 font-mono">
{{ current.model }} · {{ current.ratio }} · {{ current.resolution }}
<span v-if="current.kind === 'video'"> · {{ current.duration }}</span>
<span v-if="current.elapsed_ms"> · {{ (current.elapsed_ms / 1000).toFixed(1) }}s</span>
<!-- RIGHT: concurrent gallery — one card per task, newest first; filled up to
10 with the user's recent results. No lock: 生成 can be clicked anytime.
min-w-0 keeps a long prompt from blowing the 1fr track wider than the page. -->
<div class="min-w-0">
<div v-if="displayItems.length" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
<div v-for="item in displayItems" :key="item.id"
class="group relative rounded-xl overflow-hidden ring-1 ring-slate-200 bg-slate-100 aspect-[4/5]">
<!-- done: media + caption -->
<template v-if="item.status === 'done' && item.url">
<video v-if="item.kind === 'video'" :src="item.url" muted loop preload="metadata"
@click="onVideoClick(item)"
:title="refMode === 'frame' && maxRefs > 0 ? '点击:把末帧设为首帧' : '点击放大'"
class="absolute inset-0 w-full h-full object-cover cursor-pointer"
@mouseenter="$event.target.play && $event.target.play()"
@mouseleave="$event.target.pause && $event.target.pause()" />
<img v-else :src="item.url" loading="lazy" @click="useAsRef(item)"
:title="maxRefs > 0 ? '点击作为参考图' : ''"
class="absolute inset-0 w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
:class="maxRefs > 0 ? 'cursor-pointer' : 'cursor-default'" />
<div class="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/85 via-black/30 to-transparent pointer-events-none"></div>
<!-- hover action: just zoom (clicking the image itself = 参考图) -->
<div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button @click.stop="lightbox = item" 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="open" class="w-3.5 h-3.5" />
</button>
</div>
<div class="absolute inset-x-0 bottom-0 p-2.5 pointer-events-none">
<div class="text-[11px] leading-tight text-white font-medium line-clamp-2" :title="item.prompt">{{ item.prompt }}</div>
<div class="text-[9px] text-white/55 mt-0.5 font-mono truncate">{{ item.model }}<span v-if="item.elapsed_ms"> · {{ (item.elapsed_ms / 1000).toFixed(1) }}s</span></div>
</div>
</template>
<!-- pending / running -->
<div v-else-if="item.status === 'pending' || item.status === 'running'"
class="absolute inset-0 grid place-items-center text-slate-400 text-xs px-3 text-center">
<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>
{{ item.kind === 'video' ? '生成视频中…' : '生成中…' }}
<span class="text-[10px] text-slate-400/80 line-clamp-1 max-w-full">{{ item.prompt }}</span>
</div>
</div>
<!-- only when a finished result exists — hidden while pending/failed -->
<div v-if="current.url && current.status !== 'pending' && current.status !== 'failed'"
class="flex items-center gap-1.5 shrink-0">
<a :href="current.url" :download="''" class="btn-soft" title="下载">
<Icon name="download" class="w-3.5 h-3.5" />
</a>
<button @click="copyLink(current.url)" class="btn-soft" title="复制链接">
<Icon name="copy" class="w-3.5 h-3.5" />
</button>
<!-- failed -->
<div v-else class="absolute inset-0 grid place-items-center text-rose-500 text-xs px-3 text-center">
<div>
<Icon name="close" class="w-6 h-6 mx-auto mb-1 opacity-60" />
<div>生成失败</div>
<div v-if="item.error" class="text-[10px] text-rose-400 line-clamp-2 mt-1">{{ item.error }}</div>
</div>
</div>
</div>
<div class="bg-slate-50 grid place-items-center min-h-[260px]">
<div v-if="current.status === 'pending'" class="text-sm text-slate-400 py-12 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 text-slate-400" />
</span>
{{ statusText || '生成中' }}
</div>
<div v-else-if="current.status === 'failed'" class="text-sm text-rose-600 py-12 px-5 max-w-xl text-center">
<div class="font-medium mb-1">生成失败</div>
<div class="text-xs text-rose-500 break-all">{{ current.error }}</div>
</div>
<template v-else>
<video v-if="current.kind === 'video'" :src="current.url" controls
class="max-w-full max-h-[600px] object-contain" />
<img v-else :src="current.url" @click="lightbox = current"
class="max-w-full max-h-[600px] object-contain cursor-zoom-in" />
</template>
</div>
</div>
</div>
+46 -52
View File
@@ -174,8 +174,8 @@ onMounted(refresh)
</div>
<!-- grid -->
<div v-if="loading" class="text-center text-xs text-white/40 py-12">加载中</div>
<div v-else-if="!filtered.length" class="text-center text-xs text-white/40 py-12">没有条目</div>
<div v-if="loading" class="text-center text-xs text-[color:var(--fg-faint)] py-12">加载中</div>
<div v-else-if="!filtered.length" class="text-center text-xs text-[color:var(--fg-faint)] py-12">没有条目</div>
<div v-else class="grid sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
<div v-for="rec in pagedItems" :key="rec.id"
class="media-card relative rounded-2xl overflow-hidden ring-1 ring-white/10 aspect-[4/3] group bg-white/[0.04]"
@@ -211,13 +211,13 @@ onMounted(refresh)
<!-- pagination — shown when there's more than one page worth of entries -->
<div v-if="!loading && totalPages > 1"
class="card !p-3 flex items-center justify-between gap-3">
<div class="text-xs text-white/55 tabular-nums px-2">
<span class="text-white/85">{{ (page - 1) * pageSize + 1 }}{{ Math.min(filtered.length, page * pageSize) }}</span>
<div class="text-xs text-[color:var(--fg-3)] tabular-nums px-2">
<span class="text-[color:var(--fg)]">{{ (page - 1) * pageSize + 1 }}{{ Math.min(filtered.length, page * pageSize) }}</span>
/ {{ filtered.length }}
</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-white/35"></span>
<span v-if="n === null" class="px-1 text-[color:var(--fg-faint)]"></span>
<button v-else @click="goPage(n)" class="pg" :class="page === n && 'pg-on'">{{ n }}</button>
</template>
</div>
@@ -226,20 +226,20 @@ onMounted(refresh)
<!-- ======= form modal ======= -->
<transition name="fade">
<div v-if="editing"
class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm grid place-items-center p-4"
class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4"
@click.self="closeForm">
<div class="card w-full max-w-2xl !shadow-2xl">
<div class="px-5 py-3 border-b border-white/[0.06] flex items-center justify-between">
<div class="card w-full max-w-2xl !shadow-2xl my-auto">
<div class="px-5 py-3 border-b border-[color:var(--hairline)] flex items-center justify-between">
<h2 class="text-sm font-semibold">
{{ form.id ? '编辑' : '新增' }} ·
{{ form.kind === 'hero' ? 'Hero 卡片' : form.kind === 'bento' ? 'Bento 灵感' : '作品' }}
</h2>
<button @click="closeForm" class="text-white/40 hover:text-white">
<button @click="closeForm" class="text-[color:var(--fg-faint)] hover:text-[color:var(--fg)]">
<Icon name="close" class="w-4 h-4" />
</button>
</div>
<div class="p-5 space-y-4 max-h-[70vh] overflow-y-auto">
<div class="p-5 space-y-4">
<!-- live preview -->
<div class="relative rounded-2xl overflow-hidden ring-1 ring-white/10 aspect-[5/2] bg-white/[0.04]"
:style="bgFor(form.image)">
@@ -256,7 +256,7 @@ onMounted(refresh)
<div class="grid sm:grid-cols-2 gap-3">
<div>
<label class="block text-xs text-white/55 mb-1.5">类型</label>
<label class="block text-xs text-[color:var(--fg-3)] mb-1.5">类型</label>
<div class="flex gap-1.5">
<button type="button" @click="form.kind = 'hero'" class="kind-btn" :class="form.kind === 'hero' && 'on'">Hero</button>
<button type="button" @click="form.kind = 'bento'" class="kind-btn" :class="form.kind === 'bento' && 'on'">Bento</button>
@@ -264,62 +264,62 @@ onMounted(refresh)
</div>
</div>
<div>
<label class="block text-xs text-white/55 mb-1.5">权重 <span class="text-white/35">(越大越靠前)</span></label>
<label class="block text-xs text-[color:var(--fg-3)] mb-1.5">权重 <span class="text-[color:var(--fg-faint)]">(越大越靠前)</span></label>
<input v-model.number="form.weight" type="number" class="field" />
</div>
</div>
<!-- image picker (the central change admins pick a real image) -->
<div>
<label class="block text-xs text-white/55 mb-1.5">底图</label>
<label class="block text-xs text-[color:var(--fg-3)] mb-1.5">底图</label>
<div class="flex gap-2">
<input v-model="form.image" class="field font-mono text-[11px]"
placeholder="user/abc.png 或 https://…" />
<button type="button" @click="openPicker" class="btn-soft shrink-0">选择已生成</button>
</div>
<p class="text-[11px] text-white/35 mt-1">填写 /generated 下的相对路径,或粘贴一个外链 URL</p>
<p class="text-[11px] text-[color:var(--fg-faint)] mt-1">填写 /generated 下的相对路径,或粘贴一个外链 URL</p>
</div>
<template v-if="form.kind !== 'work'">
<div class="grid sm:grid-cols-2 gap-3">
<div>
<label class="block text-xs text-white/55 mb-1.5">标题</label>
<label class="block text-xs text-[color:var(--fg-3)] mb-1.5">标题</label>
<input v-model="form.title" class="field" placeholder="电影感人物" />
</div>
<div>
<label class="block text-xs text-white/55 mb-1.5">副标题</label>
<label class="block text-xs text-[color:var(--fg-3)] mb-1.5">副标题</label>
<input v-model="form.subtitle" class="field" placeholder="CINEMATIC PORTRAIT" />
</div>
</div>
<div>
<label class="block text-xs text-white/55 mb-1.5">提示词 <span class="text-white/35">( Bento 后会预填到画图)</span></label>
<label class="block text-xs text-[color:var(--fg-3)] mb-1.5">提示词 <span class="text-[color:var(--fg-faint)]">( Bento 后会预填到画图)</span></label>
<textarea v-model="form.prompt" rows="3" class="field resize-none"
placeholder="一位身穿米色风衣的女子站在雨夜的霓虹街道,胶片质感,浅景深,电影感"></textarea>
</div>
</template>
<template v-else>
<div>
<label class="block text-xs text-white/55 mb-1.5">作品标题 <span class="text-white/35">(可选)</span></label>
<label class="block text-xs text-[color:var(--fg-3)] mb-1.5">作品标题 <span class="text-[color:var(--fg-faint)]">(可选)</span></label>
<input v-model="form.title" class="field" placeholder="留空则只展示图片" />
</div>
</template>
<div v-if="form.kind === 'bento'">
<label class="block text-xs text-white/55 mb-1.5">网格跨度 <span class="text-white/35">(Tailwind class)</span></label>
<label class="block text-xs text-[color:var(--fg-3)] mb-1.5">网格跨度 <span class="text-[color:var(--fg-faint)]">(Tailwind class)</span></label>
<div class="flex gap-1.5 flex-wrap mb-2">
<button v-for="s in SPAN_PRESETS" :key="s" type="button" @click="form.span = s"
class="px-2.5 py-1 text-[11px] rounded-lg ring-1 ring-white/10 hover:bg-white/[0.08]"
:class="form.span === s ? 'bg-white text-slate-900' : 'bg-white/[0.04] text-white/70'">
class="px-2.5 py-1 text-[11px] rounded-lg ring-1 ring-[color:var(--hairline)] hover:bg-[color:var(--hover)]"
:class="form.span === s ? 'bg-[color:var(--btn-solid-bg)] text-[color:var(--btn-solid-fg)]' : 'bg-[color:var(--surface-2)] text-[color:var(--fg-2)]'">
{{ s || '默认 1×1' }}
</button>
</div>
<input v-model="form.span" class="field font-mono text-[11px]" placeholder="md:col-span-2" />
</div>
<p v-if="error" class="text-xs text-rose-300">{{ error }}</p>
<p v-if="error" class="text-xs text-rose-500">{{ error }}</p>
</div>
<div class="px-5 py-3 border-t border-white/[0.06] flex items-center justify-end gap-2">
<div class="px-5 py-3 border-t border-[color:var(--hairline)] flex items-center justify-end gap-2">
<button @click="closeForm" class="btn-ghost">取消</button>
<button @click="save" :disabled="saving" class="btn-primary">
{{ saving ? '保存中…' : '保存' }}
@@ -335,14 +335,14 @@ onMounted(refresh)
class="fixed inset-0 z-[60] bg-black/80 backdrop-blur-sm grid place-items-center p-4"
@click.self="closePicker">
<div class="card w-full max-w-4xl !shadow-2xl">
<div class="px-5 py-3 border-b border-white/[0.06] flex items-center justify-between">
<div class="px-5 py-3 border-b border-[color:var(--hairline)] flex items-center justify-between">
<h2 class="text-sm font-semibold">选择底图 · 最近生成</h2>
<button @click="closePicker" class="text-white/40 hover:text-white">
<button @click="closePicker" class="text-[color:var(--fg-faint)] hover:text-[color:var(--fg)]">
<Icon name="close" class="w-4 h-4" />
</button>
</div>
<div class="p-4 max-h-[70vh] overflow-y-auto">
<div v-if="!recentFiles.length" class="text-center text-xs text-white/40 py-10">尚未有生成过的图片</div>
<div v-if="!recentFiles.length" class="text-center text-xs text-[color:var(--fg-faint)] py-10">尚未有生成过的图片</div>
<div v-else class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
<button v-for="f in recentFiles" :key="f.name" type="button" @click="pickImage(f)"
class="relative aspect-square rounded-lg overflow-hidden ring-1 ring-white/10 hover:ring-fuchsia-400/60 transition-all">
@@ -357,41 +357,35 @@ onMounted(refresh)
</template>
<style scoped>
/* All colors come from the theme vars (:root light / html.dark dark) so the view
adapts to BOTH themes. Selected states use --btn-solid-* which inverts per
theme (light: dark bg/white text · dark: white bg/dark text). */
.filter-pill {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
border-radius: 0.5rem;
background: rgb(255 255 255 / 0.06);
color: rgb(255 255 255 / 0.65);
background: var(--surface-2);
color: var(--fg-2);
transition: background 0.15s, color 0.15s;
}
.filter-pill:hover { background: rgb(255 255 255 / 0.1); color: white; }
.filter-pill.on { background: white; color: rgb(15 23 42); }
.filter-pill:hover { background: var(--hover); color: var(--fg); }
.filter-pill.on { background: var(--btn-solid-bg); color: var(--btn-solid-fg); }
.kind-btn {
flex: 1;
padding: 0.5rem 0;
border-radius: 0.5rem;
font-size: 0.75rem;
background: rgb(255 255 255 / 0.06);
color: rgb(255 255 255 / 0.7);
background: var(--surface-2);
color: var(--fg-2);
transition: background 0.15s, color 0.15s;
}
.kind-btn:hover { background: rgb(255 255 255 / 0.1); }
.kind-btn.on { background: white; color: rgb(15 23 42); }
.kind-btn:hover { background: var(--hover); }
.kind-btn.on { background: var(--btn-solid-bg); color: var(--btn-solid-fg); }
.field {
width: 100%;
padding: 0.5rem 0.7rem;
border-radius: 0.6rem;
font-size: 0.85rem;
outline: none;
background: rgb(255 255 255 / 0.04);
border: 1px solid rgb(255 255 255 / 0.1);
color: white;
transition: border-color 0.18s, background 0.18s;
}
.field:focus { border-color: rgb(167 139 250 / 0.65); background: rgb(255 255 255 / 0.06); }
/* No scoped .field — use the GLOBAL .field (bg-white + border-slate-200 in light,
.public-dark .field in dark) so inputs match every other modal and the border
is clearly visible. A scoped override here only re-broke the border. */
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
@@ -403,15 +397,15 @@ onMounted(refresh)
font-weight: 500;
text-align: center;
border-radius: 0.45rem;
color: rgb(255 255 255 / 0.7);
background: rgb(255 255 255 / 0.04);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
color: var(--fg-2);
background: var(--surface-2);
box-shadow: inset 0 0 0 1px var(--hairline);
transition: background 0.15s, color 0.15s;
}
.pg:hover:not(.pg-on) { background: rgb(255 255 255 / 0.1); color: white; }
.pg:hover:not(.pg-on) { background: var(--hover); color: var(--fg); }
.pg-on {
background: rgb(255 255 255 / 0.92);
color: rgb(15 23 42);
background: var(--btn-solid-bg);
color: var(--btn-solid-fg);
box-shadow: none;
}
</style>
+19 -1
View File
@@ -7,6 +7,7 @@ import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { api, generatedUrl } from '../api'
import { fmtDate, fmtClock, fmtTs } from '../utils/format'
import { copyText } from '../utils/clipboard'
import { points } from '../credits'
import Icon from '../components/Icon.vue'
import MediaLightbox from '../components/MediaLightbox.vue'
@@ -23,6 +24,15 @@ const page = ref(1)
const pageSize = 20
const lightbox = ref(null)
const toast = ref('')
let toastTimer = null
async function copyPrompt(e) {
if (!e.prompt) return
toast.value = (await copyText(e.prompt)) ? '指令已复制' : '复制失败'
clearTimeout(toastTimer)
toastTimer = setTimeout(() => (toast.value = ''), 1800)
}
// 来源筛选走服务端:画图台 = source "user",API = source "v1"。
const SOURCE_PARAM = { web: 'user', api: 'v1' }
@@ -230,7 +240,10 @@ const params = (e) => {
</div>
</td>
<td class="px-3 py-3 align-middle min-w-0">
<div class="text-xs text-slate-700 truncate" :title="e.prompt">{{ e.prompt || '—' }}</div>
<div class="text-xs text-slate-700 truncate transition-colors"
:class="e.prompt ? 'cursor-pointer hover:text-slate-900' : ''"
:title="e.prompt ? '点击复制提示词' : ''"
@click="e.prompt && copyPrompt(e)">{{ e.prompt || '' }}</div>
<div v-if="e.error" class="mt-1 text-[11px] text-rose-600 truncate" :title="e.error"> {{ e.error }}</div>
</td>
<td class="px-3 py-3 align-middle text-xs text-slate-500 tabular-nums">{{ params(e) || '—' }}</td>
@@ -265,6 +278,11 @@ const params = (e) => {
:meta="[lightbox.model, lightbox.ratio, lightbox.resolution, lightbox.duration].filter(Boolean).join(' · ')"
:download-name="lightbox.file"
@close="lightbox = null" />
<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>
</section>
</template>
+16 -7
View File
@@ -3,6 +3,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { api, generatedUrl } from '../api'
import { fmtTs } from '../utils/format'
import { copyText } from '../utils/clipboard'
import Icon from '../components/Icon.vue'
import MediaLightbox from '../components/MediaLightbox.vue'
@@ -80,12 +81,17 @@ function fmtMs(ms) {
async function copyLink(name) {
try {
const u = generatedUrl(name)
await navigator.clipboard.writeText(u.startsWith('http') ? u : location.origin + u)
toast.value = '链接已复制'
setTimeout(() => (toast.value = ''), 1500)
} catch {}
const u = generatedUrl(name)
const ok = await copyText(u.startsWith('http') ? u : location.origin + u)
toast.value = ok ? '链接已复制' : '复制失败'
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('')
@@ -198,7 +204,10 @@ onUnmounted(() => {
<!-- 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" :title="e.prompt">{{ e.prompt }}</div>
<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="truncate" :title="e.model || ''">{{ e.model || '' }}</span>
<span class="shrink-0 flex items-center gap-1">
+16 -2
View File
@@ -20,7 +20,7 @@ const showAdd = ref(false)
const editing = ref(null)
const toast = ref('')
const addForm = ref({ email: '', name: '', password: '', role: 'user', credits: 0 })
const addForm = ref({ email: '', name: '', password: '', role: 'user', credits: 0, notes: '' })
const STATUS_OPTIONS = [
{ value: 'active', label: '正常' },
@@ -100,7 +100,7 @@ async function createUser() {
const r = await api('/users', jsonBody('POST', addForm.value))
if (r.ok) {
showAdd.value = false
addForm.value = { email: '', name: '', password: '', role: 'user', credits: 0 }
addForm.value = { email: '', name: '', password: '', role: 'user', credits: 0, notes: '' }
flash('用户已创建')
load()
} else flash(r.data?.detail || '创建失败')
@@ -115,6 +115,7 @@ async function saveEdit() {
status: u.status,
credits: u.credits,
role: u.role,
notes: u.notes || '',
}
if (u._newPassword) patch.password = u._newPassword
const r = await api(`/users/${u.id}`, jsonBody('PATCH', patch))
@@ -244,6 +245,7 @@ async function quickCredits(u, delta) {
<col class="w-9" /> <!-- select -->
<col class="w-40" /> <!-- username -->
<col /> <!-- email (flex) -->
<col class="w-36" /> <!-- notes -->
<col class="w-20" /> <!-- role -->
<col class="w-16" /> <!-- status switch -->
<col class="w-24" /> <!-- credits -->
@@ -261,6 +263,7 @@ async function quickCredits(u, delta) {
</th>
<th class="text-left px-5 py-3 font-medium">用户名</th>
<th class="text-left px-3 py-3 font-medium">邮箱</th>
<th class="text-left px-3 py-3 font-medium">备注</th>
<th class="text-left px-3 py-3 font-medium">角色</th>
<th class="text-left px-3 py-3 font-medium">状态</th>
<th class="text-right px-3 py-3 font-medium">积分</th>
@@ -284,6 +287,9 @@ async function quickCredits(u, delta) {
<td class="px-3 py-3.5 align-middle text-xs text-white/75 truncate" :title="u.email">
{{ u.email || '—' }}
</td>
<td class="px-3 py-3.5 align-middle text-xs truncate" :class="u.notes ? 'text-white/70' : 'text-white/25'" :title="u.notes || ''">
{{ u.notes || '—' }}
</td>
<td class="px-3 py-3.5 align-middle">
<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1 whitespace-nowrap"
:class="u.role === 'admin'
@@ -390,6 +396,10 @@ async function quickCredits(u, delta) {
<label class="lbl">角色</label>
<SelectMenu v-model="addForm.role" :options="ROLE_OPTIONS" />
</div>
<div>
<label class="lbl">备注 <span class="text-white/35">(可选)</span></label>
<textarea v-model="addForm.notes" rows="2" class="field resize-none" placeholder="给该用户加个备注,仅管理员可见"></textarea>
</div>
<div class="flex justify-end gap-2 pt-2">
<button @click="showAdd = false" class="btn-soft">取消</button>
<button @click="createUser" class="btn-primary">创建</button>
@@ -434,6 +444,10 @@ async function quickCredits(u, delta) {
<label class="lbl">积分</label>
<input v-model.number="editing.credits" type="number" min="0" step="1" class="field" />
</div>
<div>
<label class="lbl">备注 <span class="text-white/35">(可选)</span></label>
<textarea v-model="editing.notes" rows="2" class="field resize-none" placeholder="给该用户加个备注,仅管理员可见"></textarea>
</div>
<div>
<label class="lbl">重置密码 <span class="text-white/35">(留空保持不变)</span></label>
<input v-model="editing._newPassword" type="password" class="field" placeholder="新密码(8-24位,含大小写/数字/符号)" autocomplete="new-password" />