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.
 
 
 
 
 
 

256 lines
18 KiB

<template>
<div>
<!-- 演示横幅 -->
<div class="mb-4 rounded-lg px-4 py-2.5 text-xs flex items-center gap-2" style="background:#eef2ff;border:1px solid #c7d2fe;color:#4338ca">
<span class="font-semibold">演示效果</span>
<span class="text-indigo-400">·</span>
<span>本页为 UI 演示,数据为本地 mock。由「应用注册」列表点击「服务配置」进入,配置随应用隔离。</span>
</div>
<!-- 返回 + 标题 -->
<div class="flex items-center gap-2 mb-4">
<button class="btn btn-ghost btn-sm" @click="goBack">&larr; 返回应用列表</button>
<span class="text-slate-300">/</span>
<h2 class="text-lg font-semibold text-slate-800">应用服务配置</h2>
</div>
<!-- 应用信息条 -->
<div class="rounded-xl px-5 py-4 mb-5 flex items-center gap-4" style="background:linear-gradient(135deg,#eef2ff,#faf5ff);border:1px solid #e0e7ff">
<div class="app-avatar">{{ appInitial }}</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-base font-bold text-slate-800">{{ appName }}</span>
<span class="badge badge-sm" style="background:#e0e7ff;color:#4338ca;border:none">#{{ appId }}</span>
<span v-if="appRegion" class="badge badge-ghost badge-sm">{{ appRegion }}</span>
</div>
<div class="text-[11px] text-slate-500 mt-0.5">正在配置该应用的第三方登录、支付、推送与基础信息;仅对本应用生效,与其他应用隔离。</div>
</div>
<span class="badge badge-success badge-sm text-white">SaaS 独立配置</span>
</div>
<div class="flex gap-4" style="align-items:flex-start">
<!-- 左侧分类 -->
<div style="width:208px;flex-shrink:0">
<div class="flex flex-col gap-1.5">
<div
v-for="c in categories"
:key="c.id"
class="cat-item"
:class="{ active: curCat === c.id }"
@click="curCat = c.id"
>
<div class="cat-icon" :style="{ background: c.color }">
<svg width="17" height="17" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="c.icon" /></svg>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-slate-700">{{ c.label }}</div>
<div class="text-[11px] text-slate-400 truncate">{{ c.desc }}</div>
</div>
<span v-if="c.count" class="cnt-badge">{{ c.count }}</span>
</div>
</div>
</div>
<!-- 右侧详情 -->
<div class="flex-1 min-w-0 flex flex-col gap-3">
<!-- ===== 登录认证 ===== -->
<template v-if="curCat === 'auth'">
<div v-for="p in authProviders" :key="p.id" class="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
<div class="flex items-center gap-3 px-5 py-4 cursor-pointer" @click="toggleOpen(p)">
<div class="provider-logo" :style="{ background: p.color }">{{ p.short }}</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-semibold text-slate-800">{{ p.name }}</span>
<span class="badge badge-xs" :class="p.enable ? 'badge-success text-white' : 'badge-ghost'">{{ p.enable ? '已启用' : '未启用' }}</span>
</div>
<div class="text-[11px] text-slate-400 mt-0.5">{{ p.desc }}</div>
</div>
<input v-model="p.enable" type="checkbox" class="toggle toggle-primary toggle-sm" @click.stop />
<svg class="w-4 h-4 text-slate-300 transition-transform" :class="{ 'rotate-180': p._open }" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>
</div>
<div v-show="p._open" class="px-5 pb-5 pt-1 border-t border-slate-50">
<p v-if="p.note" class="text-[11px] text-slate-500 bg-slate-50 rounded-lg px-3 py-2 mb-3">{{ p.note }}</p>
<div class="grid grid-cols-2 gap-x-5 gap-y-3">
<div v-for="f in p.fields" :key="f.key" class="form-control" :class="{ 'col-span-2': f.wide }">
<label class="label py-1"><span class="label-text text-sm">{{ f.label }}<span v-if="f.required" class="text-rose-500 ml-0.5">*</span><span v-if="f.encrypted" class="lock-tag ml-1.5">🔒</span></span></label>
<div class="relative">
<input v-model="f.value" :type="f.encrypted && !f._show ? 'password' : 'text'" class="input input-bordered input-sm w-full" :class="{ 'font-mono': f.mono, 'pr-14': f.encrypted }" :placeholder="f.placeholder" :readonly="f.readonly" />
<button v-if="f.encrypted" type="button" class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400 hover:text-slate-700" @click="f._show = !f._show">{{ f._show ? '隐藏' : '显示' }}</button>
</div>
<span v-if="f.hint" class="text-[11px] text-slate-400 mt-1">{{ f.hint }}</span>
</div>
</div>
<div class="flex justify-end mt-4">
<button class="btn btn-primary btn-sm text-white" @click="save">保存</button>
</div>
</div>
</div>
</template>
<!-- ===== 支付配置 ===== -->
<template v-else-if="curCat === 'pay'">
<div v-for="p in payProviders" :key="p.id" class="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
<div class="flex items-center gap-3 px-5 py-4 cursor-pointer" @click="toggleOpen(p)">
<div class="provider-logo" :style="{ background: p.color }">{{ p.short }}</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-semibold text-slate-800">{{ p.name }}</span>
<span class="badge badge-xs" :class="p.enable ? 'badge-success text-white' : 'badge-ghost'">{{ p.enable ? '已启用' : '未启用' }}</span>
</div>
<div class="text-[11px] text-slate-400 mt-0.5">{{ p.desc }}</div>
</div>
<input v-model="p.enable" type="checkbox" class="toggle toggle-primary toggle-sm" @click.stop />
<svg class="w-4 h-4 text-slate-300 transition-transform" :class="{ 'rotate-180': p._open }" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>
</div>
<div v-show="p._open" class="px-5 pb-5 pt-1 border-t border-slate-50">
<div class="grid grid-cols-2 gap-x-5 gap-y-3">
<div v-for="f in p.fields" :key="f.key" class="form-control" :class="{ 'col-span-2': f.wide }">
<label class="label py-1"><span class="label-text text-sm">{{ f.label }}<span v-if="f.encrypted" class="lock-tag ml-1.5">🔒</span></span></label>
<input v-model="f.value" :type="f.encrypted ? 'password' : 'text'" class="input input-bordered input-sm w-full" :class="{ 'font-mono': f.mono }" :placeholder="f.placeholder" />
</div>
</div>
<div class="flex justify-end mt-4"><button class="btn btn-primary btn-sm text-white" @click="save">保存</button></div>
</div>
</div>
</template>
<!-- ===== 消息推送 ===== -->
<template v-else-if="curCat === 'push'">
<div class="bg-white rounded-2xl border border-slate-100 shadow-sm p-6">
<h3 class="text-base font-semibold text-slate-800 mb-1">消息推送</h3>
<p class="text-xs text-slate-400 mb-4">推送通道(极光 / FCM / APNs)在「系统配置」统一接入,此处为各应用的开关与包名映射。</p>
<div class="grid grid-cols-2 gap-x-5 gap-y-3">
<div class="form-control"><label class="label py-1"><span class="label-text text-sm">Android 包名</span></label><input v-model="push.androidPkg" class="input input-bordered input-sm font-mono" /></div>
<div class="form-control"><label class="label py-1"><span class="label-text text-sm">iOS Bundle ID</span></label><input v-model="push.iosBundle" class="input input-bordered input-sm font-mono" /></div>
<div class="form-control"><label class="label py-1"><span class="label-text text-sm">极光 AppKey(应用级)</span></label><input v-model="push.jpushKey" class="input input-bordered input-sm font-mono" /></div>
<div class="form-control"><label class="label py-1"><span class="label-text text-sm">推送总开关</span></label><input v-model="push.enable" type="checkbox" class="toggle toggle-primary mt-2" /></div>
</div>
<div class="flex justify-end mt-5"><button class="btn btn-primary btn-sm text-white" @click="save">保存</button></div>
</div>
</template>
<!-- ===== 基础信息 ===== -->
<template v-else-if="curCat === 'basic'">
<div class="bg-white rounded-2xl border border-slate-100 shadow-sm p-6">
<h3 class="text-base font-semibold text-slate-800 mb-4">应用基础信息</h3>
<div class="grid grid-cols-2 gap-x-5 gap-y-3">
<div class="form-control"><label class="label py-1"><span class="label-text text-sm">应用名称</span></label><input v-model="basic.name" class="input input-bordered input-sm" /></div>
<div class="form-control"><label class="label py-1"><span class="label-text text-sm">应用简称</span></label><input v-model="basic.shortName" class="input input-bordered input-sm" /></div>
<div class="form-control col-span-2"><label class="label py-1"><span class="label-text text-sm">应用 Logo URL</span></label><input v-model="basic.logo" class="input input-bordered input-sm font-mono" /></div>
<div class="form-control"><label class="label py-1"><span class="label-text text-sm">用户协议链接</span></label><input v-model="basic.terms" class="input input-bordered input-sm" /></div>
<div class="form-control"><label class="label py-1"><span class="label-text text-sm">隐私政策链接</span></label><input v-model="basic.privacy" class="input input-bordered input-sm" /></div>
<div class="form-control"><label class="label py-1"><span class="label-text text-sm">客服邮箱</span></label><input v-model="basic.support" class="input input-bordered input-sm" /></div>
<div class="form-control"><label class="label py-1"><span class="label-text text-sm">默认语言</span></label><input v-model="basic.lang" class="input input-bordered input-sm" /></div>
</div>
<div class="flex justify-end mt-5"><button class="btn btn-primary btn-sm text-white" @click="save">保存</button></div>
</div>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { useToast } from '~/composables/useToast'
definePageMeta({ title: '应用服务配置' })
const { success } = useToast()
const route = useRoute()
const router = useRouter()
interface Field { key: string; label: string; value: string; placeholder?: string; hint?: string; required?: boolean; encrypted?: boolean; mono?: boolean; wide?: boolean; readonly?: boolean; _show?: boolean }
interface ProviderCard { id: string; name: string; short: string; desc: string; color: string; enable: boolean; note?: string; fields: Field[]; _open?: boolean }
// 应用上下文(由「应用注册」列表跳转携带 query)
const appId = computed(() => Number(route.query.id) || 0)
const appName = computed(() => (route.query.name as string) || '未指定应用')
const appRegion = computed(() => (route.query.region as string) || '')
const appInitial = computed(() => (appName.value || 'A').charAt(0).toUpperCase())
function goBack() { router.push('/apps') }
const categories = computed(() => [
{ id: 'auth', label: '登录认证', desc: '微信 / Apple / Google', color: '#6366f1', count: authProviders.filter((p) => p.enable).length, icon: 'M12 11c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM12 11v6m0-6V5m6 6a3 3 0 11-6 0 3 3 0 016 0z' },
{ id: 'pay', label: '支付配置', desc: '微信支付 / 支付宝', color: '#10b981', count: payProviders.filter((p) => p.enable).length, icon: 'M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z' },
{ id: 'push', label: '消息推送', desc: '极光 / FCM / APNs', color: '#ec4899', count: 0, icon: 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9' },
{ id: 'basic', label: '基础信息', desc: '名称 / Logo / 协议', color: '#f59e0b', count: 0, icon: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z' },
])
const curCat = ref('auth')
// 登录认证(重点:微信登录)
const authProviders = reactive<ProviderCard[]>([
{
id: 'wechat', name: '微信登录', short: '微', desc: '微信开放平台 App / 公众号授权登录', color: '#07c160', enable: true, _open: true,
note: '微信开放平台 → 创建移动应用,审核通过后获取 AppID 与 AppSecret。App 登录用「移动应用」,公众号网页授权用「公众号」的 AppID。',
fields: [
{ key: 'appid', label: 'AppID', value: 'wx9f8e7d6c5b4a3210', mono: true, required: true },
{ key: 'secret', label: 'AppSecret', value: '', mono: true, encrypted: true, required: true, hint: '留空保留原密文' },
{ key: 'mch', label: '关联商户号', value: '', mono: true, hint: '可选,用于微信支付打通' },
{ key: 'scope', label: '授权 scope', value: 'snsapi_userinfo', mono: true },
],
},
{
id: 'apple', name: 'Apple 登录', short: '', desc: 'Sign in with Apple(iOS 上架必备)', color: '#111827', enable: true, _open: false,
fields: [
{ key: 'clientId', label: 'Client ID (Bundle)', value: 'com.yunyan.voitrans', mono: true, required: true },
{ key: 'teamId', label: 'Team ID', value: 'A1B2C3D4E5', mono: true, required: true },
{ key: 'keyId', label: 'Key ID', value: 'XYZ1234567', mono: true, required: true },
{ key: 'privateKey', label: 'AuthKey (.p8)', value: '', encrypted: true, wide: true, required: true, placeholder: '粘贴 .p8 私钥内容' },
],
},
{
id: 'google', name: 'Google 登录', short: 'G', desc: 'Google OAuth 2.0(海外应用)', color: '#ea4335', enable: false, _open: false,
fields: [
{ key: 'clientId', label: 'Client ID', value: '', mono: true, wide: true, required: true, placeholder: 'xxx.apps.googleusercontent.com' },
{ key: 'clientSecret', label: 'Client Secret', value: '', mono: true, encrypted: true, required: true },
],
},
{
id: 'phone', name: '手机短信登录', short: '号', desc: '手机号 + 验证码登录', color: '#0ea5e9', enable: true, _open: false,
note: '短信通道复用「系统配置 → 短信服务」,此处仅控制本应用是否开放手机号登录及验证码有效期。',
fields: [
{ key: 'channel', label: '短信通道', value: '系统配置 · 腾讯云 SMS', readonly: true, wide: true },
{ key: 'expire', label: '验证码有效期(秒)', value: '300', mono: true },
],
},
])
// 支付配置
const payProviders = reactive<ProviderCard[]>([
{
id: 'wxpay', name: '微信支付', short: '微', desc: 'App / JSAPI 支付', color: '#07c160', enable: true, _open: false,
fields: [
{ key: 'mchid', label: '商户号 MchId', value: '1612345678', mono: true },
{ key: 'appid', label: '关联 AppID', value: 'wx9f8e7d6c5b4a3210', mono: true },
{ key: 'apiv3', label: 'APIv3 密钥', value: '', mono: true, encrypted: true },
{ key: 'serialNo', label: '证书序列号', value: '', mono: true, wide: true },
],
},
{
id: 'alipay', name: '支付宝', short: '支', desc: 'App 支付', color: '#1677ff', enable: false, _open: false,
fields: [
{ key: 'appid', label: 'AppID', value: '', mono: true },
{ key: 'privateKey', label: '应用私钥', value: '', encrypted: true, wide: true },
{ key: 'publicKey', label: '支付宝公钥', value: '', wide: true },
],
},
])
const push = reactive({ androidPkg: 'com.yunyan.voitrans', iosBundle: 'com.yunyan.voitrans', jpushKey: 'a1b2c3d4e5f6', enable: true })
const basic = reactive({ name: 'Voitrans 同声传译', shortName: 'Voitrans', logo: 'https://cdn.yunyan.com/app/voitrans/logo.png', terms: 'https://yunyan.com/terms', privacy: 'https://yunyan.com/privacy', support: 'support@yunyan.com', lang: 'zh-CN' })
function toggleOpen(p: ProviderCard) { p._open = !p._open }
function save() { success(`「${appName.value}」的配置已保存(演示,未落库)`) }
</script>
<style scoped>
.cat-item { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 11px; border: 1px solid #eef2f7; background: #fff; cursor: pointer; transition: all .15s; }
.cat-item:hover { border-color: #c7d2fe; background: #f5f3ff; }
.cat-item.active { border-color: #6366f1; background: linear-gradient(135deg,#eef2ff,#f5f3ff); box-shadow: 0 4px 12px rgba(99,102,241,.12); }
.cat-icon { width: 32px; height: 32px; border-radius: 9px; display: flex; align-items: center; justify-content: center; color: #fff; flex-shrink: 0; }
.cnt-badge { font-size: 11px; font-weight: 700; color: #4338ca; background: #e0e7ff; min-width: 20px; height: 20px; padding: 0 6px; border-radius: 10px; display: flex; align-items: center; justify-content: center; }
.provider-logo { width: 38px; height: 38px; border-radius: 10px; display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 800; font-size: 14px; flex-shrink: 0; }
.lock-tag { font-size: 10px; font-weight: 600; color: #dc2626; background: #fef2f2; padding: 1px 6px; border-radius: 5px; }
.app-avatar { width: 48px; height: 48px; border-radius: 13px; background: linear-gradient(135deg,#4F46E5,#7C3AED); color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 20px; flex-shrink: 0; box-shadow: 0 6px 18px rgba(79,70,229,.3); }
</style>