Compare commits
3 Commits
bc33b81121
...
06e6486aa3
| Author | SHA1 | Date | |
|---|---|---|---|
| 06e6486aa3 | |||
| 4472d406d9 | |||
| 0e6d996718 |
31
CHANGELOG.md
31
CHANGELOG.md
@@ -1,6 +1,37 @@
|
|||||||
# 更新日志
|
# 更新日志
|
||||||
|
|
||||||
> 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。
|
> 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。
|
||||||
|
---
|
||||||
|
## 0.0.21(2026-06-15)
|
||||||
|
|
||||||
|
**文件结构重组:components/ pages/ 按职责分层**
|
||||||
|
|
||||||
|
### components/ → 三层拆分
|
||||||
|
|
||||||
|
- `providers/`(状态管理):AppProvider / ThemeProvider / SettingsProvider
|
||||||
|
- `shell/`(应用外壳):Layout / PageDispatcher / navbar/
|
||||||
|
- `ui/`(通用原子):FloatingPanel / LegalTextModal
|
||||||
|
|
||||||
|
### pages/ → 重命名消除歧义
|
||||||
|
|
||||||
|
- `headers/` → `panels/`(NavBar 面板页,非 HTTP headers)
|
||||||
|
- `login/` → `auth/`(含登录/注册/忘记密码/修改密码)
|
||||||
|
- `settings/blocks/` → `sections/`
|
||||||
|
- `home/components/useUI/` → `home/controls/`
|
||||||
|
|
||||||
|
### home/ → 四层子结构
|
||||||
|
|
||||||
|
- `panels/` — 布局壳(LeftPanel / CenterPanel / RightPanel)
|
||||||
|
- `features/` — 功能面板(Banner / ModelInput / ModelSelector / Output / TaskHistory)
|
||||||
|
- `controls/` — 表单控件库(12 个控件 + Widget 注册表)
|
||||||
|
- `hooks/` — 纯 Hook(useModelUI)
|
||||||
|
|
||||||
|
### 其他
|
||||||
|
|
||||||
|
- HomeContext 从 HomeContent.tsx 拆分独立文件(解决 react-refresh ESLint 警告)
|
||||||
|
- uploadValidation 内联到 ImageInput(controls/ 不再混工具函数)
|
||||||
|
- 删除空占位 Test3.tsx
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 0.0.20(2026-06-12)
|
## 0.0.20(2026-06-12)
|
||||||
|
|||||||
@@ -22,4 +22,5 @@
|
|||||||
- CLAUDE.md
|
- CLAUDE.md
|
||||||
- 永远不要修改CLAUDE.md文档
|
- 永远不要修改CLAUDE.md文档
|
||||||
- 在各个文档中的更新需要保持精简。且职责划分保持严谨,除了架构、项目文档,已完成的事需要从TODO.md文档中移除。
|
- 在各个文档中的更新需要保持精简。且职责划分保持严谨,除了架构、项目文档,已完成的事需要从TODO.md文档中移除。
|
||||||
|
- 在没有得到修改指令时,不要修改文档文件。
|
||||||
|
|
||||||
|
|||||||
30
PROJECT.md
30
PROJECT.md
@@ -68,18 +68,16 @@
|
|||||||
├── src/ # 渲染进程(React 19 SPA)
|
├── src/ # 渲染进程(React 19 SPA)
|
||||||
│ ├── main.tsx # React 挂载点 + 全局错误捕获
|
│ ├── main.tsx # React 挂载点 + 全局错误捕获
|
||||||
│ ├── App.tsx # 根组件(路由 + 登录弹窗)
|
│ ├── App.tsx # 根组件(路由 + 登录弹窗)
|
||||||
│ ├── components/ # 通用 UI 组件 + Provider
|
│ ├── components/ # 可复用组件(按职责分三层)
|
||||||
│ │ ├── AppProvider.tsx # 全局状态(platform / edition / login)
|
│ │ ├── providers/ # 状态管理(App/Theme/SettingsProvider)
|
||||||
│ │ ├── ThemeProvider.tsx # 主题状态(light / dark)
|
│ │ ├── shell/ # 应用外壳(Layout/PageDispatcher/navbar/)
|
||||||
│ │ ├── SettingsProvider.tsx # 设置状态(存储路径等)
|
│ │ └── ui/ # 通用 UI 原子(FloatingPanel/LegalTextModal)
|
||||||
│ │ ├── Layout.tsx # 页面壳(NavBar + Outlet)
|
│ ├── hooks/ # 自定义 Hooks(业务逻辑封装)
|
||||||
│ │ └── navbar/ # 导航栏 + 公告抽屉
|
│ ├── pages/ # 业务页面(按功能模块组织)
|
||||||
│ ├── contexts/ # Context 定义(app / settings / theme)
|
│ │ ├── home/ # 首页(panels/ features/ controls/ hooks/)
|
||||||
│ ├── hooks/ # 自定义 Hooks(useUpdater / useTheme)
|
│ │ ├── auth/ # 认证模块(登录/注册/忘记密码/修改密码)
|
||||||
│ ├── pages/ # 页面组件
|
│ │ ├── settings/ # 设置面板(sections/)
|
||||||
│ │ ├── home/ # 首页仪表盘
|
│ │ └── panels/ # NavBar 触发的面板页(Account/Billing/WechatWork)
|
||||||
│ │ ├── login/ # 登录 / 注册页
|
|
||||||
│ │ └── settings/ # 设置面板(主题 / 存储 / 账号安全 / 系统信息 / 更新)
|
|
||||||
│ ├── router/index.tsx # HashRouter 路由配置
|
│ ├── router/index.tsx # HashRouter 路由配置
|
||||||
│ ├── services/ # HTTP 请求封装
|
│ ├── services/ # HTTP 请求封装
|
||||||
│ │ ├── request.ts # Axios 封装 + 拦截器 + Token 刷新
|
│ │ ├── request.ts # Axios 封装 + 拦截器 + Token 刷新
|
||||||
@@ -151,12 +149,14 @@
|
|||||||
## 状态管理
|
## 状态管理
|
||||||
|
|
||||||
- 使用 React Context + Provider 模式管理全局状态,按领域拆分:AppContext(平台/登录)、ThemeContext(主题)、
|
- 使用 React Context + Provider 模式管理全局状态,按领域拆分:AppContext(平台/登录)、ThemeContext(主题)、
|
||||||
SettingsContext(设置项)。避免引入 Redux 等重量级方案。
|
SettingsContext(设置项)。各 Context 定义与 Provider 合并在 `components/providers/` 目录下,避免引入 Redux 等重量级方案。
|
||||||
|
|
||||||
- Provider 组件在 main.tsx 中按层级嵌套挂载:
|
- Provider 在 main.tsx 中按层级嵌套挂载:
|
||||||
AppProvider → ThemeProvider → SettingsProvider → AntdApp → App
|
AppProvider → ThemeProvider → SettingsProvider → AntdApp → App
|
||||||
|
|
||||||
- 各模块通过自定义 Hook 消费(useAppContext / useTheme / useSettings),Hook 内部做 null 检查。
|
- 各模块通过自定义 Hook 消费(useAppContext / useTheme / useSettings),Hook 内部做 null 检查并抛出明确错误信息。
|
||||||
|
|
||||||
|
- 页面级状态(如首页的 selectedModel / selectedTask / 分页)放在 HomeContext 中,由 HomeContent 提供,遵循与全局 Provider 相同的 Context + Provider + Hook 模式。
|
||||||
|
|
||||||
## 日志系统
|
## 日志系统
|
||||||
|
|
||||||
|
|||||||
6
TODO.md
6
TODO.md
@@ -1,13 +1,9 @@
|
|||||||
# TODO — 船长·HeiXiu 待办与优化清单
|
# TODO — 船长·HeiXiu 待办与优化清单
|
||||||
|
|
||||||
> 最后更新:2026-06-12
|
> 最后更新:2026-06-15
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
[//]: # (警告:(48, 14) ESLint: Fast refresh only works when a file only exports components. Move your React context(s) to a separate file. (react-refresh/only-export-components))
|
|
||||||
|
|
||||||
[//]: # (警告:(52, 17) ESLint: Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components. (react-refresh/only-export-components))
|
|
||||||
|
|
||||||
## 🟡 功能待完善(优先级中)
|
## 🟡 功能待完善(优先级中)
|
||||||
|
|
||||||
### 设置页面
|
### 设置页面
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
import { useAppContext } from '@/components/AppProvider';
|
import { useAppContext } from '@/components/providers/AppProvider';
|
||||||
import { LoginPage } from './pages/login';
|
import { LoginPage } from './pages/auth';
|
||||||
import { SettingsPage } from './pages/settings';
|
import { SettingsPage } from './pages/settings';
|
||||||
import { on, off, EVENTS } from './utils/event-bus';
|
import { on, off, EVENTS } from './utils/event-bus';
|
||||||
import { Layout } from '@/components/Layout';
|
import { Layout } from '@/components/shell/Layout';
|
||||||
import { HomePage } from '@/pages/home/HomePage';
|
import { HomePage } from '@/pages/home/HomePage';
|
||||||
import { PageDispatcher } from '@/components/PageDispatcher';
|
import { PageDispatcher } from '@/components/shell/PageDispatcher';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根组件 — 主窗口
|
* 根组件 — 主窗口
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { flushSync } from 'react-dom';
|
|||||||
import { ConfigProvider } from 'antd';
|
import { ConfigProvider } from 'antd';
|
||||||
import zhCN from 'antd/locale/zh_CN';
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
|
|
||||||
import { getThemeConfig } from '../theme/antd-theme';
|
import { getThemeConfig } from '../../theme/antd-theme';
|
||||||
|
|
||||||
// ---------- Context 类型 ----------
|
// ---------- Context 类型 ----------
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { theme as antTheme } from 'antd';
|
import { theme as antTheme } from 'antd';
|
||||||
import { NavBar } from './navbar';
|
import { NavBar } from './navbar';
|
||||||
import { BannerCarousel } from '@/pages/home/components/BannerCarousel';
|
import { BannerCarousel } from '@/pages/home/features/BannerCarousel';
|
||||||
|
|
||||||
export function Layout({ children }: { children: ReactNode }) {
|
export function Layout({ children }: { children: ReactNode }) {
|
||||||
const { token } = antTheme.useToken();
|
const { token } = antTheme.useToken();
|
||||||
@@ -21,9 +21,11 @@ import {Drawer, Modal} from 'antd';
|
|||||||
import {EVENTS, off, on} from '@/utils/event-bus';
|
import {EVENTS, off, on} from '@/utils/event-bus';
|
||||||
import {FloatingPanel} from '@/components/ui/FloatingPanel';
|
import {FloatingPanel} from '@/components/ui/FloatingPanel';
|
||||||
|
|
||||||
import {WechatWorkPage} from '@/pages/headers/WechatWorkPage';
|
import {WechatWorkPage} from '@/pages/panels/WechatWorkPage';
|
||||||
import {AccountInfo} from '@/pages/headers/AccountInfo.tsx';
|
import {AccountInfo} from '@/pages/panels/AccountInfo';
|
||||||
import {BillingInfo} from "@/pages/headers/BillingInfo.tsx";
|
import {BillingInfo} from '@/pages/panels/BillingInfo';
|
||||||
|
import {RechargePage} from '@/pages/panels/RechargePage';
|
||||||
|
import {OrdersPage} from '@/pages/panels/OrdersPage';
|
||||||
|
|
||||||
// ---------- 类型 ----------
|
// ---------- 类型 ----------
|
||||||
|
|
||||||
@@ -81,13 +83,16 @@ const PAGE_MAP: Record<string, PageConfig> = {
|
|||||||
height: () => Math.min(window.outerHeight * 0.65, 680),
|
height: () => Math.min(window.outerHeight * 0.65, 680),
|
||||||
},
|
},
|
||||||
'/vip': {label: '开会员/激活'},
|
'/vip': {label: '开会员/激活'},
|
||||||
'/recharge': {label: '充值积分'},
|
'/recharge': {component: RechargePage, label: '充值', width: 520},
|
||||||
'/billing': {label: '账单记录',
|
'/billing': {label: '账单记录',
|
||||||
component: BillingInfo,
|
component: BillingInfo,
|
||||||
width: 960,
|
width: 960,
|
||||||
height: 680,},
|
height: 680,},
|
||||||
'/orders': {
|
'/orders': {
|
||||||
|
component: OrdersPage,
|
||||||
label: '订单明细',
|
label: '订单明细',
|
||||||
|
width: 820,
|
||||||
|
height: 640,
|
||||||
},
|
},
|
||||||
'/projects': {label: '项目管理'},
|
'/projects': {label: '项目管理'},
|
||||||
'/quota': {
|
'/quota': {
|
||||||
@@ -126,6 +131,12 @@ function resolveHeight(target: string): number | undefined {
|
|||||||
export function PageDispatcher() {
|
export function PageDispatcher() {
|
||||||
const [activePanel, setActivePanel] = useState<ActivePanel | null>(null);
|
const [activePanel, setActivePanel] = useState<ActivePanel | null>(null);
|
||||||
const nextIdRef = useRef(0);
|
const nextIdRef = useRef(0);
|
||||||
|
const [locked, setLocked] = useState(false);
|
||||||
|
|
||||||
|
// 关闭面板
|
||||||
|
const closePanel = useCallback(() => {
|
||||||
|
setActivePanel(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 监听 OPEN_PAGE 事件
|
// 监听 OPEN_PAGE 事件
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -149,11 +160,26 @@ export function PageDispatcher() {
|
|||||||
return () => off(EVENTS.OPEN_PAGE, handler);
|
return () => off(EVENTS.OPEN_PAGE, handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 关闭面板
|
// 监听 CLOSE_PAGE 事件(允许页面组件内部"取消"按钮关闭容器)
|
||||||
const closePanel = useCallback(() => {
|
useEffect(() => {
|
||||||
setActivePanel(null);
|
on(EVENTS.CLOSE_PAGE, closePanel);
|
||||||
|
return () => off(EVENTS.CLOSE_PAGE, closePanel);
|
||||||
|
}, [closePanel]);
|
||||||
|
|
||||||
|
// 监听 PAGE_LOCK 事件(页面组件操作进行中时禁止关闭容器)
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (payload: unknown) => {
|
||||||
|
setLocked((payload as { locked: boolean }).locked);
|
||||||
|
};
|
||||||
|
on(EVENTS.PAGE_LOCK, handler);
|
||||||
|
return () => off(EVENTS.PAGE_LOCK, handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 面板关闭时重置锁定状态(防止下次打开时仍处于锁定)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activePanel) setLocked(false);
|
||||||
|
}, [activePanel]);
|
||||||
|
|
||||||
// 页面组件(useMemo 稳定引用)
|
// 页面组件(useMemo 稳定引用)
|
||||||
const PanelPage = useMemo<ComponentType | null>(
|
const PanelPage = useMemo<ComponentType | null>(
|
||||||
() => (activePanel ? resolvePage(activePanel.target) : null),
|
() => (activePanel ? resolvePage(activePanel.target) : null),
|
||||||
@@ -178,7 +204,7 @@ export function PageDispatcher() {
|
|||||||
onCancel={closePanel}
|
onCancel={closePanel}
|
||||||
footer={null}
|
footer={null}
|
||||||
destroyOnHidden={true}
|
destroyOnHidden={true}
|
||||||
mask={{closable: true}}
|
mask={{closable: !locked}}
|
||||||
width={panelWidth ?? defaultModalWidth}
|
width={panelWidth ?? defaultModalWidth}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line react-hooks/static-components */}
|
{/* eslint-disable-next-line react-hooks/static-components */}
|
||||||
@@ -193,6 +219,9 @@ export function PageDispatcher() {
|
|||||||
title={activePanel.label}
|
title={activePanel.label}
|
||||||
onClose={closePanel}
|
onClose={closePanel}
|
||||||
destroyOnHidden={true}
|
destroyOnHidden={true}
|
||||||
|
mask={{
|
||||||
|
closable:locked
|
||||||
|
}}
|
||||||
size={panelWidth ?? defaultDrawerWidth}
|
size={panelWidth ?? defaultDrawerWidth}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line react-hooks/static-components */}
|
{/* eslint-disable-next-line react-hooks/static-components */}
|
||||||
@@ -10,8 +10,8 @@
|
|||||||
import {useState, useCallback, useMemo} from 'react';
|
import {useState, useCallback, useMemo} from 'react';
|
||||||
import {App} from 'antd';
|
import {App} from 'antd';
|
||||||
|
|
||||||
import {useAppContext} from '@/components/AppProvider';
|
import {useAppContext} from '@/components/providers/AppProvider';
|
||||||
import {useTheme} from '@/components/ThemeProvider';
|
import {useTheme} from '@/components/providers/ThemeProvider';
|
||||||
import {emit, EVENTS} from '@/utils/event-bus.ts';
|
import {emit, EVENTS} from '@/utils/event-bus.ts';
|
||||||
import {NavItem} from './NavItem';
|
import {NavItem} from './NavItem';
|
||||||
import {AnnouncementDrawer} from './AnnouncementDrawer';
|
import {AnnouncementDrawer} from './AnnouncementDrawer';
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
// 布局:图标(SVG)在上,文字在下
|
// 布局:图标(SVG)在上,文字在下
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useTheme } from '@/components/ThemeProvider';
|
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||||
import type { NavItemConfig } from './types';
|
import type { NavItemConfig } from './types';
|
||||||
|
|
||||||
interface NavItemProps {
|
interface NavItemProps {
|
||||||
@@ -65,7 +65,7 @@ const personalRight: NavItemConfig[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'recharge-points',
|
key: 'recharge-points',
|
||||||
label: '充值积分',
|
label: '充值',
|
||||||
iconSrc: rechargeSvg,
|
iconSrc: rechargeSvg,
|
||||||
action: 'page',
|
action: 'page',
|
||||||
target: '/recharge',
|
target: '/recharge',
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useState, useRef, useCallback, useEffect, type ReactNode } from 'react';
|
import { useState, useRef, useCallback, useEffect, type ReactNode } from 'react';
|
||||||
import { useTheme } from '@/components/ThemeProvider';
|
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||||
|
|
||||||
// ---------- 类型 ----------
|
// ---------- 类型 ----------
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import { Modal, Typography } from 'antd';
|
import { Modal, Typography } from 'antd';
|
||||||
import { FileTextOutlined } from '@ant-design/icons';
|
import { FileTextOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { useTheme } from '@/components/ThemeProvider';
|
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||||
|
|
||||||
// Vite ?raw 导入:将 .txt 内容作为字符串直接内联
|
// Vite ?raw 导入:将 .txt 内容作为字符串直接内联
|
||||||
import serviceAgreement from '@/assets/legal/用户服务协议.txt?raw';
|
import serviceAgreement from '@/assets/legal/用户服务协议.txt?raw';
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import {useMemo, useState, useRef, useEffect, useCallback} from 'react';
|
import {useMemo, useState, useRef, useEffect, useCallback} from 'react';
|
||||||
import {useAppContext} from '@/components/AppProvider';
|
import {useAppContext} from '@/components/providers/AppProvider';
|
||||||
import { TaskAPI, POLLING_STATUSES, type TaskListRequestParams, type TaskInfoItemResponseBody, type CreatTaskRequestBody, type TaskStatusResponseBody, type TaskStatusString } from '@/services/modules/task';
|
import { TaskAPI, POLLING_STATUSES, type TaskListRequestParams, type TaskInfoItemResponseBody, type CreatTaskRequestBody, type TaskStatusResponseBody, type TaskStatusString } from '@/services/modules/task';
|
||||||
import { ModelAPI, MODEL_CATEGORY_LABELS, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/models';
|
import { ModelAPI, MODEL_CATEGORY_LABELS, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/models';
|
||||||
import { fetchBanners, type Banner } from '@/services/modules/banner';
|
import { fetchBanners, type Banner } from '@/services/modules/banner';
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import ReactDOM from 'react-dom/client';
|
|||||||
import {App as AntdApp} from 'antd';
|
import {App as AntdApp} from 'antd';
|
||||||
|
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import {ThemeProvider} from './components/ThemeProvider';
|
import {ThemeProvider} from './components/providers/ThemeProvider';
|
||||||
import {AppProvider} from './components/AppProvider';
|
import {AppProvider} from './components/providers/AppProvider';
|
||||||
import {SettingsProvider} from './components/SettingsProvider';
|
import {SettingsProvider} from './components/providers/SettingsProvider';
|
||||||
// 全局样式(包含 Tailwind 指令 + 主题扩展 + 重置)
|
// 全局样式(包含 Tailwind 指令 + 主题扩展 + 重置)
|
||||||
import './theme/globals.css';
|
import './theme/globals.css';
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { useState, useCallback } from 'react';
|
|||||||
import { Modal, Input, Button, Typography, App } from 'antd';
|
import { Modal, Input, Button, Typography, App } from 'antd';
|
||||||
import { LockOutlined, KeyOutlined, SafetyOutlined } from '@ant-design/icons';
|
import { LockOutlined, KeyOutlined, SafetyOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { useTheme } from '@/components/ThemeProvider';
|
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||||
import { AuthAPI } from '@/services/modules/auth';
|
import { AuthAPI } from '@/services/modules/auth';
|
||||||
import { validatePassword, validateConfirmPassword, runFieldChecks } from '../config/validators';
|
import { validatePassword, validateConfirmPassword, runFieldChecks } from '../config/validators';
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
KeyOutlined,
|
KeyOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
import { useTheme } from '@/components/ThemeProvider';
|
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||||
import { AuthAPI } from '@/services/modules/auth';
|
import { AuthAPI } from '@/services/modules/auth';
|
||||||
import {
|
import {
|
||||||
validatePhone,
|
validatePhone,
|
||||||
@@ -10,8 +10,8 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { Modal, Tabs, Typography, App } from 'antd';
|
import { Modal, Tabs, Typography, App } from 'antd';
|
||||||
|
|
||||||
import { useAppContext } from '@/components/AppProvider';
|
import { useAppContext } from '@/components/providers/AppProvider';
|
||||||
import { useTheme } from '@/components/ThemeProvider';
|
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||||
import { LoginForm } from './components/LoginForm';
|
import { LoginForm } from './components/LoginForm';
|
||||||
import { RegisterForm } from './components/RegisterForm';
|
import { RegisterForm } from './components/RegisterForm';
|
||||||
import { ForgotPasswordModal } from './components/ForgotPasswordModal';
|
import { ForgotPasswordModal } from './components/ForgotPasswordModal';
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export function Test3() {
|
|
||||||
return (
|
|
||||||
<></>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -3,52 +3,22 @@
|
|||||||
// 可拖拽调整三栏宽度,所有数据请求在登录后发送
|
// 可拖拽调整三栏宽度,所有数据请求在登录后发送
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import React, { createContext, useContext, useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||||
import { theme as antTheme, Empty } from 'antd';
|
import { theme as antTheme, Empty } from 'antd';
|
||||||
|
|
||||||
import { useAppContext } from '@/components/AppProvider';
|
import { useAppContext } from '@/components/providers/AppProvider';
|
||||||
import type { ModelsListResponseBody } from '@/services/modules/models';
|
import type { ModelsListResponseBody } from '@/services/modules/models';
|
||||||
import type { TaskInfoItemResponseBody } from '@/services/modules/task';
|
import type { TaskInfoItemResponseBody } from '@/services/modules/task';
|
||||||
import { LeftPanel } from './components/LeftPanel';
|
import { LeftPanel } from './panels/LeftPanel';
|
||||||
import { CenterPanel } from './components/CenterPanel';
|
import { CenterPanel } from './panels/CenterPanel';
|
||||||
import { RightPanel } from './components/RightPanel';
|
import { RightPanel } from './panels/RightPanel';
|
||||||
|
|
||||||
import { Typography } from 'antd';
|
import { Typography } from 'antd';
|
||||||
|
|
||||||
|
import { HomeContext } from './HomeContext';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
// ---------- Context 类型 ----------
|
|
||||||
|
|
||||||
export interface HomeContextValue {
|
|
||||||
/** 当前选中的模型 */
|
|
||||||
selectedModel: ModelsListResponseBody | null;
|
|
||||||
setSelectedModel: (model: ModelsListResponseBody | null) => void;
|
|
||||||
/** 任务记录分页 — 当前页码 */
|
|
||||||
taskPage: number;
|
|
||||||
/** 任务记录分页 — 每页条数 */
|
|
||||||
taskPageSize: number;
|
|
||||||
setTaskPage: (page: number) => void;
|
|
||||||
setTaskPageSize: (size: number) => void;
|
|
||||||
/** 当前选中的任务记录(用于右侧预览) */
|
|
||||||
selectedTask: TaskInfoItemResponseBody | null;
|
|
||||||
setSelectedTask: (task: TaskInfoItemResponseBody | null) => void;
|
|
||||||
/** 侧边栏折叠状态 */
|
|
||||||
isLeftCollapsed: boolean;
|
|
||||||
toggleLeftCollapsed: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const HomeContext = createContext<HomeContextValue | null>(null);
|
|
||||||
|
|
||||||
// ---------- Consumer Hook ----------
|
|
||||||
|
|
||||||
export function useHomeContext(): HomeContextValue {
|
|
||||||
const ctx = useContext(HomeContext);
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error('useHomeContext() 必须在 <HomeContent> 内部调用');
|
|
||||||
}
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 常量 ----------
|
// ---------- 常量 ----------
|
||||||
|
|
||||||
/** 左列最小/默认宽度 */
|
/** 左列最小/默认宽度 */
|
||||||
|
|||||||
43
src/pages/home/HomeContext.tsx
Normal file
43
src/pages/home/HomeContext.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// ============================================================
|
||||||
|
// HomeContext — 首页共享状态(Context + Provider + Hook)
|
||||||
|
//
|
||||||
|
// 原与 HomeContent.tsx 耦合在同一文件,现独立以便:
|
||||||
|
// - 消费者可以明确只 import 状态 Hook,不关心布局细节
|
||||||
|
// - 新增 consumer 时不需要翻阅 358 行 HomeContent
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
import type { ModelsListResponseBody } from '@/services/modules/models';
|
||||||
|
import type { TaskInfoItemResponseBody } from '@/services/modules/task';
|
||||||
|
|
||||||
|
// ---------- Context 类型 ----------
|
||||||
|
|
||||||
|
export interface HomeContextValue {
|
||||||
|
/** 当前选中的模型 */
|
||||||
|
selectedModel: ModelsListResponseBody | null;
|
||||||
|
setSelectedModel: (model: ModelsListResponseBody | null) => void;
|
||||||
|
/** 任务记录分页 — 当前页码 */
|
||||||
|
taskPage: number;
|
||||||
|
/** 任务记录分页 — 每页条数 */
|
||||||
|
taskPageSize: number;
|
||||||
|
setTaskPage: (page: number) => void;
|
||||||
|
setTaskPageSize: (size: number) => void;
|
||||||
|
/** 当前选中的任务记录(用于右侧预览) */
|
||||||
|
selectedTask: TaskInfoItemResponseBody | null;
|
||||||
|
setSelectedTask: (task: TaskInfoItemResponseBody | null) => void;
|
||||||
|
/** 侧边栏折叠状态 */
|
||||||
|
isLeftCollapsed: boolean;
|
||||||
|
toggleLeftCollapsed: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HomeContext = createContext<HomeContextValue | null>(null);
|
||||||
|
|
||||||
|
// ---------- Consumer Hook ----------
|
||||||
|
|
||||||
|
export function useHomeContext(): HomeContextValue {
|
||||||
|
const ctx = useContext(HomeContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useHomeContext() 必须在 <HomeContent> 内部调用');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// uploadValidation — 文件上传前校验工具
|
|
||||||
// ImageInput / ImageListInput 共用
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { Upload, message } from 'antd';
|
|
||||||
import type { RcFile } from 'antd/es/upload';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建图片上传前的类型 & 大小校验函数
|
|
||||||
* @param maxSizeMb 最大文件大小(MB),默认 10
|
|
||||||
*/
|
|
||||||
export function createImageBeforeUpload(maxSizeMb: number = 10) {
|
|
||||||
return (file: RcFile) => {
|
|
||||||
const isImage = file.type.startsWith('image/');
|
|
||||||
if (!isImage) {
|
|
||||||
message.error('只能上传图片文件');
|
|
||||||
return Upload.LIST_IGNORE;
|
|
||||||
}
|
|
||||||
if (file.size / 1024 / 1024 >= maxSizeMb) {
|
|
||||||
message.error(`图片大小不能超过 ${maxSizeMb}MB`);
|
|
||||||
return Upload.LIST_IGNORE;
|
|
||||||
}
|
|
||||||
return true; // 校验通过,交由 customRequest 执行实际上传
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -14,15 +14,33 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { Upload, Switch, Space, Typography } from 'antd';
|
import { Upload, Switch, Space, Typography, message } from 'antd';
|
||||||
import { UploadOutlined } from '@ant-design/icons';
|
import { UploadOutlined } from '@ant-design/icons';
|
||||||
import type { UploadFile } from 'antd/es/upload';
|
import type { UploadFile, RcFile } from 'antd/es/upload';
|
||||||
import type { WidgetProps } from './types';
|
import type { WidgetProps } from './types';
|
||||||
import { createImageBeforeUpload } from './uploadValidation';
|
|
||||||
import { useFileUpload } from '@/hooks/use-api';
|
import { useFileUpload } from '@/hooks/use-api';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建图片上传前的类型 & 大小校验函数(ImageInput / ImageListInput 共用)
|
||||||
|
* @param maxSizeMb 最大文件大小(MB),默认 10
|
||||||
|
*/
|
||||||
|
export function createImageBeforeUpload(maxSizeMb: number = 10) {
|
||||||
|
return (file: RcFile) => {
|
||||||
|
const isImage = file.type.startsWith('image/');
|
||||||
|
if (!isImage) {
|
||||||
|
message.error('只能上传图片文件');
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
}
|
||||||
|
if (file.size / 1024 / 1024 >= maxSizeMb) {
|
||||||
|
message.error(`图片大小不能超过 ${maxSizeMb}MB`);
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** 构建图片 accept 字符串 */
|
/** 构建图片 accept 字符串 */
|
||||||
function buildAccept(filters?: string[]): string {
|
function buildAccept(filters?: string[]): string {
|
||||||
if (!filters || filters.length === 0) return 'image/png,image/jpg,image/jpeg,image/webp';
|
if (!filters || filters.length === 0) return 'image/png,image/jpg,image/jpeg,image/webp';
|
||||||
@@ -7,7 +7,7 @@ import { Upload } from 'antd';
|
|||||||
import { InboxOutlined } from '@ant-design/icons';
|
import { InboxOutlined } from '@ant-design/icons';
|
||||||
import type { UploadFile } from 'antd/es/upload';
|
import type { UploadFile } from 'antd/es/upload';
|
||||||
import type { WidgetProps } from './types';
|
import type { WidgetProps } from './types';
|
||||||
import { createImageBeforeUpload } from './uploadValidation';
|
import { createImageBeforeUpload } from './ImageInput';
|
||||||
import { useFileUpload } from '@/hooks/use-api';
|
import { useFileUpload } from '@/hooks/use-api';
|
||||||
|
|
||||||
const { Dragger } = Upload;
|
const { Dragger } = Upload;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// useUI/index.ts — Widget 注册表
|
// controls/index.ts — Widget 注册表
|
||||||
//
|
//
|
||||||
// 职责:
|
// 职责:
|
||||||
// 1. 定义 WidgetProps 统一接口
|
// 1. 定义 WidgetProps 统一接口
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
// 3. 导出 WidgetType 联合类型供外部使用
|
// 3. 导出 WidgetType 联合类型供外部使用
|
||||||
//
|
//
|
||||||
// 新增控件类型只需两步:
|
// 新增控件类型只需两步:
|
||||||
// ① 在 useUI/ 下创建组件文件
|
// ① 在 controls/ 下创建组件文件
|
||||||
// ② 在 WIDGET_MAP 中加一行
|
// ② 在 WIDGET_MAP 中加一行
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// useUI/types.ts — Widget 类型定义(与 index.ts 分离以避免循环引用)
|
// controls/types.ts — Widget 类型定义(与 index.ts 分离以避免循环引用)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
// 布局:Header(模型信息+meta) → Body(fields.map 渲染) → Footer(标签+重置+提交)
|
// 布局:Header(模型信息+meta) → Body(fields.map 渲染) → Footer(标签+重置+提交)
|
||||||
//
|
//
|
||||||
// 与旧版的区别:
|
// 与旧版的区别:
|
||||||
// - 控件渲染逻辑已提取到 useUI/ 组件体系
|
// - 控件渲染逻辑已提取到 controls/ 组件体系
|
||||||
// - param_schema 解析已提取到 useModelUI Hook
|
// - param_schema 解析已提取到 useModelUI Hook
|
||||||
// - 字段间条件依赖(constraints_config.requires)自动处理禁用
|
// - 字段间条件依赖(constraints_config.requires)自动处理禁用
|
||||||
// - 本组件只负责:布局、Form 容器、提交逻辑、依赖禁用
|
// - 本组件只负责:布局、Form 容器、提交逻辑、依赖禁用
|
||||||
@@ -27,13 +27,13 @@ import type { FormInstance } from 'antd';
|
|||||||
import { SendOutlined, ClockCircleOutlined, ReloadOutlined } from '@ant-design/icons';
|
import { SendOutlined, ClockCircleOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
import type { UploadFile } from 'antd/es/upload';
|
import type { UploadFile } from 'antd/es/upload';
|
||||||
|
|
||||||
import { useHomeContext } from '@/pages/home/HomeContent';
|
import { useHomeContext } from '@/pages/home/HomeContext';
|
||||||
import { useAppContext } from '@/components/AppProvider';
|
import { useAppContext } from '@/components/providers/AppProvider';
|
||||||
import { useSubmitTask } from '@/hooks/use-api';
|
import { useSubmitTask } from '@/hooks/use-api';
|
||||||
import { emit, EVENTS } from '@/utils/event-bus';
|
import { emit, EVENTS } from '@/utils/event-bus';
|
||||||
import { OutputTypeMap } from '@/services/modules/task';
|
import { OutputTypeMap } from '@/services/modules/task';
|
||||||
import type { OutputTypeString, TaskInfoItemResponseBody } from '@/services/modules/task';
|
import type { OutputTypeString, TaskInfoItemResponseBody } from '@/services/modules/task';
|
||||||
import { useModelUI, isValueEmpty, type UIFieldConfig } from './useModelUI';
|
import { useModelUI, isValueEmpty, type UIFieldConfig } from '../hooks/useModelUI';
|
||||||
import {
|
import {
|
||||||
calculateCost,
|
calculateCost,
|
||||||
formatYuan,
|
formatYuan,
|
||||||
@@ -7,7 +7,7 @@ import React, { useState, useMemo, useCallback } from 'react';
|
|||||||
import { Tabs, Spin, Empty, Tag, Typography, Button, Result, message, theme as antTheme } from 'antd';
|
import { Tabs, Spin, Empty, Tag, Typography, Button, Result, message, theme as antTheme } from 'antd';
|
||||||
import { AppstoreOutlined } from '@ant-design/icons';
|
import { AppstoreOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { useHomeContext } from '@/pages/home/HomeContent';
|
import { useHomeContext } from '@/pages/home/HomeContext';
|
||||||
import { MODEL_CATEGORIES, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/models';
|
import { MODEL_CATEGORIES, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/models';
|
||||||
import { EnumResolver } from '@/utils/enum-resolver';
|
import { EnumResolver } from '@/utils/enum-resolver';
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
CloseCircleOutlined,
|
CloseCircleOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
import { useHomeContext } from '@/pages/home/HomeContent';
|
import { useHomeContext } from '@/pages/home/HomeContext';
|
||||||
import { useTaskDetail } from '@/hooks/use-api';
|
import { useTaskDetail } from '@/hooks/use-api';
|
||||||
import { POLLING_STATUSES, type TaskStatusString } from '@/services/modules/task';
|
import { POLLING_STATUSES, type TaskStatusString } from '@/services/modules/task';
|
||||||
|
|
||||||
@@ -19,9 +19,9 @@ import { HistoryOutlined, SearchOutlined, SettingOutlined } from '@ant-design/ic
|
|||||||
|
|
||||||
import { useTaskList, useTaskPolling } from '@/hooks/use-api';
|
import { useTaskList, useTaskPolling } from '@/hooks/use-api';
|
||||||
import type { ModelsListResponseBody } from '@/services/modules/models';
|
import type { ModelsListResponseBody } from '@/services/modules/models';
|
||||||
import { useAppContext } from '@/components/AppProvider';
|
import { useAppContext } from '@/components/providers/AppProvider';
|
||||||
import { useHomeContext } from '@/pages/home/HomeContent';
|
import { useHomeContext } from '@/pages/home/HomeContext';
|
||||||
import { useTheme } from '@/components/ThemeProvider';
|
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||||
import { on, off, EVENTS } from '@/utils/event-bus';
|
import { on, off, EVENTS } from '@/utils/event-bus';
|
||||||
import type {
|
import type {
|
||||||
TaskInfoItemResponseBody,
|
TaskInfoItemResponseBody,
|
||||||
@@ -15,8 +15,8 @@ import { useMemo } from 'react';
|
|||||||
import type { ComponentType } from 'react';
|
import type { ComponentType } from 'react';
|
||||||
|
|
||||||
import type { ModelsListResponseBody } from '@/services/modules/models';
|
import type { ModelsListResponseBody } from '@/services/modules/models';
|
||||||
import { WIDGET_MAP, FALLBACK_WIDGET } from './useUI';
|
import { WIDGET_MAP, FALLBACK_WIDGET } from '../controls';
|
||||||
import type { WidgetProps } from './useUI';
|
import type { WidgetProps } from '../controls';
|
||||||
|
|
||||||
// ---------- 输出类型 ----------
|
// ---------- 输出类型 ----------
|
||||||
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { theme as antTheme } from 'antd';
|
import { theme as antTheme } from 'antd';
|
||||||
import { ModelInputForm } from './ModelInputForm';
|
import { ModelInputForm } from '../features/ModelInputForm';
|
||||||
|
|
||||||
export function CenterPanel() {
|
export function CenterPanel() {
|
||||||
const { token } = antTheme.useToken();
|
const { token } = antTheme.useToken();
|
||||||
@@ -7,8 +7,8 @@
|
|||||||
|
|
||||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
import { theme as antTheme } from 'antd';
|
import { theme as antTheme } from 'antd';
|
||||||
import { ModelSelector } from './ModelSelector';
|
import { ModelSelector } from '../features/ModelSelector';
|
||||||
import { TaskHistory } from './TaskHistory';
|
import { TaskHistory } from '../features/TaskHistory';
|
||||||
import { useModelList } from '@/hooks/use-api';
|
import { useModelList } from '@/hooks/use-api';
|
||||||
|
|
||||||
// ---------- 常量 ----------
|
// ---------- 常量 ----------
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { theme as antTheme } from 'antd';
|
import { theme as antTheme } from 'antd';
|
||||||
import { OutputPreview } from './OutputPreview';
|
import { OutputPreview } from '../features/OutputPreview';
|
||||||
|
|
||||||
export function RightPanel() {
|
export function RightPanel() {
|
||||||
const { token } = antTheme.useToken();
|
const { token } = antTheme.useToken();
|
||||||
321
src/pages/panels/OrdersPage.tsx
Normal file
321
src/pages/panels/OrdersPage.tsx
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Table,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
Tag,
|
||||||
|
Segmented,
|
||||||
|
Result,
|
||||||
|
App,
|
||||||
|
theme as antTheme,
|
||||||
|
} from 'antd';
|
||||||
|
import { DollarOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
import {
|
||||||
|
PaymentAPI,
|
||||||
|
PAYMENT_STATUS,
|
||||||
|
PAYMENT_METHODS,
|
||||||
|
getPaymentStatusText,
|
||||||
|
type PaymentStatus,
|
||||||
|
type OrderListResponseBody,
|
||||||
|
type OrderInfoResponseBody,
|
||||||
|
} from '@/services/modules/payments';
|
||||||
|
import { centToYuan, formatDate } from '@/utils/display';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
// ---------- 常量 ----------
|
||||||
|
|
||||||
|
/** 订单状态轮询间隔(毫秒) */
|
||||||
|
// startPolling 实现后将使用此常量
|
||||||
|
const POLL_INTERVAL_MS = 3000;
|
||||||
|
|
||||||
|
/** 状态筛选项 */
|
||||||
|
const STATUS_FILTERS = [
|
||||||
|
{ label: '全部', value: 'all' },
|
||||||
|
{ label: '待支付', value: PAYMENT_STATUS.Pending },
|
||||||
|
{ label: '已支付', value: PAYMENT_STATUS.Paid },
|
||||||
|
{ label: '已过期', value: PAYMENT_STATUS.Expired },
|
||||||
|
{ label: '支付失败', value: PAYMENT_STATUS.Failed },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 状态 → Tag 颜色映射 */
|
||||||
|
const STATUS_TAG_COLOR: Record<string, string> = {
|
||||||
|
[PAYMENT_STATUS.Pending]: 'processing',
|
||||||
|
[PAYMENT_STATUS.Paid]: 'success',
|
||||||
|
[PAYMENT_STATUS.Expired]: 'default',
|
||||||
|
[PAYMENT_STATUS.Failed]: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 支付方式 → 显示文本 */
|
||||||
|
function getPaymentChannelLabel(channel: string): string {
|
||||||
|
if (channel === PAYMENT_METHODS.Alipay) return '支付宝';
|
||||||
|
if (channel === PAYMENT_METHODS.WeChat) return '微信支付';
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 组件 ----------
|
||||||
|
|
||||||
|
export function OrdersPage() {
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const { token } = antTheme.useToken();
|
||||||
|
|
||||||
|
// ======== 筛选 & 分页 ========
|
||||||
|
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||||
|
const [pagination, setPagination] = useState({ page: 1, page_size: 10 });
|
||||||
|
|
||||||
|
// ======== 数据状态 ========
|
||||||
|
|
||||||
|
const [orders, setOrders] = useState<OrderListResponseBody[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 轮询定时器 Map(orderId → timer)
|
||||||
|
const pollingRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||||
|
// 组件是否已挂载
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
|
||||||
|
// ======== 数据获取 ========
|
||||||
|
|
||||||
|
const fetchOrders = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const params: { page: number; page_size: number; status?: PaymentStatus } = {
|
||||||
|
page: pagination.page,
|
||||||
|
page_size: pagination.page_size,
|
||||||
|
};
|
||||||
|
if (statusFilter !== 'all') {
|
||||||
|
params.status = statusFilter as PaymentStatus;
|
||||||
|
}
|
||||||
|
const data = await PaymentAPI.getOrdersListInfo(params);
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setOrders(data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setError((err as Error)?.message || '加载订单列表失败');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [pagination, statusFilter]);
|
||||||
|
|
||||||
|
// 筛选或分页变化 → 重新获取
|
||||||
|
useEffect(() => {
|
||||||
|
fetchOrders();
|
||||||
|
}, [fetchOrders]);
|
||||||
|
|
||||||
|
// 挂载/卸载管理
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
|
const polling = pollingRef.current;
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
// 清理所有轮询定时器
|
||||||
|
polling.forEach((timer) => clearTimeout(timer));
|
||||||
|
polling.clear();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ======== 单订单轮询 ========
|
||||||
|
|
||||||
|
// TODO(human): 实现 startPolling 函数
|
||||||
|
// 该函数接收 orderId,对该订单启动轮询(每 POLL_INTERVAL_MS 查一次)
|
||||||
|
// 轮询到终态时停止,并刷新列表;网络异常时延长间隔重试
|
||||||
|
// 需要考虑:
|
||||||
|
// 1. 取消之前同一订单的轮询(避免重复)
|
||||||
|
// 2. 组件卸载时清理定时器(通过 mountedRef)
|
||||||
|
// 3. 轮询结果更新到 orders state 中(局部更新后,终态时调用 fetchOrders 保证一致性)
|
||||||
|
const startPolling = useCallback((_orderId: string) => {
|
||||||
|
// TODO(human): 实现此函数(使用 POLL_INTERVAL_MS、message、fetchOrders 等闭包变量)
|
||||||
|
void POLL_INTERVAL_MS;
|
||||||
|
void message;
|
||||||
|
void fetchOrders;
|
||||||
|
void ({} as OrderInfoResponseBody);
|
||||||
|
}, [message, fetchOrders]);
|
||||||
|
|
||||||
|
// ======== 操作回调 ========
|
||||||
|
|
||||||
|
/** "继续支付":打开支付页面 + 启动轮询 */
|
||||||
|
const handleContinuePay = useCallback(
|
||||||
|
(order: OrderListResponseBody) => {
|
||||||
|
window.open(order.pay_url, '_blank');
|
||||||
|
startPolling(order.id);
|
||||||
|
message.info('已打开支付页面,请完成支付');
|
||||||
|
},
|
||||||
|
[startPolling, message],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ======== 表格列定义 ========
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '订单编号',
|
||||||
|
dataIndex: 'id',
|
||||||
|
key: 'id',
|
||||||
|
width: 200,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (id: string) => (
|
||||||
|
<Text copyable style={{ fontSize: 12 }}>
|
||||||
|
{id}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '金额',
|
||||||
|
dataIndex: 'amount_cent',
|
||||||
|
key: 'amount_cent',
|
||||||
|
width: 90,
|
||||||
|
align: 'center' as const,
|
||||||
|
render: (amount: number) => (
|
||||||
|
<Text strong style={{ fontSize: 13, color: token.colorPrimary }}>
|
||||||
|
¥{centToYuan(amount)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '支付方式',
|
||||||
|
dataIndex: 'payment_channel',
|
||||||
|
key: 'payment_channel',
|
||||||
|
width: 90,
|
||||||
|
align: 'center' as const,
|
||||||
|
render: (channel: string) => (
|
||||||
|
<Text style={{ fontSize: 12 }}>{getPaymentChannelLabel(channel)}</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
width: 90,
|
||||||
|
align: 'center' as const,
|
||||||
|
render: (status: PaymentStatus) => (
|
||||||
|
<Tag color={STATUS_TAG_COLOR[status]} style={{ fontSize: 11, lineHeight: '18px' }}>
|
||||||
|
{getPaymentStatusText(status)}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
key: 'created_at',
|
||||||
|
width: 120,
|
||||||
|
align: 'center' as const,
|
||||||
|
render: (date: Date) => (
|
||||||
|
<Text style={{ fontSize: 12, whiteSpace: 'nowrap' }}>{formatDate(date)}</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 110,
|
||||||
|
align: 'center' as const,
|
||||||
|
render: (_: unknown, record: OrderListResponseBody) => {
|
||||||
|
if (record.status === PAYMENT_STATUS.Pending && record.pay_url) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
icon={<DollarOutlined />}
|
||||||
|
onClick={() => handleContinuePay(record)}
|
||||||
|
>
|
||||||
|
继续支付
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
—
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 渲染:错误态(无数据时)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
if (error && orders.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '8px 0' }}>
|
||||||
|
<Card size="small" className="rounded-md!">
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title="订单加载失败"
|
||||||
|
subTitle={error}
|
||||||
|
extra={
|
||||||
|
<Button type="primary" size="small" onClick={fetchOrders}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 渲染:正常
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '8px 0', width: '100%' }}>
|
||||||
|
<div className="flex flex-col gap-3" style={{ width: '100%' }}>
|
||||||
|
{/* ---- 状态筛选 ---- */}
|
||||||
|
<Segmented
|
||||||
|
options={STATUS_FILTERS}
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(val) => {
|
||||||
|
setStatusFilter(val as string);
|
||||||
|
setPagination((prev) => ({ ...prev, page: 1 }));
|
||||||
|
}}
|
||||||
|
block
|
||||||
|
style={{ backgroundColor: token.colorFillSecondary }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ---- 手动刷新 ---- */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<Button size="small" icon={<ReloadOutlined />} onClick={fetchOrders} loading={loading}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- 订单表格 ---- */}
|
||||||
|
<Card size="small" styles={{ body: { padding: 0 } }} className="rounded-md!">
|
||||||
|
<Table<OrderListResponseBody>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={orders}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
size="small"
|
||||||
|
pagination={{
|
||||||
|
current: pagination.page,
|
||||||
|
pageSize: pagination.page_size,
|
||||||
|
total:
|
||||||
|
orders.length >= pagination.page_size
|
||||||
|
? pagination.page * pagination.page_size + 1
|
||||||
|
: pagination.page * pagination.page_size,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: ['10', '20', '50'],
|
||||||
|
showTotal: (total: number, range: [number, number]) =>
|
||||||
|
`${range[0]}-${range[1]},共 ${total}+ 条`,
|
||||||
|
onChange: (page: number, pageSize: number) => {
|
||||||
|
setPagination({ page, page_size: pageSize });
|
||||||
|
},
|
||||||
|
position: ['bottomCenter'],
|
||||||
|
}}
|
||||||
|
locale={{ emptyText: '暂无订单记录' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
524
src/pages/panels/RechargePage.tsx
Normal file
524
src/pages/panels/RechargePage.tsx
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
// ============================================================
|
||||||
|
// RechargePage — 积分充值表单 + 支付状态跟踪
|
||||||
|
//
|
||||||
|
// 通过 PageDispatcher 调度(Modal 容器),导航栏"充值"按钮触发。
|
||||||
|
// 两阶段视图:
|
||||||
|
// form — 金额选择 → 自定义输入 → 支付方式 → 协议 → 提交
|
||||||
|
// pending — 订单已创建,轮询支付状态,可重新打开支付页面
|
||||||
|
// paid — 支付成功
|
||||||
|
// expired — 订单过期,可重新下单
|
||||||
|
// failed — 支付失败,可重新下单
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Radio,
|
||||||
|
InputNumber,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Typography,
|
||||||
|
App,
|
||||||
|
Space,
|
||||||
|
Divider,
|
||||||
|
theme as antTheme,
|
||||||
|
Result,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
AlipayOutlined,
|
||||||
|
WechatOutlined,
|
||||||
|
WalletOutlined,
|
||||||
|
DollarOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||||
|
import { LegalTextModal } from '@/components/ui/LegalTextModal';
|
||||||
|
import {
|
||||||
|
PaymentAPI,
|
||||||
|
PAYMENT_METHODS,
|
||||||
|
PAYMENT_STATUS,
|
||||||
|
getPaymentStatusText,
|
||||||
|
type PaymentStatus,
|
||||||
|
} from '@/services/modules/payments';
|
||||||
|
import { emit, EVENTS } from '@/utils/event-bus';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
// ---------- 常量 ----------
|
||||||
|
|
||||||
|
const PRESET_AMOUNTS = [10, 100, 500, 1000, 5000, 10000] as const;
|
||||||
|
|
||||||
|
/** 单次充值上限(元) */
|
||||||
|
const MAX_AMOUNT_YUAN = 100000;
|
||||||
|
|
||||||
|
/** 订单状态轮询间隔(毫秒) */
|
||||||
|
const POLL_INTERVAL_MS = 3000;
|
||||||
|
|
||||||
|
/** 页面阶段 */
|
||||||
|
type RechargePhase = 'form' | PaymentStatus;
|
||||||
|
|
||||||
|
// ---------- 组件 ----------
|
||||||
|
|
||||||
|
export function RechargePage() {
|
||||||
|
const { isDark } = useTheme();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const { token } = antTheme.useToken();
|
||||||
|
|
||||||
|
// ======== 表单状态 ========
|
||||||
|
|
||||||
|
const [selectedAmount, setSelectedAmount] = useState<number | null>(null);
|
||||||
|
const [customAmount, setCustomAmount] = useState<number | null>(null);
|
||||||
|
const [paymentMethod, setPaymentMethod] = useState<string>(PAYMENT_METHODS.Alipay);
|
||||||
|
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
const [legalOpen, setLegalOpen] = useState(false);
|
||||||
|
|
||||||
|
// ======== 订单状态 ========
|
||||||
|
|
||||||
|
const [phase, setPhase] = useState<RechargePhase>('form');
|
||||||
|
const [orderId, setOrderId] = useState<string | null>(null);
|
||||||
|
const [orderStatus, setOrderStatus] = useState<PaymentStatus | null>(null);
|
||||||
|
const [payUrl, setPayUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// ======== 派生值 ========
|
||||||
|
|
||||||
|
/** 最终充值金额(元):自定义金额优先,否则预设金额,否则 0 */
|
||||||
|
const finalAmountYuan = useMemo(() => {
|
||||||
|
if (customAmount !== null && customAmount !== undefined) {
|
||||||
|
return customAmount;
|
||||||
|
}
|
||||||
|
return selectedAmount ?? 0;
|
||||||
|
}, [customAmount, selectedAmount]);
|
||||||
|
|
||||||
|
/** 最终充值金额(分):元 × 100,用于 API 调用 */
|
||||||
|
const finalAmountCent = finalAmountYuan * 100;
|
||||||
|
|
||||||
|
// ======== 工具函数 ========
|
||||||
|
|
||||||
|
const clearError = useCallback((name: string) => {
|
||||||
|
setErrors((prev) => {
|
||||||
|
if (!(name in prev)) return prev;
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[name];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ======== 校验 ========
|
||||||
|
|
||||||
|
const validate = useCallback((): Record<string, string> => {
|
||||||
|
const errs: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (!finalAmountYuan || finalAmountYuan <= 0) {
|
||||||
|
errs.amount = '请选择或输入充值金额';
|
||||||
|
} else if (!Number.isInteger(finalAmountYuan) || finalAmountYuan % 10 !== 0) {
|
||||||
|
errs.customAmount = '金额必须为 10 的倍数';
|
||||||
|
} else if (finalAmountYuan > MAX_AMOUNT_YUAN) {
|
||||||
|
errs.customAmount = `单次充值上限为 ${MAX_AMOUNT_YUAN.toLocaleString()} 元`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!agreedToTerms) {
|
||||||
|
errs.agreedToTerms = '请阅读并同意充值协议';
|
||||||
|
}
|
||||||
|
|
||||||
|
return errs;
|
||||||
|
}, [finalAmountYuan, agreedToTerms]);
|
||||||
|
|
||||||
|
// ======== 支付方式切换 ========
|
||||||
|
|
||||||
|
const handlePaymentChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
if (value === PAYMENT_METHODS.WeChat) {
|
||||||
|
void message.info('微信支付正在接入中,敬请期待');
|
||||||
|
return; // 不更新 state,保持支付宝选中
|
||||||
|
}
|
||||||
|
setPaymentMethod(value);
|
||||||
|
},
|
||||||
|
[message],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ======== 提交流程 ========
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
const validationErrors = validate();
|
||||||
|
if (Object.keys(validationErrors).length > 0) {
|
||||||
|
setErrors(validationErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const res = await PaymentAPI.createOrder({
|
||||||
|
amount_cent: finalAmountCent,
|
||||||
|
payment_channel: PAYMENT_METHODS.Alipay,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 保存订单信息,进入等待支付阶段
|
||||||
|
setOrderId(res.id);
|
||||||
|
setOrderStatus(PAYMENT_STATUS.Pending);
|
||||||
|
setPayUrl(res.pay_url);
|
||||||
|
setPhase(PAYMENT_STATUS.Pending);
|
||||||
|
|
||||||
|
window.open(res.pay_url, '_blank');
|
||||||
|
void message.success('订单已创建,请在新页面中完成支付');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
message.error((err as Error)?.message || '创建订单失败,请重试');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [finalAmountCent, validate, message]);
|
||||||
|
|
||||||
|
// ======== 订单状态轮询 ========
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== PAYMENT_STATUS.Pending || !orderId) return;
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const order = await PaymentAPI.getOrderInfo({ order_id: orderId });
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const newStatus = order.status;
|
||||||
|
setOrderStatus(newStatus);
|
||||||
|
|
||||||
|
if (newStatus === PAYMENT_STATUS.Pending) {
|
||||||
|
// 仍待支付 → 继续轮询
|
||||||
|
timer = setTimeout(poll, POLL_INTERVAL_MS);
|
||||||
|
} else {
|
||||||
|
// 终态 → 停止轮询,切换阶段
|
||||||
|
setPhase(newStatus);
|
||||||
|
if (newStatus === PAYMENT_STATUS.Paid) {
|
||||||
|
void message.success('支付成功,积分已到账');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 网络异常 → 延长间隔后继续轮询
|
||||||
|
if (!cancelled) timer = setTimeout(poll, POLL_INTERVAL_MS * 2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
timer = setTimeout(poll, POLL_INTERVAL_MS);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [phase, orderId, message]);
|
||||||
|
|
||||||
|
// ======== 页面锁定(待支付时禁止关闭容器) ========
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const locked = phase === PAYMENT_STATUS.Pending;
|
||||||
|
emit(EVENTS.PAGE_LOCK, { locked });
|
||||||
|
// 组件卸载时确保解锁(防止锁定残留)
|
||||||
|
return () => {
|
||||||
|
if (locked) emit(EVENTS.PAGE_LOCK, { locked: false });
|
||||||
|
};
|
||||||
|
}, [phase]);
|
||||||
|
|
||||||
|
// ======== 操作回调 ========
|
||||||
|
|
||||||
|
/** 重新下单:回到表单,保留之前填写的金额 */
|
||||||
|
const handleRetry = useCallback(() => {
|
||||||
|
setPhase('form');
|
||||||
|
setOrderId(null);
|
||||||
|
setOrderStatus(null);
|
||||||
|
setPayUrl(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** 关闭充值页面 */
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
emit(EVENTS.CLOSE_PAGE);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// 渲染:支付状态视图(非 form 阶段)
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
if (phase !== 'form') {
|
||||||
|
const statusText = orderStatus ? getPaymentStatusText(orderStatus) : '';
|
||||||
|
const isPending = phase === PAYMENT_STATUS.Pending;
|
||||||
|
const isPaid = phase === PAYMENT_STATUS.Paid;
|
||||||
|
const isTerminal = !isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ padding: '16px 0' }}>
|
||||||
|
<Result
|
||||||
|
status={
|
||||||
|
isPaid
|
||||||
|
? 'success'
|
||||||
|
: isPending
|
||||||
|
? 'info'
|
||||||
|
: 'error'
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
isPaid
|
||||||
|
? '支付成功'
|
||||||
|
: isPending
|
||||||
|
? `等待支付(${statusText})`
|
||||||
|
: statusText
|
||||||
|
}
|
||||||
|
subTitle={
|
||||||
|
isPaid
|
||||||
|
? `已到账积分:${finalAmountYuan.toLocaleString()} 积分`
|
||||||
|
: isPending
|
||||||
|
? '请在新打开的浏览器页面中完成支付'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* ---- 订单信息卡片 ---- */}
|
||||||
|
{isPending && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: isDark ? '#2E2A5A' : '#F5F3FF',
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 16,
|
||||||
|
textAlign: 'left',
|
||||||
|
maxWidth: 360,
|
||||||
|
margin: '0 auto 16px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space orientation="vertical" size={6} style={{ width: '100%' }}>
|
||||||
|
<Text style={{ fontSize: 12 }} type="secondary">
|
||||||
|
订单编号:<Text copyable style={{ fontSize: 12 }}>{orderId}</Text>
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 13 }}>
|
||||||
|
<DollarOutlined style={{ marginRight: 6, color: token.colorPrimary }} />
|
||||||
|
充值金额:
|
||||||
|
<Text strong>¥{finalAmountYuan.toLocaleString()} 元</Text>
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 13 }}>
|
||||||
|
<WalletOutlined style={{ marginRight: 6, color: token.colorSuccess }} />
|
||||||
|
到账积分:
|
||||||
|
<Text strong>{finalAmountYuan.toLocaleString()}</Text> 积分
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- 操作按钮 ---- */}
|
||||||
|
<Space size={12}>
|
||||||
|
{isPending && payUrl && (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<DollarOutlined />}
|
||||||
|
onClick={() => window.open(payUrl, '_blank')}
|
||||||
|
>
|
||||||
|
打开支付页面
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isTerminal && !isPaid && (
|
||||||
|
<Button type="primary" onClick={handleRetry}>
|
||||||
|
重新下单
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={handleClose}>
|
||||||
|
{isPaid ? '完成' : '关闭(取消支付)'}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Result>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- 协议弹窗(待支付阶段仍可查看) ---- */}
|
||||||
|
<LegalTextModal
|
||||||
|
open={legalOpen}
|
||||||
|
type="points"
|
||||||
|
onClose={() => setLegalOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// 渲染:表单视图(form 阶段)
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ padding: '4px 0' }}>
|
||||||
|
{/* ---- 页面副标题 ---- */}
|
||||||
|
<Text
|
||||||
|
type="secondary"
|
||||||
|
style={{ fontSize: 13, marginBottom: 20, display: 'block' }}
|
||||||
|
>
|
||||||
|
选择充值金额或输入自定义金额(元)
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* ---- Part 1:预设金额单选组 ---- */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Radio.Group
|
||||||
|
value={selectedAmount}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedAmount(e.target.value);
|
||||||
|
setCustomAmount(null);
|
||||||
|
clearError('amount');
|
||||||
|
}}
|
||||||
|
optionType="button"
|
||||||
|
buttonStyle="solid"
|
||||||
|
style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}
|
||||||
|
>
|
||||||
|
{PRESET_AMOUNTS.map((amt) => (
|
||||||
|
<Radio.Button key={amt} value={amt}>
|
||||||
|
{amt} 元
|
||||||
|
</Radio.Button>
|
||||||
|
))}
|
||||||
|
</Radio.Group>
|
||||||
|
{errors.amount && (
|
||||||
|
<Text type="danger" style={{ fontSize: 12, marginTop: 4, display: 'block' }}>
|
||||||
|
{errors.amount}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- Part 2:自定义金额输入 ---- */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Space.Compact style={{ width: '100%' }}>
|
||||||
|
<InputNumber
|
||||||
|
value={customAmount}
|
||||||
|
onChange={(val) => {
|
||||||
|
setCustomAmount(val);
|
||||||
|
setSelectedAmount(null);
|
||||||
|
clearError('customAmount');
|
||||||
|
}}
|
||||||
|
min={10}
|
||||||
|
step={10}
|
||||||
|
max={MAX_AMOUNT_YUAN}
|
||||||
|
placeholder="输入自定义金额(10的倍数)"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
size="large"
|
||||||
|
precision={0}
|
||||||
|
/>
|
||||||
|
<Button size="large" disabled style={{ minWidth: 44 }}>
|
||||||
|
元
|
||||||
|
</Button>
|
||||||
|
</Space.Compact>
|
||||||
|
{errors.customAmount && (
|
||||||
|
<Text type="danger" style={{ fontSize: 12, marginTop: 4, display: 'block' }}>
|
||||||
|
{errors.customAmount}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- Part 3:到账信息 + 支付金额 ---- */}
|
||||||
|
{finalAmountYuan > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: isDark ? '#2E2A5A' : '#F5F3FF',
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space orientation="vertical" size={6}>
|
||||||
|
<Text style={{ fontSize: 13 }}>
|
||||||
|
<WalletOutlined style={{ marginRight: 6, color: token.colorPrimary }} />
|
||||||
|
到账积分:
|
||||||
|
<Text strong>{finalAmountYuan.toLocaleString()}</Text> 积分
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 13 }}>
|
||||||
|
<DollarOutlined style={{ marginRight: 6, color: token.colorSuccess }} />
|
||||||
|
支付金额:
|
||||||
|
<Text strong style={{ color: token.colorPrimary, fontSize: 15 }}>
|
||||||
|
¥{finalAmountYuan.toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
{' '}元
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider style={{ margin: '12px 0' }} />
|
||||||
|
|
||||||
|
{/* ---- Part 4:支付方式 ---- */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Text
|
||||||
|
type="secondary"
|
||||||
|
style={{ fontSize: 12, marginBottom: 8, display: 'block' }}
|
||||||
|
>
|
||||||
|
选择支付方式
|
||||||
|
</Text>
|
||||||
|
<Radio.Group
|
||||||
|
value={paymentMethod}
|
||||||
|
onChange={(e) => handlePaymentChange(e.target.value)}
|
||||||
|
optionType="button"
|
||||||
|
buttonStyle="solid"
|
||||||
|
>
|
||||||
|
<Radio.Button value={PAYMENT_METHODS.Alipay}>
|
||||||
|
<Space size={4}>
|
||||||
|
<AlipayOutlined />
|
||||||
|
支付宝
|
||||||
|
</Space>
|
||||||
|
</Radio.Button>
|
||||||
|
<Radio.Button value={PAYMENT_METHODS.WeChat}>
|
||||||
|
<Space size={4}>
|
||||||
|
<WechatOutlined />
|
||||||
|
微信支付
|
||||||
|
</Space>
|
||||||
|
</Radio.Button>
|
||||||
|
</Radio.Group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '12px 0' }} />
|
||||||
|
|
||||||
|
{/* ---- Part 5:充值协议 ---- */}
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
<Checkbox
|
||||||
|
checked={agreedToTerms}
|
||||||
|
onChange={(e) => {
|
||||||
|
setAgreedToTerms(e.target.checked);
|
||||||
|
clearError('agreedToTerms');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 12 }}>
|
||||||
|
我已阅读并同意
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={() => setLegalOpen(true)}
|
||||||
|
style={{
|
||||||
|
padding: '0 2px',
|
||||||
|
fontSize: 12,
|
||||||
|
height: 'auto',
|
||||||
|
lineHeight: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
《积分充值服务协议》
|
||||||
|
</Button>
|
||||||
|
</Text>
|
||||||
|
</Checkbox>
|
||||||
|
{errors.agreedToTerms && (
|
||||||
|
<Text type="danger" style={{ fontSize: 12, marginTop: 4, display: 'block' }}>
|
||||||
|
{errors.agreedToTerms}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- Part 6:操作按钮 ---- */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||||
|
<Button onClick={handleClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
loading={submitting}
|
||||||
|
onClick={() => void handleSubmit()}
|
||||||
|
disabled={finalAmountYuan <= 0}
|
||||||
|
>
|
||||||
|
提交
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- 协议弹窗 ---- */}
|
||||||
|
<LegalTextModal
|
||||||
|
open={legalOpen}
|
||||||
|
type="points"
|
||||||
|
onClose={() => setLegalOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,13 +14,13 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { Modal } from 'antd';
|
import { Modal } from 'antd';
|
||||||
import { SettingOutlined, CloseOutlined } from '@ant-design/icons';
|
import { SettingOutlined, CloseOutlined } from '@ant-design/icons';
|
||||||
import { useAppContext } from '@/components/AppProvider';
|
import { useAppContext } from '@/components/providers/AppProvider';
|
||||||
import { isMacOS } from '@/utils/platform';
|
import { isMacOS } from '@/utils/platform';
|
||||||
import { ThemeSetting } from './blocks/ThemeSetting';
|
import { ThemeSetting } from './sections/ThemeSetting';
|
||||||
import { StoragePathSetting } from './blocks/StoragePathSetting';
|
import { StoragePathSetting } from './sections/StoragePathSetting';
|
||||||
import { SystemInfoSetting } from './blocks/SystemInfoSetting';
|
import { SystemInfoSetting } from './sections/SystemInfoSetting';
|
||||||
import { UpdateSetting } from './blocks/UpdateSetting';
|
import { UpdateSetting } from './sections/UpdateSetting';
|
||||||
import { PasswordSetting } from './blocks/PasswordSetting';
|
import { PasswordSetting } from './sections/PasswordSetting';
|
||||||
|
|
||||||
interface SettingsPageProps {
|
interface SettingsPageProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import { useState } from 'react';
|
|||||||
import { Card, Button, Typography } from 'antd';
|
import { Card, Button, Typography } from 'antd';
|
||||||
import { LockOutlined, PhoneOutlined } from '@ant-design/icons';
|
import { LockOutlined, PhoneOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { ChangePasswordModal } from '@/pages/login/components/ChangePasswordModal';
|
import { ChangePasswordModal } from '@/pages/auth/components/ChangePasswordModal';
|
||||||
import { ForgotPasswordModal } from '@/pages/login/components/ForgotPasswordModal';
|
import { ForgotPasswordModal } from '@/pages/auth/components/ForgotPasswordModal';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ import { useState, useCallback } from 'react';
|
|||||||
import { Card, Button, Input, Typography, Space, App } from 'antd';
|
import { Card, Button, Input, Typography, Space, App } from 'antd';
|
||||||
import { FolderOpenOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons';
|
import { FolderOpenOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons';
|
||||||
import type { Edition } from '@shared/types';
|
import type { Edition } from '@shared/types';
|
||||||
import { useSettings } from '@/components/SettingsProvider';
|
import { useSettings } from '@/components/providers/SettingsProvider';
|
||||||
import { hasIpc, safeIpcInvoke } from '@/utils/ipc';
|
import { hasIpc, safeIpcInvoke } from '@/utils/ipc';
|
||||||
import { BIDIRECTIONAL } from '@shared/constants/ipc-channels';
|
import { BIDIRECTIONAL } from '@shared/constants/ipc-channels';
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
InfoCircleOutlined,
|
InfoCircleOutlined,
|
||||||
EnvironmentOutlined,
|
EnvironmentOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useAppContext } from '@/components/AppProvider';
|
import { useAppContext } from '@/components/providers/AppProvider';
|
||||||
import { getPlatformName } from '@/utils/platform';
|
import { getPlatformName } from '@/utils/platform';
|
||||||
import { APP_VERSION } from '@shared/constants/version';
|
import { APP_VERSION } from '@shared/constants/version';
|
||||||
import { getEditionName } from '@shared/constants/app';
|
import { getEditionName } from '@shared/constants/app';
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import { Card, Segmented, Typography } from 'antd';
|
import { Card, Segmented, Typography } from 'antd';
|
||||||
import { BulbOutlined, BulbFilled } from '@ant-design/icons';
|
import { BulbOutlined, BulbFilled } from '@ant-design/icons';
|
||||||
import { useTheme } from '@/components/ThemeProvider';
|
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
99
src/services/modules/payments.ts
Normal file
99
src/services/modules/payments.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { get, post } from '@/services/request';
|
||||||
|
|
||||||
|
export const PAYMENT_METHODS = {
|
||||||
|
Alipay: 'alipay',
|
||||||
|
WeChat: 'wechat',
|
||||||
|
Mock: "mock"
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type PaymentMethod = typeof PAYMENT_METHODS[keyof typeof PAYMENT_METHODS];
|
||||||
|
|
||||||
|
export type AvailablePaymentMethod = typeof PAYMENT_METHODS.Alipay;
|
||||||
|
|
||||||
|
export interface CreateOrderRequestBody {
|
||||||
|
amount_cent: number,
|
||||||
|
payment_channel: AvailablePaymentMethod
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PAYMENT_STATUS = {
|
||||||
|
Pending: 'pending', // 待支付
|
||||||
|
Paid: 'paid', // 已支付
|
||||||
|
Expired: 'expired', // 已过期
|
||||||
|
Failed: 'failed', // 支付失败
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type PaymentStatus = typeof PAYMENT_STATUS[keyof typeof PAYMENT_STATUS];
|
||||||
|
// 中文映射表
|
||||||
|
export const PAYMENT_STATUS_TEXT_MAP: Record<PaymentStatus, string> = {
|
||||||
|
[PAYMENT_STATUS.Pending]: '待支付',
|
||||||
|
[PAYMENT_STATUS.Paid]: '已支付',
|
||||||
|
[PAYMENT_STATUS.Expired]: '已过期',
|
||||||
|
[PAYMENT_STATUS.Failed]: '支付失败',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 辅助函数:根据状态获取中文
|
||||||
|
export function getPaymentStatusText(status: PaymentStatus): string {
|
||||||
|
return PAYMENT_STATUS_TEXT_MAP[status];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateOrderResponseBody {
|
||||||
|
id: string,
|
||||||
|
user_id: string,
|
||||||
|
amount_cent: number,
|
||||||
|
status: PaymentStatus,
|
||||||
|
payment_channel: AvailablePaymentMethod,
|
||||||
|
out_trade_no: string,
|
||||||
|
transaction_id: string,
|
||||||
|
pay_url: string,
|
||||||
|
expires_at: Date,
|
||||||
|
paid_at: Date,
|
||||||
|
created_at: Date,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderListRequestParam {
|
||||||
|
status?: PaymentStatus,
|
||||||
|
page: number,
|
||||||
|
page_size: number,
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||||
|
export interface OrderListResponseBody extends CreateOrderResponseBody {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderInfoRequestParam {
|
||||||
|
order_id: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||||
|
export interface OrderInfoResponseBody extends CreateOrderResponseBody {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const PaymentUrlObj = {
|
||||||
|
createOrder: "/api/v1/payments/orders",
|
||||||
|
getOrderList: "/api/v1/payments/orders",
|
||||||
|
getOrderInfo: "/api/v1/payments/orders/{order_id}",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 调用方式:PaymentAPI.createOrder() / PaymentAPI.getOrdersListInfo() 等
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export class PaymentAPI {
|
||||||
|
/** 创建支付订单 */
|
||||||
|
static async createOrder(body: CreateOrderRequestBody): Promise<CreateOrderResponseBody> {
|
||||||
|
return await post<CreateOrderResponseBody>(PaymentUrlObj.createOrder, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取订单列表(支持按状态筛选 + 分页) */
|
||||||
|
static async getOrdersListInfo(param: OrderListRequestParam, signal?: AbortSignal): Promise<OrderListResponseBody[]> {
|
||||||
|
return await get<OrderListResponseBody[]>(PaymentUrlObj.getOrderList, param as unknown as Record<string, unknown>, { signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取单个订单详情 */
|
||||||
|
static async getOrderInfo(param: OrderInfoRequestParam): Promise<OrderInfoResponseBody> {
|
||||||
|
const url = PaymentUrlObj.getOrderInfo.replace('{order_id}', param.order_id);
|
||||||
|
return await get<OrderInfoResponseBody>(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,4 +75,8 @@ export const EVENTS = {
|
|||||||
OPEN_SETTINGS: 'settings:open',
|
OPEN_SETTINGS: 'settings:open',
|
||||||
/** 打开页面 Modal(导航栏点击页面导航项触发,携带 target 路径标识符) */
|
/** 打开页面 Modal(导航栏点击页面导航项触发,携带 target 路径标识符) */
|
||||||
OPEN_PAGE: 'page:open',
|
OPEN_PAGE: 'page:open',
|
||||||
|
/** 关闭当前页面(由页面组件内部按钮触发,PageDispatcher 响应) */
|
||||||
|
CLOSE_PAGE: 'page:close',
|
||||||
|
/** 锁定/解锁页面关闭(页面组件内部操作进行中时禁止关闭容器,PageDispatcher 响应) */
|
||||||
|
PAGE_LOCK: 'page:lock',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
Reference in New Issue
Block a user