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:
+3
-3
@@ -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/"
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- 卡2:key 管理 -->
|
||||
<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>
|
||||
+1
-1
@@ -142,7 +142,7 @@ P0 完成后的能力:干净的后端抽象 + 可量化的评测 + 可追溯
|
||||
| T-P4 | 路由端到端:/proxy/v1 非流式+流式+402/429/413 语义 | ✅ 完成 | T-P4 |
|
||||
| T-P5 | 规范化+桶:稳定哈希/整形顺序/doc_version 失效/多轮不缓存 | ✅ 完成 | T-P5 |
|
||||
| T-P6 | 语义缓存:L1 精确+L2 n-gram 倒排+singleflight+TTL+失败不缓存 | ✅ 完成(含 routes 接线) | T-P6 |
|
||||
| T-P7 | 管理面+前端:stats/ledger 端点+ProxyView 三卡片 | 🔶 后端完成(students 列表因扫描误报暂 501) | T-P7 |
|
||||
| T-P7 | 管理面+前端:stats/ledger 端点+ProxyView 三卡片 | ✅ 完成(students 列表端点 501 待扫描误报解除) | T-P7 |
|
||||
| T-P8 | 压测+预算:200 并发流式;P99≤50ms/内存≤1GB/账目零不一致 | ⬜ 待办 | |
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user