Files
tiantian-system/app/pages/developer/config/[id].vue
2026-04-08 17:10:58 +08:00

694 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div v-if="mounted" class="app-config-page">
<!-- 页面头部 -->
<div class="page-header">
<!-- 应用信息卡片 -->
<div class="app-info-card">
<!-- 左侧返回 + 应用图标 + 基本信息 -->
<div class="app-info-main">
<a-button
type="text"
class="back-btn"
@click="navigateTo('/developer/apps')"
>
<template #icon><LeftOutlined /></template>
</a-button>
<div class="app-avatar">
<img
v-if="appInfo?.icon || appInfo?.logo"
:src="appInfo?.icon || appInfo?.logo"
class="app-avatar-img"
/>
<span v-else class="app-avatar-placeholder">
{{ appInfo?.productName?.charAt(0)?.toUpperCase() || '?' }}
</span>
</div>
<div class="app-meta">
<div class="app-name-row">
<span v-if="appInfo?.productName" class="app-name">{{ appInfo.productName }}</span>
<span v-else-if="appInfoLoading" class="app-name-skeleton">
<a-skeleton-input active size="small" style="width: 120px" />
</span>
<span v-else class="app-name app-name-unknown">未知应用</span>
<a-tag
v-if="appTypeLabel"
:color="appTypeColor"
class="app-type-tag"
>{{ appTypeLabel }}</a-tag>
<a-tag
v-if="appInfo?.status !== undefined"
:color="appStatusColor"
class="app-status-tag"
>{{ appStatusLabel }}</a-tag>
</div>
<div class="app-sub-info">
<span v-if="appInfo?.productCode" class="app-code">
<CodeOutlined class="sub-icon" />
{{ appInfo.productCode }}
</span>
<span v-if="appInfo?.domain" class="app-domain">
<GlobalOutlined class="sub-icon" />
{{ appInfo.domain }}
</span>
<span v-if="appInfo?.createTime" class="app-create-time">
<ClockCircleOutlined class="sub-icon" />
创建于 {{ appInfo.createTime?.slice(0, 10) }}
</span>
</div>
</div>
</div>
<!-- 右侧页面说明 -->
<div class="page-desc-block">
<div class="page-title-text">应用配置</div>
<div class="page-desc-text">管理应用的 API回调支付等配置</div>
</div>
</div>
</div>
<!-- 配置类型标签页 -->
<a-tabs v-model:activeKey="activeType" class="config-tabs" @change="handleTypeChange">
<a-tab-pane
v-for="type in configTypes"
:key="type.key"
:tab="type.name"
>
<!-- 配置表单 -->
<div v-if="!loading" class="config-content">
<a-alert
v-if="type.description"
:message="type.description"
type="info"
show-icon
class="mb-4"
/>
<a-form
:model="formData"
layout="vertical"
class="config-form"
>
<a-row :gutter="24">
<a-col
v-for="field in type.configs"
:key="field.key"
:span="field.type === 'textarea' ? 24 : 12"
>
<a-form-item
:label="field.label"
:name="field.key"
:required="field.required"
:rules="field.required ? [{ required: true, message: `请输入${field.label}` }] : []"
>
<!-- 密码输入 -->
<a-input-password
v-if="field.type === 'password'"
v-model:value="formData[field.key]"
:placeholder="field.placeholder"
/>
<!-- 文本域 -->
<a-textarea
v-else-if="field.type === 'textarea'"
v-model:value="formData[field.key]"
:placeholder="field.placeholder"
:rows="4"
/>
<!-- 下拉选择 -->
<a-select
v-else-if="field.type === 'select'"
v-model:value="formData[field.key]"
:placeholder="field.placeholder"
>
<a-select-option
v-for="opt in field.options"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</a-select-option>
</a-select>
<!-- 开关 -->
<a-switch
v-else-if="field.type === 'switch'"
v-model:checked="formData[field.key]"
/>
<!-- JSON 编辑器 -->
<a-textarea
v-else-if="field.type === 'json'"
v-model:value="formData[field.key]"
:placeholder="field.placeholder"
:rows="8"
style="font-family: monospace"
/>
<!-- 数字输入 -->
<a-input-number
v-else-if="field.type === 'number'"
v-model:value="formData[field.key]"
:placeholder="field.placeholder"
style="width: 100%"
/>
<!-- 普通输入 -->
<a-input
v-else
v-model:value="formData[field.key]"
:placeholder="field.placeholder"
/>
<!-- 字段说明 -->
<div v-if="field.description" class="field-desc">
<InfoCircleOutlined class="field-desc-icon" />
{{ field.description }}
</div>
</a-form-item>
</a-col>
</a-row>
</a-form>
<!-- 操作按钮 -->
<div class="form-actions">
<a-space>
<a-button @click="handleReset">
重置
</a-button>
<a-button type="primary" :loading="saving" @click="handleSave">
保存配置
</a-button>
</a-space>
</div>
</div>
<!-- 加载中 -->
<div v-else class="loading-wrap">
<a-spin size="large" tip="加载中..." />
</div>
</a-tab-pane>
</a-tabs>
</div>
</template>
<script setup lang="ts">
import { InfoCircleOutlined, LeftOutlined, CodeOutlined, GlobalOutlined, ClockCircleOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { getConfigsMap, listAppConfig, saveAppConfig, updateAppConfig } from '@/api/app/appConfig'
import type { AppConfig, ConfigType, ConfigField } from '@/api/app/appConfig/model'
import { getAppProduct } from '@/api/app/appProduct'
import type { AppProduct } from '@/api/app/appProduct/model'
definePageMeta({ layout: 'developer' })
useHead({ title: '应用配置 - 开发者中心' })
const route = useRoute()
const mounted = ref(false)
const productId = computed(() => {
if (!mounted.value) return 0
return parseInt(route.params.id as string || route.query.productId as string || '0')
})
// ── 应用信息 ──────────────────────────────────────────
const appInfo = ref<AppProduct | null>(null)
const appInfoLoading = ref(false)
async function loadAppInfo(id: number) {
if (!id) return
appInfoLoading.value = true
try {
appInfo.value = await getAppProduct(id)
// 同步页面 title
if (appInfo.value?.productName) {
useHead({ title: `${appInfo.value.productName} - 应用配置` })
}
} catch {
// 加载应用信息失败不阻塞主流程,静默处理
} finally {
appInfoLoading.value = false
}
}
const APP_TYPE_MAP: Record<string, { label: string; color: string }> = {
web: { label: 'Web 应用', color: 'blue' },
miniprogram: { label: '小程序', color: 'green' },
mobile: { label: '移动 App', color: 'purple' },
api: { label: 'API 服务', color: 'orange' },
internal: { label: '内部工具', color: 'default' },
}
const appTypeLabel = computed(() => {
const t = appInfo.value?.appType
if (t === 10) return '网站'
if (t === 20) return '微信小程序'
if (t === 30) return '抖音小程序'
if (t === 40) return '百度小程序'
if (t === 50) return '支付宝小程序'
if (t === 60) return 'Android APP'
if (t === 70) return 'iOS APP'
if (t === 80) return 'macOS 应用'
if (t === 90) return 'Windows 应用'
if (t === 100) return '插件'
return t?.toString() || ''
})
const appTypeColor = computed(() => {
const t = appInfo.value?.appType
if (t === 20 || t === 30 || t === 40 || t === 50) return 'green'
if (t === 60 || t === 70 || t === 80 || t === 90) return 'purple'
return 'blue'
})
const APP_STATUS_MAP: Record<number, { label: string; color: string }> = {
0: { label: '未开通', color: 'default' },
1: { label: '运行中', color: 'success' },
2: { label: '维护中', color: 'warning' },
3: { label: '已关闭', color: 'error' },
4: { label: '已欠费', color: 'error' },
5: { label: '违规关停', color: 'error' },
}
const appStatusLabel = computed(() => {
const s = appInfo.value?.status
return s !== undefined ? (APP_STATUS_MAP[s]?.label ?? `状态${s}`) : ''
})
const appStatusColor = computed(() => {
const s = appInfo.value?.status
return s !== undefined ? (APP_STATUS_MAP[s]?.color ?? 'default') : 'default'
})
// 配置类型定义
const configTypes: ConfigType[] = [
{
key: 'api',
name: 'API 配置',
icon: '🔌',
description: '配置应用的 API 基础信息,包括地址、超时等',
configs: [
{ key: 'api.baseUrl', label: 'API 基础地址', type: 'input', placeholder: 'https://api.example.com' },
{ key: 'api.timeout', label: '请求超时(秒)', type: 'number', placeholder: '30', defaultValue: 30 },
{ key: 'api.enableCache', label: '启用缓存', type: 'switch', defaultValue: true },
]
},
{
key: 'callback',
name: '回调地址',
icon: '🔔',
description: '配置第三方平台的回调地址,用于接收异步通知',
configs: [
{ key: 'callback.url', label: '回调 URL', type: 'input', placeholder: 'https://yourdomain.com/callback', required: true },
{ key: 'callback.secret', label: '回调密钥', type: 'password', placeholder: '用于验证回调签名' },
]
},
{
key: 'wechat',
name: '微信配置',
icon: '💬',
description: '配置微信小程序或公众号的 AppID 和 AppSecret',
configs: [
{ key: 'wechat.appId', label: 'AppID', type: 'input', placeholder: 'wx1234567890abcdef', required: true },
{ key: 'wechat.appSecret', label: 'AppSecret', type: 'password', placeholder: '微信小程序密钥' },
{ key: 'wechat.type', label: '应用类型', type: 'select', defaultValue: 'miniprogram', options: [
{ label: '小程序', value: 'miniprogram' },
{ label: '公众号', value: 'mp' },
{ label: '网页应用', value: 'web' },
]},
]
},
{
key: 'payment',
name: '支付配置',
icon: '💰',
description: '配置微信支付、支付宝等支付渠道',
configs: [
{ key: 'payment.enabled', label: '启用支付', type: 'switch', defaultValue: false },
{ key: 'payment.provider', label: '支付渠道', type: 'select', defaultValue: 'wechat', options: [
{ label: '微信支付', value: 'wechat' },
{ label: '支付宝', value: 'alipay' },
{ label: 'Stripe', value: 'stripe' },
]},
{ key: 'payment.mchId', label: '商户号', type: 'input', placeholder: '支付商户号' },
{ key: 'payment.apiKey', label: 'API 密钥', type: 'password', placeholder: '支付 API 密钥' },
]
},
{
key: 'git',
name: 'Git 仓库',
icon: '🔧',
description: '配置代码仓库地址,用于持续集成和部署',
configs: [
{ key: 'git.repository', label: '仓库地址', type: 'input', placeholder: 'https://github.com/user/repo.git' },
{ key: 'git.branch', label: '默认分支', type: 'input', placeholder: 'main', defaultValue: 'main' },
{ key: 'git.accessToken', label: '访问令牌', type: 'password', placeholder: 'Git 访问令牌' },
]
},
]
const activeType = ref('api')
const loading = ref(false)
const saving = ref(false)
// 表单数据(根据当前配置类型动态生成)
const formData = reactive<Record<string, any>>({})
// 原始数据(用于重置)
let originalData: Record<string, any> = {}
// 已有配置映射(用于更新时获取 configId
const existingConfigs = ref<Map<string, number>>(new Map())
// 初始化表单数据
function initForm() {
const currentType = configTypes.find(t => t.key === activeType.value)
if (!currentType) return
// 清空表单
Object.keys(formData).forEach(key => delete formData[key])
// 设置默认值
currentType.configs.forEach(field => {
if (field.defaultValue !== undefined) {
formData[field.key] = field.defaultValue
}
})
}
// 加载配置
async function loadConfigs() {
const id = parseInt(route.params.id as string || route.query.productId as string || '0')
if (!id || id === 0) {
message.warning('缺少应用 ID')
navigateTo('/developer/apps')
return
}
loading.value = true
try {
const configs = await getConfigsMap(id)
originalData = { ...configs }
// 合并配置到表单
Object.keys(configs).forEach(key => {
formData[key] = configs[key]
})
// 加载配置列表以获取 configId
const configList = await listAppConfig({ productId: id })
existingConfigs.value = new Map()
configList.forEach(config => {
if (config.configId && config.configKey) {
existingConfigs.value.set(config.configKey, config.configId)
}
})
} catch (e: any) {
message.error(e.message || '加载配置失败')
} finally {
loading.value = false
}
}
// 切换配置类型
function handleTypeChange() {
initForm()
loadConfigs()
}
// 保存配置
async function handleSave() {
const currentType = configTypes.find(t => t.key === activeType.value)
if (!currentType) return
saving.value = true
try {
const id = parseInt(route.params.id as string || route.query.productId as string || '0')
if (!id || id === 0) {
message.error('缺少应用 ID')
return
}
const savePromises = currentType.configs.map(async (field) => {
const value = formData[field.key]
const configId = existingConfigs.value.get(field.key)
const config: AppConfig = {
configId,
productId: id,
configKey: field.key,
configValue: value !== undefined && value !== null ? String(value) : '',
configType: activeType.value,
isEncrypted: 0,
isSecret: 0,
description: field.label,
sortNumber: field.type === 'textarea' ? 999 : 0,
} as AppConfig
// 如果存在 configId 则更新,否则新增
if (configId) {
await updateAppConfig(config)
} else {
await saveAppConfig(config)
}
})
await Promise.all(savePromises)
message.success('配置保存成功')
originalData = { ...formData }
} catch (e: any) {
message.error(e.message || '保存配置失败')
} finally {
saving.value = false
}
}
// 重置
function handleReset() {
Object.keys(formData).forEach(key => {
formData[key] = originalData[key]
})
}
onMounted(() => {
// 标记页面已挂载
mounted.value = true
// 检查是否有 productId 参数
const paramId = route.params.id || route.query.productId
if (!paramId) {
navigateTo('/developer/apps')
return
}
const id = parseInt(paramId as string || '0')
loadAppInfo(id)
initForm()
loadConfigs()
})
</script>
<style scoped>
.app-config-page {
padding: 24px;
}
/* ── 页面头部 ─────────────────────────── */
.page-header {
margin-bottom: 20px;
}
.app-info-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 12px;
padding: 16px 20px;
}
/* 左侧:返回按钮 + 图标 + 基本信息 */
.app-info-main {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.back-btn {
flex-shrink: 0;
color: rgba(0, 0, 0, 0.45);
padding: 0 6px;
height: 32px;
border-radius: 8px;
}
.back-btn:hover {
background: #f0f5ff;
color: #4f46e5;
}
/* 应用头像 */
.app-avatar {
width: 48px;
height: 48px;
flex-shrink: 0;
border-radius: 12px;
overflow: hidden;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(0, 0, 0, 0.06);
}
.app-avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.app-avatar-placeholder {
font-size: 20px;
font-weight: 700;
color: #fff;
line-height: 1;
text-transform: uppercase;
}
/* 应用 Meta 信息 */
.app-meta {
flex: 1;
min-width: 0;
}
.app-name-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 5px;
}
.app-name {
font-size: 16px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
line-height: 1.4;
}
.app-name-unknown {
color: rgba(0, 0, 0, 0.35);
}
.app-type-tag,
.app-status-tag {
font-size: 11px;
line-height: 18px;
padding: 0 6px;
border-radius: 4px;
}
/* 次级信息行 */
.app-sub-info {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.app-code,
.app-domain,
.app-create-time {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
font-family: 'SFMono-Regular', Consolas, monospace;
}
.app-domain,
.app-create-time {
font-family: inherit;
}
.sub-icon {
font-size: 11px;
opacity: 0.7;
}
/* 右侧:页面说明 */
.page-desc-block {
flex-shrink: 0;
text-align: right;
padding-left: 16px;
border-left: 1px solid #f0f0f0;
}
.page-title-text {
font-size: 15px;
font-weight: 600;
color: rgba(0, 0, 0, 0.75);
line-height: 1.4;
}
.page-desc-text {
font-size: 12px;
color: rgba(0, 0, 0, 0.35);
margin-top: 3px;
}
/* ── 配置区域 ─────────────────────────── */
.config-tabs {
background: #fff;
padding: 20px;
border-radius: 12px;
min-height: 500px;
}
.config-content {
padding: 8px 0;
}
.config-form {
margin-bottom: 24px;
}
.field-desc {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #999;
margin-top: 4px;
}
.field-desc-icon {
color: #1890ff;
}
.form-actions {
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid #f0f0f0;
}
.loading-wrap {
display: flex;
align-items: center;
justify-content: center;
min-height: 300px;
}
.mb-4 {
margin-bottom: 16px;
}
</style>