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.
326 lines
15 KiB
326 lines
15 KiB
<template>
|
|
<div class="card">
|
|
<div class="card-head">
|
|
<div>
|
|
<div class="card-title">业务功能配置</div>
|
|
<div class="card-desc">
|
|
登录 / 短信 / 邮件 / 支付等应用独立配置,保存在<b>「{{ depName }}」这个部署的业务库</b>(不进公共库)。密钥字段加密落库,显示时脱敏。
|
|
</div>
|
|
</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="m-4 alert alert-warning py-2 text-sm">
|
|
<span>
|
|
无法连接应用业务库:{{ dbError }}<br />
|
|
<span class="text-xs opacity-70">待部署的应用先完成部署、或检查业务库 DSN 连通后再配置。</span>
|
|
</span>
|
|
</div>
|
|
|
|
<div v-if="loading" class="py-14 text-center"><span class="loading loading-spinner loading-sm"></span></div>
|
|
|
|
<div v-else class="cfg">
|
|
<aside class="cfg-nav">
|
|
<div v-for="grp in groups" :key="grp.name">
|
|
<div class="cfg-group">{{ grp.name }}</div>
|
|
<button
|
|
v-for="m in grp.modules"
|
|
:key="m.module"
|
|
class="cfg-item"
|
|
:aria-current="curModule === m.module"
|
|
@click="curModule = m.module"
|
|
>
|
|
<span>{{ m.name }}</span>
|
|
<span v-if="dataModules.has(m.module)" class="dot" title="库中已有配置"></span>
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
|
|
<section class="cfg-form">
|
|
<template v-if="curDef">
|
|
<div class="flex items-start justify-between gap-3 mb-4 flex-wrap">
|
|
<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 visibleFields" :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>
|
|
|
|
<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 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
|
|
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>
|
|
|
|
<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>
|
|
|
|
<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>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, reactive, computed, onMounted } from 'vue'
|
|
import { useApi } from '~/composables/useApi'
|
|
import { useToast } from '~/composables/useToast'
|
|
|
|
const props = defineProps<{ appId: number; depName: string }>()
|
|
|
|
const { consoleApi } = useApi()
|
|
const { success, info, error } = useToast() as any
|
|
|
|
interface FieldOption { value: string; label: string }
|
|
interface FieldCond { key: string; values: string[] }
|
|
interface ModuleField {
|
|
key: string; label: string; type: string; secret: boolean; required: boolean
|
|
placeholder?: string; desc?: string; options?: FieldOption[]; show_if?: FieldCond
|
|
}
|
|
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 ApplyResult { module: string; status: string; err?: string }
|
|
interface ConfigAck { app_name: string; instance: string; results: ApplyResult[] }
|
|
|
|
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] || {})
|
|
|
|
// 按 show_if 过滤字段:如短信模块只显示当前所选服务商(Provider)的凭据字段。
|
|
// 只影响显示,保存时仍提交全量字段(见 save),所以另一家的凭据不会被隐藏字段清掉。
|
|
const visibleFields = computed(() => {
|
|
const def = curDef.value
|
|
if (!def) return [] as ModuleField[]
|
|
return def.fields.filter((f) => !f.show_if || f.show_if.values.includes(cur.value[f.show_if.key] ?? ''))
|
|
})
|
|
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'
|
|
}
|
|
|
|
async function loadCatalog() {
|
|
try {
|
|
const d = await consoleApi<{ modules: ModuleDef[] }>('modulecfg/catalog', {})
|
|
catalog.value = d?.modules ?? []
|
|
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 (!props.appId) return
|
|
loading.value = true
|
|
dbError.value = ''
|
|
try {
|
|
const d = await consoleApi<{ items: ModItem[] }>('modulecfg/list', { app_id: props.appId })
|
|
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
|
|
}
|
|
}
|
|
|
|
// reportAcks 把各业务服务的热重载回执翻成一句人话(一条回执 = 一个服务,如 home / api)。
|
|
// 三态如实呈现:把"需重启才生效"说成"已生效",比不给回执更糟——用户会以为新凭据已在用。
|
|
function reportAcks(acks: ConfigAck[] | undefined, module: string | undefined, verb = '配置已保存') {
|
|
const list = acks ?? []
|
|
if (!list.length) {
|
|
info(`${verb},但未收到任何服务的生效回执 —— 目标应用可能未运行、未填对外地址(base_url)、或两端 FIELD_ENCRYPT_KEY 不一致,需重启后生效`)
|
|
return
|
|
}
|
|
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} 个服务的回执`)
|
|
}
|
|
}
|
|
|
|
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: props.appId, module: curModule.value, items })
|
|
reportAcks(d?.acks, curModule.value)
|
|
await loadItems()
|
|
} catch (e: any) {
|
|
error(e?.message ?? '保存失败')
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
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: props.appId, module: curModule.value })
|
|
reportAcks(d?.acks, curModule.value, '已还原为配置文件初始值')
|
|
await loadItems()
|
|
} catch (e: any) {
|
|
error(e?.message ?? '重置失败')
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
// resetAll 整库还原为配置文件初始值(module="*")。破坏性大——会覆盖后台手工保存过的所有模块,故要求打字确认。
|
|
async function resetAll() {
|
|
const ans = prompt(
|
|
`⚠️ 危险操作:把「${props.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: props.appId, module: '*' })
|
|
reportAcks(d?.acks, undefined, '整库已还原为配置文件初始值')
|
|
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: props.appId, module: curModule.value })
|
|
success('已清空')
|
|
await loadItems()
|
|
} catch (e: any) {
|
|
error(e?.message ?? '清空失败')
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await loadCatalog()
|
|
await loadItems()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.card { background: #fff; border: 1px solid #eef2f7; border-radius: 14px; box-shadow: 0 1px 2px rgba(15, 23, 42, .04); overflow: hidden; }
|
|
.card-head { padding: 15px 18px; border-bottom: 1px solid #eef2f7; display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; flex-wrap: wrap; }
|
|
.card-title { font-size: 14.5px; font-weight: 700; color: #1e293b; }
|
|
.card-desc { font-size: 11.5px; color: #64748b; margin-top: 3px; max-width: 78ch; line-height: 1.55; }
|
|
|
|
.cfg { display: grid; grid-template-columns: 190px 1fr; }
|
|
.cfg-nav { border-right: 1px solid #eef2f7; padding: 12px 10px; background: #fcfcfd; }
|
|
.cfg-group { font-size: 10px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: #94a3b8; padding: 10px 8px 4px; }
|
|
.cfg-item {
|
|
width: 100%; border: 0; background: none; font-size: 13px; color: #334155; text-align: left;
|
|
padding: 7px 10px; border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
|
}
|
|
.cfg-item:hover { background: #f8fafc; }
|
|
.cfg-item[aria-current='true'] { background: #eef2ff; color: #4338ca; font-weight: 600; }
|
|
.cfg-item .dot { width: 6px; height: 6px; border-radius: 50%; background: #4ade80; flex-shrink: 0; }
|
|
.cfg-form { padding: 16px 20px; min-width: 0; }
|
|
.lock-tag { font-size: 10px; font-weight: 600; color: #dc2626; background: #fef2f2; padding: 1px 5px; border-radius: 5px; }
|
|
|
|
@media (max-width: 900px) {
|
|
.cfg { grid-template-columns: 1fr; }
|
|
.cfg-nav { border-right: 0; border-bottom: 1px solid #eef2f7; }
|
|
}
|
|
</style>
|
|
|