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.
 
 
 
 
 
 

310 lines
12 KiB

<template>
<div>
<!-- 列表视图 -->
<div v-show="view === 'list'">
<div class="flex items-center justify-between mb-5">
<div>
<h2 class="text-lg font-semibold text-slate-800">应用环境配置</h2>
<p class="text-xs text-slate-400 mt-1">应用自有的 key/value 配置(音乐、OSS 等),直接读写所选应用的业务库。结构同「全局环境配置」但不分区域。</p>
</div>
<button class="btn btn-primary btn-sm text-white" :disabled="!curApp" @click="showForm()">+ 新增配置</button>
</div>
<div class="flex items-center gap-3 mb-4 flex-wrap">
<span class="text-sm text-slate-500">应用</span>
<select v-model="curAppName" class="select select-bordered select-sm min-w-40" @change="onAppNameChange">
<option value="" disabled>请选择应用</option>
<option v-for="n in appNames" :key="n" :value="n">{{ n }}</option>
</select>
<span class="text-sm text-slate-500">部署环境</span>
<select
v-model.number="curApp"
class="select select-bordered select-sm min-w-48"
:disabled="!curAppName"
@change="onAppChange"
>
<option :value="0" disabled>请选择环境</option>
<option v-for="d in appDeployments" :key="d.id" :value="d.id">{{ deployLabel(d) }}</option>
</select>
<input v-model="keyword" class="input input-bordered input-sm w-48" placeholder="按分组/键搜索" />
</div>
<div v-if="!curApp" class="bg-white rounded-xl shadow-sm border border-slate-100 text-center text-slate-400 py-16">
<div class="text-4xl mb-2">🗂️</div>
<p class="text-sm">{{ curAppName ? '该应用还没有部署环境,请先在「应用中心」为它部署一个环境' : '请先在上方选择应用和部署环境' }}</p>
</div>
<div v-else class="bg-white rounded-xl shadow-sm border border-slate-100">
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>键</th><th>值</th><th>描述</th><th class="text-right">操作</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="4" class="text-center py-8"><span class="loading loading-spinner loading-sm"></span></td>
</tr>
<tr v-else-if="!filteredRows.length">
<td colspan="4" class="text-center text-slate-400 py-8">暂无配置</td>
</tr>
<template v-for="grp in groupedRows" :key="grp.key">
<tr class="bg-slate-100/80">
<td colspan="4" class="py-2">
<span class="font-semibold text-slate-600 text-sm">▸ {{ grp.label }}</span>
<span class="text-xs text-slate-400 ml-2">{{ grp.items.length }} 项</span>
</td>
</tr>
<tr v-for="c in grp.items" :key="c.id" class="hover">
<td class="font-mono text-sm font-medium">{{ c.key || '' }}</td>
<td class="text-sm text-slate-500 max-w-xs truncate" :title="c.value || ''">{{ c.value || '' }}</td>
<td class="text-sm text-slate-400">{{ c.description || '' }}</td>
<td class="text-right">
<div class="inline-flex gap-1">
<button class="btn btn-ghost btn-xs" @click="showForm(c)">编辑</button>
<button class="btn btn-ghost btn-xs text-red-500" @click="delCfg(c)">删除</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
<!-- 表单视图 -->
<div v-show="view === 'form'">
<div class="flex items-center gap-3 mb-5">
<button class="btn btn-ghost btn-sm" @click="showList()">&larr; 返回列表</button>
<h2 class="text-lg font-semibold text-slate-800">{{ isEditing ? '编辑配置' : '新增配置' }}</h2>
<span class="text-xs text-slate-400">配置目标:{{ curAppLabel }}</span>
</div>
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6 max-w-2xl">
<form @submit.prevent="saveCfg">
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">分组 (group)</span></label>
<input v-model="form.group" type="text" class="input input-bordered w-full" placeholder="如 music / oss / other" />
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">键 (key) *</span></label>
<input v-model="form.key" type="text" class="input input-bordered w-full" required placeholder="如 oss_access_key" />
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">值 (value)</span></label>
<textarea v-model="form.value" class="textarea textarea-bordered w-full font-mono text-sm" rows="3"></textarea>
</div>
<div class="form-control mb-6">
<label class="label"><span class="label-text font-medium">描述</span></label>
<input v-model="form.description" type="text" class="input input-bordered w-full" />
</div>
<div class="flex gap-3">
<button type="button" class="btn btn-ghost" @click="showList()">取消</button>
<button type="submit" class="btn btn-primary text-white" :disabled="submitting">保存</button>
</div>
</form>
</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'
import { regionLabel } from '~/utils/regions'
definePageMeta({ title: '应用环境配置' })
// 部署应用(与应用中心 apps/list 同源;id 为部署注册 id,用于连接该应用业务库)
// 同一个应用会在多个环境各有一条部署记录,靠 env_id 区分——配置按「应用 × 环境」独立。
interface Deployment { id: number; app_name: string; status: string; name: string; region: string; env_id: number }
// 部署环境(envs/list 同源,这里只用来给部署显示环境名)
interface EnvOption { id: number; name: string; code: string; enabled: boolean }
// 应用 config 项(结构同 DBGlobalConfigItem 去掉 region)
interface AppConfig {
id: number
group: string
key: string
value: string
description: string
}
const { consoleApi } = useApi()
const { success, error } = useToast()
const view = ref<'list' | 'form'>('list')
const apps = ref<Deployment[]>([]) // 全部部署(跨应用、跨环境)
const envs = ref<EnvOption[]>([]) // 全部环境
const curAppName = ref('') // 一级:选中的应用
const curApp = ref(0) // 二级:选中的部署 app_id(= 应用 × 环境)
const items = ref<AppConfig[]>([])
const loading = ref(false)
const submitting = ref(false)
const isEditing = ref(false)
const keyword = ref('')
const form = reactive({ id: 0, group: '', key: '', value: '', description: '' })
// 应用名列表(一级选项):部署记录按 app_name 去重
const appNames = computed(() =>
[...new Set(apps.value.map((a) => a.app_name || a.name).filter(Boolean))].sort()
)
// 当前应用下的各环境部署(二级选项)
const appDeployments = computed(() =>
apps.value.filter((a) => (a.app_name || a.name) === curAppName.value)
)
// 部署对应的环境名;区域不同也一并标出,避免同环境多区域时分不清
function deployLabel(d: Deployment) {
const e = envs.value.find((x) => x.id === d.env_id)
const envName = e ? (e.name || e.code) : d.env_id ? `环境#${d.env_id}` : '未关联环境'
const suffix = d.status === 'pending' ? '(待部署)' : ''
return d.region ? `${envName} · ${regionLabel(d.region)}${suffix}` : `${envName}${suffix}`
}
const curAppLabel = computed(() => {
const a = apps.value.find((x) => x.id === curApp.value)
return a ? `${a.app_name || a.name} · ${deployLabel(a)}` : ''
})
// 关键字过滤(按 group + key)
const filteredRows = computed(() => {
const kw = keyword.value.trim().toLowerCase()
if (!kw) return items.value
return items.value.filter((c) => ((c.group || '') + (c.key || '')).toLowerCase().includes(kw))
})
// 按 group 分组:相同组归在一起(未分组排最前)
const groupedRows = computed(() => {
const groups: Record<string, AppConfig[]> = {}
filteredRows.value.forEach((c) => {
const g = c.group || ''
;(groups[g] = groups[g] || []).push(c)
})
const names = Object.keys(groups).sort((a, b) => {
if (a === '') return -1
if (b === '') return 1
return a < b ? -1 : 1
})
return names.map((g) => ({ key: g, label: g || '未分组', items: groups[g] }))
})
async function loadApps() {
try {
const [list, es] = await Promise.all([
consoleApi<Deployment[]>('apps/list', {}),
consoleApi<EnvOption[]>('envs/list', {}),
])
apps.value = list ?? []
envs.value = es ?? []
// 默认选中第一个应用及其第一个部署;优先已接入的(待部署的业务库通常还连不上)
if (!curAppName.value && appNames.value.length) {
curAppName.value = appNames.value[0]!
pickDefaultDeployment()
await loadData()
}
} catch (e: any) {
error(e?.message ?? '获取应用列表失败')
apps.value = []
envs.value = []
}
}
// 在当前应用下挑一个默认部署环境
function pickDefaultDeployment() {
const list = appDeployments.value
const first = list.find((d) => d.status !== 'pending') ?? list[0]
curApp.value = first?.id ?? 0
}
// 换应用:重挑环境并加载该环境的配置
function onAppNameChange() {
pickDefaultDeployment()
loadData()
}
async function loadData() {
if (!curApp.value) {
items.value = []
return
}
loading.value = true
try {
const data = await consoleApi<{ items: AppConfig[] }>('appcfg/list', { app_id: curApp.value })
items.value = data?.items ?? []
} catch (e: any) {
error(e?.message ?? '获取配置失败')
items.value = []
} finally {
loading.value = false
}
}
function onAppChange() {
loadData()
}
function showList() {
view.value = 'list'
loadData()
}
function showForm(cfg?: AppConfig) {
if (cfg) {
isEditing.value = true
form.id = cfg.id || 0
form.group = cfg.group || ''
form.key = cfg.key || ''
form.value = cfg.value || ''
form.description = cfg.description || ''
} else {
isEditing.value = false
form.id = 0
form.group = ''
form.key = ''
form.value = ''
form.description = ''
}
view.value = 'form'
}
async function saveCfg() {
if (!curApp.value) {
error("请先选择应用和部署环境")
return
}
submitting.value = true
try {
const data: Record<string, unknown> = {
app_id: curApp.value,
group: form.group.trim(),
key: form.key.trim(),
value: form.value,
description: form.description.trim(),
}
if (isEditing.value) {
data.id = Number(form.id)
await consoleApi('appcfg/update', data)
} else {
await consoleApi('appcfg/add', data)
}
success('保存成功')
showList()
} catch (e: any) {
error(e?.message ?? '保存失败')
} finally {
submitting.value = false
}
}
function delCfg(c: AppConfig) {
if (!window.confirm('确定删除该配置项吗?')) return
consoleApi('appcfg/del', { app_id: curApp.value, ids: [c.id] })
.then(() => { success('删除成功'); loadData() })
.catch((e: any) => error(e?.message ?? '删除失败'))
}
onMounted(loadApps)
</script>