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.
264 lines
11 KiB
264 lines
11 KiB
<template>
|
|
<div>
|
|
<div class="rounded-lg px-3 py-2 mb-4 text-xs" style="background:#f8fafc;border:1px solid #e2e8f0;color:#475569">
|
|
这里只放<b>应用自己的业务参数</b>(导航方式、客服 QQ、智能体标识…),直接读写所选部署的业务库 config 表。
|
|
第三方服务的<b>凭据请到「第三方服务」标签页配</b>——2026-09-12 起凭据统一收在那里(带加密、区域分叉与巡检),
|
|
下发给客户端时按字段上的「下发键名」自动回填一份 env,老客户端无需升级。
|
|
</div>
|
|
<div class="flex items-center justify-between mb-4 flex-wrap gap-3">
|
|
<div class="flex items-center gap-3 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="loadData"
|
|
>
|
|
<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>
|
|
<button class="btn btn-primary btn-sm text-white" :disabled="!curApp" @click="openForm()">+ 新增参数</button>
|
|
</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="openForm(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 v-if="formOpen" class="modal modal-open">
|
|
<div class="modal-box max-w-xl">
|
|
<h3 class="font-bold text-base mb-1">{{ form.id ? '编辑参数' : '新增参数' }}</h3>
|
|
<p class="text-xs text-slate-400 mb-4">配置目标:{{ curAppLabel }}</p>
|
|
<div class="space-y-3">
|
|
<div>
|
|
<label class="text-xs text-slate-500">分组 (group)</label>
|
|
<input v-model="form.group" class="input input-bordered input-sm w-full" placeholder="如 APP / AI / other" />
|
|
</div>
|
|
<div>
|
|
<label class="text-xs text-slate-500">键 (key) *</label>
|
|
<input v-model="form.key" class="input input-bordered input-sm w-full font-mono" placeholder="如 APP_NAVIGATION_MODE" />
|
|
</div>
|
|
<div>
|
|
<label class="text-xs text-slate-500">值 (value)</label>
|
|
<textarea v-model="form.value" class="textarea textarea-bordered w-full font-mono text-sm" rows="3"></textarea>
|
|
</div>
|
|
<div>
|
|
<label class="text-xs text-slate-500">描述</label>
|
|
<input v-model="form.description" class="input input-bordered input-sm w-full" />
|
|
</div>
|
|
</div>
|
|
<div class="modal-action">
|
|
<button class="btn btn-ghost btn-sm" @click="formOpen = false">取消</button>
|
|
<button class="btn btn-primary btn-sm text-white" :disabled="submitting" @click="saveCfg">保存</button>
|
|
</div>
|
|
</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'
|
|
|
|
// 「应用环境配置」原来是独立页面(pages/appconfig.vue),2026-09-12 并进「服务与环境配置」的第二个标签页。
|
|
// 合并的理由:凭据已统一收进 svc_config,这张表剩下的只是应用自己的业务参数,
|
|
// 单独占一个一级菜单项与「第三方服务配置」并列,只会让人分不清凭据该填哪儿。
|
|
|
|
// 部署应用(与应用中心 apps/list 同源;id 为部署注册 id,用于连接该应用业务库)。
|
|
// 同一个应用会在多个环境各有一条部署记录,靠 env_id 区分——参数按「应用 × 环境」独立。
|
|
interface Deployment { id: number; app_name: string; status: string; name: string; region: string; env_id: number }
|
|
interface EnvOption { id: number; name: string; code: string; enabled: boolean }
|
|
interface AppConfig { id: number; group: string; key: string; value: string; description: string }
|
|
|
|
const { consoleApi } = useApi()
|
|
const { success, error } = useToast()
|
|
|
|
const apps = ref<Deployment[]>([])
|
|
const envs = ref<EnvOption[]>([])
|
|
const curAppName = ref('')
|
|
const curApp = ref(0)
|
|
const items = ref<AppConfig[]>([])
|
|
const loading = ref(false)
|
|
const submitting = ref(false)
|
|
const keyword = ref('')
|
|
const formOpen = ref(false)
|
|
const form = reactive({ id: 0, group: '', key: '', value: '', description: '' })
|
|
|
|
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)}` : ''
|
|
})
|
|
|
|
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 openForm(cfg?: AppConfig) {
|
|
form.id = cfg?.id ?? 0
|
|
form.group = cfg?.group ?? ''
|
|
form.key = cfg?.key ?? ''
|
|
form.value = cfg?.value ?? ''
|
|
form.description = cfg?.description ?? ''
|
|
formOpen.value = true
|
|
}
|
|
|
|
async function saveCfg() {
|
|
if (!curApp.value) { error('请先选择应用和部署环境'); return }
|
|
if (!form.key.trim()) { error('键(key) 必填'); 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 (form.id) {
|
|
data.id = Number(form.id)
|
|
await consoleApi('appcfg/update', data)
|
|
} else {
|
|
await consoleApi('appcfg/add', data)
|
|
}
|
|
success('保存成功')
|
|
formOpen.value = false
|
|
loadData()
|
|
} catch (e: any) {
|
|
error(e?.message ?? '保存失败')
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
function delCfg(c: AppConfig) {
|
|
if (!window.confirm(`确定删除参数 ${c.key} 吗?`)) return
|
|
consoleApi('appcfg/del', { app_id: curApp.value, ids: [c.id] })
|
|
.then(() => { success('删除成功'); loadData() })
|
|
.catch((e: any) => error(e?.message ?? '删除失败'))
|
|
}
|
|
|
|
onMounted(loadApps)
|
|
</script>
|
|
|