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:
@@ -10,7 +10,7 @@ const RATIO_OPTS = ['1:1', '16:9', '9:16', '4:3', '3:4', '21:9', '3:2', '5:4', '
|
||||
const IMG_RES = ['1K', '2K', '4K']
|
||||
const VID_RES = ['720p', '1080p', '2K', '4K']
|
||||
const ALL_RES = ['1K', '2K', '4K', '720p', '1080p']
|
||||
const DUR_OPTS = ['5s', '6s', '8s', '10s']
|
||||
const DUR_OPTS = ['5s', '6s', '8s', '10s', '15s']
|
||||
|
||||
const id = ref('')
|
||||
const type = ref('image')
|
||||
@@ -21,9 +21,30 @@ const weight = ref(0)
|
||||
// tier -> { price, agent } — blank price means the tier is NOT supported.
|
||||
const res = ref(Object.fromEntries(ALL_RES.map((r) => [r, { price: '', agent: '' }])))
|
||||
const dur = ref(Object.fromEntries(DUR_OPTS.map((d) => [d, { price: '', agent: '' }])))
|
||||
// Duration rows shown: the presets above + any custom seconds the admin adds.
|
||||
const durList = ref([...DUR_OPTS])
|
||||
const customDurInput = ref('')
|
||||
const error = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
// Add a custom duration (any positive integer seconds, e.g. 12 → "12s").
|
||||
function addCustomDur() {
|
||||
const n = parseInt(customDurInput.value, 10)
|
||||
if (!(n > 0)) return
|
||||
const key = n + 's'
|
||||
if (!durList.value.includes(key)) {
|
||||
if (!dur.value[key]) dur.value[key] = { price: '', agent: '' }
|
||||
durList.value.push(key)
|
||||
}
|
||||
customDurInput.value = ''
|
||||
}
|
||||
// Custom (non-preset) durations can be removed; presets stay.
|
||||
function removeDur(key) {
|
||||
if (DUR_OPTS.includes(key)) return
|
||||
durList.value = durList.value.filter((k) => k !== key)
|
||||
delete dur.value[key]
|
||||
}
|
||||
|
||||
const isVideo = computed(() => type.value === 'video')
|
||||
// Resolution tiers depend on type: image = 1K/2K/4K, video = 540p/720p/1080p/2K/4K.
|
||||
const resOpts = computed(() => (isVideo.value ? VID_RES : IMG_RES))
|
||||
@@ -145,12 +166,22 @@ async function save() {
|
||||
<div v-if="isVideo">
|
||||
<label class="text-xs text-slate-500 block mb-1.5">时长 · 价格(总价 = 分辨率价 + 时长价;<strong class="text-slate-600">留空 = 不支持</strong>)</label>
|
||||
<div class="space-y-1.5">
|
||||
<div v-for="d in DUR_OPTS" :key="d" class="flex items-center gap-2">
|
||||
<div v-for="d in durList" :key="d" class="flex items-center gap-2">
|
||||
<span class="w-16 text-xs font-mono text-slate-500">{{ d }}</span>
|
||||
<input v-model="dur[d].price" type="number" class="field !py-1 flex-1" placeholder="普通价(留空=不支持)" />
|
||||
<input v-model="dur[d].agent" type="number" class="field !py-1 flex-1" placeholder="代理价(留空跟随)" />
|
||||
<button v-if="!DUR_OPTS.includes(d)" type="button" @click="removeDur(d)"
|
||||
class="shrink-0 w-7 h-7 grid place-items-center rounded text-slate-400 hover:text-rose-500 hover:bg-rose-50" title="删除该时长">
|
||||
<Icon name="close" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 自定义时长:输入任意秒数,Sora 类上游支持的任意时长都能加 -->
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<input v-model="customDurInput" type="number" min="1" @keydown.enter.prevent="addCustomDur"
|
||||
class="field !py-1 w-32" placeholder="自定义秒数" />
|
||||
<button type="button" @click="addCustomDur" class="btn-soft text-xs whitespace-nowrap">+ 添加时长</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
// admin (图片管理 / 日志) and the user-facing (画图记录) surfaces. Parent controls
|
||||
// mount via v-if and passes the resolved media URL + meta; the component owns the
|
||||
// overlay shell, image/video element, prompt + meta block, and action buttons.
|
||||
import { ref } from 'vue'
|
||||
import { copyText } from '../utils/clipboard'
|
||||
import Icon from './Icon.vue'
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
src: { type: String, required: true }, // resolved media URL (generatedUrl)
|
||||
kind: { type: String, default: 'image' }, // 'image' | 'video'
|
||||
prompt: { type: String, default: '' },
|
||||
@@ -14,12 +16,25 @@ defineProps({
|
||||
downloadName: { type: String, default: '' },
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const toast = ref('')
|
||||
let toastTimer = null
|
||||
async function copyPrompt() {
|
||||
if (!props.prompt) return
|
||||
toast.value = (await copyText(props.prompt)) ? '指令已复制' : '复制失败'
|
||||
clearTimeout(toastTimer)
|
||||
toastTimer = setTimeout(() => (toast.value = ''), 1800)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<transition name="lb-fade" appear>
|
||||
<div class="media-card fixed inset-0 z-50 bg-slate-950/85 backdrop-blur-sm flex items-center justify-center p-6"
|
||||
@click.self="emit('close')">
|
||||
<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 ring-1 ring-white/10">
|
||||
{{ toast }}
|
||||
</div>
|
||||
<!-- Wrapper shrinks to the media's rendered width, so the info row below
|
||||
lines up flush with the image's left & right edges (one clean column). -->
|
||||
<div class="flex flex-col max-h-full max-w-full">
|
||||
@@ -29,7 +44,8 @@ const emit = defineEmits(['close'])
|
||||
|
||||
<div class="mt-3 flex items-start justify-between gap-4 text-white">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div v-if="prompt" class="text-sm font-medium leading-snug line-clamp-3 break-words" :title="prompt">{{ prompt }}</div>
|
||||
<div v-if="prompt" @click="copyPrompt" title="点击复制提示词"
|
||||
class="text-sm font-medium leading-snug line-clamp-3 break-words cursor-pointer transition-colors hover:text-white/75">{{ prompt }}</div>
|
||||
<div v-if="meta" class="text-xs text-white/60 mt-1 font-mono break-all">{{ meta }}</div>
|
||||
<div v-if="metaSub" class="text-xs text-white/45 mt-1">{{ metaSub }}</div>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,8 @@ const currentLabel = computed(() => route.meta?.label || '')
|
||||
<!-- ===== Sidebar ===== -->
|
||||
<aside class="w-60 shrink-0 border-r border-[color:var(--hairline)] bg-[var(--surface)] backdrop-blur-md flex flex-col">
|
||||
<router-link to="/" class="h-16 flex items-center gap-2.5 px-5 border-b border-[color:var(--hairline)] group">
|
||||
<Logo :size="32" class="rounded-[10px] shadow-lg shadow-violet-500/20 ring-1 ring-white/10" />
|
||||
<img v-if="site.logo" :src="site.logo" :alt="site.title" class="w-8 h-8 rounded-[10px] object-contain shadow-lg shadow-violet-500/20 ring-1 ring-white/10" />
|
||||
<Logo v-else :size="32" class="rounded-[10px] shadow-lg shadow-violet-500/20 ring-1 ring-white/10" />
|
||||
<div class="leading-tight min-w-0">
|
||||
<div class="text-sm font-semibold truncate tracking-tight text-[color:var(--fg)]">{{ site.title }}</div>
|
||||
<div class="text-[11px] text-[color:var(--fg-3)] truncate">Admin</div>
|
||||
|
||||
@@ -107,10 +107,11 @@ const currentLabel = computed(() => {
|
||||
stamp doesn't jump around. -->
|
||||
<header class="relative z-10 px-8 md:px-14 pt-10 pb-4 flex items-center justify-between gap-4">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-[22px] font-bold tracking-tight bg-gradient-to-r from-fuchsia-300 via-violet-300 to-sky-300 bg-clip-text text-transparent">
|
||||
<img v-if="site.logo" :src="site.logo" :alt="site.title" class="h-7 w-auto self-center object-contain" />
|
||||
<span v-else class="text-[22px] font-bold tracking-tight bg-gradient-to-r from-fuchsia-300 via-violet-300 to-sky-300 bg-clip-text text-transparent">
|
||||
{{ site.title }}
|
||||
</span>
|
||||
<span class="text-[10px] uppercase tracking-[0.3em] text-[color:var(--fg-faint)]">{{ route.path === '/' ? 'AI 生图 · 生视频' : currentLabel }}</span>
|
||||
<span class="text-[10px] uppercase tracking-[0.3em] text-[color:var(--fg-faint)]">{{ route.path === '/' ? (site.subtitle || 'AI 生图 · 生视频') : currentLabel }}</span>
|
||||
</div>
|
||||
<router-link v-if="showBalance" to="/settings"
|
||||
class="text-xs text-[color:var(--fg-2)] hover:text-[color:var(--fg)] tabular-nums transition-colors">
|
||||
|
||||
@@ -34,7 +34,7 @@ const routes = [
|
||||
children: [
|
||||
{ path: '', component: HomeView, meta: { label: '首页' } },
|
||||
{ path: 'user', component: PlaygroundView, meta: { label: '画图' } },
|
||||
{ path: 'logs', component: UserLogsView, meta: { label: '记录' } },
|
||||
{ path: 'logs', component: UserLogsView, meta: { label: '图片' } },
|
||||
{ path: 'mylogs', component: UserLogsTableView, meta: { label: '日志' } },
|
||||
{ path: 'invite', component: InviteView, meta: { label: '邀请' } },
|
||||
{ path: 'docs', component: DocsView, meta: { label: '文档' } },
|
||||
|
||||
@@ -8,6 +8,8 @@ const BASE = import.meta.env.VITE_API_BASE || ''
|
||||
|
||||
export const site = reactive({
|
||||
title: 'Vivid',
|
||||
logo: '',
|
||||
subtitle: '',
|
||||
// Defaults so the 关于 page is never blank even if /site hasn't loaded (or a
|
||||
// cache serves an older payload without `contact`). The backend value, once
|
||||
// fetched, overrides these.
|
||||
@@ -28,6 +30,8 @@ export async function loadSite() {
|
||||
if (r.ok) {
|
||||
const data = await r.json()
|
||||
if (data.title) site.title = String(data.title)
|
||||
site.logo = data.logo ? String(data.logo) : ''
|
||||
site.subtitle = data.subtitle ? String(data.subtitle) : ''
|
||||
if (data.contact) site.contact = { ...site.contact, ...data.contact }
|
||||
}
|
||||
} catch { /* offline — keep the default. */ }
|
||||
|
||||
@@ -101,6 +101,11 @@ html:not(.dark) .theme-text :is(.fp, .pg, .act, .filter-pill, .kind-btn, .preset
|
||||
color: var(--fg); background: var(--hover);
|
||||
}
|
||||
html:not(.dark) .theme-text :is(.fp-on, .pg-on, .seg-on, .opt-on) { background: rgb(15 23 42); color: #fff; box-shadow: none; }
|
||||
/* ShowcaseView marks the selected filter/kind tab with an `.on` modifier on
|
||||
`.filter-pill`/`.kind-btn` (not the `-on` class above). Without this, the
|
||||
neutral `.filter-pill`/`.kind-btn` rescue paints selected == unselected and
|
||||
the highlight vanishes on a white page. Equal-or-higher specificity, later. */
|
||||
html:not(.dark) .theme-text :is(.filter-pill, .kind-btn).on { background: rgb(15 23 42); color: #fff; box-shadow: none; }
|
||||
/* Selected COLORED filter pills — their scoped (dark) selected colors get
|
||||
overridden by the neutral `.fp` rescue above, so re-state a light-mode
|
||||
variant (tinted bg + dark-enough text + colored ring) with equal specificity
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// Robust clipboard copy. navigator.clipboard only exists in a SECURE context
|
||||
// (https or localhost) — on http://<ip> it's undefined, so the modern path
|
||||
// throws and we fall back to the legacy execCommand('copy') via a hidden
|
||||
// textarea, which works without a secure context. Returns true on success.
|
||||
export async function copyText(text) {
|
||||
const s = text == null ? '' : String(text)
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(s)
|
||||
return true
|
||||
}
|
||||
} catch { /* fall through to the legacy path */ }
|
||||
try {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = s
|
||||
ta.setAttribute('readonly', '')
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.top = '-9999px'
|
||||
ta.style.opacity = '0'
|
||||
document.body.appendChild(ta)
|
||||
ta.focus()
|
||||
ta.select()
|
||||
const ok = document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
return ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 (24–48h 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>
|
||||
|
||||
@@ -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 1–4 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' ? '生成视频中 (约 1–3 分钟)…' : '生成中…'
|
||||
// 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' ? '生成视频中 (约 1–3 分钟)…' : '生成中…'
|
||||
// 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' ? '生成视频中 (约 1–3 分钟)…' : '生成中…'
|
||||
}
|
||||
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"
|
||||
<!-- 生图张数 1–4 (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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user