0.11.1: 旗舰版完善(资源权限等级/个人环境变量/收藏/企业报表/租户概览/ARM64发布/多渠道接入)

- 迁移 000035-000037(权限等级/环境变量/渠道)
- 新增 internal/channel 渠道抽象层(webhook/企微/钉钉/飞书)
- 全部功能端到端验证通过(25 包单测)
This commit is contained in:
2026-08-13 11:54:34 +08:00
parent c22669c31d
commit 4563979a15
25 changed files with 1664 additions and 7 deletions
@@ -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>