feat: fulfillment modes, MQ, admin status, docs; scrub compose secrets

Made-with: Cursor
This commit is contained in:
2026-04-03 16:26:38 +08:00
parent 1f16966174
commit b4c5103fb3
56 changed files with 4088 additions and 3742 deletions

View File

@@ -42,19 +42,45 @@
</div>
<div class="form-field">
<div class="flex items-center gap-3 flex-wrap">
<label>商品库存</label>
<span class="tag"> {{ form.inventoryItems.length }} </span>
<button class="ghost px-3.5 py-1.5 text-[13px] rounded-full" type="button" @click="addInventoryItem">添加商品库存</button>
<label>发货方式</label>
<div class="flex flex-col gap-2 rounded-lg border border-white/35 bg-white/70 p-3">
<label class="flex items-start gap-2.5 cursor-pointer font-semibold text-apptext m-0">
<input v-model="form.fulfillmentType" class="mt-1 w-auto accent-accent" type="radio" value="card" />
<span>
卡密 / 逐条库存
<span class="block text-[13px] font-normal text-muted">每条库存单独添加下单按条扣减售完不可再买</span>
</span>
</label>
<label class="flex items-start gap-2.5 cursor-pointer font-semibold text-apptext m-0">
<input v-model="form.fulfillmentType" class="mt-1 w-auto accent-accent" type="radio" value="fixed" />
<span>
固定内容不限库存
<span class="block text-[13px] font-normal text-muted">同一篇内容反复发货如网盘链接统一说明无需维护条数</span>
</span>
</label>
</div>
<p class="tag">每个输入框保存一条可发放内容购买后会直接展示给用户</p>
</div>
<div v-if="form.fulfillmentType === 'fixed'" class="form-field">
<label>固定发货内容</label>
<textarea v-model="form.fixedContent" rows="5" placeholder="例如https://… 网盘链接,或一段固定发给买家的文字"></textarea>
<p class="tag">保存后顾客每笔订单会收到这份内容若一次买多件会复制多行相同内容便于核对</p>
</div>
<div v-else class="form-field">
<div class="flex items-center gap-3 flex-wrap">
<label>卡密 / 库存条目</label>
<span class="tag"> {{ form.inventoryItems.length }} </span>
<button class="ghost px-3.5 py-1.5 text-[13px] rounded-full" type="button" @click="addInventoryItem">添加一行</button>
</div>
<p class="tag">每条一行手动逐个添加购买后按顺序发放并扣减库存</p>
<div class="flex flex-col gap-2 mt-1">
<div v-for="(_, index) in form.inventoryItems" :key="index" class="flex items-center gap-2 max-md:flex-col max-md:items-stretch">
<input
:ref="(el) => setInventoryInputRef(el, index)"
v-model="form.inventoryItems[index]"
class="flex-1"
placeholder="库存内容,可填卡密、下载链接等"
placeholder="单条卡密或可发放内容"
/>
<button
v-if="form.inventoryItems.length > 1"
@@ -176,6 +202,7 @@ const normalizeScreenshotUrls = (values = []) =>
const makeEmptyForm = () => ({
id: '', name: '', price: 0, discountPrice: 0, tagsText: '', coverUrl: '',
screenshotUrls: createScreenshotSlots(), inventoryItems: createInventoryItems(),
fulfillmentType: 'card', fixedContent: '',
description: '', active: true, requireLogin: false, maxPerAccount: 0,
deliveryMode: 'auto', showNote: true, showContact: true
})
@@ -183,14 +210,18 @@ const makeEmptyForm = () => ({
const form = reactive(makeEmptyForm())
const fillForm = (item) => {
const ft = (item?.fulfillmentType || 'card').toLowerCase() === 'fixed' ? 'fixed' : 'card'
Object.assign(form, {
id: item?.id || '', name: item?.name || '', price: item?.price || 0, discountPrice: item?.discountPrice || 0,
tagsText: (item?.tags || []).join(','), coverUrl: item?.coverUrl || '',
screenshotUrls: createScreenshotSlots(item?.screenshotUrls || []),
inventoryItems: createInventoryItems(item?.codes || []),
inventoryItems: ft === 'fixed' ? [''] : createInventoryItems(item?.codes || []),
fulfillmentType: ft,
fixedContent: item?.fixedContent || '',
description: item?.description || '', active: item?.active ?? true,
requireLogin: item?.requireLogin ?? false, maxPerAccount: item?.maxPerAccount ?? 0,
deliveryMode: 'auto', showNote: item?.showNote ?? true, showContact: item?.showContact ?? true
deliveryMode: item?.deliveryMode || 'auto',
showNote: item?.showNote ?? true, showContact: item?.showContact ?? true
})
}
@@ -220,13 +251,16 @@ const removeInventoryItem = async (index) => {
}
const handleSubmit = () => {
const ft = form.fulfillmentType === 'fixed' ? 'fixed' : 'card'
emit('submit', {
id: form.id, name: form.name, price: form.price, discountPrice: form.discountPrice || 0,
tags: form.tagsText, coverUrl: form.coverUrl,
codes: normalizeInventoryItems(form.inventoryItems),
codes: ft === 'fixed' ? [] : normalizeInventoryItems(form.inventoryItems),
fulfillmentType: ft,
fixedContent: ft === 'fixed' ? (form.fixedContent || '').trim() : '',
screenshotUrls: normalizeScreenshotUrls(form.screenshotUrls),
description: form.description, active: form.active, requireLogin: form.requireLogin,
maxPerAccount: form.maxPerAccount || 0, deliveryMode: 'auto',
maxPerAccount: form.maxPerAccount || 0, deliveryMode: form.deliveryMode || 'auto',
showNote: form.showNote, showContact: form.showContact
})
}

View File

@@ -5,7 +5,7 @@
<tr class="bg-white/70 border-b-2 border-white/35">
<th class="px-4 py-3 text-sm font-semibold uppercase tracking-[0.6px] text-muted max-md:px-2.5 max-md:py-2 max-md:text-xs">商品</th>
<th class="px-4 py-3 text-sm font-semibold uppercase tracking-[0.6px] text-muted max-md:px-2.5 max-md:py-2 max-md:text-xs">价格</th>
<th class="px-4 py-3 text-sm font-semibold uppercase tracking-[0.6px] text-muted text-center max-md:px-2.5 max-md:py-2 max-md:text-xs">库存</th>
<th class="px-4 py-3 text-sm font-semibold uppercase tracking-[0.6px] text-muted text-center max-md:px-2.5 max-md:py-2 max-md:text-xs">库存 / 方式</th>
<th class="px-4 py-3 text-sm font-semibold uppercase tracking-[0.6px] text-muted text-center max-md:px-2.5 max-md:py-2 max-md:text-xs">浏览量</th>
<th class="px-4 py-3 text-sm font-semibold uppercase tracking-[0.6px] text-muted max-md:px-2.5 max-md:py-2 max-md:text-xs">状态</th>
<th class="px-4 py-3 text-sm font-semibold uppercase tracking-[0.6px] text-muted max-md:px-2.5 max-md:py-2 max-md:text-xs">操作</th>
@@ -40,9 +40,10 @@
</td>
<td class="px-4 py-3.5 text-center align-middle max-md:px-2.5 max-md:py-2">
<span
class="inline-block px-2.5 py-0.5 rounded-full text-[15px] font-semibold"
:class="item.quantity === 0 ? 'bg-[rgba(201,90,106,0.12)] text-[#c95a6a]' : 'bg-[rgba(100,185,140,0.15)] text-[#3a9a68]'"
>{{ item.quantity }}</span>
class="inline-block px-2.5 py-0.5 rounded-full text-[13px] font-semibold"
:class="stockBadgeClass(item)"
>{{ stockLabel(item) }}</span>
<div class="text-[11px] text-muted mt-1">{{ fulfillmentHint(item) }}</div>
</td>
<td class="px-4 py-3.5 text-center text-[15px] text-muted align-middle max-md:px-2.5 max-md:py-2">{{ item.viewCount || 0 }}</td>
<td class="px-4 py-3.5 align-middle max-md:px-2.5 max-md:py-2">
@@ -77,6 +78,19 @@
</template>
<script setup>
import { isFixedFulfillment, stockLabel as stockLabelText } from '../../shared/fulfillment'
defineProps({ products: { type: Array, default: () => [] } })
defineEmits(['edit', 'toggle', 'remove'])
const stockLabel = (item) => stockLabelText(item)
const stockBadgeClass = (item) => {
if (isFixedFulfillment(item)) return 'bg-[rgba(100,185,140,0.15)] text-[#3a9a68]'
return (item.quantity ?? 0) === 0
? 'bg-[rgba(201,90,106,0.12)] text-[#c95a6a]'
: 'bg-[rgba(100,185,140,0.15)] text-[#3a9a68]'
}
const fulfillmentHint = (item) => (isFixedFulfillment(item) ? '固定内容' : '卡密')
</script>

View File

@@ -0,0 +1,254 @@
<template>
<div>
<div class="flex items-center justify-between mb-4">
<p class="text-[13px] text-muted">后端 APIMySQLRedisRabbitMQ 探活与配置摘要需管理 Token</p>
<button
class="ghost inline-flex items-center gap-1.5 text-sm px-3.5 py-1.5"
:class="loading ? 'opacity-50 pointer-events-none' : ''"
type="button"
@click="load"
>
<svg
width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
:class="loading ? 'animate-spin' : ''"
>
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
<path d="M21 3v5h-5"/>
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
<path d="M8 16H3v5"/>
</svg>
{{ loading ? '检测中…' : '刷新' }}
</button>
</div>
<div
v-if="error"
class="mb-4 px-4 py-3 rounded-lg text-sm text-[#c95a6a] bg-[rgba(201,90,106,0.08)] border border-[rgba(201,90,106,0.2)]"
>
{{ error }}
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- 后端 API -->
<div class="px-[18px] py-4 bg-white/50 border border-white/35 rounded-xl max-md:px-3">
<div class="flex items-center justify-between flex-wrap gap-2 mb-3">
<div class="flex items-center gap-2.5">
<svg class="text-accent opacity-80" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
<span class="text-[15px] font-semibold text-apptext">后端 API</span>
</div>
<div class="flex items-center gap-1.5">
<span class="inline-block w-2 h-2 rounded-full flex-shrink-0" :class="dotClass(be?.status, loading)" :title="labelText(be?.status, loading)" />
<span class="text-[13px] font-medium" :class="textClass(be?.status)">{{ labelText(be?.status, loading) }}</span>
</div>
</div>
<div class="border-t border-white/35 mb-3" />
<template v-if="be">
<div class="grid gap-2 text-[13px]">
<div class="flex justify-between gap-2"><span class="text-muted">应用环境</span><span class="font-medium text-apptext text-right">{{ be.appEnv === 'production' ? '生产' : '开发' }}</span></div>
<div class="flex justify-between gap-2"><span class="text-muted">Gin 模式</span><span class="font-medium text-apptext text-right">{{ be.ginMode || '—' }}</span></div>
<div class="flex justify-between gap-2"><span class="text-muted">当前请求 Host</span><span class="font-medium text-apptext text-right break-all">{{ be.requestHost || '—' }}</span></div>
<div class="flex justify-between gap-2"><span class="text-muted">进程监听</span><span class="font-medium text-apptext text-right break-all">{{ be.listenAddr || '—' }}</span></div>
<div class="flex justify-between gap-2"><span class="text-muted">对外基址</span><span class="font-medium text-apptext text-right break-all">{{ be.publicBaseUrl || '—' }}</span></div>
<div class="flex justify-between gap-2"><span class="text-muted">运行时长</span><span class="font-medium text-apptext text-right">{{ formatUptime(be.uptimeSeconds) }}</span></div>
<div class="flex justify-between gap-2"><span class="text-muted">认证 API</span><span class="font-medium text-apptext text-right">{{ be.authApiConfigured ? '已配置' : '未配置' }}</span></div>
<div class="flex justify-between gap-2"><span class="text-muted">RabbitMQ 功能</span><span class="font-medium text-apptext text-right">{{ be.rabbitmqEnabled ? '开' : '关' }}</span></div>
<div class="flex justify-between gap-2"><span class="text-muted">Redis 功能</span><span class="font-medium text-apptext text-right">{{ be.redisEnabled ? '开' : '关' }}</span></div>
</div>
</template>
</div>
<!-- MySQL -->
<div class="px-[18px] py-4 bg-white/50 border border-white/35 rounded-xl max-md:px-3">
<div class="flex items-center justify-between flex-wrap gap-2 mb-3">
<div class="flex items-center gap-2.5">
<svg class="text-accent opacity-80" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<ellipse cx="12" cy="5" rx="9" ry="3"/>
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/>
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
</svg>
<span class="text-[15px] font-semibold text-apptext">MySQL</span>
</div>
<div class="flex items-center gap-1.5">
<span class="inline-block w-2 h-2 rounded-full flex-shrink-0" :class="dotClass(mysql?.status, loading)" :title="labelText(mysql?.status, loading)" />
<span class="text-[13px] font-medium" :class="textClass(mysql?.status)">{{ labelText(mysql?.status, loading) }}</span>
</div>
</div>
<div class="border-t border-white/35 mb-3" />
<template v-if="mysql">
<div class="grid gap-2 text-[13px]">
<div v-if="mysql.host" class="flex justify-between gap-2"><span class="text-muted">地址</span><span class="font-medium text-apptext text-right break-all">{{ mysql.host }}{{ mysql.port ? ':' + mysql.port : '' }}</span></div>
<div v-if="mysql.socket" class="flex justify-between gap-2"><span class="text-muted">Socket</span><span class="font-medium text-apptext text-right break-all">{{ mysql.socket }}</span></div>
<div v-if="mysql.database" class="flex justify-between gap-2"><span class="text-muted">库名</span><span class="font-medium text-apptext text-right break-all">{{ mysql.database }}</span></div>
<div v-if="mysql.user" class="flex justify-between gap-2"><span class="text-muted">用户</span><span class="font-medium text-apptext text-right">{{ mysql.user }}</span></div>
<div v-if="mysql.status === 'ok' && mysql.version" class="flex justify-between gap-2"><span class="text-muted">版本</span><span class="font-medium text-apptext text-right break-all">{{ mysql.version }}</span></div>
<div v-if="mysql.status === 'ok'" class="flex justify-between gap-2"><span class="text-muted">连接池</span><span class="font-medium text-apptext text-right"> {{ mysql.openConnections }} / {{ mysql.inUse }} / {{ mysql.idle }}</span></div>
</div>
<p v-if="mysql.message" class="mt-3 text-[12px] text-[#c95a6a] leading-relaxed break-all">{{ mysql.message }}</p>
<p v-if="mysql.dsnParseError" class="mt-2 text-[12px] text-[#b8860b] break-all">DSN 解析{{ mysql.dsnParseError }}</p>
</template>
</div>
<!-- Redis -->
<div class="px-[18px] py-4 bg-white/50 border border-white/35 rounded-xl max-md:px-3">
<div class="flex items-center justify-between flex-wrap gap-2 mb-3">
<div class="flex items-center gap-2.5">
<svg class="text-accent opacity-80" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2v20"/>
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
</svg>
<span class="text-[15px] font-semibold text-apptext">Redis</span>
</div>
<div class="flex items-center gap-1.5">
<span class="inline-block w-2 h-2 rounded-full flex-shrink-0" :class="dotClass(rd?.status, loading)" :title="labelText(rd?.status, loading)" />
<span class="text-[13px] font-medium" :class="textClass(rd?.status)">{{ labelText(rd?.status, loading) }}</span>
</div>
</div>
<div class="border-t border-white/35 mb-3" />
<template v-if="rd">
<div class="grid gap-2 text-[13px]">
<div class="flex justify-between gap-2"><span class="text-muted">功能开关</span><span class="font-medium text-apptext text-right">{{ rd.enabled ? '已启用' : '未启用' }}</span></div>
<div v-if="rd.enabled" class="flex justify-between gap-2"><span class="text-muted">逻辑环境</span><span class="font-medium text-apptext text-right">{{ rd.env === 'prod' ? '生产 (prod)' : '开发 (dev)' }}</span></div>
<div v-if="rd.host" class="flex justify-between gap-2"><span class="text-muted">地址</span><span class="font-medium text-apptext text-right break-all">{{ rd.host }}:{{ rd.port || '6379' }}</span></div>
<div v-if="rd.enabled" class="flex justify-between gap-2"><span class="text-muted">DB 编号</span><span class="font-medium text-apptext text-right">{{ rd.dbIndex ?? '' }}</span></div>
<div v-if="rd.status === 'ok' && rd.redisVersion" class="flex justify-between gap-2"><span class="text-muted">版本</span><span class="font-medium text-apptext text-right">{{ rd.redisVersion }}</span></div>
<div v-if="rd.status === 'ok' && rd.usedMemoryHuman" class="flex justify-between gap-2"><span class="text-muted">占用内存</span><span class="font-medium text-apptext text-right">{{ rd.usedMemoryHuman }}</span></div>
<div v-if="rd.status === 'ok'" class="flex justify-between gap-2"><span class="text-muted">Key 约数</span><span class="font-medium text-apptext text-right">{{ rd.keysApprox ?? '' }}</span></div>
</div>
<p v-if="rd.message" class="mt-3 text-[12px] text-[#c95a6a] leading-relaxed break-all">{{ rd.message }}</p>
</template>
</div>
<!-- RabbitMQ -->
<div class="px-[18px] py-4 bg-white/50 border border-white/35 rounded-xl max-md:px-3">
<div class="flex items-center justify-between flex-wrap gap-2 mb-3">
<div class="flex items-center gap-2.5">
<svg class="text-accent opacity-80" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 12h16"/>
<path d="M4 8h12"/>
<path d="M4 16h8"/>
<circle cx="18" cy="8" r="2"/>
<circle cx="14" cy="16" r="2"/>
</svg>
<span class="text-[15px] font-semibold text-apptext">RabbitMQ</span>
</div>
<div class="flex items-center gap-1.5">
<span class="inline-block w-2 h-2 rounded-full flex-shrink-0" :class="dotClass(rb?.status, loading)" :title="labelText(rb?.status, loading)" />
<span class="text-[13px] font-medium" :class="textClass(rb?.status)">{{ labelText(rb?.status, loading) }}</span>
</div>
</div>
<div class="border-t border-white/35 mb-3" />
<template v-if="rb">
<div class="grid gap-2 text-[13px]">
<div class="flex justify-between gap-2"><span class="text-muted">功能开关</span><span class="font-medium text-apptext text-right">{{ rb.enabled ? '已启用' : '未启用' }}</span></div>
<div v-if="rb.enabled" class="flex justify-between gap-2"><span class="text-muted">逻辑环境</span><span class="font-medium text-apptext text-right">{{ rb.env === 'prod' ? '生产 (prod)' : '开发 (dev)' }}</span></div>
<div v-if="rb.brokerHost" class="flex justify-between gap-2"><span class="text-muted">Broker 地址</span><span class="font-medium text-apptext text-right break-all">{{ rb.brokerHost }}:{{ rb.brokerPort }}</span></div>
<div v-if="rb.vhost != null && rb.vhost !== ''" class="flex justify-between gap-2"><span class="text-muted">vhost</span><span class="font-medium text-apptext text-right break-all">{{ rb.vhost }}</span></div>
<div v-if="rb.brokerUser" class="flex justify-between gap-2"><span class="text-muted">连接用户</span><span class="font-medium text-apptext text-right">{{ rb.brokerUser }}</span></div>
<div class="flex justify-between gap-2"><span class="text-muted">交换机</span><span class="font-medium text-apptext text-right break-all">{{ rb.exchange || '—' }}</span></div>
<div class="flex justify-between gap-2"><span class="text-muted">队列</span><span class="font-medium text-apptext text-right break-all">{{ rb.queue || '—' }}</span></div>
<div class="flex justify-between gap-2"><span class="text-muted">路由键</span><span class="font-medium text-apptext text-right break-all">{{ rb.routingKey || '—' }}</span></div>
<div v-if="rb.status === 'ok'" class="flex justify-between gap-2"><span class="text-muted">待投递消息数</span><span class="font-medium text-apptext text-right">{{ rb.messagesReady ?? '' }}</span></div>
<div v-if="rb.status === 'ok'" class="flex justify-between gap-2"><span class="text-muted">消费者数</span><span class="font-medium text-apptext text-right">{{ rb.consumers ?? '' }}</span></div>
</div>
<p v-if="rb.message" class="mt-3 text-[12px] text-[#c95a6a] leading-relaxed break-all">{{ rb.message }}</p>
</template>
</div>
</div>
<p v-if="updatedAt" class="mt-3 text-[12px] text-muted text-right">最后检测{{ updatedAt }}</p>
</div>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { fetchSystemStatus } from '../../shared/api'
const props = defineProps({
adminToken: { type: String, default: '' }
})
const data = ref(null)
const loading = ref(false)
const error = ref('')
const updatedAt = ref('')
const be = computed(() => data.value?.backend || null)
const mysql = computed(() => data.value?.mysql || null)
const rd = computed(() => data.value?.redis || null)
const rb = computed(() => data.value?.rabbitmq || null)
function formatUptime(sec) {
if (sec == null || sec < 0) return '—'
const s = Math.floor(sec)
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const r = s % 60
if (h > 0) return `${h} 小时 ${m}`
if (m > 0) return `${m}${r}`
return `${r}`
}
function labelText(status, isLoading) {
if (status == null && isLoading) return '检测中…'
if (status == null) return '未加载'
if (status === 'ok') return '正常'
if (status === 'disabled') return '未启用'
if (status === 'misconfigured') return '配置不全'
if (status === 'degraded') return '部分可用'
if (status === 'error') return '异常'
return '未知'
}
function dotClass(status, isLoading) {
if (status === 'ok') return 'bg-[#3a9a68]'
if (status === 'disabled') return 'bg-[#c0bcc4]'
if (status === 'degraded' || status === 'misconfigured') return 'bg-[#c9a227]'
if (status == null && isLoading) return 'bg-[#b49acb] animate-pulse'
return 'bg-[#c95a6a]'
}
function textClass(status) {
if (status === 'ok') return 'text-[#3a9a68]'
if (status === 'disabled') return 'text-muted'
if (status === 'degraded' || status === 'misconfigured') return 'text-[#b8860b]'
return 'text-[#c95a6a]'
}
const load = async () => {
if (!props.adminToken) {
error.value = '请先输入管理 Token'
return
}
loading.value = true
error.value = ''
try {
data.value = await fetchSystemStatus(props.adminToken)
updatedAt.value = new Date().toLocaleTimeString('zh-CN')
} catch (e) {
error.value = e?.response?.status === 401
? 'Token 无效,无权查看'
: '获取失败:' + (e?.message || '网络错误')
} finally {
loading.value = false
}
}
onMounted(() => {
if (props.adminToken) load()
})
watch(
() => props.adminToken,
(v) => {
if (v) load()
}
)
defineExpose({ load })
</script>