feat: 数据获取架构重构 + Mac指纹采集 + Token刷新容错 + 账单页面 (0.0.19)

## 架构重构
  - useAsyncData 集成 AbortController:cleanup 时真正中断 HTTP 请求,
    不再仅丢弃响应(解决 StrictMode 双重请求问题)
  - API 模块统一 signal?: AbortSignal 参数,透传 axios config
  - use-api.ts 成为数据 Hook 集中管理中心,新增 useAccountInfo、
    useBillingBalance、useBillingLedger
  - 页面组件改为纯渲染:const {data,loading,error,refetch} = useXxx()

  ## 新功能
  - Mac 硬件指纹采集(electron/preload/device.ts):
    ioreg(UUID) + sysctl(CPU) + system_profiler(HDD三级回退)
  - 账单页面 BillingInfo(余额卡片 + 账单表格 + 分页)
  - PageDispatcher 注册 /billing 页面(FloatingPanel 960×680)
  - 共享工具函数 centToYuan / formatDate(src/utils/display.ts)

  ## Bug 修复
  - Token 刷新容错:网络错误时保留 refresh_token,等网络恢复后重试;
    仅 RefreshError(token 过期)时清除(auth-token.ts)
  - AppProvider 自动登录时 nullable 字段默认值(email→''、admin_permissions→[])
  - AccountInfo 重构消除 ~40 行手写状态管理代码

  ## 代码质量
  - AccountInfo 消除 centToYuan/formatDate 重复定义
  - BillingInfo 标题层级精简(移除冗余页面/Card标题)
  - antd Table align 字面量类型修正('left' as const)
  - billing.ts signal 参数位置修复(params→config)
  - modelFirstFetchDone 模块级变量移除(StrictMode 不安全)
This commit is contained in:
2026-06-11 20:08:44 +08:00
parent 95019316b2
commit 4237dcbaf6
24 changed files with 808 additions and 172 deletions

View File

@@ -147,15 +147,15 @@ export function AppProvider({ children }: AppProviderProps) {
user_id: info.id,
username: info.username,
role: info.role,
email: info.email,
email: info.email ?? '',
balance_cent: info.balance_cent,
account_type: info.account_type,
display_name: info.display_name,
company_id: info.company_id,
real_name: info.real_name,
display_name: info.display_name ?? '',
company_id: info.company_id ?? '',
real_name: info.real_name ?? '',
phone: info.phone,
license_code: '',
admin_permissions: info.admin_permissions,
admin_permissions: info.admin_permissions ?? [],
};
setUser(u);
emit(EVENTS.LOGIN_SUCCESS, u);

View File

@@ -20,8 +20,10 @@ import {type ComponentType, useCallback, useEffect, useMemo, useRef, useState} f
import {Drawer, Modal} from 'antd';
import {EVENTS, off, on} from '@/utils/event-bus';
import {FloatingPanel} from '@/components/ui/FloatingPanel';
import {WechatWorkPage} from '@/pages/headers/WechatWorkPage';
import {AccountInfo} from '@/pages/headers/AccountInfo.tsx';
import {BillingInfo} from "@/pages/headers/BillingInfo.tsx";
// ---------- 类型 ----------
@@ -80,8 +82,13 @@ const PAGE_MAP: Record<string, PageConfig> = {
},
'/vip': {label: '开会员/激活'},
'/recharge': {label: '充值积分'},
'/billing': {label: '消费记录'},
'/orders': {label: '订单明细'},
'/billing': {label: '账单记录',
component: BillingInfo,
width: 960,
height: 680,},
'/orders': {
label: '订单明细',
},
'/projects': {label: '项目管理'},
'/quota': {label: '查配额'},
};

View File

@@ -17,6 +17,9 @@ import {useAppContext} from '@/components/AppProvider';
import { TaskAPI, type TaskListRequestParams, type TaskInfoItemResponseBody, type CreatTaskRequestBody } from '@/services/modules/task';
import { ModelAPI, MODEL_CATEGORY_LABELS, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/models';
import { fetchBanners, type Banner } from '@/services/modules/banner';
import { AuthAPI, type UserInfoBody } from '@/services/modules/auth';
import { VipAPI, type VipLevelsItem } from '@/services/modules/vip-api';
import { BillingAPI, type BalanceResponseBody, type LedgerItemResponseBody, type LedgerReqeustParam } from '@/services/modules/billing';
import {useAsyncData, UseAsyncDataReturn, useAsyncMutation} from './use-async';
import {getCachedModels, setCachedModels} from '@/services/cache/model-cache';
@@ -29,7 +32,7 @@ export function useBannerList() {
const {isLoggedIn} = useAppContext();
return useAsyncData<Banner[]>(
fetchBanners,
(signal) => fetchBanners(signal),
[],
{enabled: isLoggedIn, label: 'banner-list'},
);
@@ -39,9 +42,6 @@ export function useBannerList() {
// 模型
// ============================================================
/** 模块级:单次会话中是否已完成首次 API 拉取 */
let modelFirstFetchDone = false;
/** 模型列表数据 Hook缓存优先 + 后台刷新)
*
* 策略:
@@ -61,11 +61,10 @@ export function useModelList() {
);
const result: UseAsyncDataReturn<ModelsListResponseBody[]> = useAsyncData<ModelsListResponseBody[]>(
async () => {
const models = await ModelAPI.fetchModels({page: 1, page_size: 200});
async (signal) => {
const models = await ModelAPI.fetchModels({page: 1, page_size: 200}, signal);
// 异步写入加密缓存best-effort不影响数据流
setCachedModels(models).catch(() => {});
modelFirstFetchDone = true;
setInstantData(models);
return models;
},
@@ -123,7 +122,7 @@ export function useTaskList(params: UseTaskListParams) {
const {requestParams, refreshSignal = 0} = params;
return useAsyncData(
() => TaskAPI.getTaskList(requestParams),
(signal) => TaskAPI.getTaskList(requestParams, signal),
[requestParams, refreshSignal],
{enabled: isLoggedIn, label: 'task-list'},
);
@@ -138,12 +137,53 @@ export function useTaskDetail(taskId: string | null | undefined) {
const {isLoggedIn} = useAppContext();
return useAsyncData<TaskInfoItemResponseBody>(
() => TaskAPI.getTaskInfo(taskId!),
(signal) => TaskAPI.getTaskInfo(taskId!, signal),
[taskId],
{enabled: isLoggedIn && !!taskId, label: 'task-detail'},
);
}
// ============================================================
// 账户信息(用户信息 + VIP 等级,并行请求)
// ============================================================
/** 账户信息页数据 Hook — 返回 [userInfo, vipsInfo] 元组 */
export function useAccountInfo() {
const { isLoggedIn } = useAppContext();
return useAsyncData<[UserInfoBody, VipLevelsItem[]]>(
(signal) => Promise.all([
AuthAPI.getUserInfo(signal),
VipAPI.getVipLevels(signal),
]),
[],
{ enabled: isLoggedIn, label: 'account-info' },
);
}
// ============================================================
// 账单
// ============================================================
/** 账户余额 Hook */
export function useBillingBalance() {
const { isLoggedIn } = useAppContext();
return useAsyncData<BalanceResponseBody>(
(signal) => BillingAPI.getBalance(signal),
[],
{ enabled: isLoggedIn, label: 'billing-balance' },
);
}
/** 账单明细 Hook支持分页参数 */
export function useBillingLedger(params: LedgerReqeustParam) {
const { isLoggedIn } = useAppContext();
return useAsyncData<LedgerItemResponseBody[]>(
(signal) => BillingAPI.getLedger(params, signal),
[params],
{ enabled: isLoggedIn, label: 'billing-ledger' },
);
}
// ============================================================
// 提交任务
// ============================================================

View File

@@ -82,7 +82,7 @@ export interface UseAsyncDataReturn<T> {
* - `enabled: false` 时不发起请求(适用于需登录后请求的场景)
*/
export function useAsyncData<T>(
fetcher: () => Promise<T>,
fetcher: (signal: AbortSignal) => Promise<T>,
deps: unknown[],
options: UseAsyncDataOptions = {},
): UseAsyncDataReturn<T> {
@@ -117,16 +117,19 @@ export function useAsyncData<T>(
const raceId = ++raceRef.current;
let cancelled = false;
const abortController = new AbortController();
const run = async () => {
setLoading(true);
setError(null);
try {
const result = await fetcher();
const result = await fetcher(abortController.signal);
if (!cancelled && raceId === raceRef.current && mountedRef.current) {
setData(result);
}
} catch (err) {
// 请求被 AbortController 显式取消 → 静默忽略,不设 error 状态
if (abortController.signal.aborted) return;
if (!cancelled && raceId === raceRef.current && mountedRef.current) {
const normalized = err instanceof Error ? err : new Error(String(err));
setError(normalized);
@@ -150,6 +153,7 @@ export function useAsyncData<T>(
return () => {
cancelled = true;
abortController.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, refreshTick, ...deps]);

View File

@@ -1,4 +1,3 @@
import {useEffect, useState, useCallback} from 'react';
import {
Card,
Descriptions,
@@ -18,48 +17,16 @@ import {
BarChartOutlined,
RightOutlined,
} from '@ant-design/icons';
import {AuthAPI, type UserInfoBody, UserAccountTypeLabelMap} from '@/services/modules/auth';
import {VipAPI, VipLevelsItem} from "@/services/modules/vip-api.ts";
import { UserAccountTypeLabelMap } from '@/services/modules/auth';
import type { VipLevelsItem } from '@/services/modules/vip-api';
import { useAccountInfo } from '@/hooks/use-api';
import { centToYuan, formatDate } from '@/utils/display';
const {Text, Title} = Typography;
/** 将分转为元,保留两位小数 */
function centToYuan(cent: number): string {
return (cent / 100).toFixed(2);
}
/** 格式化日期 */
function formatDate(date: Date | string | undefined): string {
if (!date) return '—';
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleDateString('zh-CN', {year: 'numeric', month: '2-digit', day: '2-digit'});
}
export function AccountInfo() {
const {token} = antTheme.useToken();
const [userInfo, setUserInfo] = useState<UserInfoBody | null>(null);
const [vipsInfo, setVipsInfo] = useState<VipLevelsItem[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const info: UserInfoBody = await AuthAPI.getUserInfo();
const vips: VipLevelsItem[] = await VipAPI.getVipLevels();
setVipsInfo(vips);
setUserInfo(info);
} catch (err) {
setError((err as Error).message || '加载账户信息失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const { data, loading, error, errorMessage, refetch } = useAccountInfo();
// ======== 加载态 ========
if (loading) {
@@ -71,14 +38,14 @@ export function AccountInfo() {
}
// ======== 错误态 ========
if (error || !userInfo) {
if (error || !data) {
return (
<Result
status="error"
title="加载失败"
subTitle={error || '未能获取账户信息'}
subTitle={errorMessage || '未能获取账户信息'}
extra={
<Button type="primary" onClick={load}>
<Button type="primary" onClick={refetch}>
</Button>
}
@@ -87,6 +54,7 @@ export function AccountInfo() {
}
// ======== 数据展示 ========
const [userInfo, vipsInfo] = data;
const totalBalance: number = userInfo.balance_cent + userInfo.gift_balance_cent;
// 匹配当前 VIP 等级vip_level_id → VipLevelsItem

View File

@@ -0,0 +1,281 @@
import {useState, useMemo} from 'react';
import {
Card,
Table,
Button,
Typography,
Skeleton,
Result,
Space,
Tag,
theme as antTheme,
} from 'antd';
import {
WalletOutlined,
RiseOutlined,
ApiOutlined,
} from '@ant-design/icons';
import {useBillingBalance, useBillingLedger} from '@/hooks/use-api';
import type {LedgerItemResponseBody, LedgerReqeustParam} from '@/services/modules/billing';
import {centToYuan, formatDate} from '@/utils/display';
const {Text, Title} = Typography;
// ============================================================
// 余额卡片
// ============================================================
function BalanceCard() {
const {token} = antTheme.useToken();
const {data, loading, error, errorMessage, refetch} = useBillingBalance();
// ======== 加载态 ========
if (loading) {
return (
<Card size="small" className="rounded-md!">
<Skeleton active paragraph={{rows: 3}} />
</Card>
);
}
// ======== 错误态 ========
if (error || !data) {
return (
<Card size="small" className="rounded-md!">
<Result
status="error"
title="余额加载失败"
subTitle={errorMessage}
extra={
<Button type="primary" size="small" onClick={refetch}>
</Button>
}
/>
</Card>
);
}
// ======== 正常展示 ========
const totalBalance = (data.balance_cent ?? 0) + (data.gift_balance_cent ?? 0);
return (
<Card
size="small"
styles={{body: {padding: '16px 20px'}}}
className="rounded-md!"
>
<Text type="secondary" style={{fontSize: 12, marginBottom: 8, display: 'block'}}>
<WalletOutlined style={{marginRight: 6}} />
</Text>
<Title level={3} style={{margin: '0 0 12px 0'}}>
{centToYuan(totalBalance)}
<Text type="secondary" style={{fontSize: 14, marginLeft: 4}}>
{data.currency}
</Text>
</Title>
<Space size={32}>
<div>
<Text type="secondary" style={{fontSize: 12}}></Text>
<br />
<Text strong>{centToYuan(data.balance_cent ?? 0)}</Text>
</div>
<div>
<Text type="secondary" style={{fontSize: 12}}></Text>
<br />
<Text strong>{centToYuan(data.gift_balance_cent ?? 0)}</Text>
</div>
<div>
<Space size={4}>
<RiseOutlined style={{color: token.colorWarning}} />
<Text type="secondary" style={{fontSize: 12}}></Text>
</Space>
<br />
<Text strong>{centToYuan(data.today_spent_cent)}</Text>
</div>
<div>
<Space size={4}>
<ApiOutlined style={{color: token.colorPrimary}} />
<Text type="secondary" style={{fontSize: 12}}></Text>
</Space>
<br />
<Text strong>{data.today_calls} </Text>
</div>
</Space>
</Card>
);
}
// ============================================================
// 账单表格
// ============================================================
/** entry_type → 中文标签映射(按需扩展) */
const ENTRY_TYPE_LABELS: Record<string, string> = {
task_cost: '任务消耗',
recharge: '充值',
gift: '赠送',
refund: '退款',
vip_purchase: '套餐购买',
};
function resolveEntryLabel(type: string): string {
return ENTRY_TYPE_LABELS[type] || type;
}
/** entry_type → Tag 颜色映射 */
function entryTypeColor(type: string): string {
if (type === 'task_cost' || type === 'vip_purchase') return 'orange';
if (type === 'recharge' || type === 'gift') return 'green';
if (type === 'refund') return 'blue';
return 'default';
}
function LedgerTable() {
const {token} = antTheme.useToken();
const [pagination, setPagination] = useState({page: 1, page_size: 10});
const params: LedgerReqeustParam = useMemo(() => ({
month: null,
page: pagination.page,
page_size: pagination.page_size,
}), [pagination]);
const {data, loading, error, errorMessage, refetch} = useBillingLedger(params);
const columns = [
{
title: '时间',
dataIndex: 'created_at',
key: 'created_at',
width: 120,
align: 'center' as const,
render: (_: unknown, record: LedgerItemResponseBody) => (
<Text style={{fontSize: 12, whiteSpace: 'nowrap'}}>
{formatDate(record.created_at)}
</Text>
),
},
{
title: '类型',
dataIndex: 'entry_type',
key: 'entry_type',
width: 100,
align: 'center' as const,
render: (type: string) => (
<Tag color={entryTypeColor(type)} style={{fontSize: 11, lineHeight: '18px'}}>
{resolveEntryLabel(type)}
</Tag>
),
},
{
title: '金额',
dataIndex: 'amount_cent',
key: 'amount_cent',
width: 100,
align: 'center' as const,
render: (amount: number): React.ReactNode => {
const isNegative = amount < 0;
return (
<Text
strong
style={{
fontSize: 13,
color: isNegative ? token.colorError : token.colorSuccess,
}}
>
{isNegative ? '-' : '+'}
{centToYuan(Math.abs(amount))}
</Text>
);
},
},
{
title: '余额',
dataIndex: 'balance_after_cent',
key: 'balance_after_cent',
width: 100,
align: 'center' as const,
render: (val: number) => (
<Text style={{fontSize: 12}}>{centToYuan(val)}</Text>
),
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
ellipsis: true,
align: 'left' as const,
render: (desc: string) => (
<Text style={{fontSize: 12}}>{desc || '—'}</Text>
),
},
];
// ======== 错误态 ========
if (error) {
return (
<Card size="small" className="rounded-md!">
<Result
status="error"
title="账单加载失败"
subTitle={errorMessage}
extra={
<Button type="primary" size="small" onClick={refetch}>
</Button>
}
/>
</Card>
);
}
// ======== 正常 / 加载态 ========
return (
<Card
size="small"
styles={{body: {padding: 0}}}
className="rounded-md!"
>
<Table<LedgerItemResponseBody>
columns={columns}
dataSource={data ?? []}
rowKey="id"
loading={loading}
size="small"
pagination={{
current: pagination.page,
pageSize: pagination.page_size,
total: (data ?? []).length >= pagination.page_size
? pagination.page * pagination.page_size + 1
: pagination.page * pagination.page_size,
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50'],
showTotal: (total: number, range: [number, number]) =>
`${range[0]}-${range[1]},共 ${total}+ 条`,
onChange: (page: number, pageSize: number) => {
setPagination({page, page_size: pageSize});
},
position: ['bottomCenter'],
}}
locale={{emptyText: '暂无消费记录'}}
/>
</Card>
);
}
// ============================================================
// 页面入口
// ============================================================
export function BillingInfo() {
return (
<div style={{padding: '8px 0', width: '100%'}}>
<div className="flex flex-col gap-4" style={{width: '100%'}}>
<BalanceCard />
<LedgerTable />
</div>
</div>
);
}

View File

@@ -1,9 +0,0 @@
import {Component} from "react";
export class Test1 extends Component {
render() {
return (
<></>
);
}
}

View File

@@ -714,7 +714,7 @@ export function TaskHistory({ allModels }: TaskHistoryProps) {
</div>
{/* 表格 — 横向 + 纵向滚动,筛选/排序通过列头操作 */}
<div ref={tableWrapperRef} style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
<div ref={tableWrapperRef} style={{ flex: 1, overflow: 'hidden', minHeight: Math.min(window.outerHeight, window.outerHeight, 630) }}>
<style>{tableCss}</style>
<Table<TaskInfoItemResponseBody>
className="task-table"

View File

@@ -11,8 +11,9 @@
// - 内存缓存供同步读取setTokenRefreshHandler 回调内使用)
// ============================================================
import { setToken, setTokenRefreshHandler } from './request';
import { setToken, setTokenRefreshHandler, RequestError, RequestErrorType } from './request';
import { AuthAPI, type RefreshTokenResponseBody } from './modules/auth';
import { isRefreshError } from './types';
import {
getSafeStore,
setSafeStore,
@@ -71,9 +72,24 @@ export function initAuthRefresh(): void {
await setStoredRefreshToken(res.refresh_token);
return res.access_token;
} catch {
// 刷新失败 → 清除残留
clearStoredRefreshToken();
} catch (err) {
// 区分错误类型:
// - RefreshError → refresh_token 已过期/无效 → 清除
// - 网络错误NetworkError/Timeout→ 保留 refresh_token等网络恢复后重试
// - 其他认证错误 → 清除
if (isRefreshError(err)) {
clearStoredRefreshToken();
} else if (err instanceof RequestError) {
if (err.type === RequestErrorType.NETWORK || err.type === RequestErrorType.TIMEOUT) {
// 网络暂时不可用,保留 refresh_token等下次 401 时再试
return null;
}
// HTTP/Business 错误(如 5xx→ 也保留,可能是服务端临时故障
clearStoredRefreshToken();
} else {
// 未知错误 → 保守清除
clearStoredRefreshToken();
}
return null;
}
});

View File

@@ -32,7 +32,7 @@ export const UserRoleTypeLabelMap: Record<UserRoleTypeValue, string> = {
[UserRoleTypeEnum.User]: "用户账户",
[UserRoleTypeEnum.Admin]: "管理账户",
[UserRoleTypeEnum.SuperAdmin]: "超管账户"
}
};
export const UserAccountTypeEnum = {
Individual: 'individual',
@@ -197,8 +197,8 @@ export class AuthAPI {
}
/** 获取当前用户信息 */
static async getUserInfo(): Promise<UserInfoBody> {
return await get<UserInfoBody>(AuthUrlObj.userInfo);
static async getUserInfo(signal?: AbortSignal): Promise<UserInfoBody> {
return await get<UserInfoBody>(AuthUrlObj.userInfo, undefined, { signal });
}
/** 修改密码(已登录) */

View File

@@ -38,6 +38,6 @@ export type BannerListResponse = Banner[];
* 获取 Banner 轮播图列表
* GET /api/v1/banners
*/
export function fetchBanners(): Promise<BannerListResponse> {
return get<BannerListResponse>(BannerUrlObj.list);
export function fetchBanners(signal?: AbortSignal): Promise<BannerListResponse> {
return get<BannerListResponse>(BannerUrlObj.list, undefined, { signal });
}

View File

@@ -0,0 +1,57 @@
import {type Nullable} from "@/utils/type";
import {get} from "@/services/request.ts";
const BillingUrlObj = {
getBalance: '/api/v1/billing/balance',
getLedger: '/api/v1/billing/ledger',
ExportLedger: "/api/v1/billing/ledger/export"
} as const;
export interface BalanceResponseBody {
user_id: string,
balance_cent: Nullable<number>,
gift_balance_cent: Nullable<number>,
currency: "CNY",
today_spent_cent: number,
today_calls: number
}
export interface LedgerReqeustParam {
month: Nullable<Date>,
page: number,
page_size: number
}
export interface LedgerItemResponseBody {
id: string,
entry_type: string,
balance_cent: string,
amount_cent: number,
currency: string,
balance_after_cent: number,
task_id: string,
description: "string",
accounting_month: "string",
accounting_day: "string",
created_at: Date
}
export interface ExportLedgerRequstParam {
start_time: Date,
end_time: Date,
entry_type: Nullable<string>
debug?: boolean
}
export class BillingAPI {
static getBalance(signal?: AbortSignal): Promise<BalanceResponseBody> {
return get<BalanceResponseBody>(BillingUrlObj.getBalance, undefined, {signal});
}
static getLedger(param: LedgerReqeustParam, signal?: AbortSignal): Promise<LedgerItemResponseBody[]> {
return get<LedgerItemResponseBody[]>(BillingUrlObj.getLedger, param as unknown as Record<string, unknown>, {signal});
}
static exportUrl = BillingUrlObj.ExportLedger;
}

View File

@@ -144,8 +144,8 @@ export class ModelAPI {
* 获取可用模型列表
* GET /api/v1/models
*/
static fetchModels(params: ModelsListReqeustParams): Promise<ModelsListResponseBody[]> {
return get<ModelsListResponseBody[]>(ModelsUrlObj.modelList, params as unknown as Record<string, unknown>);
static fetchModels(params: ModelsListReqeustParams, signal?: AbortSignal): Promise<ModelsListResponseBody[]> {
return get<ModelsListResponseBody[]>(ModelsUrlObj.modelList, params as unknown as Record<string, unknown>, { signal });
}
/**

View File

@@ -11,7 +11,7 @@
// ============================================================
import {get, post} from '../request';
import {getDeviceId} from '../../utils/device';
import {getDeviceId} from '@/utils/device.ts';
// ============================================================
// 基础枚举 / 字面量类型
@@ -207,8 +207,8 @@ const TaskUrlObj = {
export class TaskAPI {
/** 获取任务列表(分页/游标) */
static getTaskList(params: TaskListRequestParams): Promise<TaskInfoDataResponseBody> {
return get<TaskInfoDataResponseBody>(TaskUrlObj.task, params as Record<string, unknown>);
static getTaskList(params: TaskListRequestParams, signal?: AbortSignal): Promise<TaskInfoDataResponseBody> {
return get<TaskInfoDataResponseBody>(TaskUrlObj.task, params as Record<string, unknown>, { signal });
}
/** 提交新任务device_id 由 API 层自动注入 getDeviceId(),调用方无需传入) */
@@ -220,8 +220,8 @@ export class TaskAPI {
}
/** 获取任务详情 */
static getTaskInfo(task_id: string): Promise<TaskInfoItemResponseBody> {
return get<TaskInfoItemResponseBody>(TaskUrlObj.taskInfo(task_id));
static getTaskInfo(task_id: string, signal?: AbortSignal): Promise<TaskInfoItemResponseBody> {
return get<TaskInfoItemResponseBody>(TaskUrlObj.taskInfo(task_id), undefined, { signal });
}
/** 轻量轮询任务状态 */

View File

@@ -1,7 +1,7 @@
import {post, get} from '../request';
import {type UserInfoBody} from "@/services/modules/auth";
import {type Nullable} from "@/utils/type";
type Nullable<T> = T | null;
// ============================================================
// VipApiUrl — VIP 模块路由常量
@@ -133,8 +133,8 @@ export class VipAPI {
}
/** 获取 VIP 等级列表(已登录时按账户类型过滤) */
static async getVipLevels(): Promise<VipLevelsItem[]> {
return await get<VipLevelsItem[]>(VipApiUrl.GetVipLevelsList);
static async getVipLevels(signal?: AbortSignal): Promise<VipLevelsItem[]> {
return await get<VipLevelsItem[]>(VipApiUrl.GetVipLevelsList, undefined, {signal});
}
/** 创建 VIP 套餐购买订单 */

View File

@@ -4,32 +4,32 @@
/** 后端统一响应结构 */
export interface ApiResponse<T = unknown> {
/** 业务状态码0 表示成功 */
code: number;
/** 响应数据 */
data: T;
/** 提示信息 */
message: string;
/** 请求追踪 ID可选用于排查问题 */
traceId?: string;
/** 业务状态码0 表示成功 */
code: number;
/** 响应数据 */
data: T;
/** 提示信息 */
message: string;
/** 请求追踪 ID可选用于排查问题 */
traceId?: string;
}
/** 分页请求参数 */
export interface PaginationParams {
page: number;
pageSize: number;
page: number;
pageSize: number;
}
/** 分页响应数据 */
export interface PaginatedData<T> {
/** 数据列表 */
list: T[];
/** 总条数 */
total: number;
/** 当前页码 */
page: number;
/** 每页条数 */
pageSize: number;
/** 数据列表 */
list: T[];
/** 总条数 */
total: number;
/** 当前页码 */
page: number;
/** 每页条数 */
pageSize: number;
}
/** 分页响应包装 */
@@ -37,39 +37,39 @@ export type PaginatedResponse<T> = ApiResponse<PaginatedData<T>>;
/** 请求错误类型 */
export enum RequestErrorType {
/** 网络错误(断网等) */
NETWORK = 'NETWORK',
/** 请求超时 */
TIMEOUT = 'TIMEOUT',
/** HTTP 状态码异常4xx / 5xx */
HTTP = 'HTTP',
/** 业务错误code ≠ 0 */
BUSINESS = 'BUSINESS',
/** 请求被取消 */
CANCELLED = 'CANCELLED',
/** 认证过期refresh_token 失效,需重新登录) */
AUTH_EXPIRED = 'AUTH_EXPIRED',
/** 网络错误(断网等) */
NETWORK = 'NETWORK',
/** 请求超时 */
TIMEOUT = 'TIMEOUT',
/** HTTP 状态码异常4xx / 5xx */
HTTP = 'HTTP',
/** 业务错误code ≠ 0 */
BUSINESS = 'BUSINESS',
/** 请求被取消 */
CANCELLED = 'CANCELLED',
/** 认证过期refresh_token 失效,需重新登录) */
AUTH_EXPIRED = 'AUTH_EXPIRED',
}
/** 请求错误 */
export class RequestError extends Error {
type: RequestErrorType;
code?: number;
httpStatus?: number;
traceId?: string;
type: RequestErrorType;
code?: number;
httpStatus?: number;
traceId?: string;
constructor(
type: RequestErrorType,
message: string,
options?: { code?: number; httpStatus?: number; traceId?: string },
) {
super(message);
this.name = 'RequestError';
this.type = type;
this.code = options?.code;
this.httpStatus = options?.httpStatus;
this.traceId = options?.traceId;
}
constructor(
type: RequestErrorType,
message: string,
options?: { code?: number; httpStatus?: number; traceId?: string },
) {
super(message);
this.name = 'RequestError';
this.type = type;
this.code = options?.code;
this.httpStatus = options?.httpStatus;
this.traceId = options?.traceId;
}
}
// ---------- 自定义错误子类(全局统一捕获 → 事件总线触发)----------
@@ -81,13 +81,13 @@ export class RequestError extends Error {
* 用法throw new RefreshError('登录已过期,请重新登录');
*/
export class RefreshError extends RequestError {
constructor(message = '登录已过期,请重新登录') {
super(RequestErrorType.AUTH_EXPIRED, message);
this.name = 'RefreshError';
}
constructor(message = '登录已过期,请重新登录') {
super(RequestErrorType.AUTH_EXPIRED, message);
this.name = 'RefreshError';
}
}
/** 判断一个错误是否为 RefreshError支持 instanceof 或 type 判断) */
export function isRefreshError(err: unknown): err is RefreshError {
return err instanceof RefreshError || (err as RequestError)?.type === RequestErrorType.AUTH_EXPIRED;
return err instanceof RefreshError || (err as RequestError)?.type === RequestErrorType.AUTH_EXPIRED;
}

16
src/utils/display.ts Normal file
View File

@@ -0,0 +1,16 @@
// ============================================================
// display — 显示格式化工具(金额、日期等)
// 各页面共用的纯函数,避免重复定义
// ============================================================
/** 将分转为元,保留两位小数 */
export function centToYuan(cent: number): string {
return (cent / 100).toFixed(2);
}
/** 格式化日期为 zh-CN 本地字符串 */
export function formatDate(date: Date | string | undefined): string {
if (!date) return '—';
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' });
}

1
src/utils/type.ts Normal file
View File

@@ -0,0 +1 @@
export type Nullable<T> =T | null;