feat(studio): 个人中心 New-API 绑定摘要接入真后端(A 类:接已就绪后端)

A 类(后端就绪、前端 0 消费):02G GET /account/new-api-binding 后端就绪(curl code:0、
未绑定时 fail-closed 返 bindingStatus=unbound),但 studio 无任何展示(盘点 02G  缺口)。接入:
- useAccountNewApiBinding hook + openapi 导出 NewApiBindingSummary
- PersonalCenter「New-API 绑定」区:bindingStatus 中文徽标(已绑定/未绑定/同步失败/同步中)+ 上次同步时间
- mock handler + vitest 契约 + account-newapi-binding.spec.ts 真后端 e2e

验证:全量 e2e 45→46/0;vitest 80→81;tsc/eslint 绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-22 07:37:22 -07:00
parent bcb670ba4d
commit 7408615037
6 changed files with 112 additions and 1 deletions

View File

@ -0,0 +1,48 @@
import { expect, test, type Page } from '@playwright/test';
/**
* New-API e2e(,MSW off)A :后端 getAppNewApiBinding studio 0
*
* 反假绿:盘点 02G "New-API 绑定摘要"(curl code:0 fail-closed bindingStatus=unbound),
* GET /account/new-api-binding code:0 + New-API
* bindingStatus
* 设计:不硬编码绑定态(),, bindingStatus
*/
const TOKEN = 'test1';
async function seedToken(page: Page): Promise<void> {
await page.addInitScript((t) => {
window.localStorage.setItem('accessToken', t);
window.localStorage.setItem('tenantId', '1');
}, TOKEN);
}
const STATUS_LABELS: Record<string, string> = {
bound: '已绑定',
unbound: '未绑定',
sync_failed: '同步失败',
pending: '同步中',
};
test('个人中心渲染 New-API 绑定摘要(真实后端)', async ({ page }) => {
await seedToken(page);
const bindingResp = page.waitForResponse(
(r) =>
/\/account\/new-api-binding$/.test(new URL(r.url()).pathname) &&
r.request().method() === 'GET',
{ timeout: 15_000 }
);
await page.goto('/account');
const resp = await bindingResp;
expect(resp.status()).toBe(200);
const body = await resp.json();
expect(body.code).toBe(0); // 后端就绪(未绑定时 fail-closed unbound,非报错)。
// 「New-API 绑定」区渲染(标题恒在) + 状态徽标按真实 bindingStatus 映射(证真后端态被 UI 消费)。
await expect(page.getByRole('heading', { name: 'New-API 绑定' })).toBeVisible({ timeout: 10_000 });
const expectedLabel = STATUS_LABELS[body.data?.bindingStatus] ?? body.data?.bindingStatus;
if (expectedLabel) {
await expect(page.getByText(expectedLabel).first()).toBeVisible({ timeout: 10_000 });
}
});

View File

@ -127,4 +127,13 @@ export const accountHandlers = [
http.get('/app-api/muse/account/usage', () => {
return HttpResponse.json({ code: 0, msg: 'success', data: mockUsage });
}),
// GET /app-api/muse/account/new-api-binding — New-API 绑定摘要(dev mock 默认未绑定)
http.get('/app-api/muse/account/new-api-binding', () => {
return HttpResponse.json({
code: 0,
msg: 'success',
data: { bindingStatus: 'unbound', boundAt: null, lastSyncAt: null, canRecheck: false },
});
}),
];

View File

@ -2,6 +2,7 @@ import { useState } from 'react';
import {
useAccountEntitlements,
useAccountLicenses,
useAccountNewApiBinding,
useAccountProfile,
useAccountPublishRecords,
useAccountPurchases,
@ -19,6 +20,20 @@ function severityBadgeClass(severity: string): string {
return 'bg-slate-100 text-slate-500';
}
/** New-API 绑定状态 → 中文文案。 */
const newApiBindingLabels: Record<string, string> = {
bound: '已绑定',
unbound: '未绑定',
sync_failed: '同步失败',
pending: '同步中',
};
/** New-API 绑定状态 → 徽标配色(bound 绿 / sync_failed 红 / 其余中性)。 */
function newApiBindingBadgeClass(status: string): string {
if (status === 'bound') return 'bg-emerald-50 text-emerald-700';
if (status === 'sync_failed') return 'bg-rose-50 text-rose-700';
return 'bg-slate-100 text-slate-500';
}
/** 个人中心首页,聚合资料、用量、权益和账户状态。 */
export default function PersonalCenter() {
const { data: profile, isLoading: isProfileLoading } = useAccountProfile();
@ -28,6 +43,7 @@ export default function PersonalCenter() {
const { data: licenses } = useAccountLicenses();
const { data: publishRecords } = useAccountPublishRecords();
const { data: securityEvents } = useAccountSecurityEvents();
const { data: newApiBinding } = useAccountNewApiBinding();
const acknowledgeEvent = useAcknowledgeSecurityEvent();
const updateProfile = useUpdateAccountProfile();
const [nickname, setNickname] = useState('');
@ -240,6 +256,23 @@ export default function PersonalCenter() {
</ul>
)}
</section>
{/* New-API 绑定摘要:展示配额/计费外部账户绑定态(bound/unbound/sync_failed/pending)。后端就绪、此前 0 消费。 */}
<section className="mt-6 rounded-3xl border border-slate-100 bg-white p-6 shadow-sm">
<h2 className="text-base font-bold text-slate-800">New-API </h2>
{!newApiBinding ? (
<p className="mt-3 text-sm text-slate-400"></p>
) : (
<div className="mt-3 flex items-center gap-3">
<span className={`rounded-full px-3 py-1 text-xs font-bold ${newApiBindingBadgeClass(newApiBinding.bindingStatus)}`}>
{newApiBindingLabels[newApiBinding.bindingStatus] ?? newApiBinding.bindingStatus}
</span>
{newApiBinding.lastSyncAt && (
<span className="text-xs text-slate-400">{newApiBinding.lastSyncAt}</span>
)}
</div>
)}
</section>
</div>
</div>
);

View File

@ -7,6 +7,7 @@ import { resetMockDb } from '@/api/mocks/handlers/account';
import { server } from '@/test/setup';
import {
useAccountEntitlements,
useAccountNewApiBinding,
useAccountProfile,
useAccountPublishRecords,
useAccountUsage,
@ -33,6 +34,13 @@ describe('useAccount API Hooks 单元测试', () => {
testQueryClient = createTestQueryClient();
});
it('应能读取 New-API 绑定摘要(后端就绪、前端 0 消费缺口补齐)', async () => {
const { result } = renderHook(() => useAccountNewApiBinding(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
// mock 默认未绑定;契约:bindingStatus 解出枚举值之一。
expect(result.current.data?.bindingStatus).toBe('unbound');
});
it('应能读取账户资料并保存昵称', async () => {
const { result: profileHook } = renderHook(() => useAccountProfile(), { wrapper });
await waitFor(() => expect(profileHook.current.isSuccess).toBe(true));

View File

@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '@/api/client';
import type { AccountProfile, AppEntitlementDetail, AppUsageSummary, ProfileUpdateRequest } from '@/types/openapi';
import type { AccountProfile, AppEntitlementDetail, AppUsageSummary, NewApiBindingSummary, ProfileUpdateRequest } from '@/types/openapi';
export type UsagePeriod = 'today' | 'week' | 'month' | 'billing_cycle';
@ -47,6 +47,18 @@ export function useAccountUsage(period: UsagePeriod = 'month') {
});
}
/**
* New-API (/) getAppNewApiBinding ;
* fail-closed bindingStatus=unboundcanRecheck=false(New-API ) studio 0
*/
export function useAccountNewApiBinding() {
return useQuery<NewApiBindingSummary, Error>({
queryKey: ['accountNewApiBinding'],
queryFn: () => api.get<NewApiBindingSummary>('/account/new-api-binding'),
staleTime: 30_000,
});
}
/** 账户购买记录条目(对齐后端 AppPurchaseRecordRespVO 关键字段)。 */
export interface AccountPurchaseRecord {
recordId: string;

View File

@ -46,6 +46,7 @@ export type AccountProfile = AccountComponents['schemas']['AccountProfile'];
export type AppEntitlementDetail = AccountComponents['schemas']['AppEntitlementDetail'];
export type AppUsageSummary = AccountComponents['schemas']['AppUsageSummary'];
export type ProfileUpdateRequest = AccountComponents['schemas']['ProfileUpdateRequest'];
export type NewApiBindingSummary = AccountComponents['schemas']['NewApiBindingSummary'];
// Planning / Meta-ProjectionB5 planning 编辑器按 schema 字段填值)
export type PlanningStructure = ContentComponents['schemas']['PlanningStructure'];
export type PlanningSection = ContentComponents['schemas']['PlanningSection'];