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.
422 lines
19 KiB
422 lines
19 KiB
<template>
|
|
<div>
|
|
<div class="flex flex-wrap items-start justify-between gap-3 mb-4">
|
|
<div>
|
|
<div class="text-sm font-semibold text-slate-700">渠道分发</div>
|
|
<div class="text-xs text-slate-400 mt-1 max-w-3xl">
|
|
<b>因渠道而异</b>的东西:本渠道下载地址、上架状态、支付渠道、是否允许跳过设备绑定。
|
|
下载地址<b>留空就回退上面的「默认下载地址」</b>,所以只有需要直达某家商店时才填。
|
|
支付渠道在这里控制「开放哪几种支付入口」,商户凭据在应用的业务功能配置里填。
|
|
</div>
|
|
</div>
|
|
<button class="btn btn-primary btn-sm text-white shrink-0" @click="startAdd()">+ 添加渠道</button>
|
|
</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-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 各不相同,填错会把用户引到别家商店。
|
|
留空则回退到<b>上方「发版配置」</b>里的「默认下载地址」——
|
|
那里填网页下载页的话,这里留空就等于交给它按品牌自动分流。
|
|
</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, watch, onMounted } from 'vue'
|
|
import { useApi } from '~/composables/useApi'
|
|
import { useToast } from '~/composables/useToast'
|
|
|
|
// 与后端 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()
|
|
|
|
|
|
// 作用域由外层「版本与分发」页统一给:两块配置共用一个应用选择器。
|
|
// 用 computed 顶替原来的 scopeApp ref,正文里所有 scopeApp.value 的写法原样保留 ——
|
|
// 逐个改成 props.appName 只会在这次合并里多制造几十处可能改漏的地方。
|
|
const props = defineProps<{ appName: string }>()
|
|
const scopeApp = computed(() => props.appName)
|
|
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 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,不能用 watch 的 immediate:immediate 会在 **SSR 阶段**
|
|
// 就执行,而这些接口要登录态,服务端渲染时必然失败 —— 表现是整页 500(踩过)。
|
|
// watch 只负责「外层换了应用」时重新拉。
|
|
onMounted(loadAll)
|
|
watch(() => props.appName, () => { loadAll() })
|
|
// 给外层「版本与分发」页用:上面的面板传完安装包会改写官方渠道的地址,这边要重拉才看得到。
|
|
defineExpose({ reload: 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>
|
|
|