feat(proxy): T-P7 管理面前端(ProxyView 三卡片 + 路由/NAV 注册)

- webapp/api:getProxyStats/getProxyLedger/listProxyStudents/createStudent/
  topup/issueKey/revokeKey 封装
- ProxyView.vue 三卡片:① 命中率-毛利看板(h_g/h_p/综合/收入/成本/毛利/分桶)
  ② 学生与 key 管理(建学生/签发(明文仅显一次警示)/充值/选中签发)
  ③ 用量流水表(时间/模型/状态徽标/收入/成本/毛利)
- App.vue NAV + router 注册 /proxy;npm run build 产物更新
- 全量 423 passed
This commit is contained in:
tzt
2026-09-05 16:19:26 +08:00
parent 4f272dc66d
commit 7caab52bf1
6 changed files with 320 additions and 4 deletions
@@ -1,14 +1,14 @@
{
"schemaVersion": "mimosa-hook-status/v1",
"recordedAt": "2026-09-05T07:03:44.339Z",
"recordedAt": "2026-09-05T08:17:05.111Z",
"sessionId": "sess_e50d4f25-3ac6-43f2-b2f3-8833b3150465",
"event": "PostToolUse",
"toolName": "Edit",
"file": "src/views/MetricsView.vue",
"file": "src/App.vue",
"outcome": "clear",
"coverage": "complete",
"findingCount": 0,
"durationMs": 2,
"durationMs": 3,
"hostState": "hook_complete",
"reportHint": ".mimosa/reports/"
}
+1
View File
@@ -38,6 +38,7 @@ const NAV = [
{ to: '/agent', icon: '🤖', label: '智能体' },
{ to: '/review', icon: '🔍', label: '人工检验' },
{ to: '/metrics', icon: '📊', label: '指标' },
{ to: '/proxy', icon: '🏷️', label: '代理' },
{ to: '/settings', icon: '⚙️', label: '设置' },
]
</script>
+81
View File
@@ -543,6 +543,87 @@ export function watchAgent(requestId: string) {
}
}
// ── 代理层管理面(T-P7) ─────────────────────────────────────────────────────
export interface ProxyStats {
requests: number
h_g: number
h_p: number
revenue_milli: number
cost_milli: number
margin_milli: number
by_bucket?: Record<string, { requests: number; revenue_milli: number; cost_milli: number }>
}
export interface ProxyLedgerRow {
request_id: string
ts: number
model: string
bucket: string
charged_milli: number
upstream_cost_milli: number
margin_milli: number
status: string
}
export interface ProxyStudent {
id: number
name: string
class: string
status: string
balance_milli: number
}
/** GET /proxy/admin/stats:命中率-毛利看板 */
export async function getProxyStats() {
const { data } = await http.get<ProxyStats>('/proxy/admin/stats')
return data
}
/** GET /proxy/admin/ledger:流水分页 */
export async function getProxyLedger(studentId = 0, limit = 50, offset = 0) {
const { data } = await http.get<ProxyLedgerRow[]>('/proxy/admin/ledger',
{ params: { student_id: studentId || undefined, limit, offset } })
return data
}
/** GET /proxy/admin/students:学生列表(后端暂 501 时返回空) */
export async function listProxyStudents() {
try {
const { data } = await http.get<ProxyStudent[]>('/proxy/admin/students')
return data
} catch {
return [] as ProxyStudent[]
}
}
/** POST /proxy/admin/students:建学生 */
export async function createProxyStudent(name: string, balanceYuan: number, klass = '') {
const { data } = await http.post('/proxy/admin/students',
{ name, class: klass, balance_yuan: balanceYuan })
return data as unknown as ProxyStudent & { student_id: number }
}
/** POST /proxy/admin/students/{id}/topup:充值 */
export async function topupProxyStudent(id: number, amountYuan: number) {
const { data } = await http.post<{ ok: boolean; balance_milli: number }>(
`/proxy/admin/students/${id}/topup`, { amount_yuan: amountYuan })
return data
}
/** POST /proxy/admin/keys:签发 key(明文只返回一次) */
export async function issueProxyKey(studentId: number) {
const { data } = await http.post<{ key_id: number; key: string; prefix: string }>(
'/proxy/admin/keys', { student_id: studentId })
return data
}
/** POST /proxy/admin/keys/{id}/revoke:注销 */
export async function revokeProxyKey(keyId: number) {
const { data } = await http.post<{ ok: boolean }>(`/proxy/admin/keys/${keyId}/revoke`)
return data
}
// ── 辅助:轮询直到完成(用于不需要 SSE 的场景)──────────────────────────────────
export async function pollUntilDone(
+5
View File
@@ -36,6 +36,11 @@ const router = createRouter({
name: 'metrics',
component: () => import('@/views/MetricsView.vue'),
},
{
path: '/proxy',
name: 'proxy',
component: () => import('@/views/ProxyView.vue'),
},
{
path: '/settings',
name: 'settings',
+229
View File
@@ -0,0 +1,229 @@
<template>
<div class="proxy-view">
<header class="page-head">
<h2>🏷 校园代理管理</h2>
<p class="sub">学生 key / 用量流水 / 命中率-毛利看板毫元计费1 = 1000 毫元</p>
</header>
<div v-if="error" class="error">{{ error }}</div>
<!-- 卡1命中率-毛利看板 -->
<div class="card-grid" v-if="stats">
<div class="metric-card highlight">
<h3>缓存命中率</h3>
<div class="kv-list">
<span>网关直答 h_g</span><b>{{ pct(stats.h_g) }}</b>
<span>上游前缀 h_p</span><b>{{ pct(stats.h_p) }}</b>
<span>综合近似</span><b>{{ pct(stats.h_g + stats.h_p) }}</b>
</div>
<p class="hint">北极星h = h_g + h_p目标 50%</p>
</div>
<div class="metric-card">
<h3>毛利毫元</h3>
<div class="kv-list">
<span>收入 Σcharged</span><b>{{ stats.revenue_milli }}</b>
<span>成本 Σcost</span><b>{{ stats.cost_milli }}</b>
<span>毛利</span>
<b :class="stats.margin_milli >= 0 ? 'pos' : 'neg'">{{ stats.margin_milli }}</b>
<span>请求数</span><b>{{ stats.requests }}</b>
</div>
</div>
<div class="metric-card" v-if="buckets.length">
<h3>分桶</h3>
<div class="kv-list">
<template v-for="b in buckets" :key="b.name">
<span>{{ b.name }}</span>
<b>{{ b.requests }} / {{ b.revenue_milli }} 毫元</b>
</template>
</div>
</div>
</div>
<!-- 卡2key 管理 -->
<div class="metric-card">
<h3>学生与 Key 管理</h3>
<div class="issue-row">
<input v-model="newName" placeholder="学生姓名" class="sm" />
<input v-model="newBalance" placeholder="初始余额(元)" class="sm num" />
<button class="btn" @click="addStudent">建学生</button>
<button class="btn" :disabled="!selectedStudent" @click="issue">签发 Key</button>
<button class="btn" :disabled="!selectedStudent" @click="topup">充值 1 </button>
</div>
<div v-if="issuedKey" class="issued">
key仅显示一次请复制 <code>{{ issuedKey }}</code>
</div>
<table class="tbl" v-if="students.length">
<thead>
<tr><th>ID</th><th>姓名</th><th>余额(毫元)</th><th>状态</th><th>操作</th></tr>
</thead>
<tbody>
<tr v-for="s in students" :key="s.id" :class="{ sel: s.id === selectedStudent }"
@click="selectedStudent = s.id">
<td>{{ s.id }}</td><td>{{ s.name }}</td><td>{{ s.balance_milli }}</td>
<td>{{ s.status }}</td>
<td><button class="mini" @click.stop="selectedStudent = s.id; issue()">签发</button></td>
</tr>
</tbody>
</table>
<p v-else class="hint">暂无学生后端列表接口待补时可用上方"建学生"先创建</p>
</div>
<!-- 卡3用量流水 -->
<div class="metric-card">
<h3>用量流水</h3>
<table class="tbl" v-if="ledger.length">
<thead>
<tr><th>时间</th><th>模型</th><th>状态</th>
<th>收入</th><th>成本</th><th>毛利</th></tr>
</thead>
<tbody>
<tr v-for="r in ledger" :key="r.request_id">
<td class="mono">{{ fmtTime(r.ts) }}</td>
<td class="mono">{{ r.model }}</td>
<td><span :class="['st', r.status]">{{ r.status }}</span></td>
<td>{{ r.charged_milli }}</td>
<td>{{ r.upstream_cost_milli }}</td>
<td>{{ r.margin_milli }}</td>
</tr>
</tbody>
</table>
<p v-else class="hint">暂无流水</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import {
getProxyStats, getProxyLedger, listProxyStudents,
createProxyStudent, topupProxyStudent, issueProxyKey,
} from '@/api'
import type { ProxyStats, ProxyLedgerRow, ProxyStudent } from '@/api'
const stats = ref<ProxyStats | null>(null)
const ledger = ref<ProxyLedgerRow[]>([])
const students = ref<ProxyStudent[]>([])
const selectedStudent = ref<number>(0)
const newName = ref('')
const newBalance = ref('1')
const issuedKey = ref('')
const error = ref('')
const buckets = computed(() => {
const bb = stats.value?.by_bucket || {}
return Object.entries(bb).map(([name, v]) => ({ name, ...v }))
})
function pct(v: number) { return (v * 100).toFixed(1) + '%' }
function fmtTime(ts: number) {
const d = new Date(ts * 1000)
return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
async function load() {
error.value = ''
try {
const [s, l, st] = await Promise.all([
getProxyStats(), getProxyLedger(0, 50, 0), listProxyStudents()])
stats.value = s
ledger.value = l
students.value = st
} catch (e: any) {
error.value = e?.response?.data?.detail || e?.message || String(e)
}
}
async function addStudent() {
if (!newName.value.trim()) return
try {
const r = await createProxyStudent(newName.value.trim(), Number(newBalance.value) || 0)
selectedStudent.value = (r as any).student_id || 0
newName.value = ''
issuedKey.value = ''
await load()
} catch (e: any) { error.value = e?.message || String(e) }
}
async function issue() {
if (!selectedStudent.value) return
try {
const r = await issueProxyKey(selectedStudent.value)
issuedKey.value = r.key
await load()
} catch (e: any) { error.value = e?.response?.data?.detail || e?.message || String(e) }
}
async function topup() {
if (!selectedStudent.value) return
try {
await topupProxyStudent(selectedStudent.value, 1)
await load()
} catch (e: any) { error.value = e?.message || String(e) }
}
onMounted(load)
</script>
<style scoped>
.proxy-view { padding: 20px 24px; height: 100%; overflow-y: auto; }
.page-head { display: flex; flex-direction: column; margin-bottom: 16px; }
.sub { color: var(--c-text-2); font-size: 13px; margin-top: 2px; }
.error { color: var(--c-err); margin-bottom: 10px; }
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.metric-card {
background: var(--c-surface);
border: 1px solid var(--c-border-soft);
border-radius: var(--radius);
padding: 14px 16px;
box-shadow: var(--shadow-card);
}
.metric-card h3 { font-size: 14px; color: var(--c-text); margin-bottom: 10px; }
.kv-list { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 10px; font-size: 13px; }
.kv-list span { color: var(--c-text-2); }
.pos { color: var(--c-ok); }
.neg { color: var(--c-err); }
.hint { color: var(--c-caption); font-size: 11px; margin-top: 8px; }
.issue-row { display: flex; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; }
.issue-row .sm {
border: 1px solid var(--c-border);
border-radius: 6px;
padding: 6px 10px;
font-size: 13px;
}
.issue-row .num { width: 110px; }
.btn {
border: 1px solid var(--c-border);
background: var(--c-surface);
border-radius: 6px;
padding: 6px 12px;
font-size: 13px;
cursor: pointer;
}
.btn:hover:not(:disabled) { border-color: var(--c-primary); color: var(--c-primary); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.issued {
background: var(--c-warn-soft);
border: 1px solid var(--c-warn);
border-radius: 6px;
padding: 8px 10px;
font-size: 12px;
margin-bottom: 10px;
word-break: break-all;
}
.tbl { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.tbl th, .tbl td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--c-border-soft); }
.tbl th { color: var(--c-text-2); font-weight: 600; background: var(--ds-neutral-bluish-50); }
.tbl tr.sel { background: var(--c-primary-soft); }
.mono { font-family: var(--font-mono); font-size: 11.5px; }
.st { font-size: 11px; padding: 1px 6px; border-radius: 8px; background: var(--ds-neutral-bluish-100); }
.st.cached { background: var(--c-ok-soft); color: var(--c-ok); }
.st.failed, .st.aborted, .st.insufficient { background: var(--c-err-soft); color: var(--c-err); }
.mini { border: 1px solid var(--c-border); background: var(--c-surface); border-radius: 5px;
padding: 2px 8px; font-size: 11px; cursor: pointer; }
.mini:hover { border-color: var(--c-primary); color: var(--c-primary); }
</style>