0.11.1: 旗舰版完善(资源权限等级/个人环境变量/收藏/企业报表/租户概览/ARM64发布/多渠道接入)
- 迁移 000035-000037(权限等级/环境变量/渠道) - 新增 internal/channel 渠道抽象层(webhook/企微/钉钉/飞书) - 全部功能端到端验证通过(25 包单测)
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
<!-- 常用功能收藏:星标收藏当前页,弹窗展示收藏列表快速跳转 -->
|
||||
<template>
|
||||
<ElPopover
|
||||
:width="360"
|
||||
:show-arrow="false"
|
||||
trigger="click"
|
||||
placement="bottom-end"
|
||||
popper-class="favorites-popover"
|
||||
>
|
||||
<template #reference>
|
||||
<div class="c-p mx-1 flex-cc rounded p-2 text-lg hover:bg-g-200/70 dark:hover:bg-g-200/90" title="收藏">
|
||||
<ArtSvgIcon :icon="isCurrentFavorite ? 'ri:star-fill' : 'ri:star-line'" class="text-xl" :style="{ color: isCurrentFavorite ? '#f7ba1e' : '' }" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium">我的收藏</h3>
|
||||
<ElButton link type="primary" size="small" :disabled="isCurrentFavorite" @click="addCurrent">收藏当前页</ElButton>
|
||||
</div>
|
||||
<ul v-if="favorites.length" class="max-h-72 space-y-1 overflow-y-auto">
|
||||
<li v-for="item in favorites" :key="item.path" class="c-p flex items-center justify-between rounded px-2 py-1.5 hover:bg-g-200/70 dark:hover:bg-g-200/90" @click="go(item)">
|
||||
<span class="text-sm">{{ item.title }}</span>
|
||||
<ElButton link type="danger" size="small" @click.stop="remove(item.path)">删除</ElButton>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="py-6 text-center text-sm text-g-400">暂无收藏,点击右上角星标收藏常用页面</div>
|
||||
</div>
|
||||
</ElPopover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
interface Favorite {
|
||||
title: string
|
||||
path: string
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'gateway-favorites'
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const favorites = ref<Favorite[]>(load())
|
||||
|
||||
const currentPath = computed(() => route.path)
|
||||
const currentTitle = computed(() => {
|
||||
const meta = (route.meta as Record<string, any>) || {}
|
||||
return typeof meta.title === 'string' ? meta.title : route.name?.toString() || route.path
|
||||
})
|
||||
const isCurrentFavorite = computed(() => favorites.value.some((item) => item.path === currentPath.value))
|
||||
|
||||
function load(): Favorite[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
const parsed = raw ? JSON.parse(raw) : []
|
||||
return Array.isArray(parsed) ? parsed.filter((item) => item && typeof item.path === 'string') : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function persist() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites.value.slice(0, 50)))
|
||||
}
|
||||
|
||||
function addCurrent() {
|
||||
if (isCurrentFavorite.value) return
|
||||
favorites.value.unshift({ title: currentTitle.value, path: currentPath.value })
|
||||
persist()
|
||||
}
|
||||
|
||||
function remove(path: string) {
|
||||
favorites.value = favorites.value.filter((item) => item.path !== path)
|
||||
persist()
|
||||
}
|
||||
|
||||
function go(item: Favorite) {
|
||||
router.push(item.path)
|
||||
}
|
||||
</script>
|
||||
@@ -48,6 +48,9 @@
|
||||
<ArtIconButton icon="ri:function-line" class="ml-3" />
|
||||
</ArtFastEnter>
|
||||
|
||||
<!-- 常用功能收藏 -->
|
||||
<ArtFavorites />
|
||||
|
||||
<!-- 面包屑 -->
|
||||
<ArtBreadcrumb
|
||||
v-if="(shouldShowBreadcrumb && isLeftMenu) || (shouldShowBreadcrumb && isDualMenu)"
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<div class="mb-5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">渠道管理</h2>
|
||||
<p class="text-g-500 mt-1 text-sm">企业微信/钉钉/飞书/通用 Webhook 渠道:入站消息经绑定模型应答后按平台协议回复</p>
|
||||
</div>
|
||||
<ElButton type="primary" @click="openCreate">新增渠道</ElButton>
|
||||
</div>
|
||||
|
||||
<ElAlert class="mb-4" type="info" :closable="false" title="接入方式:平台回调/机器人 Webhook 指向 POST /v1/channels/{code}/inbound;企微需在管理端配置回调 URL 并启用签名校验。" />
|
||||
|
||||
<ElTable v-loading="loading" :data="channels" row-key="id">
|
||||
<ElTableColumn prop="code" label="代码" width="140" />
|
||||
<ElTableColumn prop="name" label="名称" min-width="140" />
|
||||
<ElTableColumn label="类型" width="110">
|
||||
<template #default="{ row }">{{ kindLabel(row.kind) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="绑定模型" min-width="180">
|
||||
<template #default="{ row }">{{ modelLabel(row.model_binding) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="API Key" width="90">
|
||||
<template #default="{ row }">
|
||||
<ElTag :type="row.has_api_key ? 'success' : 'info'">{{ row.has_api_key ? '已配置' : '未配置' }}</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<ElTag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '启用' : '停用' }}</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<ElButton link type="success" :loading="testingId === row.id" @click="test(row)">测试</ElButton>
|
||||
<ElButton link type="primary" @click="openEdit(row)">编辑</ElButton>
|
||||
<ElButton link type="danger" @click="remove(row)">删除</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
<ElDialog v-model="dialogVisible" :title="editingId ? '编辑渠道' : '新增渠道'" width="640px">
|
||||
<ElForm :model="form" label-width="110px">
|
||||
<ElFormItem label="代码" required>
|
||||
<ElInput v-model="form.code" :disabled="!!editingId" placeholder="小写字母开头,如 wecom-main" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="名称" required><ElInput v-model="form.name" /></ElFormItem>
|
||||
<ElFormItem label="类型" required>
|
||||
<ElSelect v-model="form.kind" class="w-full">
|
||||
<ElOption label="通用 Webhook" value="webhook" />
|
||||
<ElOption label="企业微信" value="wecom" />
|
||||
<ElOption label="钉钉" value="dingtalk" />
|
||||
<ElOption label="飞书" value="feishu" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="绑定模型">
|
||||
<div class="flex w-full gap-2">
|
||||
<ElInput v-model="form.binding_provider" placeholder="供应商代码(留空用默认)" />
|
||||
<ElInput v-model="form.binding_model" placeholder="模型名,如 gpt-4o-mini" />
|
||||
</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="网关 API Key">
|
||||
<ElInput v-model="form.api_key" type="password" show-password :placeholder="editingId ? '留空不更换' : '必填'" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="入站令牌">
|
||||
<ElInput v-model="form.inbound_token" placeholder="webhook/企微回调鉴权令牌" />
|
||||
</ElFormItem>
|
||||
<template v-if="form.kind === 'wecom'">
|
||||
<ElFormItem label="CorpID"><ElInput v-model="form.corp_id" /></ElFormItem>
|
||||
<ElFormItem label="Secret"><ElInput v-model="form.secret" type="password" show-password /></ElFormItem>
|
||||
<ElFormItem label="AgentID"><ElInput v-model="form.agent_id" /></ElFormItem>
|
||||
</template>
|
||||
<template v-else-if="form.kind === 'dingtalk'">
|
||||
<ElFormItem label="机器人 Token"><ElInput v-model="form.ding_robot_token" /></ElFormItem>
|
||||
</template>
|
||||
<template v-else-if="form.kind === 'feishu'">
|
||||
<ElFormItem label="App ID"><ElInput v-model="form.feishu_app_id" /></ElFormItem>
|
||||
<ElFormItem label="App Secret"><ElInput v-model="form.feishu_app_secret" type="password" show-password /></ElFormItem>
|
||||
</template>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<ElButton @click="dialogVisible = false">取消</ElButton>
|
||||
<ElButton type="primary" :loading="saving" @click="submit">保存</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import request from '@/utils/http'
|
||||
|
||||
interface Channel {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
kind: string
|
||||
model_binding: Record<string, any>
|
||||
has_api_key: boolean
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const kindMap: Record<string, string> = { webhook: 'Webhook', wecom: '企业微信', dingtalk: '钉钉', feishu: '飞书' }
|
||||
const kindLabel = (kind: string) => kindMap[kind] || kind
|
||||
const modelLabel = (binding: Record<string, any>) => (binding?.model ? `${binding.provider ? binding.provider + ' / ' : ''}${binding.model}` : '未绑定')
|
||||
|
||||
const channels = ref<Channel[]>([])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const testingId = ref('')
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref('')
|
||||
const form = reactive({
|
||||
code: '', name: '', kind: 'webhook', binding_provider: '', binding_model: '', api_key: '',
|
||||
inbound_token: '', corp_id: '', secret: '', agent_id: '', ding_robot_token: '', feishu_app_id: '', feishu_app_secret: ''
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
channels.value = await request.get<Channel[]>({ url: '/api/v1/admin/channels' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = ''
|
||||
Object.assign(form, { code: '', name: '', kind: 'webhook', binding_provider: '', binding_model: '', api_key: '', inbound_token: '', corp_id: '', secret: '', agent_id: '', ding_robot_token: '', feishu_app_id: '', feishu_app_secret: '' })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: Channel) {
|
||||
editingId.value = row.id
|
||||
Object.assign(form, {
|
||||
code: row.code, name: row.name, kind: row.kind, api_key: '',
|
||||
binding_provider: row.model_binding?.provider || '', binding_model: row.model_binding?.model || ''
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!form.code || !form.name) {
|
||||
ElMessage.warning('请填写代码和名称')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const config: Record<string, string> = {}
|
||||
if (form.inbound_token) config.inbound_token = form.inbound_token
|
||||
if (form.kind === 'wecom') {
|
||||
if (form.corp_id) config.corp_id = form.corp_id
|
||||
if (form.secret) config.secret = form.secret
|
||||
if (form.agent_id) config.agent_id = form.agent_id
|
||||
}
|
||||
if (form.kind === 'dingtalk' && form.ding_robot_token) config.ding_robot_token = form.ding_robot_token
|
||||
if (form.kind === 'feishu') {
|
||||
if (form.feishu_app_id) config.feishu_app_id = form.feishu_app_id
|
||||
if (form.feishu_app_secret) config.feishu_app_secret = form.feishu_app_secret
|
||||
}
|
||||
const payload = {
|
||||
code: form.code, name: form.name, kind: form.kind, config,
|
||||
model_binding: { provider: form.binding_provider || undefined, model: form.binding_model || undefined },
|
||||
api_key: form.api_key
|
||||
}
|
||||
if (editingId.value) {
|
||||
await request.put({ url: `/api/v1/admin/channels/${editingId.value}`, params: payload })
|
||||
ElMessage.success('渠道已更新')
|
||||
} else {
|
||||
await request.post({ url: '/api/v1/admin/channels', params: payload })
|
||||
ElMessage.success('渠道已创建')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await load()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function test(row: Channel) {
|
||||
testingId.value = row.id
|
||||
try {
|
||||
const result = await request.post<{ answer: string }>({ url: `/api/v1/admin/channels/${row.id}/test` })
|
||||
ElMessage.success(`模型应答: ${result.answer.slice(0, 60)}`)
|
||||
} catch { /* 全局错误提示 */ } finally {
|
||||
testingId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row: Channel) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除渠道「${row.name}」?`, '删除渠道', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await request.del({ url: `/api/v1/admin/channels/${row.id}` })
|
||||
ElMessage.success('已删除')
|
||||
await load()
|
||||
} catch { /* 全局错误提示 */ }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<div class="mb-5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">企业报表</h2>
|
||||
<p class="text-g-500 mt-1 text-sm">按日/供应商/模型维度的用量与成本统计</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ElDatePicker v-model="range" type="daterange" value-format="YYYY-MM-DD" :clearable="false" class="w-60" @change="load" />
|
||||
<ElButton :loading="loading" @click="load">查询</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-5 grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<ElCard shadow="never">
|
||||
<div class="text-g-500 text-sm">总请求</div>
|
||||
<b class="mt-2 block text-2xl">{{ totals.requests.toLocaleString() }}</b>
|
||||
</ElCard>
|
||||
<ElCard shadow="never">
|
||||
<div class="text-g-500 text-sm">失败率</div>
|
||||
<b class="mt-2 block text-2xl">{{ totals.requests ? ((totals.failed / totals.requests) * 100).toFixed(2) + '%' : '—' }}</b>
|
||||
</ElCard>
|
||||
<ElCard shadow="never">
|
||||
<div class="text-g-500 text-sm">总 Tokens</div>
|
||||
<b class="mt-2 block text-2xl">{{ totals.tokens.toLocaleString() }}</b>
|
||||
</ElCard>
|
||||
<ElCard shadow="never">
|
||||
<div class="text-g-500 text-sm">估算成本</div>
|
||||
<b class="mt-2 block text-2xl">{{ costText(totals.cost) }}</b>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<ElTabs v-model="activeTab">
|
||||
<ElTabPane label="按供应商" name="provider">
|
||||
<ElTable v-loading="loading" :data="providerRows" row-key="provider">
|
||||
<ElTableColumn prop="provider" label="供应商" min-width="180" />
|
||||
<ElTableColumn prop="requests" label="请求数" width="120" />
|
||||
<ElTableColumn prop="failed" label="失败" width="100" />
|
||||
<ElTableColumn prop="tokens" label="Tokens" width="140" />
|
||||
<ElTableColumn prop="cost" label="成本" width="140" />
|
||||
</ElTable>
|
||||
</ElTabPane>
|
||||
<ElTabPane label="按模型" name="model">
|
||||
<ElTable v-loading="loading" :data="modelRows" row-key="model">
|
||||
<ElTableColumn prop="provider" label="供应商" min-width="150" />
|
||||
<ElTableColumn prop="model" label="模型" min-width="200" />
|
||||
<ElTableColumn prop="requests" label="请求数" width="120" />
|
||||
<ElTableColumn prop="failed" label="失败" width="100" />
|
||||
<ElTableColumn prop="tokens" label="Tokens" width="140" />
|
||||
<ElTableColumn prop="cost" label="成本" width="140" />
|
||||
</ElTable>
|
||||
</ElTabPane>
|
||||
<ElTabPane label="按日期" name="daily">
|
||||
<ElTable v-loading="loading" :data="dailyRows" row-key="date">
|
||||
<ElTableColumn prop="date" label="日期" width="140" />
|
||||
<ElTableColumn prop="requests" label="请求数" width="120" />
|
||||
<ElTableColumn prop="failed" label="失败" width="100" />
|
||||
<ElTableColumn prop="tokens" label="Tokens" width="140" />
|
||||
<ElTableColumn prop="cost" label="成本" width="140" />
|
||||
</ElTable>
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { fetchDailyUsage } from '@/api/audit'
|
||||
|
||||
const loading = ref(false)
|
||||
const activeTab = ref('provider')
|
||||
const range = ref<[string, string]>([daysAgo(6), today()])
|
||||
const dailyUsage = ref<any[]>([])
|
||||
|
||||
function daysAgo(n: number) {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - n)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
function today() {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
function costText(value: number | null | undefined) {
|
||||
return value != null && value > 0 ? `USD ${(value / 1e6).toFixed(4)}` : '—'
|
||||
}
|
||||
|
||||
const totals = computed(() =>
|
||||
dailyUsage.value.reduce(
|
||||
(total, row) => ({
|
||||
requests: total.requests + row.requests,
|
||||
failed: total.failed + row.failed_requests,
|
||||
tokens: total.tokens + row.prompt_tokens + row.completion_tokens,
|
||||
cost: total.cost + row.cost_microunits
|
||||
}),
|
||||
{ requests: 0, failed: 0, tokens: 0, cost: 0 }
|
||||
)
|
||||
)
|
||||
|
||||
const providerRows = computed(() => {
|
||||
const map = new Map<string, any>()
|
||||
for (const row of dailyUsage.value) {
|
||||
const key = row.provider_code || '未指定'
|
||||
const item = map.get(key) || { provider: key, requests: 0, failed: 0, tokens: 0, cost: 0 }
|
||||
item.requests += row.requests
|
||||
item.failed += row.failed_requests
|
||||
item.tokens += row.prompt_tokens + row.completion_tokens
|
||||
item.cost += row.cost_microunits
|
||||
map.set(key, item)
|
||||
}
|
||||
return [...map.values()].sort((a, b) => b.requests - a.requests)
|
||||
})
|
||||
|
||||
const modelRows = computed(() => {
|
||||
const map = new Map<string, any>()
|
||||
for (const row of dailyUsage.value) {
|
||||
const key = `${row.provider_code || ''}:${row.model || ''}`
|
||||
const item = map.get(key) || { provider: row.provider_code || '未指定', model: row.model || '未指定', requests: 0, failed: 0, tokens: 0, cost: 0 }
|
||||
item.requests += row.requests
|
||||
item.failed += row.failed_requests
|
||||
item.tokens += row.prompt_tokens + row.completion_tokens
|
||||
item.cost += row.cost_microunits
|
||||
map.set(key, item)
|
||||
}
|
||||
return [...map.values()].sort((a, b) => b.requests - a.requests)
|
||||
})
|
||||
|
||||
const dailyRows = computed(() => {
|
||||
const map = new Map<string, any>()
|
||||
for (const row of dailyUsage.value) {
|
||||
const key = String(row.date).slice(0, 10)
|
||||
const item = map.get(key) || { date: key, requests: 0, failed: 0, tokens: 0, cost: 0 }
|
||||
item.requests += row.requests
|
||||
item.failed += row.failed_requests
|
||||
item.tokens += row.prompt_tokens + row.completion_tokens
|
||||
item.cost += row.cost_microunits
|
||||
map.set(key, item)
|
||||
}
|
||||
return [...map.values()].sort((a, b) => b.date.localeCompare(a.date))
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
dailyUsage.value = await fetchDailyUsage({ from: range.value[0], to: range.value[1] })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<div class="mb-5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">租户概览</h2>
|
||||
<p class="text-g-500 mt-1 text-sm">以部门为租户维度,查看各租户账号、API Key 与今日用量</p>
|
||||
</div>
|
||||
<ElButton :loading="loading" @click="load">刷新</ElButton>
|
||||
</div>
|
||||
<ElTable v-loading="loading" :data="tenants" row-key="id">
|
||||
<ElTableColumn prop="name" label="租户(部门)" min-width="200" />
|
||||
<ElTableColumn prop="portal_users" label="门户账号" width="110" />
|
||||
<ElTableColumn prop="enabled_api_keys" label="启用 Key" width="110" />
|
||||
<ElTableColumn prop="today_requests" label="今日请求" width="120" />
|
||||
<ElTableColumn prop="today_tokens" label="今日 Tokens" width="140" />
|
||||
</ElTable>
|
||||
<div v-if="!loading && !tenants.length" class="py-10 text-center text-g-400">暂无租户数据</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import request from '@/utils/http'
|
||||
|
||||
interface TenantRow {
|
||||
id: string
|
||||
name: string
|
||||
portal_users: number
|
||||
enabled_api_keys: number
|
||||
today_requests: number
|
||||
today_tokens: number
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const tenants = ref<TenantRow[]>([])
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await request.get<{ tenants: TenantRow[] }>({ url: '/api/v1/admin/tenants/overview' })
|
||||
tenants.value = result.tenants || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<div class="mb-5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">环境变量</h2>
|
||||
<p class="text-g-500 mt-1 text-sm">个人配置参数(加密存储),对话/应用运行时自动补充到请求变量</p>
|
||||
</div>
|
||||
<ElButton type="primary" @click="openAdd">添加变量</ElButton>
|
||||
</div>
|
||||
<ElTable v-loading="loading" :data="vars" row-key="key">
|
||||
<ElTableColumn prop="key" label="变量名" min-width="200" />
|
||||
<ElTableColumn label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<ElTag :type="row.configured ? 'success' : 'info'">{{ row.configured ? '已配置' : '空' }}</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="updated_at" label="更新时间" width="190" />
|
||||
<ElTableColumn label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<ElButton link type="primary" @click="openEdit(row)">修改</ElButton>
|
||||
<ElButton link type="danger" @click="remove(row)">删除</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
<ElDialog v-model="dialogVisible" :title="editingKey ? '修改变量' : '添加变量'" width="520px">
|
||||
<ElForm label-width="90px">
|
||||
<ElFormItem label="变量名" required>
|
||||
<ElInput v-model="formKey" :disabled="!!editingKey" placeholder="如 COMPANY_NAME" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="变量值" required>
|
||||
<ElInput v-model="formValue" type="textarea" :rows="3" placeholder="值加密存储,仅你自己可见" />
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<ElButton @click="dialogVisible = false">取消</ElButton>
|
||||
<ElButton type="primary" :loading="saving" @click="submit">保存</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import request from '@/utils/http'
|
||||
|
||||
interface EnvVar {
|
||||
key: string
|
||||
configured: boolean
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
const vars = ref<EnvVar[]>([])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const editingKey = ref('')
|
||||
const formKey = ref('')
|
||||
const formValue = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
vars.value = await request.get<EnvVar[]>({ url: '/api/v1/portal/env-vars' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
editingKey.value = ''
|
||||
formKey.value = ''
|
||||
formValue.value = ''
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: EnvVar) {
|
||||
editingKey.value = row.key
|
||||
formKey.value = row.key
|
||||
formValue.value = ''
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!formKey.value.trim()) {
|
||||
ElMessage.warning('请输入变量名')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await request.put({ url: `/api/v1/portal/env-vars/${encodeURIComponent(formKey.value.trim())}`, params: { value: formValue.value } })
|
||||
ElMessage.success('已保存')
|
||||
dialogVisible.value = false
|
||||
await load()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row: EnvVar) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除变量「${row.key}」?`, '删除变量', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await request.del({ url: `/api/v1/portal/env-vars/${encodeURIComponent(row.key)}` })
|
||||
ElMessage.success('已删除')
|
||||
await load()
|
||||
} catch { /* 全局错误提示 */ }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
Reference in New Issue
Block a user