Files
projectAIpopular/webapp/src/views/ProxyView.vue
T
tzt 7caab52bf1 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
2026-09-05 16:19:26 +08:00

230 lines
8.3 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>