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,
|
LogOut,
|
||||||
PanelsLeftBottom,
|
PanelsLeftBottom,
|
||||||
Bell,
|
Bell,
|
||||||
|
Info,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import ReactDOM from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useRouter, usePathname } from "next/navigation";
|
import { useRouter, usePathname } from "next/navigation";
|
||||||
import React, { useRef, useEffect, useLayoutEffect, useState } from "react";
|
import React, { useRef, useEffect, useLayoutEffect, useState } from "react";
|
||||||
import { logoutUser } from "@/lib/auth";
|
import { logoutUser } from "@/lib/auth";
|
||||||
@ -337,7 +338,7 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{mounted && isOpen
|
{mounted && isOpen
|
||||||
? ReactDOM.createPortal(
|
? ((createPortal as any)(
|
||||||
<motion.div
|
<motion.div
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
initial={{ opacity: 0, scale: 0.95, y: -20 }}
|
initial={{ opacity: 0, scale: 0.95, y: -20 }}
|
||||||
@ -384,6 +385,15 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
|
|||||||
? "Loading..."
|
? "Loading..."
|
||||||
: `${credits} credits`}
|
: `${credits} credits`}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Purchase Credits 按钮 */}
|
{/* Purchase Credits 按钮 */}
|
||||||
@ -464,7 +474,7 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
|
|||||||
</UserCard>
|
</UserCard>
|
||||||
</motion.div>,
|
</motion.div>,
|
||||||
document.body
|
document.body
|
||||||
)
|
) as unknown as React.ReactNode)
|
||||||
: null}
|
: null}
|
||||||
</div>
|
</div>
|
||||||
</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