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
314 lines
14 KiB
<template>
|
|
<div>
|
|
<!-- 返回 + 标题 -->
|
|
<div class="flex items-center gap-2 mb-4">
|
|
<button class="btn btn-ghost btn-sm" @click="goBack">← 返回应用列表</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>
|
|
|