From 691b80a89f5d9467bc6bbd8416ae833e7f8e1099 Mon Sep 17 00:00:00 2001 From: shihao <3127647737@qq.com> Date: Wed, 22 Apr 2026 11:46:40 +0800 Subject: [PATCH] feat: merge admin routes into unified frontend --- README.md | 20 +-- deploy/README.md | 3 +- .../app/admin/(secure)/callback-logs/page.tsx | 44 +++++ .../src/app/admin/(secure)/dashboard/page.tsx | 42 +++++ .../app/admin/(secure)/growth-rules/page.tsx | 151 ++++++++++++++++++ .../admin/(secure)/invite-relations/page.tsx | 40 +++++ .../src/app/admin/(secure)/layout.tsx | 10 ++ .../app/admin/(secure)/pricing-rules/page.tsx | 42 +++++ .../admin/(secure)/provider-accounts/page.tsx | 63 ++++++++ .../admin/(secure)/provider-models/page.tsx | 50 ++++++ .../admin/(secure)/recharge-orders/page.tsx | 54 +++++++ .../app/admin/(secure)/redeem-codes/page.tsx | 107 +++++++++++++ .../app/admin/(secure)/system-config/page.tsx | 65 ++++++++ .../src/app/admin/(secure)/users/page.tsx | 95 +++++++++++ .../(secure)/video-model-bindings/page.tsx | 40 +++++ .../app/admin/(secure)/video-models/page.tsx | 43 +++++ .../app/admin/(secure)/video-tasks/page.tsx | 59 +++++++ frontend-web/src/app/admin/login/page.tsx | 79 +++++++++ frontend-web/src/app/admin/page.tsx | 5 + frontend-web/src/app/globals.css | 10 +- frontend-web/src/components/admin-shell.tsx | 117 ++++++++++++++ frontend-web/src/components/status-badge.tsx | 2 + package-lock.json | 8 +- package.json | 10 +- 启动命令.txt | 20 +-- 25 files changed, 1144 insertions(+), 35 deletions(-) create mode 100644 frontend-web/src/app/admin/(secure)/callback-logs/page.tsx create mode 100644 frontend-web/src/app/admin/(secure)/dashboard/page.tsx create mode 100644 frontend-web/src/app/admin/(secure)/growth-rules/page.tsx create mode 100644 frontend-web/src/app/admin/(secure)/invite-relations/page.tsx create mode 100644 frontend-web/src/app/admin/(secure)/layout.tsx create mode 100644 frontend-web/src/app/admin/(secure)/pricing-rules/page.tsx create mode 100644 frontend-web/src/app/admin/(secure)/provider-accounts/page.tsx create mode 100644 frontend-web/src/app/admin/(secure)/provider-models/page.tsx create mode 100644 frontend-web/src/app/admin/(secure)/recharge-orders/page.tsx create mode 100644 frontend-web/src/app/admin/(secure)/redeem-codes/page.tsx create mode 100644 frontend-web/src/app/admin/(secure)/system-config/page.tsx create mode 100644 frontend-web/src/app/admin/(secure)/users/page.tsx create mode 100644 frontend-web/src/app/admin/(secure)/video-model-bindings/page.tsx create mode 100644 frontend-web/src/app/admin/(secure)/video-models/page.tsx create mode 100644 frontend-web/src/app/admin/(secure)/video-tasks/page.tsx create mode 100644 frontend-web/src/app/admin/login/page.tsx create mode 100644 frontend-web/src/app/admin/page.tsx create mode 100644 frontend-web/src/components/admin-shell.tsx diff --git a/README.md b/README.md index 043c46f..57d4b01 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,7 @@ 基于 `docs/AI视频平台开发文档.md` 实现的单仓 AI 视频平台原型,包含: - `backend`:FastAPI + SQLAlchemy + Celery 风格任务链路 -- `frontend-web`:用户前台(Next.js) -- `frontend-admin`:管理后台(Next.js) +- `frontend-web`:统一前端(Next.js),同时承载用户前台与 `/admin` 管理后台 - `sql`:初始化库表与基础种子数据 - `deploy`:部署相关文件占位 @@ -14,7 +13,6 @@ AIVideo/ backend/ frontend-web/ - frontend-admin/ docs/ deploy/ sql/ @@ -39,17 +37,11 @@ copy .env.example .env uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 ``` -### 3. 启动前台 +### 3. 启动统一前端 ```bash npm install -npm --workspace frontend-web run dev -``` - -### 4. 启动后台 - -```bash -npm --workspace frontend-admin run dev +npm run dev ``` ## 默认账号 @@ -57,9 +49,13 @@ npm --workspace frontend-admin run dev - 用户端:自行注册 - 管理后台:`admin / Admin@123456` +## 访问地址 + +- 用户前台:`http://localhost:3000` +- 管理后台:`http://localhost:3000/admin/login` + ## 说明 - 本地默认支持 mock OpenAI 与 mock Seedance 任务链路。 - 若配置真实供应商账号与可访问的 `baseUrl`,后端会按对应协议发起真实请求。 - 任务结果默认落到 `backend/storage_data`,并通过 `/storage` 暴露访问。 - diff --git a/deploy/README.md b/deploy/README.md index 235734c..ea477ec 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -6,7 +6,6 @@ Recommended local stack: - `redis`: cache and async queue broker - `minio`: object storage - `backend`: FastAPI API service -- `frontend-web`: user-facing Next.js app -- `frontend-admin`: admin Next.js app +- `frontend-web`: single Next.js app that serves both user pages and `/admin` backend pages Use the root `docker-compose.yml` for local integration. diff --git a/frontend-web/src/app/admin/(secure)/callback-logs/page.tsx b/frontend-web/src/app/admin/(secure)/callback-logs/page.tsx new file mode 100644 index 0000000..a28d066 --- /dev/null +++ b/frontend-web/src/app/admin/(secure)/callback-logs/page.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { StatusBadge } from "@/components/status-badge"; +import { api } from "@/lib/api"; + +type CallbackLog = { + id: number; + sourceType: string; + sourceCode: string; + relatedNo: string; + verifyStatus: string; + processStatus: string; + errorMessage: string; +}; + +export default function CallbackLogsPage() { + const query = useQuery({ + queryKey: ["callback-logs"], + queryFn: () => api.get("/api/v1/admin/callback-logs"), + }); + + return ( +
+

回调日志

+
+ {query.data?.map((item) => ( +
+
+ {item.sourceType} / {item.sourceCode} + +
+
+ 关联号:{item.relatedNo || "-"} · 验签:{item.verifyStatus} +
+ {item.errorMessage ?
错误:{item.errorMessage}
: null} +
+ ))} +
+
+ ); +} + diff --git a/frontend-web/src/app/admin/(secure)/dashboard/page.tsx b/frontend-web/src/app/admin/(secure)/dashboard/page.tsx new file mode 100644 index 0000000..531601f --- /dev/null +++ b/frontend-web/src/app/admin/(secure)/dashboard/page.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { api } from "@/lib/api"; + +export default function DashboardPage() { + const dashboardQuery = useQuery({ + queryKey: ["admin-dashboard"], + queryFn: () => + api.get<{ + users: number; + paidOrders: number; + tasks: number; + successRate: number; + }>("/api/v1/admin/dashboard"), + }); + + const data = dashboardQuery.data; + + return ( +
+
+

用户总数

+
{data?.users ?? 0}
+
+
+

已支付订单

+
{data?.paidOrders ?? 0}
+
+
+

任务总数

+
{data?.tasks ?? 0}
+
+
+

成功率

+
{data?.successRate ?? 0}%
+
+
+ ); +} + diff --git a/frontend-web/src/app/admin/(secure)/growth-rules/page.tsx b/frontend-web/src/app/admin/(secure)/growth-rules/page.tsx new file mode 100644 index 0000000..f8faab3 --- /dev/null +++ b/frontend-web/src/app/admin/(secure)/growth-rules/page.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import { api } from "@/lib/api"; + +type GrowthRules = { + signupRewardEnabled: boolean; + signupRewardPoints: number; + inviteRewardEnabled: boolean; + inviteRewardPoints: number; + inviteRewardMinConsumePoints: number; +}; + +export default function GrowthRulesPage() { + const rulesQuery = useQuery({ + queryKey: ["growth-rules"], + queryFn: () => api.get("/api/v1/admin/growth-rules"), + }); + const [signupDraft, setSignupDraft] = useState<{ + enabled: boolean; + reward_points: number; + min_consume_points: number; + remark: string; + } | null>(null); + const [inviteDraft, setInviteDraft] = useState<{ + enabled: boolean; + reward_points: number; + min_consume_points: number; + remark: string; + } | null>(null); + const signup = signupDraft ?? { + enabled: rulesQuery.data?.signupRewardEnabled ?? true, + reward_points: rulesQuery.data?.signupRewardPoints ?? 300, + min_consume_points: 0, + remark: "signup reward", + }; + const invite = inviteDraft ?? { + enabled: rulesQuery.data?.inviteRewardEnabled ?? true, + reward_points: rulesQuery.data?.inviteRewardPoints ?? 500, + min_consume_points: rulesQuery.data?.inviteRewardMinConsumePoints ?? 100, + remark: "invite reward", + }; + + const signupMutation = useMutation({ + mutationFn: () => api.put("/api/v1/admin/growth-rules/signup", signup), + onSuccess: () => { + setSignupDraft(null); + void rulesQuery.refetch(); + }, + }); + const inviteMutation = useMutation({ + mutationFn: () => api.put("/api/v1/admin/growth-rules/invite", invite), + onSuccess: () => { + setInviteDraft(null); + void rulesQuery.refetch(); + }, + }); + + return ( +
+
+

注册奖励

+
+ + + +
+
+ +
+

邀请奖励

+
+ + + + +
+
+
+ ); +} diff --git a/frontend-web/src/app/admin/(secure)/invite-relations/page.tsx b/frontend-web/src/app/admin/(secure)/invite-relations/page.tsx new file mode 100644 index 0000000..f0a7379 --- /dev/null +++ b/frontend-web/src/app/admin/(secure)/invite-relations/page.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { StatusBadge } from "@/components/status-badge"; +import { api } from "@/lib/api"; + +type InviteRelation = { + id: number; + inviterUserId: number; + inviteeUserId: number; + rewardStatus: string; + rewardPoints: number; +}; + +export default function InviteRelationsPage() { + const query = useQuery({ + queryKey: ["admin-invite-relations"], + queryFn: () => api.get("/api/v1/admin/invite-relations"), + }); + + return ( +
+

邀请关系

+
+ {query.data?.map((item) => ( +
+
+ + 邀请人 {item.inviterUserId} to 被邀请人 {item.inviteeUserId} + + +
+
奖励积分:{item.rewardPoints}
+
+ ))} +
+
+ ); +} diff --git a/frontend-web/src/app/admin/(secure)/layout.tsx b/frontend-web/src/app/admin/(secure)/layout.tsx new file mode 100644 index 0000000..e716995 --- /dev/null +++ b/frontend-web/src/app/admin/(secure)/layout.tsx @@ -0,0 +1,10 @@ +import { AdminShell } from "@/components/admin-shell"; + +export default function SecureAdminLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} + diff --git a/frontend-web/src/app/admin/(secure)/pricing-rules/page.tsx b/frontend-web/src/app/admin/(secure)/pricing-rules/page.tsx new file mode 100644 index 0000000..82bcde8 --- /dev/null +++ b/frontend-web/src/app/admin/(secure)/pricing-rules/page.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useMutation, useQuery } from "@tanstack/react-query"; + +import { api } from "@/lib/api"; + +export default function PricingRulesPage() { + const form = { + rule_name: "影院视频默认价格", + video_model_id: 1, + points_per_second: 160, + minimum_points: 600, + effective_at: new Date().toISOString(), + expired_at: null, + version_no: 2, + status: 1, + }; + const query = useQuery({ + queryKey: ["pricing-rules"], + queryFn: () => api.get("/api/v1/admin/pricing-rules"), + }); + const mutation = useMutation({ + mutationFn: () => api.post("/api/v1/admin/pricing-rules", form), + onSuccess: () => query.refetch(), + }); + + return ( +
+
+

新增价格规则

+
{JSON.stringify(form, null, 2)}
+ +
+
+

价格规则列表

+
{JSON.stringify(query.data ?? [], null, 2)}
+
+
+ ); +} diff --git a/frontend-web/src/app/admin/(secure)/provider-accounts/page.tsx b/frontend-web/src/app/admin/(secure)/provider-accounts/page.tsx new file mode 100644 index 0000000..6674951 --- /dev/null +++ b/frontend-web/src/app/admin/(secure)/provider-accounts/page.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import { api } from "@/lib/api"; + +export default function ProviderAccountsPage() { + const [form, setForm] = useState({ + provider_code: "openai-backup", + provider_name: "OpenAI 备用账号", + api_format: "openai_official_video", + base_url: "mock://openai", + api_key: "mock", + api_secret: "", + webhook_secret: "", + timeout_seconds: 120, + max_retries: 3, + status: 1, + remark: "backup route", + }); + const query = useQuery({ + queryKey: ["provider-accounts"], + queryFn: () => api.get("/api/v1/admin/provider-accounts"), + }); + const createMutation = useMutation({ + mutationFn: () => api.post("/api/v1/admin/provider-accounts", form), + onSuccess: () => query.refetch(), + }); + + return ( +
+
+

新增供应商账号

+
+ {Object.entries(form).map(([key, value]) => ( + + ))} + +
+
+
+

当前账号

+
{JSON.stringify(query.data ?? [], null, 2)}
+
+
+ ); +} + diff --git a/frontend-web/src/app/admin/(secure)/provider-models/page.tsx b/frontend-web/src/app/admin/(secure)/provider-models/page.tsx new file mode 100644 index 0000000..25948a4 --- /dev/null +++ b/frontend-web/src/app/admin/(secure)/provider-models/page.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useMutation, useQuery } from "@tanstack/react-query"; + +import { api } from "@/lib/api"; + +export default function ProviderModelsPage() { + const form = { + provider_account_id: 1, + model_code: "sora-2-pro", + model_name: "Sora 2 Pro", + request_content_type: "multipart/form-data", + supports_text_to_video: true, + supports_image_to_video: true, + supports_video_reference: false, + supports_audio_reference: false, + supports_generate_audio: true, + supports_remix: false, + supports_webhook: true, + min_duration: 4, + max_duration: 12, + default_ratio: "16:9", + default_resolution: "1280x720", + status: 1, + }; + const query = useQuery({ + queryKey: ["provider-models"], + queryFn: () => api.get("/api/v1/admin/provider-models"), + }); + const createMutation = useMutation({ + mutationFn: () => api.post("/api/v1/admin/provider-models", form), + onSuccess: () => query.refetch(), + }); + + return ( +
+
+

新增供应商模型

+
{JSON.stringify(form, null, 2)}
+ +
+
+

当前模型

+
{JSON.stringify(query.data ?? [], null, 2)}
+
+
+ ); +} diff --git a/frontend-web/src/app/admin/(secure)/recharge-orders/page.tsx b/frontend-web/src/app/admin/(secure)/recharge-orders/page.tsx new file mode 100644 index 0000000..5672b48 --- /dev/null +++ b/frontend-web/src/app/admin/(secure)/recharge-orders/page.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useMutation, useQuery } from "@tanstack/react-query"; + +import { StatusBadge } from "@/components/status-badge"; +import { api } from "@/lib/api"; + +type OrderRow = { + id: number; + orderNo: string; + userId: number; + payAmount: string; + arrivalPoints: number; + status: string; + paymentChannelCode: string; +}; + +export default function RechargeOrdersPage() { + const ordersQuery = useQuery({ + queryKey: ["admin-orders"], + queryFn: () => api.get("/api/v1/admin/recharge-orders"), + }); + const repairMutation = useMutation({ + mutationFn: (orderId: number) => api.post(`/api/v1/admin/recharge-orders/${orderId}/repair`), + onSuccess: () => ordersQuery.refetch(), + }); + + return ( +
+

充值订单

+
+ {ordersQuery.data?.map((order) => ( +
+
+ {order.orderNo} + +
+
+ 用户 {order.userId} · {order.payAmount} 元 · 到账 {order.arrivalPoints} 积分 +
+ +
+ ))} +
+
+ ); +} + diff --git a/frontend-web/src/app/admin/(secure)/redeem-codes/page.tsx b/frontend-web/src/app/admin/(secure)/redeem-codes/page.tsx new file mode 100644 index 0000000..0aca85d --- /dev/null +++ b/frontend-web/src/app/admin/(secure)/redeem-codes/page.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import { StatusBadge } from "@/components/status-badge"; +import { api } from "@/lib/api"; + +type RedeemCodeRow = { + id: number; + batchNo: string; + redeemCode: string; + points: number; + status: string; +}; + +export default function RedeemCodesPage() { + const [form, setForm] = useState({ + batch_no: "OPS2026", + points: 800, + quantity: 3, + remark: "ops batch", + }); + const codesQuery = useQuery({ + queryKey: ["admin-redeem-codes"], + queryFn: () => api.get("/api/v1/admin/redeem-codes"), + }); + const createMutation = useMutation({ + mutationFn: () => api.post("/api/v1/admin/redeem-codes/batch-create", form), + onSuccess: () => codesQuery.refetch(), + }); + const disableMutation = useMutation({ + mutationFn: (id: number) => api.put(`/api/v1/admin/redeem-codes/${id}/disable`), + onSuccess: () => codesQuery.refetch(), + }); + + return ( +
+
+

批量生成兑换密钥

+
+ + + + +
+
+ +
+

兑换码列表

+
+ {codesQuery.data?.map((item) => ( +
+
+ {item.redeemCode} + +
+
{item.batchNo} · {item.points} 积分
+ +
+ ))} +
+
+
+ ); +} + diff --git a/frontend-web/src/app/admin/(secure)/system-config/page.tsx b/frontend-web/src/app/admin/(secure)/system-config/page.tsx new file mode 100644 index 0000000..9ac133e --- /dev/null +++ b/frontend-web/src/app/admin/(secure)/system-config/page.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import { api } from "@/lib/api"; + +type ConfigRow = { + configKey: string; + configValue: string; + groupName: string; +}; + +export default function SystemConfigPage() { + const [form, setForm] = useState({ + config_key: "site.notice", + config_value: "当前为本地联调环境。", + value_type: "string", + group_name: "site", + description: "公告", + is_public: true, + }); + const query = useQuery({ + queryKey: ["system-config"], + queryFn: () => api.get("/api/v1/admin/system-config"), + }); + const mutation = useMutation({ + mutationFn: () => api.put("/api/v1/admin/system-config", form), + onSuccess: () => query.refetch(), + }); + + return ( +
+
+

更新系统配置

+
+ {Object.entries(form).map(([key, value]) => ( + + ))} + +
+
+
+

配置清单

+
{JSON.stringify(query.data ?? [], null, 2)}
+
+
+ ); +} diff --git a/frontend-web/src/app/admin/(secure)/users/page.tsx b/frontend-web/src/app/admin/(secure)/users/page.tsx new file mode 100644 index 0000000..2562838 --- /dev/null +++ b/frontend-web/src/app/admin/(secure)/users/page.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import { api } from "@/lib/api"; + +type UserRow = { + id: number; + publicId: string; + username: string; + nickname: string; + email: string; + status: number; + createdAt: string; +}; + +export default function UsersPage() { + const [adjustForm, setAdjustForm] = useState({ + userId: 1, + amountPoints: 100, + reason: "manual bonus", + }); + const usersQuery = useQuery({ + queryKey: ["admin-users"], + queryFn: () => api.get("/api/v1/admin/users"), + }); + const adjustMutation = useMutation({ + mutationFn: () => + api.post(`/api/v1/admin/users/${adjustForm.userId}/wallet-adjust`, adjustForm), + }); + + return ( +
+
+

用户列表

+
+ {usersQuery.data?.map((user) => ( +
+ {user.nickname || user.username || user.publicId} +
+ #{user.id} · {user.email} · 状态 {user.status} +
+
+ ))} +
+
+ +
+

人工调账

+
+ + + + +
+
+
+ ); +} + diff --git a/frontend-web/src/app/admin/(secure)/video-model-bindings/page.tsx b/frontend-web/src/app/admin/(secure)/video-model-bindings/page.tsx new file mode 100644 index 0000000..8afb4bd --- /dev/null +++ b/frontend-web/src/app/admin/(secure)/video-model-bindings/page.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useMutation, useQuery } from "@tanstack/react-query"; + +import { api } from "@/lib/api"; + +export default function VideoModelBindingsPage() { + const form = { + video_model_id: 1, + provider_model_id: 1, + routing_priority: 20, + is_primary: false, + status: 1, + timeout_seconds_override: 90, + }; + const query = useQuery({ + queryKey: ["video-model-bindings"], + queryFn: () => api.get("/api/v1/admin/video-model-bindings"), + }); + const mutation = useMutation({ + mutationFn: () => api.post("/api/v1/admin/video-model-bindings", form), + onSuccess: () => query.refetch(), + }); + + return ( +
+
+

新增绑定

+
{JSON.stringify(form, null, 2)}
+ +
+
+

绑定列表

+
{JSON.stringify(query.data ?? [], null, 2)}
+
+
+ ); +} diff --git a/frontend-web/src/app/admin/(secure)/video-models/page.tsx b/frontend-web/src/app/admin/(secure)/video-models/page.tsx new file mode 100644 index 0000000..a528cfc --- /dev/null +++ b/frontend-web/src/app/admin/(secure)/video-models/page.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useMutation, useQuery } from "@tanstack/react-query"; + +import { api } from "@/lib/api"; + +export default function VideoModelsPage() { + const form = { + model_key: "cinema-pro", + model_name: "影院视频", + frontend_title: "影院视频", + frontend_description: "偏高质量的长镜头生成。", + default_duration_seconds: 8, + default_ratio: "16:9", + default_resolution: "1280x720", + status: 1, + sort_order: 40, + }; + const query = useQuery({ + queryKey: ["video-models-admin"], + queryFn: () => api.get("/api/v1/admin/video-models"), + }); + const mutation = useMutation({ + mutationFn: () => api.post("/api/v1/admin/video-models", form), + onSuccess: () => query.refetch(), + }); + + return ( +
+
+

新增平台视频模型

+
{JSON.stringify(form, null, 2)}
+ +
+
+

平台模型列表

+
{JSON.stringify(query.data ?? [], null, 2)}
+
+
+ ); +} diff --git a/frontend-web/src/app/admin/(secure)/video-tasks/page.tsx b/frontend-web/src/app/admin/(secure)/video-tasks/page.tsx new file mode 100644 index 0000000..e6f2573 --- /dev/null +++ b/frontend-web/src/app/admin/(secure)/video-tasks/page.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useMutation, useQuery } from "@tanstack/react-query"; + +import { StatusBadge } from "@/components/status-badge"; +import { api } from "@/lib/api"; + +type TaskRow = { + id: number; + taskNo: string; + taskStatus: string; + estimatedPoints: number; + finalPoints: number; + resultVideoUrl: string; +}; + +export default function VideoTasksPage() { + const query = useQuery({ + queryKey: ["admin-video-tasks"], + queryFn: () => api.get("/api/v1/admin/video-tasks"), + refetchInterval: 4_000, + }); + const retryMutation = useMutation({ + mutationFn: (taskId: number) => api.post(`/api/v1/admin/video-tasks/${taskId}/retry`), + onSuccess: () => query.refetch(), + }); + const refundMutation = useMutation({ + mutationFn: (taskId: number) => api.post(`/api/v1/admin/video-tasks/${taskId}/refund`), + onSuccess: () => query.refetch(), + }); + + return ( +
+

视频任务

+
+ {query.data?.map((task) => ( +
+
+ {task.taskNo} + +
+
+ 预估 {task.estimatedPoints} · 最终 {task.finalPoints} +
+
+ + +
+
+ ))} +
+
+ ); +} + diff --git a/frontend-web/src/app/admin/login/page.tsx b/frontend-web/src/app/admin/login/page.tsx new file mode 100644 index 0000000..36ba4d7 --- /dev/null +++ b/frontend-web/src/app/admin/login/page.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { startTransition, useState } from "react"; +import { useRouter } from "next/navigation"; + +import { api, ApiError } from "@/lib/api"; + +export default function AdminLoginPage() { + const router = useRouter(); + const [form, setForm] = useState({ + username: "admin", + password: "Admin@123456", + }); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setLoading(true); + setError(""); + try { + await api.post("/api/v1/admin/auth/login", form); + startTransition(() => router.replace("/admin/dashboard")); + } catch (err) { + setError(err instanceof ApiError ? err.message : "登录失败"); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+
Ops Console
+

+ 管好模型、价格、奖励、订单和任务链路。 +

+

+ 后台聚焦两件事:配置业务规则,以及处理异常链路。默认已创建管理员账号,适合直接联调整条 MVP。 +

+
+
+ +
+
+
AIVideo Admin
+

管理员登录

+
+ + + {error ?
{error}
: null} + +
+
+
+
+ ); +} + diff --git a/frontend-web/src/app/admin/page.tsx b/frontend-web/src/app/admin/page.tsx new file mode 100644 index 0000000..840f1f5 --- /dev/null +++ b/frontend-web/src/app/admin/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function AdminEntryPage() { + redirect("/admin/dashboard"); +} diff --git a/frontend-web/src/app/globals.css b/frontend-web/src/app/globals.css index a18880f..42d59a7 100644 --- a/frontend-web/src/app/globals.css +++ b/frontend-web/src/app/globals.css @@ -97,6 +97,12 @@ textarea::placeholder { linear-gradient(160deg, rgba(10, 13, 21, 0.98) 0%, rgba(8, 10, 16, 0.98) 100%); } +.login-grid { + min-height: 100vh; + display: grid; + grid-template-columns: 1fr 440px; +} + .auth-hero { padding: 56px; display: flex; @@ -471,7 +477,8 @@ textarea::placeholder { .panel h3, .stat-card h3, .library-header h3, -.workbench-header h3 { +.workbench-header h3, +.auth-card h3 { margin: 0 0 12px; font-size: 24px; font-family: var(--font-display), sans-serif; @@ -1083,6 +1090,7 @@ textarea::placeholder { @media (max-width: 1100px) { .auth-grid, + .login-grid, .shell-grid { grid-template-columns: 1fr; } diff --git a/frontend-web/src/components/admin-shell.tsx b/frontend-web/src/components/admin-shell.tsx new file mode 100644 index 0000000..b041720 --- /dev/null +++ b/frontend-web/src/components/admin-shell.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import clsx from "clsx"; +import { + Blocks, + ChartColumnBig, + Coins, + KeySquare, + Link2, + LogOut, + Package2, + Settings2, + Users, + Workflow, +} from "lucide-react"; +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { useEffect } from "react"; + +import { api } from "@/lib/api"; + +const navigation = [ + { href: "/admin/dashboard", label: "仪表盘", icon: ChartColumnBig }, + { href: "/admin/users", label: "用户管理", icon: Users }, + { href: "/admin/recharge-orders", label: "充值订单", icon: Coins }, + { href: "/admin/redeem-codes", label: "兑换密钥", icon: KeySquare }, + { href: "/admin/growth-rules", label: "增长奖励", icon: Link2 }, + { href: "/admin/invite-relations", label: "邀请关系", icon: Link2 }, + { href: "/admin/provider-accounts", label: "供应商账号", icon: Workflow }, + { href: "/admin/provider-models", label: "供应商模型", icon: Blocks }, + { href: "/admin/video-models", label: "平台模型", icon: Package2 }, + { href: "/admin/video-model-bindings", label: "模型绑定", icon: Workflow }, + { href: "/admin/pricing-rules", label: "价格规则", icon: Coins }, + { href: "/admin/video-tasks", label: "视频任务", icon: Workflow }, + { href: "/admin/callback-logs", label: "回调日志", icon: ChartColumnBig }, + { href: "/admin/system-config", label: "系统配置", icon: Settings2 }, +]; + +export function AdminShell({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const router = useRouter(); + const meQuery = useQuery({ + queryKey: ["admin-me"], + queryFn: () => api.get("/api/v1/admin/auth/me"), + }); + + useEffect(() => { + if (meQuery.error) { + router.replace("/admin/login"); + } + }, [meQuery.error, router]); + + if (meQuery.isLoading || !meQuery.data) { + return ( +
+
正在校验管理员身份...
+
+ ); + } + + return ( +
+ + +
+
{children}
+
+
+ ); +} + diff --git a/frontend-web/src/components/status-badge.tsx b/frontend-web/src/components/status-badge.tsx index 00a97e7..a3f1a6b 100644 --- a/frontend-web/src/components/status-badge.tsx +++ b/frontend-web/src/components/status-badge.tsx @@ -12,6 +12,7 @@ const statusMap: Record = { unused: "success", used: "ghost", disabled: "danger", + rewarded: "success", }; const statusLabelMap: Record = { @@ -26,6 +27,7 @@ const statusLabelMap: Record = { unused: "未使用", used: "已使用", disabled: "已停用", + rewarded: "已奖励", }; export function StatusBadge({ value }: { value: string }) { diff --git a/package-lock.json b/package-lock.json index 93acfd8..d39e9ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,12 +8,12 @@ "name": "aivideo-monorepo", "version": "0.1.0", "workspaces": [ - "frontend-web", - "frontend-admin" + "frontend-web" ] }, "frontend-admin": { "version": "0.1.0", + "extraneous": true, "dependencies": { "@tanstack/react-query": "^5.90.5", "clsx": "^2.1.1", @@ -3670,10 +3670,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/frontend-admin": { - "resolved": "frontend-admin", - "link": true - }, "node_modules/frontend-web": { "resolved": "frontend-web", "link": true diff --git a/package.json b/package.json index 7bfc29d..b40de0e 100644 --- a/package.json +++ b/package.json @@ -3,14 +3,14 @@ "private": true, "version": "0.1.0", "workspaces": [ - "frontend-web", - "frontend-admin" + "frontend-web" ], "scripts": { + "dev": "npm --workspace frontend-web run dev", "dev:web": "npm --workspace frontend-web run dev", - "dev:admin": "npm --workspace frontend-admin run dev", + "build": "npm --workspace frontend-web run build", "build:web": "npm --workspace frontend-web run build", - "build:admin": "npm --workspace frontend-admin run build" + "start": "npm --workspace frontend-web run start", + "lint": "npm --workspace frontend-web run lint" } } - diff --git a/启动命令.txt b/启动命令.txt index 08e8e10..855a42b 100644 --- a/启动命令.txt +++ b/启动命令.txt @@ -1,9 +1,11 @@ - -python -m pip install -r requirements.txt - copy .env.example .env - uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - - cd D:\project\AIVideo - npm install - npm --workspace frontend-web run dev - npm --workspace frontend-admin run dev \ No newline at end of file + +python -m pip install -r requirements.txt + copy .env.example .env + uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 + + cd D:\project\AIVideo + npm install + npm run dev + + 用户前台:http://localhost:3000 + 管理后台:http://localhost:3000/admin/login