新增 usage页面

This commit is contained in:
moux1024 2025-09-15 19:15:51 +08:00
parent a7e99a30d8
commit d4bb1e7713
4 changed files with 386 additions and 3 deletions

128
api/README.md Normal file
View File

@ -0,0 +1,128 @@
## Video-Flow API 使用指南(方案一:保持现状,统一规范与示例)
> 目标:在不改动现有代码结构与签名的前提下,提供清晰的一致性用法、错误处理约定与多场景接入示例。
### 目录结构与职责
- `constants.ts`:基础配置(`BASE_URL``NEXT_PUBLIC_BASE_URL` 注入)
- `request.ts`Axios 实例与拦截器、通用 `get/post/put/del``stream`SSE风格下载进度`downloadStream``streamJsonPost`
- `errorHandle.ts`:错误码映射与统一提示、特殊码处理(如 401 跳转登录、402 不弹提示)
- `common.ts`:通用类型与与上传相关的工具(获取七牛 Token、上传
- `resources.ts`:示例资源接口封装(基于 `post`
- 其他业务模块(如 `video_flow.ts``export-adapter.ts` 等)各自封装具体接口/逻辑
### 统一调用规范
1. 使用 `request.ts` 提供的 `get/post/put/del` 包装函数发起请求,返回后端响应体(已通过响应拦截器做业务码检查)。
2. 业务成功码:`code === 0``code === 202`(长任务/排队等需要前端自行处理状态)。若非成功码,拦截器会调用 `errorHandle``Promise.reject`
3. 认证:前端从 `localStorage.token` 注入 `Authorization: Bearer <token>`,请确保登录流程写入 `token`
4. 基础地址:通过环境变量 `NEXT_PUBLIC_BASE_URL` 注入,构建前需设置。
### 错误处理约定
- `errorHandle(code, message?)`统一弹出错误402 除外),并处理特殊码:
- `401`:清除 `localStorage.token` 并跳转 `/login`
- `402`:由 `request.ts` 响应拦截器在收到后端 `402` 且带 `detail` 时触发通知(积分不足),并拒绝请求
- 网络/解析类错误:`handleRequestError` 会降级为通用错误提示并抛出异常
### 常用请求示例
```ts
import { get, post, put, del } from '@/api/request';
type User = { id: number; name: string };
type ApiResponse<T> = { code: number; successful: boolean; message: string; data: T };
// GET
const user = await get<ApiResponse<User>>('/user/detail?id=1');
// POST
const created = await post<ApiResponse<User>>('/user/create', { name: 'Evan' });
// PUT
await put<ApiResponse<null>>('/user/update', { id: 1, name: 'Kent' });
// DELETE
await del<ApiResponse<null>>('/user/remove?id=1');
```
### 流式与下载SSE/文件)
1) SSE/渐进消息(基于 Axios 下载进度事件解析 `data: {}` 行)
```ts
import { stream } from '@/api/request';
await stream<{ type: string; message?: string; progress?: number }>({
url: '/api/video-flow/export/ai-clips',
method: 'POST',
data: { /* your body */ },
onMessage: (evt) => {
if (evt.type === 'progress') {
// 更新进度条
}
},
onError: (err) => { /* 兜底错误处理 */ },
onComplete: () => { /* 完成回调 */ }
});
```
2) 文本行分隔的 JSON 流(`fetch` + `ReadableStream`,每行一个 JSON
```ts
import { streamJsonPost } from '@/api/request';
await streamJsonPost('/sse/json', { /* body */ }, (json) => {
// 每次解析到一行 JSON
});
```
3) 文件下载Blob
```ts
import { downloadStream } from '@/api/request';
await downloadStream('/download/file', 'result.mp4');
```
### 多场景接入指南
#### 浏览器前端React/Next.js CSR
- 直接使用 `get/post/put/del`;确保登录后将 `token` 写入 `localStorage`
- 环境变量:在 `.env.local` 配置 `NEXT_PUBLIC_BASE_URL`
- 错误提示:由 `errorHandle` 统一处理402 会展示积分不足通知
#### Next.js Route Handler服务端 API
- 如需在 Route Handler 内调用后端接口:建议使用服务端 `fetch` 并自行附加服务端凭证;避免直接依赖 `localStorage`
- 若需要转发到现有后端并复用通知/事件流格式,可参考 `api/export-adapter.ts` 的 SSE 写法
#### Next.js Server Components/SSR
- 服务端不具备 `localStorage`,如需鉴权请改为从 Cookie/Headers 传递 token并在转发时设置 `Authorization`
- 服务器端可直接使用 `fetch(BASE_URL + path, { headers })`
#### Node/ServerlessVercel/Cloudflare
- 同 SSR 原则:不使用 `localStorage`;从密钥存储中读取后以请求头传递
- SSE 转发时,按 `text/event-stream` 写入 `ReadableStream` 并传递 `data: <json>\n\n`
### 最佳实践
- 统一通过 `request.ts` 发起请求,不绕过拦截器;对长任务/排队返回 `202` 时在页面层处理轮询或 SSE
- 上传流程:使用 `common.ts/getUploadToken` 获取 token 与 domain再使用 `uploadToQiniu` 上传,监听进度
- 将后端的业务错误码保持为服务端规范在前端只做展示与关键码分支401/402
### 常见问题FAQ
- Q: 为什么拿不到 `localStorage` 中的 token
- A: 服务端渲染或 Route Handler 中无 `localStorage`,需通过 Cookie/Headers 传递。
- Q: 返回 `code !== 0/202` 时如何自定义处理?
- A: 可在业务层 `try/catch` 捕获 `post/get` 的拒绝 Promise并根据 `error.message` 或后端 `data.message` 做分支。
- Q: SSE 收不到事件?
- A: 确认后端使用 `text/event-stream`,每条消息以 `data: <json>\n\n` 输出;前端在 `onDownloadProgress` 中按行解析。
---

16
app/usage/page.tsx Normal file
View File

@ -0,0 +1,16 @@
"use client";
import React from "react";
import UsageView from "@/components/pages/usage-view";
const UsagePage: React.FC = () => {
return (
<div data-alt="usage-page" className="min-h-screen px-4 py-6">
<UsageView />
</div>
);
};
export default UsagePage;

View File

@ -13,9 +13,10 @@ import {
LogOut,
PanelsLeftBottom,
Bell,
Info,
} from "lucide-react";
import { motion } from "framer-motion";
import ReactDOM from "react-dom";
import { createPortal } from "react-dom";
import { useRouter, usePathname } from "next/navigation";
import React, { useRef, useEffect, useLayoutEffect, useState } from "react";
import { logoutUser } from "@/lib/auth";
@ -337,7 +338,7 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
</Button>
{mounted && isOpen
? ReactDOM.createPortal(
? ((createPortal as any)(
<motion.div
ref={menuRef}
initial={{ opacity: 0, scale: 0.95, y: -20 }}
@ -384,6 +385,15 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
? "Loading..."
: `${credits} credits`}
</span>
<button
type="button"
onClick={() => window.open("/usage", "_blank")}
className="ml-1 inline-flex items-center justify-center h-6 w-6 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
data-alt="credits-info-button"
title="Usage"
>
<Info className="h-4 w-4 text-white" />
</button>
</div>
{/* Purchase Credits 按钮 */}
@ -464,7 +474,7 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
</UserCard>
</motion.div>,
document.body
)
) as unknown as React.ReactNode)
: null}
</div>
</div>

View File

@ -0,0 +1,229 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { post } from "@/api/request";
/** 接口请求与返回类型定义 */
interface ConsumptionItem {
/** 项目名称 */
project_name: string;
/** 总消耗(字符串数字) */
total_consumption: string;
}
interface ConsumptionResponseData {
consumption_list: ConsumptionItem[];
page: number;
page_size: number;
total: number;
period_days: 7 | 30 | 90;
}
interface ApiResponse<T = any> {
code: number;
message: string;
data: T;
successful: boolean;
}
type PeriodDays = 7 | 30 | 90;
/**
* Credit 使
* - 7 / 30 / 90
* - /20
* - ID
* - /使 -
*/
const UsageView: React.FC = () => {
const [days, setDays] = useState<PeriodDays>(7);
const [page, setPage] = useState<number>(1);
const pageSize = 20;
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const [items, setItems] = useState<ConsumptionItem[]>([]);
const [total, setTotal] = useState<number>(0);
const [serverPeriodDays, setServerPeriodDays] = useState<PeriodDays>(90);
/** 读取 user_id参考 dashboard/page.tsx */
const userId = useMemo(() => {
try {
const raw = localStorage.getItem("currentUser") || "{}";
const u = JSON.parse(raw);
return String(u?.id || "");
} catch {
return "";
}
}, []);
const canPrev = page > 1;
const canNext = useMemo(() => {
if (total <= 0) return false;
const maxPage = Math.ceil(total / pageSize);
return page < maxPage;
}, [page, total]);
const loadData = useCallback(async () => {
if (!userId) {
setItems([]);
setTotal(0);
setError("no-user");
return;
}
setLoading(true);
setError("");
try {
const body = {
user_id: userId,
page,
page_size: pageSize,
days,
};
const res = await post<ApiResponse<ConsumptionResponseData>>(
"/api/token/consumption",
body
);
if (res && res.successful && res.code === 0) {
setItems(res.data?.consumption_list || []);
setTotal(res.data?.total || 0);
setServerPeriodDays(res.data?.period_days || days);
} else {
setItems([]);
setTotal(0);
setError("api-error");
}
} catch {
setItems([]);
setTotal(0);
setError("api-error");
} finally {
setLoading(false);
}
}, [userId, page, pageSize, days]);
useEffect(() => {
loadData();
}, [loadData]);
const handleChangeDays = useCallback((d: PeriodDays) => {
setDays(d);
setPage(1);
}, []);
const handlePrev = useCallback(() => {
if (canPrev) setPage((p) => p - 1);
}, [canPrev]);
const handleNext = useCallback(() => {
if (canNext) setPage((p) => p + 1);
}, [canNext]);
const periodLabel = useMemo(() => serverPeriodDays || days, [serverPeriodDays, days]);
return (
<div data-alt="usage-view-container" className="mx-auto max-w-5xl p-6">
<div data-alt="header" className="mb-6 flex items-center justify-between">
<h2 className="text-xl font-semibold text-white">Credit Usage Details</h2>
<div data-alt="period-switch" className="inline-flex rounded-lg bg-white/5 p-1">
{([7, 30, 90] as PeriodDays[]).map((d) => (
<button
key={d}
type="button"
data-alt={`period-${d}`}
onClick={() => handleChangeDays(d)}
className={
`px-3 py-1.5 text-sm rounded-md transition-colors ` +
(days === d
? "bg-[#C039F6] text-white"
: "text-white/80 hover:bg-white/10")
}
>
{d}d
</button>
))}
</div>
</div>
<div data-alt="meta" className="mb-3 text-sm text-white/70">
<span data-alt="meta-period">Period: {periodLabel || "-"} days</span>
</div>
<div data-alt="table-wrapper" className="overflow-hidden rounded-lg border border-white/10">
<table data-alt="table" className="min-w-full table-fixed">
<thead className="bg-white/5">
<tr>
<th className="w-2/3 px-4 py-2 text-left text-sm font-medium text-white">Project Name</th>
<th className="w-1/3 px-4 py-2 text-right text-sm font-medium text-white">Total Consumption</th>
</tr>
</thead>
<tbody className="divide-y divide-white/10">
{loading ? (
<tr>
<td className="px-4 py-3 text-white/70" colSpan={2} data-alt="row-loading">Loading...</td>
</tr>
) : error ? (
<tr>
<td className="px-4 py-3 text-white/70" data-alt="row-error">-</td>
<td className="px-4 py-3 text-right text-white/70">-</td>
</tr>
) : items.length === 0 ? (
<tr>
<td className="px-4 py-3 text-white/70" data-alt="row-empty">-</td>
<td className="px-4 py-3 text-right text-white/70">-</td>
</tr>
) : (
items.map((it, idx) => (
<tr key={`${it.project_name}-${idx}`}>
<td className="truncate px-4 py-2 text-white/90" data-alt="cell-project-name">
{it?.project_name || "-"}
</td>
<td className="px-4 py-2 text-right text-white" data-alt="cell-total-consumption">
{it?.total_consumption ?? "-"}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<div data-alt="pagination" className="mt-4 flex items-center justify-between text-sm text-white/80">
<div data-alt="total-info">
Total {Number.isFinite(total) ? total : 0}
</div>
<div data-alt="pager" className="inline-flex gap-2">
<button
type="button"
onClick={handlePrev}
disabled={!canPrev}
className={
"rounded-md px-3 py-1.5 transition-colors " +
(canPrev ? "bg-white/10 hover:bg-white/20" : "bg-white/5 text-white/40 cursor-not-allowed")
}
data-alt="prev-page"
>
Previous
</button>
<span data-alt="page-indicator" className="px-1 py-1.5">Page {page}</span>
<button
type="button"
onClick={handleNext}
disabled={!canNext}
className={
"rounded-md px-3 py-1.5 transition-colors " +
(canNext ? "bg-white/10 hover:bg-white/20" : "bg-white/5 text-white/40 cursor-not-allowed")
}
data-alt="next-page"
>
Next
</button>
</div>
</div>
</div>
);
};
export default React.memo(UsageView);