- 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
230 lines
8.3 KiB
Vue
230 lines
8.3 KiB
Vue
<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>
|