feat(api): 实装 /api/status 接口——无鉴权公开接口,换算比率权威来源

- endpoints:AuthMode 扩展 'none'(完全公开),追加 /api/status 定义(考证实证文案 + 实测示例响应),置于列表首位
- endpoint-info-line:鉴权徽标三态化(公开/宽松/严格,map 驱动 + none 中性样式)
- endpoint-panel:requiresKey——无鉴权接口隐藏 sk-key 输入行、按钮不因空 key 禁用、空态文案区分
- spec:接口数 5→6、顺序、鉴权分布(none+strict×3+lenient×2)断言更新
- docs:接口文档同步 479 行新版(/api/status 详解 + QuotaPerUnit 考证);交互设计 §1 清单加第 0 行;README 与 side-nav 计数更新
- 验证:test 64/64 + type-check + lint + build 四绿
This commit is contained in:
2026-09-09 01:27:02 +08:00
parent 19b69e2d5a
commit 86d2ac304b
8 changed files with 143 additions and 28 deletions
+7 -5
View File
@@ -3,17 +3,18 @@ import { describe, expect, it } from 'vitest';
import { endpointList, findEndpointByPath } from './endpoints';
describe('endpointList 数据完整性', () => {
it('恰好 5 个接口(设计文档 §1 定案的对接清单)', () => {
expect(endpointList).toHaveLength(5);
it('恰好 6 个接口(设计文档 §1 定案的对接清单)', () => {
expect(endpointList).toHaveLength(6);
});
it('路径唯一(path 是 tab / queryKey 的唯一标识)', () => {
const pathList = endpointList.map((endpoint) => endpoint.path);
expect(new Set(pathList).size).toBe(5);
expect(new Set(pathList).size).toBe(6);
});
it('顺序与用户指定清单一致', () => {
expect(endpointList.map((endpoint) => endpoint.path)).toEqual([
'/api/status',
'/v1/dashboard/billing/subscription',
'/v1/dashboard/billing/usage',
'/v1/models',
@@ -25,13 +26,14 @@ describe('endpointList 数据完整性', () => {
it('全部为 GET 且带鉴权元数据', () => {
for (const endpoint of endpointList) {
expect(endpoint.method).toBe('GET');
expect(['strict', 'lenient']).toContain(endpoint.authMode);
expect(['strict', 'lenient', 'none']).toContain(endpoint.authMode);
expect(endpoint.authMiddlewareName).toBeTruthy();
}
});
it('严格/宽松鉴权分布正确(前 3 严格,后 2 宽松)', () => {
it('鉴权模式分布正确(公开 1 + 严格 3 + 宽松 2)', () => {
expect(endpointList.map((endpoint) => endpoint.authMode)).toEqual([
'none',
'strict',
'strict',
'strict',
+32 -3
View File
@@ -1,17 +1,17 @@
/**
* 5 个对接接口的静态元数据
* 对接接口的静态元数据
* 数据源:docs/NewAPI-sk-key接口文档.md(源码实证调研)
* 顺序即 sider / tab 展示顺序(设计文档 §1 定案,不分组不编号)
*/
export type AuthMode = 'strict' | 'lenient';
export type AuthMode = 'strict' | 'lenient' | 'none';
export interface EndpointDefinition {
/** 接口路径(唯一标识,同时用作 tab 标签与 queryKey 组成部分) */
readonly path: string;
/** HTTP 方法(当前全部为 GET,保留扩展位) */
readonly method: 'GET';
/** 鉴权模式:strict = TokenAuth(key 无效/过期/耗尽直接被拒);lenient = TokenAuthReadOnly(宽松,废 key 也能查) */
/** 鉴权模式:strict = TokenAuth(key 无效/过期/耗尽直接被拒);lenient = TokenAuthReadOnly(宽松,废 key 也能查);none = 完全公开(无需任何凭证) */
readonly authMode: AuthMode;
/** 鉴权中间件名(展示用) */
readonly authMiddlewareName: string;
@@ -24,6 +24,35 @@ export interface EndpointDefinition {
}
export const endpointList: readonly EndpointDefinition[] = [
{
path: '/api/status',
method: 'GET',
authMode: 'none',
authMiddlewareName: '无(公开接口)',
responseSummary: '站点配置与计费参数(含 quota_per_unit)',
notes: [
'完全公开:无任何鉴权,不持有 sk-key 也能访问(本工具对接清单中唯一)',
'quota_per_unit 是 QuotaPerUnit 当前生效值(默认 500000,源码 $0.002 / 1K tokens 锚点)——quota ÷ 该值 = 美元,换算比率的唯一权威来源,管理员可改,不要硬编码',
'接口①④交叉实测吻合验证不了管理员是否改过此值:billing 接口内部就是拿 quota ÷ QuotaPerUnit 换算,同一常量自证',
'quota_display_type 影响 billing 接口语义:USD 直接是美元;CNY 先 ÷QuotaPerUnit 再 ×usd_exchange_rate;TOKENS 是原始 token 数',
'返回 60+ 字段(版本、登录方式、前端模块开关、公告等),解析时只取计费相关字段,其余忽略;字段集合随版本演进,需容忍未知字段',
],
sampleResponse: {
success: true,
message: '',
data: {
quota_per_unit: 500000,
quota_display_type: 'USD',
usd_exchange_rate: 6.8,
display_in_currency: true,
custom_currency_symbol: '¤',
custom_currency_exchange_rate: 1,
price: 6.8,
system_name: 'AI Gateway',
version: 'v1.0.0-rc.35',
},
},
},
{
path: '/v1/dashboard/billing/subscription',
method: 'GET',
+27 -7
View File
@@ -1,18 +1,33 @@
<script setup lang="ts">
/**
* 接口信息行:method 徽标 + 路径 + 鉴权徽标(宽松/严格)
* 接口信息行:method 徽标 + 路径 + 鉴权徽标(公开/宽松/严格)
*/
import type { EndpointDefinition } from '@/api/endpoints';
const props = defineProps<{ endpoint: EndpointDefinition }>();
const authBadgeText = props.endpoint.authMode === 'lenient' ? '宽松' : '严格';
const authBadgeTitle = `${props.endpoint.authMiddlewareName}(${
props.endpoint.authMode === 'lenient'
? 'key 过期/禁用/额度耗尽仍可查询'
: 'key 无效/过期/额度耗尽会被拒绝'
})`;
const authBadgeTextMap: Record<string, string> = {
lenient: '宽松',
strict: '严格',
none: '公开',
};
const authBadgeTitleMap: Record<string, string> = {
lenient: 'TokenAuthReadOnly(key 过期/禁用/额度耗尽仍可查询)',
strict: 'TokenAuth(key 无效/过期/额度耗尽会被拒绝)',
none: '无鉴权中间件(不持有任何 key 也能访问)',
};
const authBadgeText = authBadgeTextMap[props.endpoint.authMode] ?? '未知';
const authBadgeTitle =
props.endpoint.authMode === 'none'
? authBadgeTitleMap.none
: `${props.endpoint.authMiddlewareName}(${
props.endpoint.authMode === 'lenient'
? 'key 过期/禁用/额度耗尽仍可查询'
: 'key 无效/过期/额度耗尽会被拒绝'
})`;
</script>
<template>
@@ -64,6 +79,11 @@ const authBadgeTitle = `${props.endpoint.authMiddlewareName}(${
border: 1px solid var(--td-success-color);
}
.auth-badge.none {
color: var(--td-text-color-secondary);
border: 1px solid var(--td-component-border);
}
.response-summary {
font-size: 12px;
color: var(--td-text-color-placeholder);
+18 -5
View File
@@ -44,12 +44,17 @@ const {
sendRequest,
} = useManualEndpointQuery(endpointPathRef, skKeyInput);
/** 无鉴权接口(如 /api/status)不需要 sk-key:隐藏输入行、按钮不因空 key 禁用 */
const requiresKey = computed(() => endpoint.value.authMode !== 'none');
const keyInputStatus = computed(() =>
hasSent.value && !skKeyInput.value.trim() ? 'error' : 'default',
hasSent.value && requiresKey.value && !skKeyInput.value.trim() ? 'error' : 'default',
);
const sendButtonDisabled = computed(() => requiresKey.value && !skKeyInput.value.trim());
function handleSend(): void {
if (!skKeyInput.value.trim()) return; // 空 key 不发(输入框已标红提示)
if (sendButtonDisabled.value) return; // 空 key 不发(输入框已标红提示);无鉴权接口直接发
sendRequest();
}
@@ -100,10 +105,11 @@ function downloadResponseAsFile(): void {
</t-collapse>
</section>
<!-- 调试行:sk-key + 发送 -->
<!-- 调试行:sk-key + 发送(无鉴权接口隐藏 key 输入) -->
<section class="panel-section">
<div class="request-row">
<t-input
v-if="requiresKey"
v-model="skKeyInput"
class="sk-key-input"
type="password"
@@ -113,10 +119,11 @@ function downloadResponseAsFile(): void {
:tips="keyInputStatus === 'error' ? '请填写 sk-key' : ''"
@enter="handleSend"
/>
<span v-else class="no-key-hint">公开接口,无需 sk-key</span>
<t-button
theme="primary"
:loading="isFetching"
:disabled="!skKeyInput.trim()"
:disabled="sendButtonDisabled"
@click="handleSend"
>
发送请求
@@ -156,7 +163,7 @@ function downloadResponseAsFile(): void {
<JsonTextView :json="responseData.json" :raw-text="responseData.text" />
</section>
<section v-else-if="!hasSent" class="panel-section response-placeholder">
填写 sk-key 后点击「发送请求」
{{ requiresKey ? '填写 sk-key 后点击「发送请求」' : '点击「发送请求」查看站点配置' }}
</section>
</div>
</template>
@@ -186,6 +193,12 @@ function downloadResponseAsFile(): void {
max-width: 480px;
}
.no-key-hint {
align-self: center;
font-size: 12.5px;
color: var(--td-text-color-placeholder);
}
.response-section {
flex: 1;
min-height: 220px; /* 响应区保底高度:窄窗口下不被上部固定分区压缩到不可用 */
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
/**
* 左侧接口导航:5 个路径平铺(不分组不编号,按 endpoints.ts 定案顺序)
* 左侧接口导航:路径平铺(不分组不编号,按 endpoints.ts 定案顺序)
* 点击 → openOrActivateTab(已开则激活,未开则新开)
*/