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.
 
 
 
 
 
 

314 lines
14 KiB

<template>
<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" style="background:linear-gradient(135deg,#eef2ff,#faf5ff);border:1px solid #e0e7ff">
<div class="flex items-center gap-4">
<div class="app-avatar">{{ appInitial }}</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-base font-bold text-slate-800">{{ dep.name || appName }}</span>
<span class="badge badge-sm" style="background:#e0e7ff;color:#4338ca;border:none">#{{ appId }}</span>
<span v-if="dep.app_name" class="badge badge-ghost badge-sm">应用:{{ dep.app_name }}</span>
<span v-if="dep.region" class="badge badge-ghost badge-sm">{{ dep.region }}</span>
<span v-if="dep.status === 'pending'" class="badge badge-warning badge-sm text-amber-900">待部署</span>
<span v-else class="badge badge-success badge-sm text-white">已接入</span>
</div>
<div class="text-[11px] text-slate-500 mt-1 flex flex-wrap gap-x-4 gap-y-0.5">
<span v-if="dep.base_url">地址:<span class="font-mono">{{ dep.base_url }}</span></span>
<span>业务库:<span class="font-mono">{{ dbName(dep.service_dsn) }}</span></span>
</div>
</div>
</div>
<!-- 部署环境切换(老数据 env_id=0 未绑定,可在此绑定/切换)-->
<div class="flex items-center gap-2 mt-3 pt-3 border-t border-indigo-100">
<span class="text-xs text-slate-500 shrink-0">部署环境</span>
<select v-model.number="envId" class="select select-bordered select-sm w-64" :disabled="envSaving" @change="bindEnv">
<option :value="0">未绑定</option>
<option v-for="e in envs" :key="e.id" :value="e.id">{{ e.name }}({{ e.code || '-' }})</option>
</select>
<span v-if="envSaving" class="loading loading-spinner loading-xs text-indigo-400"></span>
<span class="text-[11px] text-slate-400">切换后即时保存。</span>
</div>
</div>
<!-- ===== 业务功能配置 ===== -->
<div class="bg-white rounded-2xl border border-slate-100 shadow-sm p-5">
<div class="mb-3">
<h3 class="text-base font-semibold text-slate-800">业务功能配置</h3>
<p class="text-[11px] text-slate-400 mt-0.5">
登录 / 短信 / 邮件 / 支付等应用独立配置,保存在<b>该部署的业务库</b>(不进公共库)。密钥字段加密落库,显示时脱敏。
</p>
</div>
<!-- 业务库不可达(如待部署/连接失败)提示 -->
<div v-if="dbError" class="alert alert-warning py-2 text-sm mb-3">
<span>无法连接应用业务库:{{ dbError }}<br><span class="text-xs opacity-70">待部署的应用先完成部署、或检查业务库 DSN 连通后再配置。</span></span>
</div>
<div v-if="loading" class="py-12 text-center"><span class="loading loading-spinner loading-sm"></span></div>
<div v-else class="flex gap-5">
<!-- 左侧:按分组的模块导航 -->
<aside class="w-48 shrink-0">
<div v-for="grp in groups" :key="grp.name" class="mb-3">
<div class="text-[11px] font-semibold text-slate-400 px-2 mb-1">{{ grp.name }}</div>
<button
v-for="m in grp.modules"
:key="m.module"
class="w-full flex items-center justify-between text-left px-3 py-2 rounded-lg text-sm mb-0.5 transition-colors"
:class="curModule === m.module ? 'bg-indigo-50 text-indigo-700 font-medium' : 'text-slate-600 hover:bg-slate-50'"
@click="curModule = m.module"
>
<span>{{ m.name }}</span>
<span v-if="dataModules.has(m.module)" class="w-1.5 h-1.5 rounded-full bg-emerald-400 inline-block"></span>
</button>
</div>
</aside>
<!-- 右侧:当前模块表单 -->
<section class="flex-1 min-w-0">
<template v-if="curDef">
<div class="flex items-center justify-between mb-3">
<div>
<div class="text-sm font-semibold text-slate-800">{{ curDef.name }}</div>
<div v-if="curDef.desc" class="text-[11px] text-slate-400 mt-0.5">{{ curDef.desc }}</div>
</div>
<div class="flex items-center gap-2">
<button v-if="dataModules.has(curModule)" class="btn btn-ghost btn-xs text-rose-500" :disabled="saving" @click="clearModule">清空</button>
<button class="btn btn-primary btn-sm text-white" :disabled="saving || dbError !== ''" @click="save">
{{ saving ? '保存中…' : '保存' }}
</button>
</div>
</div>
<div class="grid grid-cols-1 gap-3 max-w-2xl">
<div v-for="f in curDef.fields" :key="f.key" class="form-control">
<label class="label py-0.5">
<span class="label-text text-sm">
{{ f.label }}
<span v-if="f.required" class="text-rose-400">*</span>
<span v-if="f.secret" class="lock-tag ml-1">🔒</span>
<span class="text-[10px] font-normal text-slate-300 ml-1 font-mono">{{ f.key }}</span>
</span>
</label>
<!-- bool -->
<label v-if="f.type === 'bool'" class="cursor-pointer flex items-center gap-2">
<input type="checkbox" class="toggle toggle-sm toggle-primary"
:checked="cur[f.key] === 'true'"
@change="setBool(f.key, $event)" />
<span class="text-xs text-slate-500">{{ cur[f.key] === 'true' ? '开启' : '关闭' }}</span>
</label>
<!-- select -->
<select v-else-if="f.type === 'select'" v-model="cur[f.key]" class="select select-bordered select-sm w-full">
<option value="">请选择</option>
<option v-for="o in f.options" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>
<!-- textarea -->
<textarea v-else-if="f.type === 'textarea'" v-model="cur[f.key]"
class="textarea textarea-bordered w-full font-mono text-xs" rows="3"
:placeholder="f.secret ? '留空/保持 [encrypted] 则不变' : (f.placeholder || '')"></textarea>
<!-- password -->
<div v-else-if="f.type === 'password'" class="relative">
<input v-model="cur[f.key]" :type="show[f.key] ? 'text' : 'password'"
class="input input-bordered input-sm w-full font-mono text-xs pr-14"
:placeholder="'留空/保持 [encrypted] 则不变'" />
<button type="button" class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400 hover:text-slate-700"
@click="show[f.key] = !show[f.key]">{{ show[f.key] ? '隐藏' : '显示' }}</button>
</div>
<!-- text -->
<input v-else v-model="cur[f.key]" type="text"
class="input input-bordered input-sm w-full font-mono text-xs" :placeholder="f.placeholder || ''" />
<span v-if="f.desc" class="text-[11px] text-slate-400 mt-0.5">{{ f.desc }}</span>
</div>
</div>
</template>
<div v-else class="py-10 text-center text-slate-400 text-sm">暂无可配置的业务模块。</div>
</section>
</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: '应用服务配置' })
const { consoleApi } = useApi()
const { success, error } = useToast()
const route = useRoute()
const router = useRouter()
interface FieldOption { value: string; label: string }
interface ModuleField {
key: string; label: string; type: string; secret: boolean; required: boolean
placeholder?: string; desc?: string; options?: FieldOption[]
}
interface ModuleDef { module: string; name: string; group: string; sys_key: string; desc?: string; fields: ModuleField[] }
interface ModItem { module: string; key: string; value: string; encrypted: boolean }
interface Deployment {
id: number; app_name: string; status: string; name: string; region: string
env_id: number; base_url: string; service_dsn: string
}
interface EnvOption { id: number; name: string; code: string }
const appId = computed(() => Number(route.query.id) || 0)
const appName = computed(() => (route.query.name as string) || '未指定应用')
const appInitial = computed(() => ((dep.app_name || appName.value || 'A').charAt(0).toUpperCase()))
const dep = reactive<Deployment>({ id: 0, app_name: '', status: '', name: '', region: '', env_id: 0, base_url: '', service_dsn: '' })
const envs = ref<EnvOption[]>([])
const envId = ref(0)
const envSaving = ref(false)
const catalog = ref<ModuleDef[]>([])
const curModule = ref('')
const values = reactive<Record<string, Record<string, string>>>({})
const dataModules = reactive(new Set<string>())
const show = reactive<Record<string, boolean>>({})
const loading = ref(false)
const saving = ref(false)
const dbError = ref('')
const curDef = computed(() => catalog.value.find((m) => m.module === curModule.value) || null)
const cur = computed(() => values[curModule.value] || {})
const groups = computed(() => {
const order: string[] = []
const map = new Map<string, ModuleDef[]>()
for (const m of catalog.value) {
if (!map.has(m.group)) { map.set(m.group, []); order.push(m.group) }
map.get(m.group)!.push(m)
}
return order.map((name) => ({ name, modules: map.get(name)! }))
})
function setBool(key: string, e: Event) {
const bag = values[curModule.value] || (values[curModule.value] = {})
bag[key] = (e.target as HTMLInputElement).checked ? 'true' : 'false'
}
function goBack() { router.push('/apps') }
function dbName(dsn?: string) {
const m = (dsn || '').match(/\/([^/?]+)\?/)
return m ? m[1] : (dsn ? '(自定义)' : '—')
}
async function loadBase() {
try {
const [deps, es] = await Promise.all([
consoleApi<Deployment[]>('apps/list', {}),
consoleApi<EnvOption[]>('envs/list', {}),
])
const found = (deps ?? []).find((d) => d.id === appId.value)
if (found) Object.assign(dep, found)
envs.value = es ?? []
envId.value = dep.env_id || 0
} catch (e: any) {
error(e?.message ?? '获取应用信息失败')
}
}
async function loadCatalog() {
try {
const d = await consoleApi<{ modules: ModuleDef[] }>('modulecfg/catalog', {})
catalog.value = d?.modules ?? []
// 初始化各模块的值容器,避免模板访问 undefined。
for (const m of catalog.value) if (!values[m.module]) values[m.module] = {}
if (catalog.value.length && !curModule.value) curModule.value = catalog.value[0].module
} catch (e: any) {
error(e?.message ?? '获取配置目录失败')
}
}
async function loadItems() {
if (!appId.value) return
loading.value = true
dbError.value = ''
try {
const d = await consoleApi<{ items: ModItem[] }>('modulecfg/list', { app_id: appId.value })
// 重置为目录默认容器,再灌入库值。
for (const m of catalog.value) values[m.module] = {}
dataModules.clear()
for (const it of (d?.items ?? [])) {
if (!values[it.module]) values[it.module] = {}
values[it.module][it.key] = it.value
dataModules.add(it.module)
}
} catch (e: any) {
dbError.value = e?.message ?? '连接失败'
} finally {
loading.value = false
}
}
async function bindEnv() {
if (!dep.id) return
envSaving.value = true
try {
await consoleApi('apps/bindenv', { id: dep.id, env_id: envId.value })
dep.env_id = envId.value
success('部署环境已更新')
} catch (e: any) {
error(e?.message ?? '切换环境失败')
envId.value = dep.env_id || 0 // 回滚
} finally {
envSaving.value = false
}
}
async function save() {
if (!curDef.value) return
saving.value = true
try {
const items = curDef.value.fields.map((f) => ({ key: f.key, value: values[curModule.value]?.[f.key] ?? '' }))
await consoleApi('modulecfg/save', { app_id: appId.value, module: curModule.value, items })
success('配置已保存')
await loadItems() // 重新拉取(密文回脱敏)
} catch (e: any) {
error(e?.message ?? '保存失败')
} finally {
saving.value = false
}
}
async function clearModule() {
if (!curModule.value) return
if (!confirm(`确认清空「${curDef.value?.name}」的全部配置?`)) return
saving.value = true
try {
await consoleApi('modulecfg/del', { app_id: appId.value, module: curModule.value })
success('已清空')
await loadItems()
} catch (e: any) {
error(e?.message ?? '清空失败')
} finally {
saving.value = false
}
}
onMounted(async () => {
await loadBase()
await loadCatalog()
await loadItems()
})
</script>
<style scoped>
.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); }
.lock-tag { font-size: 10px; font-weight: 600; color: #dc2626; background: #fef2f2; padding: 1px 5px; border-radius: 5px; }
</style>