5b89de656e
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 25s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 7s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 26s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 11s
ci-pangolin / Go — build + test (pull_request) Successful in 15s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 26s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Successful in 10s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Successful in 4m36s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Successful in 20s
ci-pangolin / Lint — shellcheck (pull_request) Failing after 10m3s
Deploy Site / deploy-site (push) Failing after 13m20s
ci-pangolin / Flutter — analyze + test (pull_request) Failing after 14m42s
ci-pangolin / OpenAPI Sync Check (pull_request) Failing after 14m52s
用户中心: - 统一浅色:theme.tsx 无偏好时不再跟随系统 prefers-color-scheme:dark(登录页变 黑真因),恒浅色;保留手动切换。 - 登录回跳:UserCenter 加 safeRedirect 白名单,登录成功按 ?redirect= 回来源页 (官网带 /,登录后回主页),无则进 overview。 - logo locale:新增 i18n brandName(zh 穿山甲/其余 Pangolin),Login/UserCenter /Subscription 三处引用。 - 存 pg_uc_email(getMe 时)/ clearSession 删,供官网读用户名。 官网(全走 CSS class 合 CSP,无内联 style): - logo locale:i18n nav.brand(zh 穿山甲/其余 Pangolin),Header+Footer。 - 登录态头部显示用户名(读同源 pg_uc_email)+ 下拉菜单(进入用户中心/切换用户/ 退出登录,复用 .langmenu 风格);未登录 Log in 带 ?redirect=/ 回跳。 - Docs 四卡片补真内容页(en+zh:quickstart/faq/protocol/privacy + Doc.astro 布局),卡片改回 <a href>;Protocol 改正 sing-box+REALITY(去 WireGuard)。 验证:同源闸绿 · 两端 build 过 · redline 0 · Header 零内联 style · 无 WireGuard。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
175 lines
5.5 KiB
TypeScript
175 lines
5.5 KiB
TypeScript
// mock.ts — MockClient:演示数据,供阶段 A 的 UI 验收使用,零网络依赖。
|
|
import {
|
|
ApiClient,
|
|
ApiError,
|
|
Device,
|
|
LoginResult,
|
|
Me,
|
|
RedeemResult,
|
|
Session,
|
|
SubscriptionInfo,
|
|
TotpSetup,
|
|
} from './types';
|
|
import { setSession, clearSession, setEmail } from './session';
|
|
|
|
const delay = (ms = 420) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
function makeSession(): Session {
|
|
return {
|
|
accessToken: 'mock-access-' + Math.random().toString(36).slice(2),
|
|
refreshToken: 'mock-refresh-' + Math.random().toString(36).slice(2),
|
|
accessExpiresAt: Date.now() + 10 * 60_000,
|
|
};
|
|
}
|
|
|
|
export class MockClient implements ApiClient {
|
|
// 演示态:PRO 会员,已开启 2FA(故登录走二段式)
|
|
private totpEnabled = true;
|
|
private pending: string | null = null;
|
|
private subUrl = 'https://sub.pangolin.vpn/s/8f3kx92m';
|
|
private devices: Device[] = [
|
|
{ id: 'd1', name: 'iPhone 15 Pro', platform: 'iOS 18', lastActive: '刚刚', current: true },
|
|
{ id: 'd2', name: 'MacBook Air', platform: 'macOS 15', lastActive: '2 小时前', current: false },
|
|
];
|
|
|
|
async login(email: string, password: string): Promise<LoginResult> {
|
|
await delay();
|
|
// 演示锁定/失败:用特定凭证触发,便于验收双语文案
|
|
if (password === 'locked') {
|
|
throw new ApiError(
|
|
{ code: 'account_locked', message_zh: '失败次数过多,账户已临时锁定', message_en: 'Too many attempts — account temporarily locked' },
|
|
30,
|
|
);
|
|
}
|
|
if (password === 'wrong' || !/\S+@\S+\.\S+/.test(email)) {
|
|
throw new ApiError({ code: 'invalid_credentials', message_zh: '邮箱或密码错误', message_en: 'Wrong email or password' });
|
|
}
|
|
if (this.totpEnabled) {
|
|
this.pending = 'mock-pending-' + Math.random().toString(36).slice(2);
|
|
return { kind: 'totp_required', pendingToken: this.pending };
|
|
}
|
|
const s = makeSession();
|
|
setSession(s);
|
|
return { kind: 'session', session: s };
|
|
}
|
|
|
|
async loginTotp(_pendingToken: string, code: string): Promise<Session> {
|
|
await delay();
|
|
if (code === '000000') {
|
|
throw new ApiError({ code: 'totp_invalid', message_zh: '动态码不正确,请重试', message_en: 'Invalid code, try again' });
|
|
}
|
|
const s = makeSession();
|
|
setSession(s);
|
|
this.pending = null;
|
|
return s;
|
|
}
|
|
|
|
// 演示态:任意非 'invalid' 票据都兑换成功;'invalid' 用于验收失败态文案。
|
|
async exchangeWebTicket(ticket: string): Promise<Session> {
|
|
await delay(300);
|
|
if (ticket === 'invalid') {
|
|
throw new ApiError({
|
|
code: 'auth.ticket_invalid',
|
|
message_zh: '登录票据无效或已过期,请重新从 App 打开',
|
|
message_en: 'Login ticket is invalid or expired, please reopen from the app',
|
|
});
|
|
}
|
|
const s = makeSession();
|
|
setSession(s);
|
|
return s;
|
|
}
|
|
|
|
async refresh(): Promise<Session> {
|
|
await delay(150);
|
|
const s = makeSession();
|
|
setSession(s);
|
|
return s;
|
|
}
|
|
|
|
async getMe(): Promise<Me> {
|
|
await delay(260);
|
|
setEmail('me@pangolin.vpn'); // 同源官网读取显示用户名;clearSession/logout 时删除
|
|
return {
|
|
email: 'me@pangolin.vpn',
|
|
plan: 'pro',
|
|
expiresAt: '2026-12-31',
|
|
devicesUsed: 2,
|
|
devicesMax: 5,
|
|
quotaTodayMin: null,
|
|
dataTodayGB: 0.8,
|
|
weeklyGB: [1.8, 2.4, 1.2, 3.1, 4.0, 5.2, 2.6],
|
|
totpEnabled: this.totpEnabled,
|
|
};
|
|
}
|
|
|
|
async getSubscription(): Promise<SubscriptionInfo> {
|
|
await delay(160);
|
|
return { url: this.subUrl };
|
|
}
|
|
|
|
async resetSubscription(): Promise<SubscriptionInfo> {
|
|
await delay(500);
|
|
this.subUrl = 'https://sub.pangolin.vpn/s/' + Math.random().toString(36).slice(2, 10);
|
|
return { url: this.subUrl };
|
|
}
|
|
|
|
async listDevices(): Promise<Device[]> {
|
|
await delay(220);
|
|
return [...this.devices];
|
|
}
|
|
|
|
async removeDevice(id: string): Promise<void> {
|
|
await delay(360);
|
|
this.devices = this.devices.filter((d) => d.id !== id);
|
|
}
|
|
|
|
async redeem(code: string): Promise<RedeemResult> {
|
|
await delay(520);
|
|
const c = code.trim().toUpperCase();
|
|
if (c === 'LOCKED') {
|
|
throw new ApiError(
|
|
{ code: 'rate_limited', message_zh: '操作过于频繁,请稍后再试', message_en: 'Too many requests, slow down' },
|
|
20,
|
|
);
|
|
}
|
|
if (c === 'USED') {
|
|
throw new ApiError({ code: 'code_used', message_zh: '激活码已被使用', message_en: 'Activation code already used' });
|
|
}
|
|
if (c.length < 4 || c === 'INVALID') {
|
|
throw new ApiError({ code: 'code_invalid', message_zh: '激活码无效', message_en: 'Invalid activation code' });
|
|
}
|
|
return { plan: 'pro', expiresAt: '2027-12-31' };
|
|
}
|
|
|
|
async totpSetup(): Promise<TotpSetup> {
|
|
await delay(260);
|
|
const secret = 'JBSWY3DPEHPK3PXP';
|
|
return {
|
|
secret,
|
|
otpauthUri: `otpauth://totp/Pangolin:me@pangolin.vpn?secret=${secret}&issuer=Pangolin`,
|
|
};
|
|
}
|
|
|
|
async totpVerify(code: string): Promise<void> {
|
|
await delay(300);
|
|
if (code === '000000' || code.length !== 6) {
|
|
throw new ApiError({ code: 'totp_invalid', message_zh: '动态码不正确,请重试', message_en: 'Invalid code, try again' });
|
|
}
|
|
this.totpEnabled = true;
|
|
}
|
|
|
|
async totpDisable(code: string): Promise<void> {
|
|
await delay(300);
|
|
if (code === '000000' || code.length !== 6) {
|
|
throw new ApiError({ code: 'totp_invalid', message_zh: '动态码不正确,请重试', message_en: 'Invalid code, try again' });
|
|
}
|
|
this.totpEnabled = false;
|
|
}
|
|
|
|
async logout(): Promise<void> {
|
|
await delay(120);
|
|
this.pending = null;
|
|
clearSession();
|
|
}
|
|
}
|