- 新增 app_configs 表与 appconfig 注册表:读取优先级 页面 → .env → 默认,删除即回退 - 页面可配 7 项:支付回调基址、成功回跳、Webhook 密钥(加密入库)、未验签放行开关、CORS、可信代理、GeoIP 路径 - 引导密钥(JWT_SECRET / OPSLIC_MASTER_KEY / OFFLINE_LICENSE_SEED)与部署参数只读展示掩码+指纹,永不入库 - 校验:URL 格式、密钥长度≥16、CIDR、release 下禁止开启未验签回调;变更写审计日志 - 消费点改造:支付回调地址、Waffo 回跳、webhook 验签、可信代理、CORS、GeoIP 统一走配置中心 - 前端新增「审计与设置 → 运行配置」页(分组表格 + 来源标记 + 编辑弹窗 + 恢复默认) - .env.example 补注释:哪些必须留在 .env、哪些已收敛到页面
This commit is contained in:
1 parent
b0b611eee2
commit
83a3630404
17 files changed
+1204
-33
No files matched your search
@@ -0,0 +1,18 @@
|
||||
import request from './request'
|
||||
|
||||
// 运行配置中心 API(.env 里的业务参数统一到页面维护) // Logan.Li
|
||||
|
||||
// 读取全部配置(机密项只返回掩码 + 指纹)
|
||||
export function getRuntimeConfig() {
|
||||
return request({ url: '/settings/runtime', method: 'get' })
|
||||
}
|
||||
|
||||
// 批量保存:values 为 { 配置键: 值 },空字符串表示清除并回退 .env
|
||||
export function updateRuntimeConfig(values) {
|
||||
return request({ url: '/settings/runtime', method: 'put', data: { values } })
|
||||
}
|
||||
|
||||
// 恢复默认:删除页面值,回退 .env / 注册表默认
|
||||
export function resetRuntimeConfig(keys) {
|
||||
return request({ url: '/settings/runtime/reset', method: 'post', data: { keys } })
|
||||
}
|
||||
@@ -70,6 +70,9 @@
|
||||
<el-menu-item index="/settings">
|
||||
<el-icon><Setting /></el-icon>系统设置
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/settings/runtime">
|
||||
<el-icon><SetUp /></el-icon>运行配置
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<el-menu-item index="/integration">
|
||||
|
||||
@@ -89,6 +89,12 @@ const routes = [
|
||||
component: () => import('@/views/Settings.vue'),
|
||||
meta: { title: '系统设置', icon: 'Setting' },
|
||||
},
|
||||
{
|
||||
path: 'settings/runtime',
|
||||
name: 'RuntimeConfig',
|
||||
component: () => import('@/views/settings/Runtime.vue'),
|
||||
meta: { title: '运行配置', icon: 'SetUp' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
<template>
|
||||
<div class="runtime-page">
|
||||
<!-- 说明与统计 -->
|
||||
<el-card shadow="never" class="intro-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="title">运行配置</span>
|
||||
<el-button :loading="loading" @click="load">刷新</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-alert type="info" :closable="false" show-icon>
|
||||
<template #title>
|
||||
<span class="alert-title">.env 只保留启动必需项,其余业务参数在本页维护</span>
|
||||
</template>
|
||||
<div class="alert-body">
|
||||
读取优先级:<b>本页配置</b> → <b>.env</b> → <b>默认值</b>;「恢复默认」即删除本页值回退 .env。
|
||||
登录令牌密钥、加密主密钥、离线签名种子等<b>引导密钥</b>必须留在 .env(数据库不可用前就要用到,
|
||||
且入库等于把钥匙锁进被加密的箱子),本页仅只读展示指纹供核对。
|
||||
</div>
|
||||
</el-alert>
|
||||
<div class="summary">
|
||||
<div class="sum-item"><b>{{ stats.editable }}</b><span>可在页面配置</span></div>
|
||||
<div class="sum-item"><b>{{ stats.envOnly }}</b><span>仅限 .env</span></div>
|
||||
<div class="sum-item"><b>{{ stats.fromPage }}</b><span>当前取自本页</span></div>
|
||||
<div class="sum-item"><b>{{ stats.restart }}</b><span>改动需重启</span></div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 分组配置 -->
|
||||
<el-card v-for="group in groups" :key="group.name" shadow="never" class="group-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="title">{{ group.name }}</span>
|
||||
<el-tag v-if="groupEnvOnly(group)" size="small" type="info">仅 .env</el-tag>
|
||||
<el-tag v-else size="small">{{ group.items.length }} 项</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="group.items" size="small" class="cfg-table">
|
||||
<el-table-column label="配置项" min-width="330">
|
||||
<template #default="{ row }">
|
||||
<div class="item-label">
|
||||
{{ row.label }}
|
||||
<el-tag v-if="row.danger" size="small" type="danger">高风险</el-tag>
|
||||
</div>
|
||||
<code class="item-key">{{ row.key }}</code>
|
||||
<div class="item-desc">{{ row.description }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="当前值" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<span v-if="!row.configured" class="muted">未配置</span>
|
||||
<el-tag v-else-if="row.kind === 'bool'" size="small" :type="row.value === '1' ? 'danger' : 'info'">
|
||||
{{ row.value === '1' ? '已开启' : '已关闭' }}
|
||||
</el-tag>
|
||||
<code v-else class="val">{{ row.masked }}</code>
|
||||
<div v-if="row.kind === 'secret' && row.fingerprint" class="hint">
|
||||
指纹 {{ row.fingerprint }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="来源" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="sourceMeta(row.source).type">
|
||||
{{ sourceMeta(row.source).label }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="生效方式" width="110">
|
||||
<template #default="{ row }">
|
||||
<span :class="{ muted: !row.requires_restart }">
|
||||
{{ row.requires_restart ? '需重启' : '立即生效' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="180" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip
|
||||
v-if="!row.editable"
|
||||
:content="row.read_only_reason"
|
||||
placement="top"
|
||||
>
|
||||
<span class="lock-hint"><el-icon><Lock /></el-icon>只读</span>
|
||||
</el-tooltip>
|
||||
<template v-else>
|
||||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button
|
||||
v-if="row.source === 'page'"
|
||||
link
|
||||
type="warning"
|
||||
@click="resetOne(row)"
|
||||
>恢复默认</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<el-dialog v-model="dialog.visible" :title="`编辑:${dialog.row?.label || ''}`" width="560px">
|
||||
<template v-if="dialog.row">
|
||||
<code class="item-key dialog-key">{{ dialog.row.key }}</code>
|
||||
<p class="item-desc dialog-desc">{{ dialog.row.description }}</p>
|
||||
|
||||
<el-form label-width="96px" @submit.prevent>
|
||||
<el-form-item v-if="dialog.row.kind === 'bool'" label="开关">
|
||||
<el-switch
|
||||
v-model="dialog.boolValue"
|
||||
inline-prompt
|
||||
active-text="开"
|
||||
inactive-text="关"
|
||||
/>
|
||||
<div v-if="dialog.row.danger" class="hint danger">
|
||||
高风险:开启后未验签的回调也会被放行,生产模式下保存会被拒绝。
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-else-if="dialog.row.kind === 'list'" label="取值">
|
||||
<el-input
|
||||
v-model="dialog.textValue"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:placeholder="dialog.row.placeholder"
|
||||
/>
|
||||
<div class="hint">多个值用英文逗号分隔</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-else :label="dialog.row.kind === 'secret' ? '新值' : '取值'">
|
||||
<el-input
|
||||
v-model="dialog.textValue"
|
||||
:type="dialog.row.kind === 'secret' ? 'password' : 'text'"
|
||||
:show-password="dialog.row.kind === 'secret'"
|
||||
:placeholder="dialog.row.kind === 'secret' ? '留空表示清除(回退 .env / 未配置)' : dialog.row.placeholder"
|
||||
/>
|
||||
<div v-if="dialog.row.kind === 'secret'" class="hint">
|
||||
当前指纹 {{ dialog.row.fingerprint || '—' }},保存后不可查看明文
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-alert
|
||||
v-if="dialog.row.requires_restart"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="该项改动需重启容器后生效"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="dialog.visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Lock } from '@element-plus/icons-vue'
|
||||
import { getRuntimeConfig, updateRuntimeConfig, resetRuntimeConfig } from '@/api/runtime'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const groups = ref([])
|
||||
const bootstrapKeys = ref([])
|
||||
|
||||
const dialog = reactive({
|
||||
visible: false,
|
||||
row: null,
|
||||
textValue: '',
|
||||
boolValue: false,
|
||||
})
|
||||
|
||||
const sourceMap = {
|
||||
page: { label: '本页配置', type: 'success' },
|
||||
env: { label: '.env', type: 'info' },
|
||||
default: { label: '默认值', type: 'info' },
|
||||
missing: { label: '未配置', type: 'warning' },
|
||||
}
|
||||
|
||||
function sourceMeta(source) {
|
||||
return sourceMap[source] || { label: source || '—', type: 'info' }
|
||||
}
|
||||
|
||||
function groupEnvOnly(group) {
|
||||
return group.items.length > 0 && group.items.every((it) => !it.editable)
|
||||
}
|
||||
|
||||
const stats = computed(() => {
|
||||
const all = groups.value.flatMap((g) => g.items)
|
||||
return {
|
||||
editable: all.filter((it) => it.editable).length,
|
||||
envOnly: all.filter((it) => !it.editable).length,
|
||||
fromPage: all.filter((it) => it.source === 'page').length,
|
||||
restart: all.filter((it) => it.requires_restart).length,
|
||||
}
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getRuntimeConfig()
|
||||
groups.value = res.data?.groups || []
|
||||
bootstrapKeys.value = res.data?.bootstrap_keys || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
dialog.row = row
|
||||
dialog.textValue = row.kind === 'secret' ? '' : row.value || ''
|
||||
dialog.boolValue = row.value === '1'
|
||||
dialog.visible = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const row = dialog.row
|
||||
if (!row) return
|
||||
let value
|
||||
if (row.kind === 'bool') {
|
||||
value = dialog.boolValue ? '1' : '0'
|
||||
if (row.danger && dialog.boolValue) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'开启后任何未验签的支付回调都会被当作已付款处理,仅限本地调试。确认继续?',
|
||||
'高风险操作',
|
||||
{ type: 'warning', confirmButtonText: '确认开启', cancelButtonText: '取消' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
value = (dialog.textValue || '').trim()
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await updateRuntimeConfig({ [row.key]: value })
|
||||
applyOverview(res.data)
|
||||
dialog.visible = false
|
||||
ElMessage.success('已保存')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function resetOne(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`将删除本页保存的「${row.label}」,回退到 .env 或默认值。确认恢复?`,
|
||||
'恢复默认',
|
||||
{ type: 'warning', confirmButtonText: '恢复', cancelButtonText: '取消' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const res = await resetRuntimeConfig([row.key])
|
||||
applyOverview(res.data)
|
||||
ElMessage.success('已恢复 .env / 默认值')
|
||||
}
|
||||
|
||||
function applyOverview(data) {
|
||||
if (!data) return
|
||||
groups.value = data.groups || []
|
||||
bootstrapKeys.value = data.bootstrap_keys || []
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.runtime-page { display: flex; flex-direction: column; gap: 16px; }
|
||||
.card-header { display: flex; align-items: center; gap: 8px; }
|
||||
.card-header .title { font-size: 15px; font-weight: 600; color: #303133; }
|
||||
.card-header > :last-child { margin-left: auto; }
|
||||
.alert-title { font-weight: 600; }
|
||||
.alert-body { font-size: 12px; line-height: 1.7; color: #606266; }
|
||||
.summary { display: flex; gap: 32px; margin-top: 16px; flex-wrap: wrap; }
|
||||
.sum-item { display: flex; flex-direction: column; }
|
||||
.sum-item b { font-size: 20px; color: #303133; }
|
||||
.sum-item span { font-size: 12px; color: #909399; }
|
||||
.item-label { font-size: 13px; font-weight: 600; color: #303133; display: flex; align-items: center; gap: 6px; }
|
||||
.item-key { font-size: 11px; color: #909399; font-family: ui-monospace, Menlo, Consolas, monospace; }
|
||||
.item-desc { font-size: 12px; color: #909399; line-height: 1.6; margin-top: 2px; }
|
||||
.dialog-desc { margin: 4px 0 16px; }
|
||||
.dialog-key { display: block; margin-top: 4px; }
|
||||
.val { font-size: 12px; color: #303133; word-break: break-all; }
|
||||
.muted { color: #c0c4cc; font-size: 12px; }
|
||||
.hint { font-size: 11px; color: #909399; line-height: 1.6; margin-top: 2px; }
|
||||
.hint.danger { color: #e6a23c; }
|
||||
.lock-hint { font-size: 12px; color: #c0c4cc; display: inline-flex; align-items: center; gap: 4px; }
|
||||
.cfg-table :deep(.el-table__cell) { vertical-align: top; }
|
||||
</style>
|
||||
Reference in new issue
Block a user