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.
 
 
 
 
 
 

405 lines
19 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 flex items-start justify-between gap-3">
<div>
<h3 class="text-base font-semibold text-slate-800">业务功能配置</h3>
<p class="text-[11px] text-slate-400 mt-0.5">
登录 / 短信 / 邮件 / 支付等应用独立配置,保存在<b>该部署的业务库</b>(不进公共库)。密钥字段加密落库,显示时脱敏。
</p>
</div>
<button class="btn btn-outline btn-error btn-xs shrink-0" :disabled="saving || dbError !== ''"
title="把该部署业务库里所有业务模块的配置,整体还原为业务服务配置文件(yaml)的初始值。用于升级/迁移把库写脏后的整体恢复。"
@click="resetAll">全部重置为配置文件</button>
</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 class="btn btn-ghost btn-xs text-amber-600" :disabled="saving"
title="丢弃库中该模块的全部配置,改用业务服务配置文件(yaml)里的初始值重新写入。用于库被改脏后还原。"
@click="resetModule">重置</button>
<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, info, error } = useToast() as any
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
}
}
interface ApplyResult { module: string; status: string; err?: string }
interface ConfigAck { app_name: string; instance: string; results: ApplyResult[] }
// reportAcks 把各业务服务的热重载回执翻成一句人话(一条回执 = 一个服务,如 home / api)。
// module 指定则只看该模块的结果;留空(全部重置)则聚合该服务所有模块的结果。
// 三态如实呈现:把"需重启才生效"说成"已生效",比不给回执更糟——用户会以为新凭据已在用。
function reportAcks(acks: ConfigAck[] | undefined, module: string | undefined, verb = '配置已保存') {
const list = acks ?? []
if (!list.length) {
info(`${verb},但未收到任何服务的生效回执 —— 目标应用可能未运行、未填对外地址(base_url)、或两端 FIELD_ENCRYPT_KEY 不一致,需重启后生效`)
return
}
// 每个服务取一个代表状态:任一模块 failed→failed;否则任一 restart_required→restart;否则 applied。
const statusOf = (a: ConfigAck): string | undefined => {
const rs = module ? a.results?.filter((r) => r.module === module) : a.results
if (!rs || !rs.length) return undefined
if (rs.some((r) => r.status === 'failed')) return 'failed'
if (rs.some((r) => r.status === 'restart_required')) return 'restart_required'
if (rs.some((r) => r.status === 'applied')) return 'applied'
return undefined
}
const errOf = (a: ConfigAck) => (module ? a.results?.find((r) => r.module === module) : a.results?.find((r) => r.status === 'failed'))?.err ?? ''
const failed = list.filter((a) => statusOf(a) === 'failed')
const restart = list.filter((a) => statusOf(a) === 'restart_required')
const applied = list.filter((a) => statusOf(a) === 'applied')
if (failed.length) {
error(`${verb},但 ${failed.length}/${list.length} 个服务重载失败(仍在使用旧配置):${failed[0].instance}: ${errOf(failed[0])}`)
} else if (restart.length) {
info(`${verb}。${restart.length}/${list.length} 个服务需重启才生效(含支付等暂不支持热更新的模块)`)
} else if (applied.length) {
success(`${verb},${applied.length}/${list.length} 个服务已实时生效`)
} else {
info(`${verb},收到 ${list.length} 个服务的回执`)
}
}
// resetModule 让业务服务用它自己配置文件(yaml)里的初始值,覆盖库中该模块的配置。
// 数据来源在业务服务那边(明文密钥不出它的容器),console 只负责发起并转达回执。
async function resetModule() {
if (!curDef.value) return
if (!confirm(
`确认把「${curDef.value.name}」还原为业务服务配置文件(yaml)里的初始值?\n\n` +
`库中该模块现有的配置将被全部覆盖,且无法撤销。`,
)) return
saving.value = true
try {
const d = await consoleApi<{ module: string; acks?: ConfigAck[] }>('modulecfg/reset', {
app_id: appId.value, module: curModule.value,
})
reportAcks(d?.acks, curModule.value, '已还原为配置文件初始值')
await loadItems()
} catch (e: any) {
error(e?.message ?? '重置失败')
} finally {
saving.value = false
}
}
// resetAll 整库还原为配置文件初始值(module="*")。破坏性大——会覆盖后台手工保存过的所有模块
// (如把邮件打回 yaml 默认发信配置),故要求打字确认。
async function resetAll() {
const depName = dep.name || appName.value || '该部署'
const ans = prompt(
`⚠️ 危险操作:把「${depName}」业务库里【所有】业务模块的配置,整体还原为业务服务配置文件(yaml)的初始值。\n\n` +
`这会覆盖你在后台手工保存过的全部配置(例如邮件的 Resend 设置会被打回 yaml 默认值),且无法撤销。\n\n` +
`确认请输入:RESET`,
)
if (ans !== 'RESET') { if (ans !== null) info('已取消(未输入 RESET)'); return }
saving.value = true
try {
const d = await consoleApi<{ module: string; acks?: ConfigAck[] }>('modulecfg/reset', {
app_id: appId.value, module: '*',
})
reportAcks(d?.acks, undefined, '整库已还原为配置文件初始值')
await loadItems()
} catch (e: any) {
error(e?.message ?? '全部重置失败')
} finally {
saving.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] ?? '' }))
const d = await consoleApi<{ saved: number; acks?: ConfigAck[] }>('modulecfg/save', { app_id: appId.value, module: curModule.value, items })
reportAcks(d?.acks, curModule.value)
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>