From d4bb1e77132ddf9d3acffb7c70751ffe4b7b45a9 Mon Sep 17 00:00:00 2001 From: moux1024 <403053463@qq.com> Date: Mon, 15 Sep 2025 19:15:51 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20usage=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/README.md | 128 ++++++++++++++++++ app/usage/page.tsx | 16 +++ components/layout/top-bar.tsx | 16 ++- components/pages/usage-view.tsx | 229 ++++++++++++++++++++++++++++++++ 4 files changed, 386 insertions(+), 3 deletions(-) create mode 100644 api/README.md create mode 100644 app/usage/page.tsx create mode 100644 components/pages/usage-view.tsx diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..f378220 --- /dev/null +++ b/api/README.md @@ -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`。 +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 = { code: number; successful: boolean; message: string; data: T }; + +// GET +const user = await get>('/user/detail?id=1'); + +// POST +const created = await post>('/user/create', { name: 'Evan' }); + +// PUT +await put>('/user/update', { id: 1, name: 'Kent' }); + +// DELETE +await del>('/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: \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: \n\n` 输出;前端在 `onDownloadProgress` 中按行解析。 + +--- + + diff --git a/app/usage/page.tsx b/app/usage/page.tsx new file mode 100644 index 0000000..1e96c9a --- /dev/null +++ b/app/usage/page.tsx @@ -0,0 +1,16 @@ +"use client"; + +import React from "react"; +import UsageView from "@/components/pages/usage-view"; + +const UsagePage: React.FC = () => { + return ( +
+ +
+ ); +}; + +export default UsagePage; + + diff --git a/components/layout/top-bar.tsx b/components/layout/top-bar.tsx index fa68baf..7978b1c 100644 --- a/components/layout/top-bar.tsx +++ b/components/layout/top-bar.tsx @@ -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 {mounted && isOpen - ? ReactDOM.createPortal( + ? ((createPortal as any)( + {/* Purchase Credits 按钮 */} @@ -464,7 +474,7 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe , document.body - ) + ) as unknown as React.ReactNode) : null} diff --git a/components/pages/usage-view.tsx b/components/pages/usage-view.tsx new file mode 100644 index 0000000..30b24cc --- /dev/null +++ b/components/pages/usage-view.tsx @@ -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 { + 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(7); + const [page, setPage] = useState(1); + const pageSize = 20; + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [serverPeriodDays, setServerPeriodDays] = useState(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>( + "/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 ( +
+
+

Credit Usage Details

+
+ {([7, 30, 90] as PeriodDays[]).map((d) => ( + + ))} +
+
+ +
+ Period: {periodLabel || "-"} days +
+ +
+ + + + + + + + + {loading ? ( + + + + ) : error ? ( + + + + + ) : items.length === 0 ? ( + + + + + ) : ( + items.map((it, idx) => ( + + + + + )) + )} + +
Project NameTotal Consumption
Loading...
--
--
+ {it?.project_name || "-"} + + {it?.total_consumption ?? "-"} +
+
+ +
+
+ Total {Number.isFinite(total) ? total : 0} +
+
+ + Page {page} + +
+
+
+ ); +}; + +export default React.memo(UsageView); + +