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.
501 lines
22 KiB
501 lines
22 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">
|
|
管理各应用在各分发渠道上的版本、强制更新与能力开关。支付渠道在这里控制「开放哪几种支付入口」,商户凭据在应用的业务功能配置里填。
|
|
</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="ver-row">
|
|
<div class="ver-box">
|
|
<span class="ver-label">线上版本</span>
|
|
<span class="ver-val">{{ c.version || '—' }}</span>
|
|
</div>
|
|
<div class="ver-box">
|
|
<span class="ver-label">最低支持</span>
|
|
<span class="ver-val" :class="{ warn: c.force_update }">{{ c.min_version || '—' }}</span>
|
|
</div>
|
|
<div v-if="c.review_version" class="ver-box">
|
|
<span class="ver-label">审核中</span>
|
|
<span class="ver-val review">{{ c.review_version }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap items-center gap-1.5 mt-2">
|
|
<span v-if="c.force_update" class="flag flag-warn">强制更新</span>
|
|
<span v-if="c.tourists" class="flag">游客登录</span>
|
|
<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 v-if="c.address" class="text-[11px] text-slate-400 mt-2 truncate">{{ 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>
|
|
<div class="grid grid-cols-3 gap-3">
|
|
<div>
|
|
<span class="ed-label">当前线上版本</span>
|
|
<input v-model="form.version" class="input input-bordered input-sm w-full" placeholder="如 1.4.2" />
|
|
</div>
|
|
<div>
|
|
<span class="ed-label">最低支持版本</span>
|
|
<input v-model="form.min_version" class="input input-bordered input-sm w-full" placeholder="低于它的客户端需更新" />
|
|
</div>
|
|
<div>
|
|
<span class="ed-label">审核中版本</span>
|
|
<input v-model="form.review_version" class="input input-bordered input-sm w-full" placeholder="过审后清空" />
|
|
</div>
|
|
</div>
|
|
<label class="flex items-center gap-2 text-sm cursor-pointer mt-3">
|
|
<input v-model="form.force_update" type="checkbox" class="checkbox checkbox-sm checkbox-primary" />
|
|
<span>强制更新</span>
|
|
<span class="text-xs text-slate-400">低于「最低支持版本」的客户端必须更新才能继续使用</span>
|
|
</label>
|
|
<p v-if="versionWarn" class="ed-warn">⚠ {{ versionWarn }}</p>
|
|
<div class="mt-3">
|
|
<span class="ed-label">下载地址</span>
|
|
<input v-model="form.address" class="input input-bordered input-sm w-full" placeholder="应用商店链接或 apk 直链" />
|
|
</div>
|
|
<div class="mt-3">
|
|
<span class="ed-label">更新日志</span>
|
|
<textarea v-model="form.changelog" class="textarea textarea-bordered w-full text-xs" rows="3" placeholder="本次更新内容,会下发给客户端展示" />
|
|
</div>
|
|
</section>
|
|
|
|
<section class="ed-section">
|
|
<div class="ed-section-title">能力开关</div>
|
|
<div class="flex flex-col gap-2">
|
|
<label class="flex items-center gap-2 text-sm cursor-pointer">
|
|
<input v-model="form.tourists" type="checkbox" class="checkbox checkbox-sm checkbox-primary" />
|
|
<span>允许游客登录</span>
|
|
</label>
|
|
<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>
|
|
</div>
|
|
</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: '渠道分发' })
|
|
|
|
interface ChannelApp {
|
|
row_id: number
|
|
app_name: string
|
|
channel: number
|
|
version: string
|
|
min_version: string
|
|
force_update: boolean
|
|
changelog: string
|
|
address: string
|
|
review_version: string
|
|
status: number
|
|
description: string
|
|
tourists: boolean
|
|
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, version: '', min_version: '', force_update: false,
|
|
changelog: '', address: '', review_version: '', status: 1, description: '',
|
|
tourists: false, 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]! }))
|
|
})
|
|
|
|
const versionWarn = computed(() => {
|
|
if (form.force_update && !form.min_version.trim()) return '开启强制更新时必须填「最低支持版本」,否则挡不住任何人'
|
|
if (form.min_version.trim() && form.version.trim() && cmpVer(form.min_version, form.version) > 0) {
|
|
return '「最低支持版本」高于「当前线上版本」,会把所有线上用户判定为需强更却无更高版本可更'
|
|
}
|
|
return ''
|
|
})
|
|
// 与后端 comm.CompareVersion 同语义:逐段按整数比,短的补 0
|
|
function cmpVer(a: string, b: string): number {
|
|
const as = a.trim().split('.'), bs = b.trim().split('.')
|
|
const n = Math.max(as.length, bs.length)
|
|
for (let i = 0; i < n; i++) {
|
|
const x = parseInt(as[i] ?? '0') || 0
|
|
const y = parseInt(bs[i] ?? '0') || 0
|
|
if (x !== y) return x < y ? -1 : 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// ===== 加载 =====
|
|
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 }
|
|
if (versionWarn.value) { error(versionWarn.value); 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; }
|
|
.ver-row { display: flex; gap: 14px; margin-top: 10px; flex-wrap: wrap; }
|
|
.ver-box { display: flex; flex-direction: column; }
|
|
.ver-label { font-size: 10px; color: #94a3b8; }
|
|
.ver-val { font-size: 13px; font-weight: 700; color: #1e293b; font-family: 'SF Mono', Menlo, monospace; }
|
|
.ver-val.warn { color: #b45309; }
|
|
.ver-val.review { color: #7c3aed; }
|
|
|
|
.flag { font-size: 10px; font-weight: 600; padding: 2px 7px; border-radius: 5px; background: #f1f5f9; color: #475569; }
|
|
.flag-warn { background: #fef3c7; color: #b45309; }
|
|
.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>
|
|
|