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.
 
 
 
 
 
 

443 lines
20 KiB

<template>
<div>
<!-- 顶部 -->
<div class="flex items-center justify-between mb-5 flex-wrap gap-3">
<div>
<h2 class="text-lg font-semibold text-slate-800">渠道分发</h2>
<p class="text-xs text-slate-400 mt-1">
管理各应用在各分发渠道上<b>因渠道而异</b>的东西:本渠道下载地址、上架状态、支付渠道、是否允许跳过设备绑定。
支付渠道在这里控制「开放哪几种支付入口」,商户凭据在应用的业务功能配置里填。
</p>
<p class="text-xs text-slate-400 mt-1">
版本号 / 强制更新 / 送审版本去
<NuxtLink class="link" to="/appversion">服务配置 → 版本控制</NuxtLink>,
游客登录开关去
<NuxtLink class="link" to="/guestaccess">服务配置 → 游客显隐控制</NuxtLink>
—— 那两组是整个应用一套的,不分渠道。
</p>
</div>
<div class="flex items-center gap-3">
<span class="text-sm text-slate-500">应用</span>
<select v-model="scopeApp" class="select select-bordered select-sm" @change="loadAll">
<option value="">全局默认(所有应用兜底)</option>
<option v-for="a in scopeApps" :key="a" :value="a">{{ a }}</option>
</select>
<button class="btn btn-primary btn-sm text-white" @click="startAdd">+ 添加渠道</button>
</div>
</div>
<!-- 支付凭据体检:开了支付入口却没配凭据,是这个页面最容易踩的坑 -->
<div v-if="payWarnings.length" class="pay-warn">
<div class="flex items-start gap-2">
<span>⚠</span>
<div class="flex-1">
<div class="text-sm font-semibold">以下支付方式已在渠道里开放,但商户凭据还没配:</div>
<div class="text-xs mt-1">
<span v-for="w in payWarnings" :key="w.key" class="warn-pill">{{ w.label }}({{ w.channels.join('、') }})</span>
</div>
</div>
<button class="btn btn-xs btn-ghost" @click="gotoPayConfig">去配置 →</button>
</div>
</div>
<div v-if="!scopeApp" class="scope-tip">
当前是<b>全局默认</b>作用域:这里配的渠道对所有应用生效,应用自己配了同渠道则以应用的为准。
</div>
<!-- 按渠道大类分组 -->
<div v-for="g in groups" :key="g.label" class="mb-6">
<div class="flex items-center gap-2 mb-3">
<span class="group-tag" :style="{ background: g.bg, color: g.color }">{{ g.icon }} {{ g.label }}</span>
<span class="text-xs text-slate-400">{{ g.items.length }} / {{ g.channels.length }} 已配置</span>
</div>
<div class="ch-grid">
<div v-for="c in g.items" :key="c.channel" class="ch-card" :class="{ off: !c.enable }" @click="startEdit(c)">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<div class="font-semibold text-sm text-slate-800">{{ channelName(c.channel) }}</div>
<div class="text-[11px] text-slate-400 mt-0.5">渠道号 {{ c.channel }}</div>
</div>
<span class="status-badge" :style="statusStyle(c.status)">{{ statusLabel(c.status) }}</span>
</div>
<div class="flex flex-wrap items-center gap-1.5 mt-2">
<span v-if="c.allowskipdevicebinding" class="flag">可跳过绑定</span>
<span v-if="!c.enable" class="flag flag-off">已停用</span>
</div>
<div v-if="payList(c).length" class="flex flex-wrap items-center gap-1.5 mt-2">
<span
v-for="p in payList(c)"
:key="p"
class="pay-pill"
:class="{ missing: payMissing(p) }"
:title="payMissing(p) ? '该支付方式的商户凭据尚未配置' : ''"
>{{ payLabel(p) }}{{ payMissing(p) ? ' ⚠' : '' }}</span>
</div>
<div class="text-[11px] mt-2 truncate" :class="c.address ? 'text-slate-400' : 'text-slate-300'">
{{ c.address || '未填本渠道地址 · 用应用默认' }}
</div>
</div>
<!-- 该组里尚未配置的渠道,点一下直接建 -->
<div
v-for="ch in g.missing"
:key="'m' + ch"
class="ch-card ch-empty"
@click="startAdd(ch)"
>
<div class="text-sm text-slate-400">{{ channelName(ch) }}</div>
<div class="text-[11px] text-slate-300 mt-1">未配置 · 点击添加</div>
</div>
</div>
</div>
<!-- 编辑弹窗 -->
<div v-show="modalOpen" class="ch-overlay">
<div class="ch-box">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<h3 class="text-base font-semibold text-slate-800">{{ isEditing ? '编辑渠道' : '添加渠道' }}</h3>
<span class="scope-chip">{{ scopeApp || '全局默认' }}</span>
</div>
<button class="btn btn-ghost btn-xs" @click="closeModal">✕ 关闭</button>
</div>
<section class="ed-section">
<div class="ed-section-title">渠道</div>
<div class="grid grid-cols-2 gap-3">
<div>
<span class="ed-label">分发渠道 <i class="req">*</i></span>
<select v-model.number="form.channel" :disabled="isEditing" class="select select-bordered select-sm w-full">
<option :value="0" disabled>请选择渠道</option>
<optgroup v-for="g in CHANNEL_GROUPS" :key="g.label" :label="g.label">
<option v-for="ch in g.channels" :key="ch" :value="ch">{{ channelName(ch) }}({{ ch }})</option>
</optgroup>
</select>
</div>
<div>
<span class="ed-label">上架状态</span>
<select v-model.number="form.status" class="select select-bordered select-sm w-full">
<option v-for="s in STATUS" :key="s.v" :value="s.v">{{ s.label }}</option>
</select>
</div>
</div>
<div class="mt-3">
<span class="ed-label">描述</span>
<input v-model="form.description" class="input input-bordered input-sm w-full" placeholder="这个渠道包的说明" />
</div>
</section>
<section class="ed-section">
<div class="ed-section-title">本渠道下载地址</div>
<input v-model="form.address" class="input input-bordered input-sm w-full" placeholder="本商店的下载链接,留空则用应用默认地址" />
<p class="ed-tip">
客户端点「立即更新」时直接打开这个地址,所以<b>必须是本渠道自己的商店链接</b>——
华为、小米、App Store 各不相同,填错会把用户引到别家商店。
留空则回退到
<NuxtLink class="link" to="/appversion">版本控制</NuxtLink>
里配的「默认下载地址」。
</p>
</section>
<section class="ed-section">
<div class="ed-section-title">能力开关</div>
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input v-model="form.allowskipdevicebinding" type="checkbox" class="checkbox checkbox-sm checkbox-primary" />
<span>允许跳过设备绑定</span>
</label>
<p class="ed-tip">
游客登录开关已移到
<NuxtLink class="link" to="/guestaccess">服务配置 → 游客显隐控制</NuxtLink>
(按应用配,不分渠道)。
</p>
</section>
<section class="ed-section">
<div class="ed-section-title">支付渠道</div>
<div class="pay-grid">
<label v-for="p in PAY_CHANNELS" :key="p.key" class="pay-item" :class="{ checked: payChecked(p.key) }">
<input type="checkbox" class="checkbox checkbox-xs checkbox-primary" :checked="payChecked(p.key)" @change="togglePay(p.key)" />
<span>{{ p.label }}</span>
<span v-if="payChecked(p.key) && payMissing(p.key)" class="pay-miss">未配凭据</span>
</label>
</div>
<p class="ed-tip">
这里只决定「这个渠道开放哪几种支付入口」;商户号、密钥等凭据在
<a v-if="scopeApp" class="link" @click.prevent="gotoPayConfig">应用「{{ scopeApp }}」→ 业务功能配置 → 支付</a>
<span v-else>各应用的「业务功能配置 → 支付」</span>
里填。
</p>
</section>
<div class="ed-footer">
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input v-model="form.enable" type="checkbox" class="checkbox checkbox-sm checkbox-primary" />
<span>启用</span>
<span class="text-xs text-slate-400">停用后该渠道配置不下发客户端</span>
</label>
<div class="flex gap-2">
<button v-if="isEditing" class="btn btn-ghost btn-sm text-error mr-auto" @click="del">删除</button>
<button class="btn btn-ghost btn-sm" @click="closeModal">取消</button>
<button class="btn btn-primary btn-sm text-white" :disabled="submitting" @click="save">保存</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
definePageMeta({ title: '渠道分发' })
// 与后端 comm.ChannelApp 一一对应。
// ⚠️ 版本号 / 最低支持版本 / 强制更新 / 更新日志 / 送审版本 / 游客登录开关**不在这里**:
// 它们是「整个应用一套」的,已搬到 app_release(服务配置 → 版本控制 / 游客显隐控制)。
// 别再加回来——按渠道各存一份意味着同一个开关有 8 处能改而客户端只读一处。
interface ChannelApp {
row_id: number
app_name: string
channel: number
address: string
status: number
description: string
allowskipdevicebinding: boolean
paychannels: string
enable: boolean
}
// 渠道枚举(与后端 pb.UserChannel / comm.Channel* 一致)
const CHANNELS: Record<number, string> = {
1: '华为', 2: '小米', 3: 'OPPO', 4: 'VIVO', 5: '荣耀',
100: '官方包', 101: 'App Store', 102: 'Google Play',
}
function channelName(v: number): string {
return CHANNELS[v] ?? '渠道' + v
}
const CHANNEL_GROUPS = [
{ label: '中国厂商', icon: '📱', color: '#0369a1', bg: '#dbeafe', channels: [1, 2, 3, 4, 5] },
{ label: '全球商店', icon: '🌍', color: '#c2410c', bg: '#fff7ed', channels: [101, 102] },
{ label: '官方渠道', icon: '🏠', color: '#059669', bg: '#dcfce7', channels: [100] },
]
// 上架状态(与 comm.ChannelStatus* 一致)
const STATUS = [
{ v: 0, label: '草稿(不下发)', color: '#64748b', bg: '#f1f5f9' },
{ v: 1, label: '已上架', color: '#059669', bg: '#dcfce7' },
{ v: 2, label: '审核中', color: '#b45309', bg: '#fef9c3' },
{ v: 3, label: '已下架', color: '#be123c', bg: '#ffe4e6' },
]
function statusLabel(v: number): string {
return STATUS.find((s) => s.v === v)?.label ?? '未知'
}
function statusStyle(v: number) {
const s = STATUS.find((x) => x.v === v)
return s ? { background: s.bg, color: s.color } : {}
}
// 支付渠道 → 业务功能配置里的模块键(与后端 comm.PayChannelModule 一致)
const PAY_CHANNELS = [
{ key: 'wxpay', label: '微信支付', module: 'wechatpay' },
{ key: 'alipay', label: '支付宝', module: 'alipay' },
{ key: 'paypal', label: 'PayPal', module: 'paypal' },
{ key: 'apple', label: 'Apple 内购', module: 'appleiap' },
{ key: 'google', label: 'Google 内购', module: 'googleiap' },
]
function payLabel(k: string): string {
return PAY_CHANNELS.find((p) => p.key === k)?.label ?? k
}
const { webApi, consoleApi } = useApi()
const { success, error } = useToast()
const router = useRouter()
const scopeApp = ref('')
const scopeApps = ref<string[]>([])
const items = ref<ChannelApp[]>([])
const payConfigured = ref<string[]>([]) // 已配凭据的模块键
const payChecked_ = ref(false) // 是否成功查到(全局作用域查不了)
const modalOpen = ref(false)
const isEditing = ref(false)
const submitting = ref(false)
function emptyForm(): ChannelApp {
return {
row_id: 0, app_name: '', channel: 0, address: '', status: 1, description: '',
allowskipdevicebinding: false, paychannels: '', enable: true,
}
}
const form = reactive<ChannelApp>(emptyForm())
const groups = computed(() =>
CHANNEL_GROUPS.map((g) => ({
...g,
items: items.value.filter((c) => g.channels.includes(c.channel)).sort((a, b) => a.channel - b.channel),
missing: g.channels.filter((ch) => !items.value.some((c) => c.channel === ch)),
})),
)
function payList(c: ChannelApp): string[] {
return (c.paychannels || '').split(',').map((x) => x.trim()).filter(Boolean)
}
// 某支付方式是否「开了但没配凭据」。查不到配置状态时一律不报警,避免误伤。
function payMissing(key: string): boolean {
if (!payChecked_.value) return false
const mod = PAY_CHANNELS.find((p) => p.key === key)?.module
return !!mod && !payConfigured.value.includes(mod)
}
// 汇总所有渠道里「开了但没配」的支付方式,连带列出是哪几个渠道开的
const payWarnings = computed(() => {
if (!payChecked_.value) return []
const map: Record<string, string[]> = {}
for (const c of items.value) {
if (!c.enable) continue
for (const p of payList(c)) {
if (!payMissing(p)) continue
;(map[p] = map[p] || []).push(channelName(c.channel))
}
}
return Object.keys(map).map((k) => ({ key: k, label: payLabel(k), channels: map[k]! }))
})
// ===== 加载 =====
async function loadScopeApps() {
try {
const list = await consoleApi<Array<{ name: string }>>('products/list', {})
scopeApps.value = (list ?? []).map((p: { name: string }) => p.name).filter(Boolean)
} catch {
scopeApps.value = []
}
}
async function loadItems() {
try {
const d = await webApi<{ items: ChannelApp[] }>('getchannelapps', { app_name: scopeApp.value })
items.value = d?.items ?? []
} catch (e: any) {
error(e?.message ?? '加载失败')
items.value = []
}
}
async function loadPayStatus() {
try {
const d = await webApi<{ configured: string[]; checked: boolean }>('getchannelpaystatus', { app_name: scopeApp.value })
payConfigured.value = d?.configured ?? []
payChecked_.value = !!d?.checked
} catch {
payConfigured.value = []
payChecked_.value = false
}
}
async function loadAll() {
await Promise.all([loadItems(), loadPayStatus()])
}
// ===== 编辑 =====
function startAdd(channel?: number) {
Object.assign(form, emptyForm())
if (typeof channel === 'number') form.channel = channel
isEditing.value = false
modalOpen.value = true
}
function startEdit(c: ChannelApp) {
Object.assign(form, emptyForm(), c)
isEditing.value = true
modalOpen.value = true
}
function closeModal() {
modalOpen.value = false
}
function payChecked(key: string): boolean {
return payList(form).includes(key)
}
function togglePay(key: string) {
const cur = payList(form)
const i = cur.indexOf(key)
if (i >= 0) cur.splice(i, 1)
else cur.push(key)
form.paychannels = cur.join(',')
}
async function save() {
if (!form.channel) { error('请选择分发渠道'); return }
submitting.value = true
try {
await webApi('savechannelapp', { ...form, app_name: scopeApp.value })
success('保存成功')
closeModal()
await loadItems()
} catch (e: any) {
error(e?.message ?? '保存失败')
} finally {
submitting.value = false
}
}
function del() {
if (!window.confirm(`确定删除「${channelName(form.channel)}」这个渠道的配置吗?`)) return
webApi('delchannelapp', { app_name: scopeApp.value, channel: form.channel })
.then(() => { success('已删除'); closeModal(); loadItems() })
.catch((e: any) => error(e?.message ?? '删除失败'))
}
function gotoPayConfig() {
if (!scopeApp.value) { error('请先在上方选择一个应用'); return }
router.push(`/apps/${encodeURIComponent(scopeApp.value)}?tab=modules`)
}
onMounted(async () => {
await loadScopeApps()
await loadAll()
})
</script>
<style scoped>
.group-tag { font-size: 12px; font-weight: 700; padding: 3px 10px; border-radius: 8px; }
.ch-grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
.ch-card { background: #fff; border: 1px solid #eef2f7; border-radius: 14px; padding: 14px 16px; cursor: pointer; transition: all .15s; }
.ch-card:hover { border-color: #c7d2fe; box-shadow: 0 4px 14px rgba(99, 102, 241, .08); }
.ch-card.off { opacity: .55; }
.ch-empty { border-style: dashed; background: #fafbfc; display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 92px; }
.ch-empty:hover { border-color: #c7d2fe; background: #f8fafc; }
.status-badge { font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 6px; white-space: nowrap; }
.flag { font-size: 10px; font-weight: 600; padding: 2px 7px; border-radius: 5px; background: #f1f5f9; color: #475569; }
.flag-off { background: #fee2e2; color: #b91c1c; }
.pay-pill { font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 6px; background: #eef2ff; color: #4338ca; }
.pay-pill.missing { background: #fef3c7; color: #b45309; }
.pay-warn { background: #fffbeb; border: 1px solid #fde68a; color: #92400e; border-radius: 12px; padding: 12px 14px; margin-bottom: 16px; }
.warn-pill { display: inline-block; margin-right: 8px; padding: 1px 8px; border-radius: 6px; background: #fef3c7; }
.scope-tip { background: #f8fafc; border: 1px solid #eef2f7; color: #64748b; font-size: 12px; border-radius: 10px; padding: 9px 13px; margin-bottom: 16px; }
/* 弹窗 */
.ch-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, .45); z-index: 100; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(2px); padding: 24px; }
.ch-box { background: #fff; border-radius: 18px; padding: 24px; width: 700px; max-width: 96vw; max-height: 90vh; overflow-y: auto; box-shadow: 0 24px 80px rgba(0, 0, 0, .18); }
.scope-chip { font-size: 11px; font-weight: 600; padding: 2px 9px; border-radius: 6px; background: #eef2ff; color: #4338ca; }
.ed-section { padding: 14px 0; border-top: 1px dashed #eef2f7; }
.ed-section:first-of-type { padding-top: 0; border-top: none; }
.ed-section-title { font-size: 11px; font-weight: 700; letter-spacing: .04em; color: #94a3b8; margin-bottom: 10px; }
.ed-label { display: block; font-size: 11px; color: #64748b; margin-bottom: 4px; }
.ed-warn { font-size: 11px; color: #b45309; margin-top: 8px; }
.ed-tip { font-size: 11px; color: #94a3b8; margin-top: 8px; }
.req { color: #dc2626; font-style: normal; }
.link { color: #4f46e5; cursor: pointer; text-decoration: underline; }
.pay-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.pay-item { display: flex; align-items: center; gap: 7px; padding: 8px 10px; border-radius: 9px; border: 1.5px solid #e2e8f0; cursor: pointer; font-size: 13px; color: #475569; transition: all .15s; }
.pay-item:hover { border-color: #c7d2fe; background: #f8fafc; }
.pay-item.checked { border-color: #6366f1; background: #eef2ff; color: #4338ca; }
.pay-miss { font-size: 10px; color: #b45309; background: #fef3c7; padding: 1px 5px; border-radius: 4px; margin-left: auto; }
.ed-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-top: 14px; border-top: 1px solid #eef2f7; }
@media (max-width: 860px) {
.pay-grid { grid-template-columns: repeat(2, 1fr); }
}
</style>