You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
416 lines
18 KiB
416 lines
18 KiB
<template>
|
|
<div>
|
|
<!-- 列表视图 -->
|
|
<div v-show="view === 'list'">
|
|
<div class="flex items-center justify-between mb-5">
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-slate-800">应用商品配置</h2>
|
|
<p class="text-xs text-slate-400 mt-1">应用的内购商品(VIP 时长、AI/会议/翻译资源点包等),支持按语言配置名称、货币与售价。统一存后台公共库,按应用隔离,同一应用的各区域部署共用一份。</p>
|
|
</div>
|
|
<button class="btn btn-primary btn-sm text-white" :disabled="!curAppName" @click="showForm()">+ 新增商品</button>
|
|
</div>
|
|
<div class="flex items-center gap-3 mb-4 flex-wrap">
|
|
<span class="text-sm text-slate-500">应用</span>
|
|
<select v-model="curAppName" class="select select-bordered select-sm min-w-48" @change="loadData">
|
|
<option value="" disabled>请选择应用</option>
|
|
<option v-for="n in appNames" :key="n" :value="n">{{ n }}</option>
|
|
</select>
|
|
<input v-model="keyword" class="input input-bordered input-sm w-56" placeholder="按商品ID/名称搜索" />
|
|
<label class="label cursor-pointer gap-2">
|
|
<input v-model="onlyEnabled" type="checkbox" class="checkbox checkbox-sm checkbox-primary" />
|
|
<span class="label-text text-sm text-slate-500">只看已上架</span>
|
|
</label>
|
|
</div>
|
|
<div v-if="!curAppName" class="bg-white rounded-xl shadow-sm border border-slate-100 text-center text-slate-400 py-16">
|
|
<div class="text-4xl mb-2">🛒</div>
|
|
<p class="text-sm">请先在上方选择应用</p>
|
|
</div>
|
|
<div v-else class="bg-white rounded-xl shadow-sm border border-slate-100">
|
|
<div class="overflow-x-auto">
|
|
<table class="table">
|
|
<thead>
|
|
<tr>
|
|
<th>商品ID</th><th>名称</th><th>类型</th><th>默认价格</th><th>发放内容</th><th>多语言</th><th>状态</th><th class="text-right">操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="loading">
|
|
<td colspan="8" class="text-center py-8"><span class="loading loading-spinner loading-sm"></span></td>
|
|
</tr>
|
|
<tr v-else-if="!filteredRows.length">
|
|
<td colspan="8" class="text-center text-slate-400 py-8">暂无商品</td>
|
|
</tr>
|
|
<tr v-for="g in filteredRows" :key="g.id" class="hover">
|
|
<td class="font-mono text-sm font-medium">{{ g.goods_id }}</td>
|
|
<td class="text-sm">{{ g.name || '-' }}</td>
|
|
<td class="text-sm text-slate-500">{{ usageLabel(g.usagetype) }}</td>
|
|
<td class="text-sm">{{ g.price }}</td>
|
|
<td class="text-xs text-slate-500">{{ rewardText(g) || '-' }}</td>
|
|
<td>
|
|
<div v-if="localeCodes(g).length" class="flex flex-wrap gap-1">
|
|
<span v-for="code in localeCodes(g)" :key="code" class="badge badge-ghost badge-sm font-mono">{{ code }}</span>
|
|
</div>
|
|
<span v-else class="text-xs text-slate-400">未配置</span>
|
|
</td>
|
|
<td>
|
|
<span class="badge badge-sm" :class="g.enable ? 'badge-success text-white' : 'badge-ghost'">{{ g.enable ? '上架' : '下架' }}</span>
|
|
</td>
|
|
<td class="text-right">
|
|
<div class="inline-flex gap-1">
|
|
<button class="btn btn-ghost btn-xs" @click="showForm(g)">编辑</button>
|
|
<button class="btn btn-ghost btn-xs text-red-500" @click="delGoods(g)">删除</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 表单视图 -->
|
|
<div v-show="view === 'form'">
|
|
<div class="flex items-center gap-3 mb-5">
|
|
<button class="btn btn-ghost btn-sm" @click="showList()">← 返回列表</button>
|
|
<h2 class="text-lg font-semibold text-slate-800">{{ isEditing ? '编辑商品' : '新增商品' }}</h2>
|
|
<span class="text-xs text-slate-400">所属应用:{{ curAppName || '-' }}</span>
|
|
</div>
|
|
<form class="space-y-5 max-w-4xl" @submit.prevent="saveGoods">
|
|
<!-- 基本信息 -->
|
|
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6">
|
|
<h3 class="text-sm font-semibold text-slate-600 mb-4">基本信息</h3>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text font-medium">商品ID (goods_id) *</span></label>
|
|
<input v-model="form.goods_id" type="text" class="input input-bordered w-full font-mono" required placeholder="如 vip_month_1(与苹果/谷歌后台的内购商品ID一致)" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text font-medium">默认名称</span></label>
|
|
<input v-model="form.name" type="text" class="input input-bordered w-full" placeholder="未匹配到语言时展示的兜底名称" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text font-medium">商品类型</span></label>
|
|
<select v-model.number="form.usagetype" class="select select-bordered w-full">
|
|
<option v-for="o in USAGE_TYPES" :key="o.value" :value="o.value">{{ o.label }}</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text font-medium">默认价格</span></label>
|
|
<input v-model.number="form.price" type="number" class="input input-bordered w-full" />
|
|
<span class="text-xs text-slate-400 mt-1">未配置该语言时的兜底价;各语言售价在下方按语言单独配。</span>
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text font-medium">排序</span></label>
|
|
<input v-model.number="form.sort" type="number" class="input input-bordered w-full" />
|
|
<span class="text-xs text-slate-400 mt-1">值小的排前面。</span>
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text font-medium">状态</span></label>
|
|
<label class="label cursor-pointer justify-start gap-3">
|
|
<input v-model="form.enable" type="checkbox" class="toggle toggle-primary toggle-sm" />
|
|
<span class="label-text">{{ form.enable ? '上架(客户端可见)' : '下架' }}</span>
|
|
</label>
|
|
</div>
|
|
<div class="form-control md:col-span-2">
|
|
<label class="label"><span class="label-text font-medium">备注</span></label>
|
|
<input v-model="form.remark" type="text" class="input input-bordered w-full" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 发放内容 -->
|
|
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6">
|
|
<h3 class="text-sm font-semibold text-slate-600 mb-1">发放内容</h3>
|
|
<p class="text-xs text-slate-400 mb-4">购买成功后给用户加的资源,按需填写,不发的留 0。</p>
|
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text font-medium">会员时长 (viptime)</span></label>
|
|
<input v-model.number="form.viptime" type="number" class="input input-bordered w-full" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text font-medium">AI 资源点 (ainum)</span></label>
|
|
<input v-model.number="form.ainum" type="number" class="input input-bordered w-full" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text font-medium">会议资源点 (meetnum)</span></label>
|
|
<input v-model.number="form.meetnum" type="number" class="input input-bordered w-full" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text font-medium">翻译资源点 (tradenum)</span></label>
|
|
<input v-model.number="form.tradenum" type="number" class="input input-bordered w-full" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 多语言 -->
|
|
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6">
|
|
<div class="flex items-center justify-between mb-1">
|
|
<h3 class="text-sm font-semibold text-slate-600">多语言配置</h3>
|
|
<div class="flex items-center gap-2">
|
|
<select v-model="langToAdd" class="select select-bordered select-sm min-w-52">
|
|
<option value="" disabled>选择语言</option>
|
|
<option v-for="o in addableLangs" :key="o.code" :value="o.code">{{ o.code }} · {{ o.name }}</option>
|
|
</select>
|
|
<button type="button" class="btn btn-outline btn-sm" :disabled="!langToAdd" @click="addLocale()">+ 添加语言</button>
|
|
</div>
|
|
</div>
|
|
<p class="text-xs text-slate-400 mb-4">客户端按自身语言取名称与售价;没配的语言回落到上方的默认名称与默认价格。</p>
|
|
<div v-if="!form.locales.length" class="text-center text-slate-400 text-sm py-8 border border-dashed border-slate-200 rounded-lg">
|
|
还没有语言配置,选一个语言添加
|
|
</div>
|
|
<div v-else class="overflow-x-auto">
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th class="w-52">语言</th><th>名称</th><th class="w-28">货币符号</th><th class="w-36">价格</th><th class="w-20 text-right">操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="(l, i) in form.locales" :key="l.lang">
|
|
<td>
|
|
<div class="font-mono text-sm">{{ l.lang }}</div>
|
|
<div class="text-xs text-slate-400">{{ langName(l.lang) }}</div>
|
|
</td>
|
|
<td><input v-model="l.name" type="text" class="input input-bordered input-sm w-full" placeholder="该语言下的商品名" /></td>
|
|
<td><input v-model="l.currency" type="text" class="input input-bordered input-sm w-full" placeholder="¥ / $" /></td>
|
|
<td><input v-model.number="l.price" type="number" step="0.01" class="input input-bordered input-sm w-full" /></td>
|
|
<td class="text-right">
|
|
<button type="button" class="btn btn-ghost btn-xs text-red-500" @click="form.locales.splice(i, 1)">删除</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex gap-3">
|
|
<button type="button" class="btn btn-ghost" @click="showList()">取消</button>
|
|
<button type="submit" class="btn btn-primary text-white" :disabled="submitting">保存</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, reactive, computed, onMounted } from 'vue'
|
|
import { useApi } from '~/composables/useApi'
|
|
import { useToast } from '~/composables/useToast'
|
|
import { LANG_OPTIONS, langName } from '~/composables/useLangOptions'
|
|
|
|
definePageMeta({ title: '应用商品配置' })
|
|
|
|
// 商品按「应用名」隔离(同一应用的多区域部署共用一份),故这里只选应用、不选部署环境——
|
|
// 与「应用环境配置」页按应用×部署环境读写业务库不同,本页数据统一落 console 公共库 app_goods。
|
|
interface GoodsLocale { name: string; currency: string; price: number }
|
|
interface Goods {
|
|
id: number
|
|
app_name: string
|
|
goods_id: string
|
|
enable: boolean
|
|
name: string
|
|
price: number
|
|
usagetype: number
|
|
ainum: number
|
|
meetnum: number
|
|
tradenum: number
|
|
viptime: number
|
|
sort: number
|
|
remark: string
|
|
locales: Record<string, GoodsLocale>
|
|
}
|
|
|
|
// pb.UsageType:1翻译 2会议 3智能体 4购买VIP时长
|
|
const USAGE_TYPES = [
|
|
{ value: 0, label: '未指定' },
|
|
{ value: 1, label: '翻译' },
|
|
{ value: 2, label: '会议' },
|
|
{ value: 3, label: '智能体' },
|
|
{ value: 4, label: 'VIP 时长' },
|
|
]
|
|
|
|
const { consoleApi } = useApi()
|
|
const { success, error } = useToast()
|
|
|
|
const view = ref<'list' | 'form'>('list')
|
|
const appNames = ref<string[]>([])
|
|
const curAppName = ref('')
|
|
const items = ref<Goods[]>([])
|
|
const loading = ref(false)
|
|
const submitting = ref(false)
|
|
const isEditing = ref(false)
|
|
const keyword = ref('')
|
|
const onlyEnabled = ref(false)
|
|
const langToAdd = ref('')
|
|
|
|
const form = reactive({
|
|
id: 0,
|
|
goods_id: '',
|
|
name: '',
|
|
enable: true,
|
|
price: 0,
|
|
usagetype: 4,
|
|
ainum: 0,
|
|
meetnum: 0,
|
|
tradenum: 0,
|
|
viptime: 0,
|
|
sort: 0,
|
|
remark: '',
|
|
locales: [] as Array<{ lang: string } & GoodsLocale>,
|
|
})
|
|
|
|
function usageLabel(v: number) {
|
|
return USAGE_TYPES.find((o) => o.value === v)?.label ?? String(v)
|
|
}
|
|
|
|
function localeCodes(g: Goods) {
|
|
return Object.keys(g.locales || {}).sort()
|
|
}
|
|
|
|
// 发放内容摘要(列表列,只显示非 0 项)
|
|
function rewardText(g: Goods) {
|
|
const parts: string[] = []
|
|
if (g.viptime) parts.push(`VIP ${g.viptime}`)
|
|
if (g.ainum) parts.push(`AI ${g.ainum}`)
|
|
if (g.meetnum) parts.push(`会议 ${g.meetnum}`)
|
|
if (g.tradenum) parts.push(`翻译 ${g.tradenum}`)
|
|
return parts.join(' · ')
|
|
}
|
|
|
|
const filteredRows = computed(() => {
|
|
const kw = keyword.value.trim().toLowerCase()
|
|
return items.value.filter((g) => {
|
|
if (onlyEnabled.value && !g.enable) return false
|
|
if (!kw) return true
|
|
return ((g.goods_id || '') + (g.name || '')).toLowerCase().includes(kw)
|
|
})
|
|
})
|
|
|
|
// 还没配过的语言(已配的不重复出现在「添加语言」下拉里)
|
|
const addableLangs = computed(() => {
|
|
const used = new Set(form.locales.map((l) => l.lang))
|
|
return LANG_OPTIONS.filter((o) => !used.has(o.code))
|
|
})
|
|
|
|
// 应用名来源:应用中心的应用表为主,再并上已有部署记录里的应用名(兜底老数据)
|
|
async function loadApps() {
|
|
try {
|
|
const [products, deploys] = await Promise.all([
|
|
consoleApi<Array<{ name: string }>>('products/list', {}).catch(() => []),
|
|
consoleApi<Array<{ app_name: string; name: string }>>('apps/list', {}).catch(() => []),
|
|
])
|
|
const names = new Set<string>()
|
|
;(products ?? []).forEach((p) => p.name && names.add(p.name))
|
|
;(deploys ?? []).forEach((d) => (d.app_name || d.name) && names.add(d.app_name || d.name))
|
|
appNames.value = [...names].sort()
|
|
if (!curAppName.value && appNames.value.length) {
|
|
curAppName.value = appNames.value[0]!
|
|
await loadData()
|
|
}
|
|
} catch (e: any) {
|
|
error(e?.message ?? '获取应用列表失败')
|
|
appNames.value = []
|
|
}
|
|
}
|
|
|
|
async function loadData() {
|
|
if (!curAppName.value) {
|
|
items.value = []
|
|
return
|
|
}
|
|
loading.value = true
|
|
try {
|
|
const data = await consoleApi<{ items: Goods[] }>('appgoods/list', { app_name: curAppName.value })
|
|
items.value = data?.items ?? []
|
|
} catch (e: any) {
|
|
error(e?.message ?? '获取商品失败')
|
|
items.value = []
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function showList() {
|
|
view.value = 'list'
|
|
loadData()
|
|
}
|
|
|
|
function showForm(g?: Goods) {
|
|
isEditing.value = !!g
|
|
form.id = g?.id ?? 0
|
|
form.goods_id = g?.goods_id ?? ''
|
|
form.name = g?.name ?? ''
|
|
form.enable = g ? !!g.enable : true
|
|
form.price = g?.price ?? 0
|
|
form.usagetype = g?.usagetype ?? 4
|
|
form.ainum = g?.ainum ?? 0
|
|
form.meetnum = g?.meetnum ?? 0
|
|
form.tradenum = g?.tradenum ?? 0
|
|
form.viptime = g?.viptime ?? 0
|
|
form.sort = g?.sort ?? 0
|
|
form.remark = g?.remark ?? ''
|
|
form.locales = Object.entries(g?.locales ?? {})
|
|
.map(([lang, v]) => ({ lang, name: v?.name ?? '', currency: v?.currency ?? '', price: Number(v?.price ?? 0) }))
|
|
.sort((a, b) => (a.lang < b.lang ? -1 : 1))
|
|
langToAdd.value = ''
|
|
view.value = 'form'
|
|
}
|
|
|
|
// 新增语言行:默认名称/货币/价格从上方基础信息带一份,改起来快
|
|
function addLocale() {
|
|
const lang = langToAdd.value
|
|
if (!lang || form.locales.some((l) => l.lang === lang)) return
|
|
form.locales.push({ lang, name: form.name, currency: '', price: form.price })
|
|
langToAdd.value = ''
|
|
}
|
|
|
|
async function saveGoods() {
|
|
if (!curAppName.value) {
|
|
error('请先选择应用')
|
|
return
|
|
}
|
|
const locales: Record<string, GoodsLocale> = {}
|
|
for (const l of form.locales) {
|
|
locales[l.lang] = { name: l.name.trim(), currency: l.currency.trim(), price: Number(l.price) || 0 }
|
|
}
|
|
submitting.value = true
|
|
try {
|
|
const data: Record<string, unknown> = {
|
|
app_name: curAppName.value,
|
|
goods_id: form.goods_id.trim(),
|
|
name: form.name.trim(),
|
|
enable: form.enable,
|
|
price: Number(form.price) || 0,
|
|
usagetype: Number(form.usagetype) || 0,
|
|
ainum: Number(form.ainum) || 0,
|
|
meetnum: Number(form.meetnum) || 0,
|
|
tradenum: Number(form.tradenum) || 0,
|
|
viptime: Number(form.viptime) || 0,
|
|
sort: Number(form.sort) || 0,
|
|
remark: form.remark.trim(),
|
|
locales,
|
|
}
|
|
if (isEditing.value) {
|
|
data.id = Number(form.id)
|
|
await consoleApi('appgoods/update', data)
|
|
} else {
|
|
await consoleApi('appgoods/add', data)
|
|
}
|
|
success('保存成功')
|
|
showList()
|
|
} catch (e: any) {
|
|
error(e?.message ?? '保存失败')
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
function delGoods(g: Goods) {
|
|
if (!window.confirm(`确定删除商品「${g.goods_id}」吗?`)) return
|
|
consoleApi('appgoods/del', { app_name: curAppName.value, ids: [g.id] })
|
|
.then(() => { success('删除成功'); loadData() })
|
|
.catch((e: any) => error(e?.message ?? '删除失败'))
|
|
}
|
|
|
|
onMounted(loadApps)
|
|
</script>
|
|
|