- Logo 渐变从粉紫改为橙/粉/蓝蜡笔色,快门圆点改橙色 - 批量替换 accent:violet→orange、fuchsia→rose、indigo→sky、purple→orange, 十六进制与 rgb 字面量同步换算为 Tailwind 对应色 - 修复类名合并冲突:管理员筛选 pill 改 fp-orange、会员号改 fp-pink(新增), style.css 中去重 fp-rose/fp-orange 并补 fp-pink 的浅色模式变体 - 选区色、标签输入框、筛选 pill、图表、滑块等所有紫色残留全部归位 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
710 lines
38 KiB
Vue
710 lines
38 KiB
Vue
<script setup>
|
||
import { ref, reactive, computed, onMounted } from 'vue'
|
||
import { api, jsonBody } from '../api'
|
||
import { site, applyFavicon } from '../site'
|
||
import TagInput from '../components/TagInput.vue'
|
||
import Logo from '../components/Logo.vue'
|
||
|
||
// ---- logs (retention window) ----
|
||
const logsCfg = reactive({ retention_days: 30 })
|
||
const logsBusy = ref(false); const logsSaved = ref(false)
|
||
async function loadLogs() {
|
||
const r = await api('/settings/logs')
|
||
if (r.ok && r.data) logsCfg.retention_days = Number(r.data.retention_days) || 30
|
||
}
|
||
async function saveLogs() {
|
||
logsBusy.value = true; logsSaved.value = false
|
||
const r = await api('/settings/logs', jsonBody('PUT', { retention_days: Number(logsCfg.retention_days) || 30 }))
|
||
logsBusy.value = false
|
||
if (r.ok) { logsSaved.value = true; setTimeout(() => (logsSaved.value = false), 2000) }
|
||
}
|
||
|
||
// ---- media (生成图片/视频文件留存) ----
|
||
const mediaCfg = reactive({ retention_days: 30 })
|
||
const mediaBusy = ref(false); const mediaSaved = ref(false); const mediaRemoved = ref(0)
|
||
async function loadMedia() {
|
||
const r = await api('/settings/media')
|
||
if (r.ok && r.data) mediaCfg.retention_days = Number(r.data.retention_days) || 30
|
||
}
|
||
async function saveMedia() {
|
||
mediaBusy.value = true; mediaSaved.value = false; mediaRemoved.value = 0
|
||
const r = await api('/settings/media', jsonBody('PUT', { retention_days: Number(mediaCfg.retention_days) || 30 }))
|
||
mediaBusy.value = false
|
||
if (r.ok) {
|
||
mediaSaved.value = true
|
||
mediaRemoved.value = Number(r.data?.removed || 0)
|
||
setTimeout(() => (mediaSaved.value = false), 2500)
|
||
}
|
||
}
|
||
|
||
// ---- site (branding shown across the app) ----
|
||
// Default homepage 子标题 — shown on the home Hero when unset, and pre-filled
|
||
// into the input so the admin edits from it (same idea as 标题 defaulting to Vivid).
|
||
const DEFAULT_SUBTITLE = '把脑海里的画面写成一句话,GPT、Gemini、Firefly、Flux 等顶级模型替你变成图像与视频。'
|
||
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 || DEFAULT_SUBTITLE
|
||
const c = r.data.contact || {}
|
||
siteForm.qq = c.qq || ''; siteForm.qq_link = c.qq_link || ''
|
||
siteForm.qq_group = c.qq_group || ''
|
||
siteForm.qq_group_link = c.qq_group_link || ''
|
||
siteForm.email = c.email || ''; siteForm.shop = c.shop || ''
|
||
}
|
||
}
|
||
// ---- logo (drag/click to stage; uploaded to RustFS only on 保存) ----
|
||
const logoStaged = ref('') // dataUrl of a newly picked logo, pending save
|
||
const logoRemove = ref(false) // true → delete logo on save (back to default)
|
||
const logoDragOver = ref(false)
|
||
const logoInput = ref(null)
|
||
// What the preview shows: staged file > (removed → none) > current saved logo.
|
||
const logoPreview = computed(() => logoStaged.value || (logoRemove.value ? '' : (siteForm.logo || '')))
|
||
function pickLogo() { logoInput.value && logoInput.value.click() }
|
||
function readLogo(f) {
|
||
if (!f || !f.type || !f.type.startsWith('image/')) return
|
||
if (f.size > 4 * 1024 * 1024) { flashSite('logo 不能超过 4MB'); return }
|
||
const reader = new FileReader()
|
||
reader.onload = () => { logoStaged.value = reader.result; logoRemove.value = false }
|
||
reader.readAsDataURL(f)
|
||
}
|
||
function onLogoInput(ev) { readLogo((ev.target.files || [])[0]); if (ev.target) ev.target.value = '' }
|
||
function onLogoDrop(ev) { ev.preventDefault(); logoDragOver.value = false; readLogo((ev.dataTransfer?.files || [])[0]) }
|
||
function clearLogo() { logoStaged.value = ''; logoRemove.value = true } // 恢复默认
|
||
const siteErr = ref('')
|
||
function flashSite(msg) { siteErr.value = msg; setTimeout(() => (siteErr.value = ''), 2500) }
|
||
|
||
async function saveSite() {
|
||
siteBusy.value = true; siteSaved.value = false; siteErr.value = ''
|
||
// 1) logo first — upload the staged file (or delete) to RustFS, only on 保存.
|
||
if (logoStaged.value) {
|
||
const lr = await api('/settings/logo', jsonBody('POST', { data: logoStaged.value }))
|
||
if (lr.ok && lr.data?.logo != null) { siteForm.logo = lr.data.logo; site.logo = lr.data.logo; applyFavicon(site.logo); logoStaged.value = '' }
|
||
else { siteBusy.value = false; flashSite(lr.data?.detail || 'Logo 上传失败'); return }
|
||
} else if (logoRemove.value) {
|
||
await api('/settings/logo', { method: 'DELETE' })
|
||
siteForm.logo = ''; site.logo = ''; applyFavicon(''); logoRemove.value = false
|
||
}
|
||
// 2) the rest of the site form (logo is managed above, not sent here).
|
||
const r = await api('/settings/site', jsonBody('PUT', {
|
||
title: siteForm.title,
|
||
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.subtitle = r.data.data?.subtitle ?? siteForm.subtitle.trim()
|
||
// Mirror into the shared `site` store so headers / wordmark update without a reload.
|
||
site.title = r.data.data?.title || siteForm.title.trim()
|
||
site.contact = r.data.data?.contact || site.contact
|
||
siteSaved.value = true
|
||
setTimeout(() => (siteSaved.value = false), 2000)
|
||
} else flashSite(r.data?.detail || '保存失败')
|
||
}
|
||
|
||
// ---- registration ----
|
||
const reg = reactive({ open: true, email_code: false, allow_password_reset: true })
|
||
// Email domain whitelist edited as tag chips so admins can't typo a comma
|
||
// out of a domain or accidentally leave a stray space.
|
||
const domains = ref([])
|
||
const regBusy = ref(false); const regSaved = ref(false)
|
||
|
||
// ---- smtp ----
|
||
const smtp = reactive({ host: '', port: 587, username: '', password: '', from_addr: '', use_tls: true })
|
||
const smtpBusy = ref(false); const smtpSaved = ref(false)
|
||
|
||
// ---- rewards ----
|
||
const credits = reactive({ checkin_enabled: true, checkin_reward: 3, invite_enabled: true, invite_reward: 3, cdk_redeem_enabled: true })
|
||
const credBusy = ref(false); const credSaved = ref(false)
|
||
|
||
// ---- deai (去AI特征 附加价格) ----
|
||
const deaiCfg = reactive({ enabled: false, price_1k: 1, price_2k: 2, price_4k: 3 })
|
||
const deaiBusy = ref(false); const deaiSaved = ref(false)
|
||
async function loadDeai() {
|
||
const r = await api('/settings/deai')
|
||
if (r.ok && r.data) Object.assign(deaiCfg, r.data)
|
||
}
|
||
async function saveDeai() {
|
||
deaiBusy.value = true; deaiSaved.value = false
|
||
const r = await api('/settings/deai', jsonBody('PUT', {
|
||
enabled: !!deaiCfg.enabled,
|
||
price_1k: Number(deaiCfg.price_1k) || 0,
|
||
price_2k: Number(deaiCfg.price_2k) || 0,
|
||
price_4k: Number(deaiCfg.price_4k) || 0,
|
||
}))
|
||
deaiBusy.value = false
|
||
if (r.ok) { deaiSaved.value = true; setTimeout(() => (deaiSaved.value = false), 2000) }
|
||
}
|
||
|
||
// ---- announcement (公告, markdown; re-pops for users who haven't seen edits) ----
|
||
const ann = reactive({ content: '' })
|
||
const annBusy = ref(false); const annSaved = ref(false)
|
||
async function loadAnnouncement() {
|
||
const r = await api('/settings/announcement')
|
||
if (r.ok && r.data) ann.content = r.data.content || ''
|
||
}
|
||
async function saveAnnouncement() {
|
||
annBusy.value = true; annSaved.value = false
|
||
const r = await api('/settings/announcement', jsonBody('PUT', { content: ann.content }))
|
||
annBusy.value = false
|
||
if (r.ok) { annSaved.value = true; setTimeout(() => (annSaved.value = false), 2000) }
|
||
}
|
||
|
||
// ---- payment (易支付 充值) ----
|
||
const pay = reactive({ enabled: false, pid: '', key: '', api_base: '', methods: ['wxpay', 'alipay'], min_amount: 1, points_ratio: 100 })
|
||
const payBusy = ref(false); const paySaved = ref(false); const payErr = ref('')
|
||
const PAY_METHODS = [{ v: 'wxpay', label: '微信' }, { v: 'alipay', label: '支付宝' }]
|
||
async function loadPay() {
|
||
const r = await api('/settings/pay')
|
||
if (r.ok && r.data) Object.assign(pay, r.data, { methods: r.data.methods || [] })
|
||
}
|
||
function togglePayMethod(m) {
|
||
const i = pay.methods.indexOf(m)
|
||
if (i >= 0) pay.methods.splice(i, 1); else pay.methods.push(m)
|
||
}
|
||
async function savePay() {
|
||
payBusy.value = true; paySaved.value = false; payErr.value = ''
|
||
const r = await api('/settings/pay', jsonBody('PUT', {
|
||
...pay, min_amount: Number(pay.min_amount) || 0, points_ratio: Number(pay.points_ratio) || 100,
|
||
}))
|
||
payBusy.value = false
|
||
if (r.ok) { paySaved.value = true; setTimeout(() => (paySaved.value = false), 2000) }
|
||
else payErr.value = r.data?.detail || '保存失败'
|
||
}
|
||
|
||
// ---- proxy (carried when calling upstream during generation) ----
|
||
const proxy = reactive({ proxy: '' })
|
||
const proxyBusy = ref(false); const proxySaved = ref(false)
|
||
async function loadProxy() {
|
||
const r = await api('/settings/proxy')
|
||
if (r.ok && r.data) proxy.proxy = r.data.proxy || ''
|
||
}
|
||
async function saveProxy() {
|
||
proxyBusy.value = true; proxySaved.value = false
|
||
const r = await api('/settings/proxy', jsonBody('PUT', { proxy: proxy.proxy }))
|
||
proxyBusy.value = false
|
||
if (r.ok) { proxySaved.value = true; setTimeout(() => (proxySaved.value = false), 2000) }
|
||
}
|
||
// Probe the currently-entered proxy (not necessarily saved) — surfaces the
|
||
// egress IP on success, or the concrete dial/DNS error on failure.
|
||
const proxyTestBusy = ref(false)
|
||
const proxyTest = reactive({ ok: null, msg: '' }) // ok: null=idle, true/false=result
|
||
async function testProxy() {
|
||
proxyTestBusy.value = true; proxyTest.ok = null; proxyTest.msg = ''
|
||
const r = await api('/settings/proxy/test', jsonBody('POST', { proxy: proxy.proxy }))
|
||
proxyTestBusy.value = false
|
||
if (r.ok && r.data?.ok) {
|
||
proxyTest.ok = true
|
||
const ip = r.data.data?.exit_ip || '未知'
|
||
const ms = r.data.data?.elapsed_ms
|
||
proxyTest.msg = `连接成功 · 出口 IP ${ip}${ms != null ? ` · ${ms}ms` : ''}`
|
||
} else {
|
||
proxyTest.ok = false
|
||
proxyTest.msg = r.data?.detail || '代理测试失败'
|
||
}
|
||
}
|
||
|
||
// Email-code requires SMTP to be configured (host saved) first.
|
||
const smtpConfigured = computed(() => !!(smtp.host || '').trim())
|
||
|
||
// SMTP save requires the four required fields to be non-empty. Password is
|
||
// optional on update — leaving it blank means "keep the current one".
|
||
const smtpReady = computed(() =>
|
||
(smtp.host || '').trim() &&
|
||
Number(smtp.port) > 0 &&
|
||
(smtp.username || '').trim() &&
|
||
(smtp.from_addr || '').trim()
|
||
)
|
||
|
||
async function loadReg() {
|
||
const r = await api('/settings/registration')
|
||
if (r.ok && r.data) {
|
||
Object.assign(reg, { open: r.data.open, email_code: r.data.email_code,
|
||
allow_password_reset: r.data.allow_password_reset })
|
||
// Normalise to lowercase + strip a leading @ on each so the chips render
|
||
// exactly what the server will compare against.
|
||
domains.value = (r.data.allowed_email_domains || [])
|
||
.map((d) => String(d).trim().toLowerCase().replace(/^@/, ''))
|
||
.filter(Boolean)
|
||
}
|
||
}
|
||
async function loadSmtp() {
|
||
const r = await api('/settings/smtp')
|
||
if (r.ok && r.data) Object.assign(smtp, r.data)
|
||
}
|
||
async function loadCredits() {
|
||
const r = await api('/settings/credits')
|
||
if (r.ok && r.data) Object.assign(credits, r.data)
|
||
}
|
||
|
||
async function saveReg() {
|
||
regBusy.value = true; regSaved.value = false
|
||
const r = await api('/settings/registration', jsonBody('PUT', {
|
||
open: reg.open, email_code: reg.email_code,
|
||
allow_password_reset: reg.allow_password_reset,
|
||
allowed_email_domains: domains.value,
|
||
}))
|
||
regBusy.value = false
|
||
if (r.ok) { regSaved.value = true; setTimeout(() => (regSaved.value = false), 2000); loadReg() }
|
||
}
|
||
async function saveSmtp() {
|
||
smtpBusy.value = true; smtpSaved.value = false
|
||
const payload = { host: smtp.host, port: Number(smtp.port) || 587, username: smtp.username,
|
||
from_addr: smtp.from_addr, use_tls: smtp.use_tls }
|
||
if (smtp.password && smtp.password !== '***') payload.password = smtp.password
|
||
const r = await api('/settings/smtp', jsonBody('PUT', payload))
|
||
smtpBusy.value = false
|
||
if (r.ok) { smtpSaved.value = true; setTimeout(() => (smtpSaved.value = false), 2000); loadSmtp() }
|
||
}
|
||
|
||
// ---- SMTP test send ----
|
||
const testEmail = ref('')
|
||
const testBusy = ref(false)
|
||
const testMsg = ref('') // result message (success or error)
|
||
const testOk = ref(false)
|
||
async function sendTest() {
|
||
const to = (testEmail.value || '').trim()
|
||
if (!to || !to.includes('@')) { testMsg.value = '请填写有效的收件邮箱'; testOk.value = false; return }
|
||
testBusy.value = true; testMsg.value = ''
|
||
// Tests the SAVED config, so save first if you just edited the fields.
|
||
const r = await api('/settings/smtp/test', jsonBody('POST', { email: to }))
|
||
testBusy.value = false
|
||
testOk.value = r.ok
|
||
testMsg.value = r.ok ? (r.data?.detail || `测试邮件已发送至 ${to}`) : (r.data?.detail || `发送失败 (${r.status})`)
|
||
}
|
||
async function saveCredits() {
|
||
credBusy.value = true; credSaved.value = false
|
||
const r = await api('/settings/credits', jsonBody('PUT', {
|
||
checkin_enabled: credits.checkin_enabled,
|
||
checkin_reward: Number(credits.checkin_reward) || 0,
|
||
invite_enabled: credits.invite_enabled,
|
||
invite_reward: Number(credits.invite_reward) || 0,
|
||
cdk_redeem_enabled: credits.cdk_redeem_enabled,
|
||
}))
|
||
credBusy.value = false
|
||
if (r.ok) { credSaved.value = true; setTimeout(() => (credSaved.value = false), 2000) }
|
||
}
|
||
|
||
onMounted(() => { loadSite(); loadReg(); loadSmtp(); loadCredits(); loadAnnouncement(); loadPay(); loadProxy(); loadLogs(); loadMedia(); loadDeai() })
|
||
</script>
|
||
|
||
<template>
|
||
<section class="space-y-5">
|
||
<!-- site -->
|
||
<div class="card p-5">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<h2 class="text-sm font-semibold">网站</h2>
|
||
<span v-if="siteSaved" class="text-xs text-emerald-300">已保存 ✓</span>
|
||
</div>
|
||
<div class="space-y-3">
|
||
<label class="row">
|
||
<span><span class="lbl">网页主标题</span><span class="hint">显示在浏览器标签、首页 Logo、侧栏和登录卡上。未设置时默认显示 "Vivid"。</span></span>
|
||
<input v-model="siteForm.title" placeholder="Vivid" class="txt" />
|
||
</label>
|
||
<div class="row">
|
||
<span><span class="lbl">Logo</span><span class="hint">侧栏 / 公开页头部 / 浏览器标签的 Logo。点击或拖拽图片到下图替换,点「保存设置」后才上传到存储(替换会自动删旧图)。下图当前显示的就是默认 Logo。</span></span>
|
||
<div class="flex items-center gap-3">
|
||
<div @click="pickLogo" @drop="onLogoDrop" @dragover.prevent="logoDragOver = true" @dragleave="logoDragOver = false"
|
||
title="点击或拖拽图片替换"
|
||
class="w-16 h-16 rounded-xl grid place-items-center overflow-hidden shrink-0 cursor-pointer transition-all"
|
||
:class="logoDragOver ? 'ring-2 ring-sky-400 bg-sky-50/40' : ''">
|
||
<img v-if="logoPreview" :src="logoPreview" class="w-full h-full object-cover" />
|
||
<Logo v-else :size="64" class="w-full h-full" />
|
||
</div>
|
||
<input ref="logoInput" type="file" accept="image/*" class="hidden" @change="onLogoInput" />
|
||
</div>
|
||
</div>
|
||
<p v-if="siteErr" class="text-xs text-rose-500 -mt-1">{{ siteErr }}</p>
|
||
<label class="row">
|
||
<span><span class="lbl">子标题</span><span class="hint">首页 Hero 大标题下方那句话。留空则显示默认:「把脑海里的画面写成一句话,GPT、Gemini、Firefly、Flux 等顶级模型替你变成图像与视频。」</span></span>
|
||
<input v-model="siteForm.subtitle" placeholder="留空 = 默认那句宣传语" 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" />
|
||
</label>
|
||
<label class="row">
|
||
<span><span class="lbl">QQ 链接</span><span class="hint">加好友链接(qm.qq.com/...)。填了则「关于」里的 QQ 可点击、新标签打开。</span></span>
|
||
<input v-model="siteForm.qq_link" placeholder="https://qm.qq.com/q/ItgCcNA7ac" class="txt" />
|
||
</label>
|
||
<label class="row">
|
||
<span><span class="lbl">QQ 群</span><span class="hint">交流群号(显示用)。留空则不显示。</span></span>
|
||
<input v-model="siteForm.qq_group" placeholder="1106849765" class="txt" />
|
||
</label>
|
||
<label class="row">
|
||
<span><span class="lbl">QQ 群链接</span><span class="hint">加群链接(qm.qq.com/...)。填了则「关于」里的 QQ 群可点击、新标签打开。</span></span>
|
||
<input v-model="siteForm.qq_group_link" placeholder="https://qm.qq.com/q/976LeMFoHu" class="txt" />
|
||
</label>
|
||
<label class="row">
|
||
<span><span class="lbl">联系邮箱</span><span class="hint">首页「联系我们」里可点击发邮件。留空则不显示。</span></span>
|
||
<input v-model="siteForm.email" placeholder="vividairun@gmail.com" class="txt" />
|
||
</label>
|
||
<label class="row">
|
||
<span><span class="lbl">商店地址</span><span class="hint">充值/购买页链接,首页「联系我们」里展示为"前往充值商店"。留空则不显示。</span></span>
|
||
<input v-model="siteForm.shop" placeholder="https://pay.ldxp.cn/shop/chiyi" class="txt" />
|
||
</label>
|
||
</div>
|
||
<div class="mt-4 flex items-center gap-3">
|
||
<button @click="saveSite" :disabled="siteBusy || !siteForm.title.trim()" class="btn-primary">{{ siteBusy ? '保存中…' : '保存设置' }}</button>
|
||
<span v-if="!siteForm.title.trim()" class="text-xs text-slate-400">请输入主标题</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- registration -->
|
||
<div class="card p-5">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<h2 class="text-sm font-semibold">注册与登录</h2>
|
||
<span v-if="regSaved" class="text-xs text-emerald-300">已保存 ✓</span>
|
||
</div>
|
||
<div class="space-y-3">
|
||
<label class="row">
|
||
<span><span class="lbl">开放注册</span><span class="hint">关闭后只能由管理员手动创建账号(首个账号不受限)。</span></span>
|
||
<input type="checkbox" v-model="reg.open" class="sw" />
|
||
</label>
|
||
<label class="row" :class="!smtpConfigured && 'opacity-50'">
|
||
<span><span class="lbl">注册/找回需要邮箱验证码</span><span class="hint">启用后注册、找回密码需输入邮件发送的验证码。<b v-if="!smtpConfigured">需先在下方配置并保存 SMTP 才能开启。</b></span></span>
|
||
<input type="checkbox" v-model="reg.email_code" :disabled="!smtpConfigured" class="sw" />
|
||
</label>
|
||
<label class="row" :class="!reg.email_code && 'opacity-50'">
|
||
<span><span class="lbl">支持找回密码</span><span class="hint">凭邮箱+邮件验证码重置。<b v-if="!reg.email_code">需先开启「邮箱验证码」才能启用。</b></span></span>
|
||
<!-- When email_code is off, password reset is effectively disabled
|
||
regardless of the stored flag (the auth endpoint gates it the
|
||
same way). Reflect that by showing the switch off — but keep
|
||
the stored value untouched so re-enabling email_code restores
|
||
the user's prior preference. -->
|
||
<input type="checkbox"
|
||
:checked="reg.email_code && reg.allow_password_reset"
|
||
:disabled="!reg.email_code"
|
||
@change="reg.allow_password_reset = $event.target.checked"
|
||
class="sw" />
|
||
</label>
|
||
<!-- Stack the tag input under the label so the chips have room -->
|
||
<div class="row !block !border-b-0">
|
||
<div class="mb-2">
|
||
<span class="lbl">允许的邮箱后缀</span><br />
|
||
<span class="hint">输入后缀按回车添加(如 gmail.com),点 × 删除。<b>留空 = 不限制</b>,允许任意域名注册。</span>
|
||
</div>
|
||
<TagInput v-model="domains" placeholder="留空 = 不限制,或输入后缀回车添加" />
|
||
</div>
|
||
</div>
|
||
<div class="mt-4 flex items-center gap-3">
|
||
<button @click="saveReg" :disabled="regBusy" class="btn-primary">{{ regBusy ? '保存中…' : '保存设置' }}</button>
|
||
<span v-if="!domains.length" class="text-xs text-slate-400">未设置后缀 = 不限制邮箱域名</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- SMTP -->
|
||
<div class="card p-5">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<h2 class="text-sm font-semibold">邮件服务 (SMTP)</h2>
|
||
<span v-if="smtpSaved" class="text-xs text-emerald-300">已保存 ✓</span>
|
||
</div>
|
||
<p class="text-xs text-slate-400 mb-4">用于发送注册 / 找回密码的验证码邮件。465 端口自动用 SSL,其余端口可选 STARTTLS。</p>
|
||
<div class="grid sm:grid-cols-2 gap-3">
|
||
<div><label class="flbl">SMTP 主机</label><input v-model="smtp.host" placeholder="smtp.gmail.com" class="field" /></div>
|
||
<div><label class="flbl">端口</label><input type="number" v-model.number="smtp.port" placeholder="587" class="field" /></div>
|
||
<div><label class="flbl">用户名</label><input v-model="smtp.username" placeholder="you@gmail.com" class="field" /></div>
|
||
<div><label class="flbl">密码 / 授权码</label><input v-model="smtp.password" type="password" placeholder="留空表示不修改" class="field" /></div>
|
||
<div><label class="flbl">发件地址 (From)</label><input v-model="smtp.from_addr" placeholder="no-reply@yourdomain.com" class="field" /></div>
|
||
</div>
|
||
<!-- STARTTLS sits below the grid as its own labelled row, matching the
|
||
layout of the toggle-style settings in 注册与登录 / 积分奖励 -->
|
||
<label class="row mt-2">
|
||
<span><span class="lbl">使用 STARTTLS</span><span class="hint">端口 587 等明文端口加密会话;465 端口已自动用 SSL,无需开启。</span></span>
|
||
<input type="checkbox" v-model="smtp.use_tls" class="sw" />
|
||
</label>
|
||
<div class="mt-4 flex items-center gap-3">
|
||
<button @click="saveSmtp" :disabled="smtpBusy || !smtpReady" class="btn-primary">{{ smtpBusy ? '保存中…' : '保存设置' }}</button>
|
||
<span v-if="!smtpReady" class="text-xs text-slate-400">请填写 主机 / 端口 / 用户名 / 发件地址</span>
|
||
</div>
|
||
|
||
<!-- Test send: verifies the SAVED config actually delivers mail -->
|
||
<div class="mt-4 pt-4 border-t border-white/[0.06]">
|
||
<label class="flbl">测试发送 <span class="text-slate-400 font-normal">(用已保存的配置发一封测试邮件验证)</span></label>
|
||
<div class="flex items-center gap-3 mt-1">
|
||
<input v-model="testEmail" type="email" placeholder="收件邮箱,如 you@example.com" class="field flex-1" @keyup.enter="sendTest" />
|
||
<button @click="sendTest" :disabled="testBusy || !smtpConfigured" class="btn-soft whitespace-nowrap">
|
||
{{ testBusy ? '发送中…' : '发送测试' }}
|
||
</button>
|
||
</div>
|
||
<p v-if="!smtpConfigured" class="text-xs text-slate-400 mt-1.5">请先保存 SMTP 配置后再测试。</p>
|
||
<p v-else-if="testMsg" class="text-xs mt-1.5" :class="testOk ? 'text-emerald-300' : 'text-rose-300'">{{ testMsg }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- proxy -->
|
||
<div class="card p-5">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<h2 class="text-sm font-semibold">代理 (生图请求)</h2>
|
||
<span v-if="proxySaved" class="text-xs text-emerald-300">已保存 ✓</span>
|
||
</div>
|
||
<p class="text-xs text-slate-400 mb-4">调用上游生成图片/视频时统一使用的 HTTP 代理,留空 = 直连。格式如 <code class="px-1 bg-slate-100 rounded">http://127.0.0.1:7890</code>。修改即时生效,无需重启。</p>
|
||
<input v-model="proxy.proxy" placeholder="留空 = 直连,如 http://127.0.0.1:7890" class="field" />
|
||
<div class="mt-4 flex items-center gap-2">
|
||
<button @click="saveProxy" :disabled="proxyBusy" class="btn-primary">{{ proxyBusy ? '保存中…' : '保存设置' }}</button>
|
||
<button @click="testProxy" :disabled="proxyTestBusy || !proxy.proxy.trim()" class="btn-ghost">{{ proxyTestBusy ? '测试中…' : '代理测试' }}</button>
|
||
</div>
|
||
<p v-if="proxyTest.msg" class="text-xs mt-2" :class="proxyTest.ok ? 'text-emerald-300' : 'text-rose-300'">{{ proxyTest.msg }}</p>
|
||
</div>
|
||
|
||
<!-- rewards -->
|
||
<div class="card p-5">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<h2 class="text-sm font-semibold">积分奖励</h2>
|
||
<span v-if="credSaved" class="text-xs text-emerald-300">已保存 ✓</span>
|
||
</div>
|
||
<div class="space-y-3">
|
||
<label class="row">
|
||
<span><span class="lbl">开启每日签到</span><span class="hint">关闭后用户无法签到领取积分。</span></span>
|
||
<input type="checkbox" v-model="credits.checkin_enabled" class="sw" />
|
||
</label>
|
||
<label class="row" :class="!credits.checkin_enabled && 'opacity-50'">
|
||
<span><span class="lbl">每日签到奖励</span><span class="hint">用户每天签到获得的积分。</span></span>
|
||
<input type="number" min="0" v-model.number="credits.checkin_reward" :disabled="!credits.checkin_enabled" class="num" />
|
||
</label>
|
||
<label class="row">
|
||
<span><span class="lbl">开启邀请奖励</span><span class="hint">关闭后邀请好友不再发放积分奖励。</span></span>
|
||
<input type="checkbox" v-model="credits.invite_enabled" class="sw" />
|
||
</label>
|
||
<label class="row" :class="!credits.invite_enabled && 'opacity-50'">
|
||
<span><span class="lbl">邀请奖励</span><span class="hint">被邀请好友首次生图后,邀请人获得的积分。</span></span>
|
||
<input type="number" min="0" v-model.number="credits.invite_reward" :disabled="!credits.invite_enabled" class="num" />
|
||
</label>
|
||
<label class="row">
|
||
<span><span class="lbl">开启兑换码</span><span class="hint">关闭后用户无法兑换兑换码,前台也不再显示兑换入口。</span></span>
|
||
<input type="checkbox" v-model="credits.cdk_redeem_enabled" class="sw" />
|
||
</label>
|
||
</div>
|
||
<div class="mt-4"><button @click="saveCredits" :disabled="credBusy" class="btn-primary">{{ credBusy ? '保存中…' : '保存设置' }}</button></div>
|
||
</div>
|
||
|
||
<!-- deai (去AI特征) -->
|
||
<div class="card p-5">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<h2 class="text-sm font-semibold">去AI特征</h2>
|
||
<span v-if="deaiSaved" class="text-xs text-emerald-300">已保存 ✓</span>
|
||
</div>
|
||
<p class="text-xs text-slate-400 mb-4">画图台开启「去AI特征」后,按画质档位在模型价格之上额外扣除的积分。仅对图片生成生效。</p>
|
||
<div class="space-y-3">
|
||
<label class="row">
|
||
<span><span class="lbl">开启去AI特征</span><span class="hint">关闭后画图台不显示该选项,也不会加价。默认关闭。</span></span>
|
||
<input type="checkbox" v-model="deaiCfg.enabled" class="sw" />
|
||
</label>
|
||
<label class="row" :class="!deaiCfg.enabled && 'opacity-50'">
|
||
<span><span class="lbl">1K 附加价格</span><span class="hint">默认 1 积分。</span></span>
|
||
<input type="number" min="0" v-model.number="deaiCfg.price_1k" class="num" />
|
||
</label>
|
||
<label class="row" :class="!deaiCfg.enabled && 'opacity-50'">
|
||
<span><span class="lbl">2K 附加价格</span><span class="hint">默认 2 积分。</span></span>
|
||
<input type="number" min="0" v-model.number="deaiCfg.price_2k" class="num" />
|
||
</label>
|
||
<label class="row" :class="!deaiCfg.enabled && 'opacity-50'">
|
||
<span><span class="lbl">4K 附加价格</span><span class="hint">默认 3 积分。</span></span>
|
||
<input type="number" min="0" v-model.number="deaiCfg.price_4k" class="num" />
|
||
</label>
|
||
</div>
|
||
<div class="mt-4"><button @click="saveDeai" :disabled="deaiBusy" class="btn-primary">{{ deaiBusy ? '保存中…' : '保存设置' }}</button></div>
|
||
</div>
|
||
|
||
<!-- announcement (公告) -->
|
||
<div class="card p-5">
|
||
<div class="flex items-center justify-between mb-1">
|
||
<h2 class="text-sm font-semibold">公告</h2>
|
||
<span v-if="annSaved" class="text-xs text-emerald-500">已保存</span>
|
||
</div>
|
||
<p class="text-xs text-slate-400 mb-3">支持 Markdown。登录用户会在首次访问时弹出;<strong class="text-slate-500">更新内容后</strong>,所有没看过新版本的用户会重新弹出。留空则不显示。</p>
|
||
<textarea v-model="ann.content" rows="8" placeholder="# 标题 支持 **加粗**、列表、[链接](https://...)、`代码` 等 Markdown 语法。"
|
||
class="field font-mono text-xs leading-relaxed" style="resize:vertical"></textarea>
|
||
<div class="mt-4"><button @click="saveAnnouncement" :disabled="annBusy" class="btn-primary">{{ annBusy ? '保存中…' : '保存设置' }}</button></div>
|
||
</div>
|
||
|
||
<!-- payment (易支付充值) -->
|
||
<div class="card p-5">
|
||
<div class="flex items-center justify-between mb-1">
|
||
<h2 class="text-sm font-semibold">充值 (易支付)</h2>
|
||
<span v-if="paySaved" class="text-xs text-emerald-500">已保存</span>
|
||
</div>
|
||
<p class="text-xs text-slate-400 mb-3">对接易支付。关闭后用户看不到充值入口。商户ID、密钥、支付地址不能为空。</p>
|
||
<div class="space-y-3">
|
||
<label class="row">
|
||
<span><span class="lbl">开启充值</span><span class="hint">关闭后前台不显示充值入口,且无法下单。</span></span>
|
||
<input type="checkbox" v-model="pay.enabled" class="sw" />
|
||
</label>
|
||
<label class="row">
|
||
<span><span class="lbl">支付地址</span><span class="hint">易支付站点根地址,自动拼 /mapi.php。</span></span>
|
||
<input v-model="pay.api_base" placeholder="https://www.ezfpy.cn" class="field !w-64" />
|
||
</label>
|
||
<label class="row">
|
||
<span><span class="lbl">商户ID (PID)</span></span>
|
||
<input v-model="pay.pid" class="field !w-64" />
|
||
</label>
|
||
<label class="row">
|
||
<span><span class="lbl">商户密钥</span></span>
|
||
<input v-model="pay.key" type="password" class="field !w-64" />
|
||
</label>
|
||
<div class="row">
|
||
<span><span class="lbl">支付方式</span><span class="hint">勾选哪些,前台就只显示哪些。</span></span>
|
||
<div class="flex gap-3">
|
||
<label v-for="m in PAY_METHODS" :key="m.v" class="inline-flex items-center gap-1.5 text-sm cursor-pointer">
|
||
<input type="checkbox" :checked="pay.methods.includes(m.v)" @change="togglePayMethod(m.v)" />
|
||
{{ m.label }}
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<label class="row">
|
||
<span><span class="lbl">最低充值金额 (元)</span></span>
|
||
<input type="number" min="0" step="0.01" v-model.number="pay.min_amount" class="num" />
|
||
</label>
|
||
<label class="row">
|
||
<span><span class="lbl">积分充值比例</span><span class="hint">1 元 = 多少积分。例如 100 → 充 10 元到账 1000 积分。</span></span>
|
||
<input type="number" min="1" v-model.number="pay.points_ratio" class="num" />
|
||
</label>
|
||
</div>
|
||
<p v-if="payErr" class="text-xs text-rose-500 mt-3">{{ payErr }}</p>
|
||
<div class="mt-4"><button @click="savePay" :disabled="payBusy" class="btn-primary">{{ payBusy ? '保存中…' : '保存设置' }}</button></div>
|
||
</div>
|
||
|
||
<!-- logs retention -->
|
||
<div class="card p-5">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<h2 class="text-sm font-semibold">日志</h2>
|
||
<span v-if="logsSaved" class="text-xs text-emerald-300">已保存 ✓</span>
|
||
</div>
|
||
<div class="space-y-3">
|
||
<label class="row">
|
||
<span>
|
||
<span class="lbl">最大留存时间</span>
|
||
<span class="hint">超过这个天数的日志会被自动清除,内存里同时还有 500 条的硬上限。范围 1–365 天,默认 30。</span>
|
||
</span>
|
||
<div class="flex items-center gap-2">
|
||
<input type="number" min="1" max="365" v-model.number="logsCfg.retention_days" class="num" />
|
||
<span class="text-xs text-white/45">天</span>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
<div class="mt-4"><button @click="saveLogs" :disabled="logsBusy || !logsCfg.retention_days" class="btn-primary">{{ logsBusy ? '保存中…' : '保存设置' }}</button></div>
|
||
</div>
|
||
|
||
<!-- media (生成文件) -->
|
||
<div class="card p-5">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<h2 class="text-sm font-semibold">生成文件 (图片 / 视频)</h2>
|
||
<span v-if="mediaSaved" class="text-xs text-emerald-300">
|
||
已保存 ✓<span v-if="mediaRemoved" class="text-white/45"> · 立即清理 {{ mediaRemoved }} 个文件</span>
|
||
</span>
|
||
</div>
|
||
<div class="space-y-3">
|
||
<label class="row">
|
||
<span>
|
||
<span class="lbl">最大留存时间</span>
|
||
<span class="hint">超过该天数的生成文件(包括用户生图和后台测试图)会被自动删除。范围 1–365 天,默认 30。每 5 分钟最多扫一次,保存设置时会立刻清理一次。</span>
|
||
</span>
|
||
<div class="flex items-center gap-2">
|
||
<input type="number" min="1" max="365" v-model.number="mediaCfg.retention_days" class="num" />
|
||
<span class="text-xs text-white/45">天</span>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
<div class="mt-4"><button @click="saveMedia" :disabled="mediaBusy || !mediaCfg.retention_days" class="btn-primary">{{ mediaBusy ? '保存中…' : '保存设置' }}</button></div>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* Colors here pair with the dark admin shell (`.public-dark` wraps <main>).
|
||
We use white-alpha instead of slate-* so they hold up against the glass
|
||
card background; the older slate values were authored for the white
|
||
light-mode admin and washed out almost completely. */
|
||
.row { display: flex; align-items: center; justify-content: space-between; gap: 1.5rem; padding: 0.75rem 0; border-bottom: 1px solid var(--hairline); }
|
||
.row:last-child { border-bottom: none; }
|
||
.row > span:first-child { display: flex; flex-direction: column; gap: 0.2rem; }
|
||
.lbl { font-weight: 500; color: var(--fg); font-size: 0.875rem; }
|
||
.hint { font-size: 0.72rem; color: var(--fg-3); line-height: 1.5; }
|
||
.hint b { color: rgb(225 29 72); font-weight: 500; }
|
||
html.dark .hint b { color: rgb(253 164 175); } /* rose — visible warning, not pure red */
|
||
.flbl { display: block; font-size: 0.72rem; color: var(--fg-3); margin-bottom: 0.35rem; }
|
||
/* Pill toggle switch — applied to <input type="checkbox" class="sw">. Keeps
|
||
the markup as-is so the existing v-model bindings keep working, but the
|
||
control now reads as an on/off slider instead of a tick box. The "locked
|
||
but currently on" state (e.g. allow_password_reset when email_code is off)
|
||
now reads as a disabled-but-on switch, which matches user intuition. */
|
||
.sw {
|
||
-webkit-appearance: none;
|
||
appearance: none;
|
||
position: relative;
|
||
flex-shrink: 0;
|
||
width: 2.25rem;
|
||
height: 1.3rem;
|
||
border-radius: 9999px;
|
||
background: rgb(203 213 225); /* slate-300 */
|
||
cursor: pointer;
|
||
transition: background 0.18s ease;
|
||
outline: none;
|
||
}
|
||
.sw::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: 2px;
|
||
left: 2px;
|
||
width: calc(1.3rem - 4px);
|
||
height: calc(1.3rem - 4px);
|
||
border-radius: 9999px;
|
||
background: white;
|
||
box-shadow: 0 1px 2px rgb(15 23 42 / 0.2);
|
||
transition: transform 0.18s ease;
|
||
}
|
||
.sw:checked { background: #4f46e5; } /* sky-600 — matches btn-primary */
|
||
.sw:checked::after { transform: translateX(calc(2.25rem - 1.3rem)); }
|
||
.sw:focus-visible { box-shadow: 0 0 0 3px rgb(236 91 19 / 0.3); }
|
||
.sw:disabled { cursor: not-allowed; opacity: 0.55; }
|
||
/* All three input variants now share the same surface as the rest of the
|
||
admin shell. Background + border use ink-alpha so they read against the
|
||
card; placeholders are tuned for legibility, not noise. */
|
||
.num, .txt, .field {
|
||
background: rgb(15 23 42 / 0.03);
|
||
border: 2px solid var(--ink-soft);
|
||
color: var(--fg);
|
||
border-radius: 0.55rem;
|
||
outline: none;
|
||
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
|
||
}
|
||
html.dark .num, html.dark .txt, html.dark .field {
|
||
background: rgb(255 255 255 / 0.04);
|
||
color: white;
|
||
}
|
||
.num::placeholder, .txt::placeholder, .field::placeholder { color: var(--fg-faint); }
|
||
.num:focus, .txt:focus, .field:focus {
|
||
border-color: var(--ink);
|
||
box-shadow: 3px 3px 0 var(--m-orange);
|
||
}
|
||
.num:disabled, .txt:disabled, .field:disabled { opacity: 0.45; cursor: not-allowed; }
|
||
.num { width: 6rem; padding: 0.4rem 0.55rem; font-size: 0.8rem; text-align: right; -moz-appearance: textfield; appearance: textfield; }
|
||
.num::-webkit-outer-spin-button, .num::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||
.txt { width: 16rem; max-width: 60%; padding: 0.4rem 0.65rem; font-size: 0.8rem; }
|
||
.field { width: 100%; padding: 0.55rem 0.75rem; font-size: 0.85rem; }
|
||
|
||
/* Section save buttons — solid memphis orange with a hard offset shadow.
|
||
The global .btn-primary under .public-dark already uses this palette; we
|
||
re-assert it here so the save action stays visually distinct. */
|
||
.btn-primary {
|
||
padding: 0.55rem 1.15rem; border-radius: 0.6rem;
|
||
font-size: 0.8rem; font-weight: 800; color: white;
|
||
background: var(--m-orange);
|
||
border: 2px solid var(--ink);
|
||
box-shadow: 3px 3px 0 var(--shadow-ink);
|
||
transition: filter 0.15s, transform 0.12s, box-shadow 0.15s, opacity 0.15s;
|
||
}
|
||
/* Re-assert the flat orange on hover for ALL states — the global
|
||
`.public-dark .btn-primary:hover` in style.css repaints the background,
|
||
so even disabled save buttons (注册与登录 / 邮件服务 default to disabled)
|
||
would flash on hover. Keep this rule unconditional; only the
|
||
brightness/lift below is gated on :not(:disabled). */
|
||
.btn-primary:hover { background: #f97316; }
|
||
.btn-primary:hover:not(:disabled) { transform: translate(-1px, -1px); box-shadow: 4px 4px 0 var(--shadow-ink); }
|
||
.btn-primary:active:not(:disabled) { transform: translate(2px, 2px); box-shadow: none; }
|
||
.btn-primary:disabled { opacity: 0.45; cursor: not-allowed; box-shadow: none; }
|
||
</style>
|