forked from 77media/video-flow
新增 usage页面
This commit is contained in:
parent
a7e99a30d8
commit
d4bb1e7713
128
api/README.md
Normal file
128
api/README.md
Normal 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/Serverless(Vercel/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
16
app/usage/page.tsx
Normal 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;
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
229
components/pages/usage-view.tsx
Normal file
229
components/pages/usage-view.tsx
Normal 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);
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user