From 418eeecdca4c2facb93a99bf77a29cf1f2e14a5a Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Thu, 18 Dec 2025 19:01:16 +0800 Subject: [PATCH 001/366] docs: markdown render error --- frontend/app/(main)/docs/how-to-use/page.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/app/(main)/docs/how-to-use/page.tsx b/frontend/app/(main)/docs/how-to-use/page.tsx index 9b1d06ff..b571b154 100644 --- a/frontend/app/(main)/docs/how-to-use/page.tsx +++ b/frontend/app/(main)/docs/how-to-use/page.tsx @@ -7,17 +7,20 @@ const CONTENT = ` > 为社区开发者与用户提供完整的平台使用说明 ## 1. 快速开始 + - 身份认证:基于 LINUX DO Connect (OAuth) - 认证方式:账户积分消耗认证 - 手续费:动态费率,由服务方承担 - 争议处理:支持服务方与消费方的双方争议处理 ## 2. 角色说明 + - **服务方**:最终积分流转的转入方 - **消费方**:最终积分流转的转出方 - **认证平台**:LINUX DO Credit 系统本身 ## 3. 接入积分服务 + ### 3.1 使用 API 接口 1. 创建应用 @@ -37,6 +40,7 @@ const CONTENT = ` - 参考文档:[API 接口文档](/docs/api) ### 3.2 使用在线服务 + - 适用场景:无代码开发基础,或只用于简单的积分服务。 - 操作步骤: 1. 前往 [集市中心](/merchant) 创建应用,获取 API 凭证 @@ -46,9 +50,11 @@ const CONTENT = ` 5. 发送给您所服务的客户使用 ### 3.3 快速集成 New API + - 适用场景:New API 站点,LINUX DO Credit 兼容 EasyPay 协议,公益站站长可直接集成。 - 操作步骤: 1. 前往 [集市中心](/merchant),点击 **创建应用**,填写 New API 站点信息: + | 字段 | 值 | | :-- | :-- | | 应用名称 | \`您的应用名称\` | @@ -59,6 +65,7 @@ const CONTENT = ` 2. 前往 New API 站点的系统设置,找到 **支付设置**。 3. 配置 LINUX DO Credit 平台参数: + | 参数 | 值 | | :-- | :-- | | 支付地址 | \`http://credit.linux.do/epay/pay\` | @@ -79,8 +86,8 @@ const CONTENT = ` 您可以在任意支持 LINUX DO Credit 的平台下使用积分。在其他平台点击使用 LINUX DO Credit 积分时,会自动跳转到 LINUX DO Credit 的积分流转服务页面,您只需要确认积分流转信息无误,并选择使用 LINUX DO Credit 进行账户认证,即可完成整个交易服务。 - ## 5. 服务(手续)费 + ### 5.1 规则说明 为了更好的维持 LINUX DO Credit 平台的积分服务机制,保证社区积分的生态可持续发展,我们会按照规范进行不同程度的服务(手续)费用收取。 From ce5b8716beaf326ed4dfd6d7a25b10b0e8430d9d Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Fri, 19 Dec 2025 18:00:55 +0800 Subject: [PATCH 002/366] docs: update README --- README.md | 266 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 240 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index b95f998f..fc874a81 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,24 @@ # LINUX DO Credit -🚀 Linux Do 社区 Credit (Content Distribution Kit) 快速分享平台 +🚀 Linux Do 社区 Credit 积分服务平台 [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Go Version](https://img.shields.io/badge/Go-1.24-blue.svg)](https://golang.org/) [![Next.js](https://img.shields.io/badge/Next.js-16-black.svg)](https://nextjs.org/) [![React](https://img.shields.io/badge/React-19-blue.svg)](https://reactjs.org/) -[![GitHub release](https://img.shields.io/github/v/release/linux-do/pay?include_prereleases)](https://github.com/linux-do/pay/releases) -[![GitHub stars](https://img.shields.io/github/stars/linux-do/pay)](https://github.com/linux-do/pay/stargazers) -[![GitHub forks](https://img.shields.io/github/forks/linux-do/pay)](https://github.com/linux-do/pay/network) -[![GitHub issues](https://img.shields.io/github/issues/linux-do/pay)](https://github.com/linux-do/pay/issues) -[![GitHub pull requests](https://img.shields.io/github/issues-pr/linux-do/pay)](https://github.com/linux-do/pay/pulls) -[![GitHub contributors](https://img.shields.io/github/contributors/linux-do/pay)](https://github.com/linux-do/pay/graphs/contributors) +[![GitHub release](https://img.shields.io/github/v/release/linux-do/credit?include_prereleases)](https://github.com/linux-do/credit/releases) +[![GitHub stars](https://img.shields.io/github/stars/linux-do/credit)](https://github.com/linux-do/credit/stargazers) +[![GitHub forks](https://img.shields.io/github/forks/linux-do/credit)](https://github.com/linux-do/credit/network) +[![GitHub issues](https://img.shields.io/github/issues/linux-do/credit)](https://github.com/linux-do/credit/issues) +[![GitHub pull requests](https://img.shields.io/github/issues-pr/linux-do/credit)](https://github.com/linux-do/credit/pulls) +[![GitHub contributors](https://img.shields.io/github/contributors/linux-do/credit)](https://github.com/linux-do/credit/graphs/contributors) -[![Backend Build](https://github.com/linux-do/pay/actions/workflows/build_backend.yml/badge.svg)](https://github.com/linux-do/pay/actions/workflows/build_backend.yml) -[![Frontend Build](https://github.com/linux-do/pay/actions/workflows/build_frontend.yml/badge.svg)](https://github.com/linux-do/pay/actions/workflows/build_frontend.yml) -[![Docker Build](https://github.com/linux-do/pay/actions/workflows/build_image.yml/badge.svg)](https://github.com/linux-do/pay/actions/workflows/build_image.yml) -[![CodeQL](https://github.com/linux-do/pay/actions/workflows/codeql.yml/badge.svg)](https://github.com/linux-do/pay/actions/workflows/codeql.yml) -[![ESLint](https://github.com/linux-do/pay/actions/workflows/eslint.yml/badge.svg)](https://github.com/linux-do/pay/actions/workflows/eslint.yml) +[![Backend Build](https://github.com/linux-do/credit/actions/workflows/build_backend.yml/badge.svg)](https://github.com/linux-do/credit/actions/workflows/build_backend.yml) +[![Frontend Build](https://github.com/linux-do/credit/actions/workflows/build_frontend.yml/badge.svg)](https://github.com/linux-do/credit/actions/workflows/build_frontend.yml) +[![Docker Build](https://github.com/linux-do/credit/actions/workflows/build_image.yml/badge.svg)](https://github.com/linux-do/credit/actions/workflows/build_image.yml) +[![CodeQL](https://github.com/linux-do/credit/actions/workflows/codeql.yml/badge.svg)](https://github.com/linux-do/credit/actions/workflows/codeql.yml) +[![ESLint](https://github.com/linux-do/credit/actions/workflows/eslint.yml/badge.svg)](https://github.com/linux-do/credit/actions/workflows/eslint.yml) ## 📖 项目简介 @@ -48,21 +48,21 @@ LINUX DO Credit 是一个为 Linux Do 社区打造的积分服务平台,旨在 ## 🛠️ 技术栈 ### 后端 -- **Go 1.25.4** - 主要开发语言 -- **Gin** - Web 框架 -- **GORM** - ORM 框架 -- **Redis** - 缓存和会话存储 -- **PostgreSQL** - 主数据库 -- **OpenTelemetry** - 可观测性 -- **Swagger** - API 文档 +- **[Go 1.25.4](https://go.dev/doc)** - 主要开发语言 +- **[GIN](https://github.com/gin-gonic/gin)** - Web 框架 +- **[GORM](https://github.com/go-gorm/gorm)** - ORM 框架 +- **[Redis](https://github.com/redis/redis)** - 缓存和会话存储 +- **[PostgreSQL]** - 主数据库 +- **[OpenTelemetry](https://opentelemetry.io)** - 可观测性 +- **[Swagger](https://github.com/swaggo/swag)** - API 文档 ### 前端 -- **Next.js 16** - React 框架 -- **React 19** - UI 库 -- **TypeScript** - 类型安全 -- **Tailwind CSS 4** - 样式框架 -- **Shadcn UI** - 组件库 -- **Lucide Icons** - 图标库 +- **[Next.js 16](https://github.com/vercel/next.js)** - React 框架 +- **[React 19](https://github.com/facebook/react)** - UI 库 +- **[TypeScript](https://github.com/microsoft/TypeScript)** - 类型安全 +- **[Tailwind CSS 4](https://github.com/tailwindlabs/tailwindcss)** - 样式框架 +- **[Shadcn UI](https://github.com/shadcn-ui/ui)** - 组件库 +- **[Lucide Icons](https://github.com/lucide-icons/lucide)** - 图标库 ## 📋 环境要求 @@ -70,4 +70,218 @@ LINUX DO Credit 是一个为 Linux Do 社区打造的积分服务平台,旨在 - **Node.js** >= 18.0 - **PostgreSQL** >= 18 - **Redis** >= 6.0 -- **pnpm** >= 8.0 (推荐) \ No newline at end of file +- **pnpm** >= 8.0 (推荐) + +## 🚀 快速开始 + +### 1. 克隆项目 + +```bash +git clone https://github.com/linux-do/credit.git +cd credit +``` + +### 2. 配置环境 + +复制配置文件并编辑: + +```bash +cp config.example.yaml config.yaml +``` + +编辑 `config.yaml` 文件,配置数据库连接、Redis、OAuth2 等信息。 + +### 3. 初始化数据库 + +```bash +# 创建数据库 +createdb -h <主机> -p 5432 -U postgres linux_do_credit + +# 如果需要指定字符集,可使用 +# psql -h <主机> -p 5432 -U postgres -c "CREATE DATABASE linux_do_credit WITH ENCODING 'UTF8' LC_COLLATE='zh_CN.UTF-8' LC_CTYPE='zh_CN.UTF-8' TEMPLATE template0;" + +# 运行迁移(启动后端时会自动执行) +``` + +### 4. 启动后端 + +```bash +# 安装 Go 依赖 +go mod tidy + +# 生成 API 文档 +make swagger + +# 启动后端服务 +go run main.go api +``` + +### 5. 启动前端 + +```bash +cd frontend + +# 安装依赖 +pnpm install + +# 启动开发服务器 +pnpm dev +``` + +### 6. 访问应用 + +- **前端界面**: http://localhost:3000 +- **API 文档**: http://localhost:8000/swagger/index.html +- **健康检查**: http://localhost:8000/api/health + +## ⚙️ 配置说明 + +### 主要配置项 + +| 配置项 | 说明 | 示例 | +|--------|------|------| +| `app.addr` | 后端服务监听地址 | `:8000` | +| `oauth2.client_id` | OAuth2 客户端 ID | `your_client_id` | +| `database.host` | PostgreSQL 数据库地址 | `127.0.0.1` | +| `database.port` | PostgreSQL 数据库端口 | `5432` | +| `database.username` | PostgreSQL 数据库用户名 | `postgres` | +| `database.password` | PostgreSQL 数据库密码 | `password` | +| `database.database` | PostgreSQL 数据库名称 | `linux_do_credit` | +| `database.ssl_mode` | PostgreSQL SSL 模式 | `disable` | +| `database.application_name` | PostgreSQL 应用标识 | `credit-server` | +| `database.search_path` | PostgreSQL 搜索路径 | `public` | +| `database.default_query_exec_mode` | SQL 缓存模式 | `cache_statement` | +| `redis.host` | Redis 服务器地址 | `127.0.0.1` | + +详细配置说明请参考 `config.example.yaml` 文件。 + +## 🔧 开发指南 + +### 后端开发 + +```bash +# 运行 API 服务器 +go run main.go api + +# 运行任务调度器 +go run main.go scheduler + +# 运行工作队列 +go run main.go worker + +# 生成 Swagger 文档 +make swagger + +# 代码格式化和检查 +make tidy +``` + +### 前端开发 + +```bash +cd frontend + +# 开发模式(使用 Turbopack) +pnpm dev + +# 构建生产版本 +pnpm build + +# 启动生产服务 +pnpm start + +# 代码检查和格式化 +pnpm lint +pnpm format +``` + +## 📚 API 文档 + +API 文档通过 Swagger 自动生成,启动后端服务后可访问: + +``` +http://localhost:8000/swagger/index.html +``` + +### 主要 API 端点 + +- `GET /api/health` - 健康检查 +- `GET /api/oauth2/login` - OAuth2 登录 +- `GET /api/projects` - 获取项目列表 +- `POST /api/projects` - 创建新项目 + +## 🧪 测试 + +```bash +# 后端测试 +go test ./... + +# 前端测试 +cd frontend +pnpm test +``` + +## 🚀 部署 + +### Docker 部署 + +```bash +# 构建镜像 +docker build -t linux-do-credit . + +# 运行容器 +docker run -d -p 8000:8000 linux-do-credit +``` + +### 生产环境部署 + +1. 构建前端资源: + ```bash + cd frontend && pnpm build + ``` + +2. 编译后端程序: + ```bash + go build -o credit main.go + ``` + +3. 配置生产环境的 `config.yaml` + +4. 启动服务: + ```bash + ./credit api + ``` + +## 🤝 贡献指南 + +我们欢迎社区贡献!请在提交代码前阅读: + +- [贡献指南](CONTRIBUTING.md) +- [行为准则](CODE_OF_CONDUCT.md) +- [贡献者许可协议](CLA.md) + +### 提交流程 + +1. Fork 本仓库 +2. 创建特性分支 (`git checkout -b feature/your-feature`) +3. 提交更改 (`git commit -am 'Add your feature'`) +4. 推送到分支 (`git push origin feature/your-feature`) +5. 创建 Pull Request + +## 📄 许可证 + +本项目基于 [Apache2.0 许可证](LICENSE) 开源。 + +## 🔗 相关链接 + +- [Linux Do 社区](https://linux.do) +- [问题反馈](https://github.com/linux-do/credit/issues) +- [功能请求](https://github.com/linux-do/credit/issues/new?template=feature_request.md) + +## ❤️ 致谢 + +感谢所有为本项目做出贡献的开发者和 Linux Do 社区的支持! + +## 📈 Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=linux-do/credit&type=Date)](https://star-history.com/#linux-do/credit&Date) \ No newline at end of file From 769e7d6ca02a8aa4d715c122e57faa9ccd14dee9 Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Fri, 19 Dec 2025 18:02:32 +0800 Subject: [PATCH 003/366] ci(workflow): update actions --- .github/workflows/build_image.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build_image.yml b/.github/workflows/build_image.yml index 2ceeb4dd..653183f5 100644 --- a/.github/workflows/build_image.yml +++ b/.github/workflows/build_image.yml @@ -32,13 +32,13 @@ jobs: - name: Build Go Binary (multi-arch) run: | # build amd64 binary - GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o pay-server-amd64 main.go + GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o credit-server-amd64 main.go # build arm64 binary - GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o pay-server-arm64 main.go + GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o credit-server-arm64 main.go # show binaries - ls -alh pay-server-* + ls -alh credit-server-* # set up docker buildx - name: Set up Docker Buildx From ee895b6a673323e1574e9266e1ad579b33c988b5 Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Fri, 19 Dec 2025 18:03:33 +0800 Subject: [PATCH 004/366] style: code optimization --- Dockerfile | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 4635e05f..05ff55b8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,10 @@ FROM alpine:latest # set the time zone to Beijing Time in the Eastern 8th Time Zone +ARG TZ=Asia/Shanghai RUN apk add --no-cache tzdata && \ - cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \ - echo "Asia/Shanghai" > /etc/timezone + cp /usr/share/zoneinfo/${TZ} /etc/localtime && \ + echo "${TZ}" > /etc/timezone WORKDIR /app @@ -11,7 +12,7 @@ WORKDIR /app ARG TARGETPLATFORM # copy the appropriate binary based on platform -COPY pay-server-${TARGETPLATFORM#linux/} ./pay-server +COPY credit-server-${TARGETPLATFORM#linux/} ./credit-server # copy docs COPY docs ./docs @@ -19,4 +20,4 @@ COPY docs ./docs EXPOSE 8000 # set entrypoint -ENTRYPOINT ["./pay-server"] +ENTRYPOINT ["./credit-server"] From 22e139f226fbce15a1e2a3e8a121fb222e133fd6 Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Fri, 19 Dec 2025 18:25:17 +0800 Subject: [PATCH 005/366] docs: supplementary docs --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fc874a81..81e5145f 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ LINUX DO Credit 是一个为 Linux Do 社区打造的积分服务平台,旨在 - **[GIN](https://github.com/gin-gonic/gin)** - Web 框架 - **[GORM](https://github.com/go-gorm/gorm)** - ORM 框架 - **[Redis](https://github.com/redis/redis)** - 缓存和会话存储 -- **[PostgreSQL]** - 主数据库 +- **[PostgreSQL](https://www.postgresql.org)** - 主数据库 - **[OpenTelemetry](https://opentelemetry.io)** - 可观测性 - **[Swagger](https://github.com/swaggo/swag)** - API 文档 From 3fb5d14f7a5c67344b1baf6a62daa6bfd520dc32 Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Fri, 19 Dec 2025 18:43:33 +0800 Subject: [PATCH 006/366] feat: optimize frontend table rendering --- frontend/README.md | 58 +++++--- frontend/app/favicon.ico | Bin 25931 -> 26109 bytes .../components/common/general/table-data.tsx | 133 +++++++++--------- frontend/components/ui/table.tsx | 27 ++-- frontend/package.json | 2 +- frontend/public/file.svg | 1 - frontend/public/globe.svg | 1 - frontend/public/icon.png | Bin 0 -> 26109 bytes frontend/public/next.svg | 1 - frontend/public/vercel.svg | 1 - frontend/public/window.svg | 1 - 11 files changed, 118 insertions(+), 107 deletions(-) delete mode 100644 frontend/public/file.svg delete mode 100644 frontend/public/globe.svg create mode 100644 frontend/public/icon.png delete mode 100644 frontend/public/next.svg delete mode 100644 frontend/public/vercel.svg delete mode 100644 frontend/public/window.svg diff --git a/frontend/README.md b/frontend/README.md index e215bc4c..59bafdf3 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,36 +1,48 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Linux Do Credit Frontend -## Getting Started +Modern frontend application for the Linux Do Credit system. + +## Tech Stack -First, run the development server: +- **Framework**: [Next.js 16](https://nextjs.org/) +- **Styling**: [Tailwind CSS 4](https://tailwindcss.com/) +- **UI Components**: [Radix UI](https://www.radix-ui.com/) +- **Icons**: [Lucide React](https://lucide.dev/) +- **Package Manager**: [pnpm](https://pnpm.io/) -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` +## Getting Started -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +### Prerequisites -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +- Node.js (Latest LTS recommended) +- pnpm -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +### Installation -## Learn More +1. Install dependencies: + ```bash + pnpm install + ``` -To learn more about Next.js, take a look at the following resources: +2. Run the development server: + ```bash + pnpm dev + ``` -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + Open [http://localhost:3000](http://localhost:3000) (or the port shown in your terminal) with your browser to see the result. -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +## Project Structure -## Deploy on Vercel +- `app/`: Next.js App Router pages and layouts +- `components/`: Reusable UI components + - `ui/`: Base UI components (buttons, inputs, etc.) + - `common/`: Shared business components +- `lib/`: Utility functions and service definitions +- `public/`: Static assets -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +## Scripts -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +- `pnpm dev`: Start development server with Turbopack +- `pnpm build`: Build the application for production +- `pnpm start`: Start production server +- `pnpm lint`: Run ESLint diff --git a/frontend/app/favicon.ico b/frontend/app/favicon.ico index 718d6fea4835ec2d246af9800eddb7ffb276240c..df85a1c10e532075953d280e37d927655107d38d 100644 GIT binary patch literal 26109 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 LrJugUA?W`A8`#=m diff --git a/frontend/components/common/general/table-data.tsx b/frontend/components/common/general/table-data.tsx index deeac1b4..4e8c0f1d 100644 --- a/frontend/components/common/general/table-data.tsx +++ b/frontend/components/common/general/table-data.tsx @@ -27,79 +27,77 @@ const ROW_HEIGHT = 36 /** * 虚拟化交易数据表格组件 * 使用 @tanstack/react-virtual 实现大数据量高性能渲染 - */ + */ export const TransactionDataTable = React.memo(function TransactionDataTable({ transactions }: { transactions: Order[] }) { - const parentRef = React.useRef(null) + const scrollAreaRef = React.useRef(null) const rowVirtualizer = useVirtualizer({ count: transactions.length, - getScrollElement: () => parentRef.current, + getScrollElement: () => { + if (!scrollAreaRef.current) return null + const viewport = scrollAreaRef.current.querySelector('[data-slot="scroll-area-viewport"]') + return viewport as Element + }, estimateSize: () => ROW_HEIGHT, overscan: 5, }) + const virtualItems = rowVirtualizer.getVirtualItems() + const totalSize = rowVirtualizer.getTotalSize() + const paddingTop = virtualItems.length > 0 ? virtualItems[0].start : 0 + const paddingBottom = virtualItems.length > 0 ? totalSize - virtualItems[virtualItems.length - 1].end : 0 + return (
- -
- - - - 名称 - 积分 - 类型 - 状态 - 积分动向 - 应用名 - 编号 - 业务单号 - 创建时间 - 交易时间 - 订单过期时间 - 操作 - - -
- -
-
- - - {rowVirtualizer.getVirtualItems().map((virtualRow) => { - const order = transactions[virtualRow.index] - return ( - - ) - })} - -
-
-
-
+ + + + + 名称 + 积分 + 类型 + 状态 + 积分动向 + 应用名 + 编号 + 业务单号 + 创建时间 + 交易时间 + 订单过期时间 + 操作 + + + + {paddingTop > 0 && ( + + + )} + {virtualItems.map((virtualRow) => { + const order = transactions[virtualRow.index] + return ( + + ) + })} + {paddingBottom > 0 && ( + + + )} + +
+
+
@@ -109,13 +107,14 @@ export const TransactionDataTable = React.memo(function TransactionDataTable({ /** * 交易表格行组件 */ -const TransactionTableRow = React.memo(function TransactionTableRow({ - order, - style -}: { +const TransactionTableRow = React.memo(React.forwardRef>(function TransactionTableRow({ + order, + style, + ...props +}, ref) { const getAmountDisplay = (amount: string) => ( {parseFloat(amount).toFixed(2)} @@ -126,7 +125,9 @@ const TransactionTableRow = React.memo(function TransactionTableRow({ return ( - {order.status === 'pending' || order.status === 'expired' ? ( + {order.status === 'pending' || order.status === 'expired' || order.type === 'community' ? (
-
) : ( @@ -245,7 +246,7 @@ const TransactionTableRow = React.memo(function TransactionTableRow({
) -}) +})) interface TransactionTableListProps { loading: boolean diff --git a/frontend/components/ui/table.tsx b/frontend/components/ui/table.tsx index 4ecb3580..51766688 100644 --- a/frontend/components/ui/table.tsx +++ b/frontend/components/ui/table.tsx @@ -52,18 +52,21 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { ) } -function TableRow({ className, ...props }: React.ComponentProps<"tr">) { - return ( - - ) -} +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableRow.displayName = "TableRow" function TableHead({ className, ...props }: React.ComponentProps<"th">) { return ( diff --git a/frontend/package.json b/frontend/package.json index c11a1065..e08392d8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,5 +1,5 @@ { - "name": "pay-demo", + "name": "linux-do-credit", "version": "1.0.0", "private": true, "scripts": { diff --git a/frontend/public/file.svg b/frontend/public/file.svg deleted file mode 100644 index 004145cd..00000000 --- a/frontend/public/file.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/public/globe.svg b/frontend/public/globe.svg deleted file mode 100644 index 567f17b0..00000000 --- a/frontend/public/globe.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/public/icon.png b/frontend/public/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..df85a1c10e532075953d280e37d927655107d38d GIT binary patch literal 26109 zcmXV21z1!|6uyWGDj|v>jkt6v-67?QpdgKubobI>0m2hu5s(t4q*++H6;YAyZjjt1 zr1Q;Hzwi0InfYhVIdkUJ%yoVBP+f)eBJD*8f=H2f6*VD)M03o+vb8`st9*OO$v42ExJ!E4N*%w}}g}D<-MwFvoP)hNP?-{4&OJ-JO%rZLp7t7o? zWH_bn=dusX)!uYl{9W2ERA-eazE)w=BI3;NgiwA|m|U8ExjQ?1L)$^I9bbPgvB3DT zUdPKpouW{4L$Y@w)9>zcE~7e%^5<}gld0#{T?1WOC)~UXdR`Ng%{=aV<#(1kx?y~U zWRD;#&DZwz)U{tTC(qBO-qL2=jFTA*Inqs?ePZ^ikK#oc!{dq18q!W^gGL8zF(bj5 z|8xy>i>Ab^Jg)JyUNCa|jLuiGwzO<-6|;;s+PU=3Ro{@KXQ227N>$CMGr6m}F#Mr* zep;HD`P6Ap(fINb3Gp}g)zkL~*$1~!sknB;{%;;+d1}nbx6uKc$-tipCK;kC~El7X-5W5b-n&5m@(Z<9q#p`VWHbxT_>S-pWKVY z8aElI7zIHVnn*=?E$@-lu~9n)ODJU8n)8)GhFyLV;mh#K8(qPS#4N^gYoq;y4_*+< zb2<{@=r-mbTu1#vq3OG zKH5^3&M(!Btr)*4^_$qQQgl_u_%3>mvi)KsqUE-s;I}c4PrG}Rf_b=WWEjy`O-)`* zovcVrhz+SeL}D@}`Ivsk@W0!;ujoG!U2j@vL%Es{)p)Za(PwTU-4q4=MZS(Fee|MO zu%M9shQIv6oE|?D`1Bou6N;Hz6?guxdg*<<_Ey*R7rg--NH;dhU}Q<6h|2}Tp)agQ z!K5vHGKEj*PRVNcktLcy9(^bKy$god&DZE!YHD8IzF4slB%6gq&uc?$EpG3o?R&dM zzb4MjNqr~##xeE{S1))U-K-0+8SzlH-k}<1{l|V;8kNt#qY)~P!0^wcX}_b}k7Z}2 zTQ>b(o!(+9w6Ej5k5+)Ca2wd*{x)@}>4qBne64MR`sA}9@(L`1FWp{1Sl#6MM+Ed> zUG&$x*^78pG`wOe>$~{y+c`%Zi8Jv+TCFWi2A){ugyxkpFw>z6z7N=ldHF9jvM{E+tS zi}4NlHs{M>wMY9$@lv5#h>%08wdA$;T`O?6n6MahB|`*RM1}NOq-j{)FLKOcP}H z(1aflWhsv1n@W!rHBFArE6Wk&L#I(0|A|BQes5;6DGQtV8(gvS{;GtIVm4(PQRq#q^@x8#S*2>Z2NMxqf64R3oi zw}cL^JEFFxY^;v$1P{7arB&~u5leSN>zfxfw)jRC7YVxCHf^MR>7@Uoibf!NK9U4n z>ONldEFI{6{vlz0+yJXG9zhiakoyXg{vM*f*J2r@*Ao(Oy2GnRUTHoETU6lrJDMVe zXZWA8H;TWFUGn&8Az&uO7}J9oF={Qz$mL>Lu68OAE>_mY;`bdp1#zbo=bwKj^V|J> z#fRa$>yh;Gi)RPgUo=#Bu{64R>)$WSdk}{gDxGC8cowIFQ3xG6`7e%7KI)jc#FU)E zb4c|QnYaU~~ z>^u89f9755N?Ve;=i0}o@%)*S`&DISmSy>wnM%(wYT&<53$vI#_a!g+^(kjs+P&qU zv;qZe{crrIrGnFMLj-+UQNGC6d0v$K53^bP{QSy`i_N^&tQ;J9XRX;{H0;Z(s+Mj# z`}vK!__nvV2X5cG@=EW)1Fda6XXiIc>465;*6cTn503Jja*^AgxUNG|jnLVLkKXI7 z<=8Kmd}P6`tsO@7et1N~twZl`UtV924|=|tmzRevEaYPfX@0u*P@~snt3kfG8VI$! z%tQ#8LuaxBZ)}It>N;~9hd&GqY@Pr4^XJE#{7LNx+A>!wes-x2{k}yRH2NxGiU#9AF z8Iq=H%tr^!W$x{ikz0@{;ArZ(>?vG0p(T~%AKnWm=cS}D z7#bXmhQ9CV`u%x<--Mt;_3~L6V&U_ux+Lbs3k5kjEiQPSzQJX>*xRrO9M7YC^^J6$ zIVOZ$caHKXtgNgY0|VLVtG;WoBLTnKtQwn^@{0fGdc!_jf(i0$E$fJ z{iNGOofpplB9+9X%e>pKe7jw&gqDwZlUgi#vOZZKJvK0SvidRChSGqC3R_%!TNR;v z{GW3An2hhviDqhU>a|@TLa{%%H- z(AkLBBVAolvi4zu(K@L)&eu#DF95Ai^;P$)*xf0kQ~%jOAAmxsD>#p+Opc8u0&M^^R-`zgyW%pQNc++B8t;^NbY3~K-4^bTsA}hMvXk_N+_?C?p#n<7WP!Vw&(g zG&pzNWXniQq@ApEP47&2w0u+fzpXD{BF)~|GRH3s-%Bm->8V;0{AA6)`k2_RW{lT= zIyEdTTiDtPXA$mydCTU4^<;g($xrU0gS`!M@6PviO^j`FGQHRZ-}AlJ8l4tHJ``O< z0!kySii(PTz5>`gKKD*8Z{59+UR^yhSY)#HKrBHUOOHLg4h%x^aF-(Y1D#fMI$t8`*i_=B+-o%!#_w`c~i z^r()ru6L*|rtW}cKR>nQKm7H3Mt9d_e;{9f1#2aZ7oGH19crSIn zbH6Eowt15i0+h0lYF+6c^-T^$oF`V_$Vl`OkxwwBCTIrk?}tt zkrvRePloriBY@>^c{69)mHt7$_7GnqUN3fVozr^gCuJ2KN`V0uQl#3j8CBtG_f6A?fIDBr8KLtw>6_+7PA6a z;}VHQbZ2gO_(hXao9H(ThvS&9{D)Ra?o{WYQrnmZi9*9QC>-OC zDPi#am4N~kiNh_&&lo`oRaEObI0V?c#x0b(EK^*G$mrLpjo+;`gZD;*V@ZHI4wYw9C0khv;n=SZ; zX#YH0#SHsWMcb;f5BuVkj&~qbm{fb@P*cAl^Rpn3^GH{IjkM0OvK#VAm6x0g0pfEo z31C$%^}8~YZikXHsh`GME_UTLF=|0k_Ck+(hjVG@P$$-10#!mV;pVp0Jb?$1L`J1F z#mo5IYI2|jhEeRS@Ac7upx8n(xNCtVxYpgq( zQZ24bk|9{qtlHZOqYC8X&11B+wfDcgytE&|1kDk&Z+c|emxqo^E|}$QA%Wl}2?=Gf zn&K25H$?VlaYgk9T$AgB!BQE+_{5FsGVWVj4Yt1jpo+=i4_qQAdhCbW zHY2Bo^ zIsVeXxHN68P%-qx)s?jKb-NS4FI;5{k>-J*D*2kPz{akkCWU(lsyJ@|4@yZ0(0Kp0 z^BG_Mc}f30OlOK@Cx1=8>X7*vD9W((k^e+(YBlQq(D5K~YD?!_1aP9V`nLQU2lDC+ z+HFGqqPp@SB9iNrJK*(VP6T&-u&^bbDSTgc>HTF2jg=p57cJ8KDLrlr4`rz(`EMrweq)fVVa=3Q*oLpw^Z9z5XdR@Y|o5atne=$wrh;+5|ffqHn5v z!MC56Kq$fURjdYdPQ({`Y}Zfsc@4|kKWfuZl05qT*Y_1pTK{985$;x3)(bf*KyY zmui;u*i#>}B!?Q8O0lIiS=A`Lq2m!KD2{1^@V26&I65If^@Mv-Ge_6bGE;VUtifj& zA0KM84nfHgD81^|(%vul(ery{{@mbHDiidJ=`U2@T`qH-H4JPb6%8?3gdm|i)xD*o zU-0ASv!kNHwwiyojr@p_k5{Q9W0YD1nRGv&5Mo}lsn{>I?KP_5TV-?#X3taumdcx& zP!{hgPpw=x;cfnsoL)?9bp}`sVUJP8ljfo({OozXR#6bI4a5ZL5KL!{D7R03w0?bc zdL5-f2t|>O1dKdwxKxw6qQOT9oXPH_&7qc_yqdXVU#&qYg371JIN%GPH3XI9c&*8d zylnWNrLYti zTwxvsPgxTEeSLesJL7%g`NqHSnE^+UtEOxH`SWp&z$&9`sAJC!pq(~rtELRD#WUHx z<&L9A1BNOgMzs*6c+1VA81?HkWPD-aA*U!iIHiQPe7UTaPx3@T1zcQQ{yVGU6UFAA zFs)Qjuv|}yIEb%I;%!Zf-8uanMULhWe{0 zA3(0P)V!|Lqfl2@H?)pud0o&p<}yY1aDu*{KAlJKpxt1hQK1R%rJ7p=&=2`N5H63xYYcz(c>r(3jh za!eFr6asvRZKS1eoPNhCyP17c4on*XHPMd;&&d|BS~@xh=5N|J;i5X88G;I;QP|GJ z?Mi#FqnmcOAJl_#r7WZ^zkuh0L{w*q`FApqbQn<^1W+*9NdL{pl9H>^8WGJsA~#rm zF0`%Em2fUH%dCVNQ}OJPuZ>mN4_OjHkUG+<9OcD#EZ62dM|T0Fcq=O-3+FBZ|B&Ou zpoQHrcjuvVVA}FqybEp<;**S8_?xQR~~f*#7^+;Cgk zW^1oIbI^HLSdFAyuQmU!ZWezHf_}X7Sg_w+_*wL# z@NE%@LeYX)=T=F1q9x8yGKNh!#v46^AZGg>I=KqDA<20m$+=Om)30s$MgG@4g5Q_8 zFQ_+cd`oKLV}+n7&m~@r{a_(x_ouDQ+j=lBqs`$R{Ur&p2R_H!gLQtptxbG4A*jpW zcWZH}HJ+c`uOj)^I}%k;ELd2X&v|z}O~}p3*{E_~cxfaDcH5jG`poGi#;Xna-O>Y? z0XkvWXcO5bpTNb>zq=N6ipQveV<;6HzZkSuP4i_!kUb?z5@N_J3h=HzA%LKb*thq0fp1}UJCMCsSkfFosiR5j0cGvr z$5+x-CaF*ffY8`&t2W2NQI^$l_a1uqv`W@DcZ=kSOzeLDYM&@FImM`+1sRTUoT~cv z3H$1?dr!mqmAi^Gt!;c!=j4eda@Gz#;lgth+=Hv~RO6ir4R5Y8c=R;1p1aU(GE{Rq znY+RrIlIQy_D!ig`@eU0IjN$a(J*smx17Is^z4}-WYl+ocoD4;b9_g*59;77XsHiA zf8DY>lhyG2dE&5+FIQan3L;FAjl*Y=IqCUohd=KopWR-avoiek>_2`-*-yJNsV%b) zDi_{gT@1KtbQgWT`n5cfQAeV%eL+Ts&}uyRuCRFOKZq%_AAC6;dXhOodS6j-AtvKV z1?ysPtMpK{hpqMyKlmOI6_lI%KJvuoh{_Ae!XgomFj@{|^1$f(`PH;1?b;0SgHw!Z z@F%)Gsg%YTtnOT?dKH8vgl*L5RgVwD(@mi`e9foOWAH@-+IK$K@@w-#XA^zsWfbM0 zph)3F#@Bc~BNG^{a86ZA^l%^ICj?F>=Ffh6QVpi2hTjAsu^K_@+AdG@PFP4t&u4!K zV{!)A*ZO}n?kQ^=HSn}{r@KJ3_Kn@ZuFsa9o}M}ezG_3$ z7EB18N2MMdyapR(ze=&ahR_y^usS|4pp)>>8=58oQ9{*C@}VOxBwJ-_Mp5xt5cx(T z!m2;F-;78Ag5*r}>kgU$bOa!7(#mW+%kgTbagF=^ZDh1ZHN~qi zUv{K!HI=o7mhRvS1~lNapOy9NTnr^LqDFv1qmwOX=(t;-GEb*~Op+c@-t1rQ=TFSI zJK@$0*0Ld~t0pBPo*W;HU=%2TdM5h^oNghbkG>hb4O{#YQ`)0X>FK#G;<1Rq5dOjU z`K-3L>C`k4o$M^t7=-jMp78c0Q9~rscF2qvxHyMD`|t5p?HJ0IG!S;;Git^@b+8Q8 z2ZYAP8DB*a1L-Lfr<+NIyQW9+z$^;biag(9l ztIUXvGrXb3^;K90AM&|T9+r3qAJD4&nw9~z2=+o*ZC}LOiE}`w*?2w&bi|Q_V|>;W zLLwpta5QNm1KQ|#4Wfy#uy8^K2kls}qN2(B3_EKIdMWR&Z*Ll8R--^zYNVb+RVaj# zfhjs{ED`}|EAfb_#qM+w%b$IiCg63@r`XZyk&zJ~F!l;Xz-{EbM&MRvdU}&pdm;lW z8%Q$MX4UyVW^@UmwTIB(Gh2Fo%*vQqa4_v!>m7 zpcy^7%JXb_IT51(JPTU*dv48az_%}oBV#*Qvh(sTNrH+OatT~sURKkjRK23{kTa&vu0fPQ+$--T`RA}3u`QY;H`3A6q(xl@8vX!O_r-CvJRyS^RYmZMg9)uQ%VY1{a9sZS`faY& z%P@;cTJcSTNs3boG)uO+;;}SxSFF`QRW(BG-L2cJ>wt)yh`&l3E*#DB?tR#SJvyYa zLA+H{MJ2TTovX>vM_>xU4(-W4sh%j5vKaKH=Eke+2G*P(b-#G*V`CqLFo*y_!#h`8CIhE*@CZ#M*=k^9WHfXGUaYsgBAS>%9QZEmKo$M1`a!Z~PH}Pf&!5U64B*NN z5wliEiUSrRRi<01F0=k^EhOSD{)Eo#*3`=8MQn%Lk!&fadk--PBMPC01u*ct3>_!U> zF}TNN78q89Ft7k!d%WeP1_P*jLG<(V!1R_Y%b!tj57-bnGiboH0qROg~Q<}A{)P7(HKC|;$%>9 zv3R_b-_Zus3kHxwpv6i5!8Tknx@4=EE+wqwUJonY$XefbDKq{H*(HF_5lxD#X%0+y znjjVR5jo%V<50K}NAOBVE(z{UP!yB@Lg{$T>E&tSe09Zul#Edz#B*{3hq1~FW}%=s zgEm|DHUS8!315@s_A${!J={^-}<%Y7@z#5EpONiRH!BSxdfGquAGrJSfESfVfJ0I1N-NXEuVF)@9?gpF}E3$7U4OG)T1mJUKpe{2HcV zCc6g~bKI{_KPWtS4|b=5w||w*jxOa}VQ0cu&K6>re8B`tkxA{FQl!Nq6h=1adK`~x zYkU#$p&5KW$pv~F{~>>lR9xxa2cbV;iig{|em{$pL4s$MhyhG+sY)sP`0+Esa*4lC zj3VU$I&g-EyP%*z#C7Jind}^x(HK-S9g^fGnV-QUofOs~jGP_I;Ysz^!4k&D74 zXzQE!ejeC{J;EowT6BoAky(JvWu)du?qsdo-N5!%7Kx<6}E%`!wc-tb|Fo zGt`sM+eMN0X{5Ameejv0%AO8MWV9iADJFCdX;CR&@!)MN$77?h%ZghTRg>FwYlp`n zgF)+ujpufo$oJ?n4R?U?vT^nRF;me(14eFCW~Qc%R&6$Is5W?~4b!5IOXabY^XNP^ z#h};>3a270hy6gQmE;q69|oo6g~G__ol1&Vi=dDy!EzWGjf?ntIF=KFDjO=4Un&P5 z*+h`h#`hG1?)Qx2)3C$~9VyuA>JW$Tu%#bSZ*P-k8tQ7kjlkm-w+;~j%RE0f=ebK5 zcmcW|l&`l@jtnp5r8thu_%>A(XeFrw^Og89o%ccTN_2(>J|9aAK0EGwz52lFkN;0e zaE#X;A0M-E{?r%c((q)rE~X8nh?sC2qcVmGDB*}sA>fu6M=-F(7-Z)t0QzP zx!h^o4$Y#5)RIP4e-4=C`LZ!9EfRS2e3L)w$ z<~XOd5*;mV$uM@oN|FuWAL!c>1S>oYRm0bLi(Yc1BkGoU?!bTH;3ll*pGdz$^%%DK?t_Da{Q8q(2O{_g-^PT5d@pEdm_#V;b&S8`SVPp|dA@{@(qS3kgLoJz z;^Yr7{X~vQ#Q21;pZe~<>3}gCzS>mn;TT-Yw*gNaKT$jYlB>$gp)m)aS45B$2V?E8Ht+T1W)I4= zCGY!}o5O3lo=%QM7roR=;7A3<^ZAr(j)yx8!;!Ge*$7G0>MbM%U$x$CcMDO)s4K9Ulyao>RD-mZ_z(*X=|=|Aed5<@vnm@#uxyE06OL7#(pni*id~slKmR=@lPtaS@ z*fiWk9s4L;MMK%jemkqeND7(zLPtp2g+fLDgTbLahjMc8^#*$QnwHKw&dC$0R5SC9 zjdlVNFq3angYvXE?1H!1pP!!I3*-^&#+>e~*?s*K)SD%pf+K_vT?@O^n|k4pGHUV+ znK^nulH0)Vu)~1~HKn_QpYJcMA(`E)G1OG0d*t}}(!J2|@|)+HRrz0~5w2t!eCFoh z;K=T`VOYA)-chDh{Or{|1EgHB@v}(JE_IJIQ^Qdoe-dE?``cM^$-Al#dgmVR1P+d* z^824WT`+o1>W8I{S-f1)de@M9CR^ zcTZ(&*!9a#Nr>*%7@~{FlUh-$L&fFf;vk28E;c83VAFrulsV4bQa|0I?SJUi%3Dzu z&E8Cx1t_M|YiqH)9g(;Gs;5J>^wd8aX)Ubq69#txC<`Vx&e+3^ph(qv<=1<_T#Kl4b;~=J$SMI2?`8 zULw*U4mP4{(@Q;(>O#_>U~5Drb4WKJiWuHI`3iyfiSzdLR$HoK-ADC42ZUF@R@|e$ z=hnRL-3zR0ooYnj&fHYc(|fsjL_Yn;-L&?X|%K&D0~Z;V##Tx`>RmdYQDu8G@j zTy?WOiwN$j?e( z4iRY(-x`g@Bp;is=%8zo7=a>8`2#Wsau5;I&T4OfK$!TGYMr3-F>^RIa=1(4oaf2e z@*t5BbWCaRMV=fjo=pi^DuAqB$SE0-9iDanNiS^SJiXMQupp=a&KcFfkHbAu$(s2k2Ed(KpmoK!v6EGKgHaf9#%{O7CaDKxZjHxB^XYD*^5X zjaXNktUk_4}X?6y+R5v%Wnt(n% zwu%$&z!@@tC*u$4v%<}2`s*Dce|y-br-=qgnQ;&BP#oMLqy31xr6?&96bfC#MRxaC zefBZk9`|8LBqY9H>)z&YmI9!7|Fg9n7wf)bFKkd*Qy&xWAf2vvh%$g1n(4(3a#5{a zw153lFp%WYX4SGWI!5g~(BnIFOn()E=;#PsLQHj&osTv55KK-Iu$fVg>l>+Vrf0#i z{(d)ko;i}LDMn2NUX;cjqU6cl(ym4X_ezp-4Uu?T36KdJ-v*>t$Id-a`wk>}S0bzP z30>9_I12OlQpk$=g~6?fO~8hdqFN!Q%T01to~XP^9SAoXe z>wZA*ZFt_QS6LhSv2e1j1h9z4eX zyVDb`cV12Buwxq(o-cg-<%^|?Mxhc<*ih9R)oHM*24!>GvnMN5=17O@+%lf&7alJo;$`u zrFR;hW#i|ljeb1o0is4DS9M`I7xKNW1c)f6*QAXMV-cys44|ySgv%5Z+(nx!lZpK8 zl10)#YE?(KCXvBjVj16G5;Iqv1;-Yh;q zI<5E6f{A3HB4QS=i;G2gi826_mUGkPwK_jDUIAu=Or_R+;QC;$|~9 zM4O0+7lKWqu{y%u20bxD5VjWfKlXsZ&(fk1_BR_u8Nk*X zt0$EQLg<4(2{XWFsr-pd94c~zjU)JVA&na()9nG@E9s`i~)H)#O^Kp^1_r!6egaBD(?j7=bpR zUR=I@jKzaq*c9$pd;*h>)nGfgHNGaSicaqG0Br~>S)aw)a!#!kIOVOH-}VORv7u66 z1Q537BLJHI0ikvV=-R%&fL}2H4*vnW0Pyeq3mp26Y*dJB>h^Ae^=*8;WQ`1J5is2b zoG>Sn&&~i?O7OmsUJQC}AS)AS;oT%J02uB60@4Nmi2Vn&0zl^93S1!)SyE(c3#k68 z*gZXgq`FFh1wy|iZltre&lQ@JK83HAOZ za)>=iLX0L-!PG0$?UNCogm{51HoCA6iXl5}4L~*wp6)1gML+H@MF2ugM*6desw!I~ zXou9~d_%eg^`b$W#LMJ$%v??uE0E?ozJ->(<&v5z98<=A3#+3=6XU?qnN|)0223J< z88G#UA?t1aGLR{9Mf(r_We{LEi`Y;9%V1Pi<|L9)eC#j6z$l)JPVOsZ2K-vxpRkJO z*@6-Xid$&A=cd%_2JWe?6XRG1eQ+U{Mfed$?_BX6vdjLw%xXjaux23l;T|Nhlt#(% zmWtzC^cvxt!2w(rU^tPbT1tHe*&cbvL(9q)g(J}o3lP7VroE!RRj zs?7pc*RAbYhr5M9HU)#aXrq6F)~v(5e+N;zXu^MkL9F6^e+S8Q&^P}MF1WCY5BwdB z)IneUH|WhO-v4({T?Z}rZ_tBPeDLpJhYtGEzd?W2sSd#qIxe+LV- z(MkUXm07FC{|-87qig;Rso>k$=EqmuNWBoR;7(}`Zq`yJLmgvPzPD~Z;(57&h6iz7LrfsuROnF=N|tZ6gNb6 z{2T0zozwa^s1NFgzcXiJ=k)#!8XzP84K~HjJ^VMQha~$qcpM9&$RCl0jX>D_yF4Wp zB!$0&#>n!2g9Wj3{r`59iI)1O!uJJ;q<=c|0g<|TnQ{6=M+JNN)_yPk*B#;VZ;>M9Wy*%EQM{4N=dr5VE3&pu4>CjQi znJdnUm~Mk~jc((S*+AnB;|pBL+8+~U`>_g>$*F@d&h z5=P*i!doml+LFb8$wborh{JwR3#Ixr=(c| zKyPTYRUAs?{R>EAlL=kiy9EHivx=Xm4_=XokDvlU+q7Oa#nVw7=~iOH3BX^6IlQ7s z89oEJM%b4zXd$R9`U{NKpb6Kz(=x%NcWcqH^uZOBykIYl{-Q;55WVLKp8+TYg2jV8 zl^t1;ZnZXWxc)_N&#i_gRB8q4yJdZfAZw(Kh=YSpM+cHVc$b%xL)h^O(yiJCPCMvFj+#if?7!{jF;b8hC7e;@N&?VCQ;te-)`=REXd!gz+sE2y z0RA^52WiTXY%QOZ;Pgi_JT0<>@JYAPhoMm{Ez#8Te?VNn7&Pd`N68DMzNJVlu`+6h zXnCWg3Zx_HCU-1D&lRM>2|i@8ySzTexCS9<`D{xcEEygLHg_G30};lV+W;7o>0PIkZvET@DYQcQ^%ZF=vaw^M#Ag#zfE<$~nOh)~R2 zO`19Awv{-Kmd4C&|LYhzjv(Dsx^Dx-Y8VGP0?S7R9w1}~>u;%A_YJThC%FGy4S0Mj z3bfHu`f&LNFh#>WL{*`C&LZr+*g4IG>hd_GTj?Sk9IxUkhAW7wynjds zKmZG44`!Imx}kL+7ovqo>Cffn+*$<}vZP=U4ry3Z`#dy4)Ps+j0oc`tPClK-Cj0Q&Xd@iq=?g=>7~(Tf7g*v$cdO!FRF6 zbcF&tw3HEC#|O*!=4z{+j)rWC6xh(^21t!bD{URaa**MpNS()zYZ<(+QbNrD7sWW+v5_y zp>gL(;bJ=D8|yJYoU^Q~x07kzF|cTfgW9+1nTlwcU8+o zJP?uwu|baL&7jsjH}AcIrH_CH)zaX!i&_qXNl01*SS(AO3AmG+tG$75NO#{1^xbni zj>;keX0G?bBxJ=doYkjhsT*E5T>x(RcspNEp9d>dVzVBDQimLb&07yX*)OyD)Guv- zS-+O55`B?Qv?7Th>O!~3>6%`bU1aqs`g0}t?UJUGiP#nT;{uHMLT%0#hrPljV9F5D z(x{nEo)$VjDH2d|i89XB=5Fbpe$p!sK(a5*;2q`(D7^pJx%?hRrwl1hLusxXwrbvffStxLadcJrAw`K`zVy zUMr^z$m4c&(8J&nrxDCkP$$RsOsH+F8J2RC=M8C{O_cAlhP zuemLJc|?vxGl@$mR_?9H-Nz$boJw;2^~?ol?X<646w-J~{g7*$CA$1}SMI}Cl$YbF z&&vPzwtZwn;uQ{oxPJdWy$iDl>PnHbH*S2ta5pfC2!%L%=W!EW%G#>lWa&3k^1@75#*tnC(X7Nv{IFn*4@C$LL^X z2q!{*WK|q{X^-+UPQ-P!+>X2!{yl3e8{;IET*w0|!{K0FTN8ra+WZj(e!y}&?WJ^3 z)c&@!YH=)iB`Xm2s+X4EI$)S#EVS`e4x*7U$d@LFKZB()Ay`-oP$~;G0sNL(Rcpkf z%)Xr-+q*Z0KB_;%IH`|=z^`DDbrv&wrV!0Zg`OWFA3@er@OQ2cPbx=lfM2_!eLo)^ z2VId1;hYk09${LSXC86%w|P+ZoEaA6S!N3TkM*Zd#0ip~UI2*GBwZfEd4S|3wc~0; zf=Ol@e?9Rb5GCk#qVABPZI|TBMR5S48BTrldQ8+Ef!x6)Yy5F^m0!l*>eKrN+7l589H|ydoDn&5lZMD_ z^9Oq#1N>{>oTU4V`&tlMT1RoR|Jo+LP^bwU@$3uqHl_GMiYyQqS_?qI&UQo;4mF>&D)v zy);DT4tsZnh(xp`h2X{Pc4Q6?xDmOKM~%G^{dDVO%mVj=W+!tQ=wZHB)j*r@$L7ux zPon&DA(~`A7Kiod{pHJ=Zo;(D%|`qVxFNZa*~XD4+eD9v6hnaJ6rh4(WvL7JYz;sq zwV8U>znu3=3(p;LyQ;2_*ny>aE~k6VG8pTkb-?Lq-zWUfJw z$$zN_ZjipQZa(&m4uRJv=}X0@Ys>U(uvl)`!^tahaW%o(%@X`mN{x4ZVlOX5D!U&x z_mjl}-dgFucGL=tXn$a!#~S(WgeUws{V;`7eVi#wTsTo?%Y?vU|88*ynK#?5#NRw$ zU3kWTy*0tc-)}LEr#|=EoxtMyx%c|{zX_ervNY{=NmEo5<)D7R8q((K%BF8X3DZ4h31Ew zXEFZL5Se1gtM3)sk}j}HvR-{}xGop>^vO8Q`e>unWtv|*Muxa9SYpsT)`s+d1y6Df z)DbY9VZk{zG9=|O$N(@WB~kMIHS>kYb5Zlpo2wKt6zlC0)m{sufBmlEblEQS4;Z?CSaiYWY}EY0lR63vt@qh566*THb{jIO^bxbS zV7EG5J$z#$ub%P{J?(GyWuNMovq+Hfa4#d|1iT9}auf^+e{AJGsiR(3V%8K=eqz?S zrYrdt@a?&1gKHF0KJ%lbK+pMnE%fdjP-9L2#j$-JgF0AitG`_ovGK^{0&^<~CR1nMke@vDzB9+ z?K+Ps*sS@&gRkI7`pF6azNN)O=46YjmW%rh0>I8JfwTJQqb}GcbphRj%&pyi@Ix18 zE-P71(`%SklXEc9>KR>GFM_=A;Rlz7&fGr}o#xs>Gy@E&Z-Z*5md7R;s2<^>fBVpf zVC^hR>q|j$6?apGvL+s|$72%J8VCDN=LC+;Zyze1(IGs1`a@uQNF|TK2B1sU3BIdw zkN}QIhZ<`lG_@P-!|W#X1pm1q8|Ywi{jkdu4;8P-&_C*(-)j-#>Ca^AJq8xL`T99(>hllEI z!^q&*jZ1x>Zm7#rwm;qOC#wUjeS3Q%eW$mcLINpgM5wUy7x&zm1=Io#oBGM>|G<3} zvRCBFMJciKWJBi_fmr0Vg(K7Pz!97i{HC@EUPA)oI&C^EQ&)lk=?;d7Un!jhJ6Aw( z*L2n!+o!vY!f!wV!odra*w~AEhJd(~t6v%QQ__7Kces%FzUQ!H!E06zD5I zjvM*n`)nPINt$9+s+J8=hn%=FmmAQ*WyvI{`}td6CJ}w zVL6K=@x2GJ#w;*>N|4K_138BtXN>pc%n$c9H)pE|sOR>Jjh1iDbKoCU4yzMRlnW%fub zJVo0U7dgw$M8Z5O5VExpZ8mms~ z&3k$HTL`(z?~Vl>za>DPv$-d7Xyn>c+9UnjD_g+@au#&5u0cXX?=^7qAfIKF8;p<| zO0qnBo)n=kdN!Y62a0x_8Ctk`aF|>70@fbealmcY)k_3@rlOdvcb=+^R;I*Z4v*dh zW_Q5Gt3P*%vTOOllPUZUOH_p#2Ekc5z9FZlC9iNz{b7pTb($y2biw+vTLm>yWn_6_$i5S%VhI>`Vu-X+6Zgc ztI?@|1JV`e976xjESboEJ(qFk`s?$&OVt|Gm6MUpd`khMutx_hPwye)gnO8Lpy<_< zHOhLJh~Ep~7juoT>OK6U4Cv2MXj8@Ey8Re^$P-c7@8ikn;KA8OHaMW^bOAr%1xqZq z9@4wrNBa2@tTp3v%V!r=SAP~Yv0VcsdZpD2f4WNfD(4yyDY znIpgfRp#QvszL=C@>@UoqwT$$RRllX%wc;QRH!qXF}?{G)LLEbX>XBgVp9Wi+%X<~ z8m_)!Lt#))1|_J_dYb#s(X{oOv6&;5jcgi#A_~8{HXlcS>ndb4mFVIJnV|LJiij5O z_;%Qv4g*_$apG4*1_QfVcKF>T9pu&&bRaU2)~{lwHt#xF6m0lTr#jImrarf;C5LAh z@nBpr6xF`kxBIZi?nI5_C}oi@O)(9Kj1E#k5h6{n0Z)THtVww%Ps?8lu)J_!?@1!M zxWUTkLxtu!NSO)~do^gy zIth+oQMm=SQm5OVhG(kIoNha>X()$pW_(It3Az4ie1g1c4L;H>1Wxw*OqG$RAtccS z29{6SO;f6egUUf^pap=Vvyqf7dnfonhz599EQg2miW}~jZGPh9nf1oRfiFrlDW6c$ zMtn?nApVeTtLe&-@(19yk%z;mpCZ8*VN%K1W)?HLSM>Y0o=-3WTV(92gmGwWUSTS^ zhvZh7WV03g<@sjqSI05G9|bU#PEAZ(j@wGyZdCFJlEV+5GGjG*rv{By0W}c8KpXC1 zkJc~;(F`41U+|P^NBMO=pL(?n^)v`1iwn(qw6``Y9FP@!E3Ts2aImsn0vSzg4#0PI z|71ohi^T8k?3;jrY}M?)ezO4A7!7so`2p~X{PxQc6!49{)`?*U+OR(LE~%CekMwX&fnBI4bLm{)9wr&=5qKJrfMjO_1{wv>i<@) zbC^~70H;ED{la>)G?=YQp!yu(ME@1Kj}d&Z+@`vBVA{my4~*x1;(>*ILO`N(JsHH3 z&#*FR=o#;aDQh0Bhj!86Y}_lt;;;HTj|)&pO@4Ghn$QNb1gKF6?I6pAUa?6MYc>j= ztONFqMojrm+P4v@quREkVT=cdRZvCQgzGz?3)#(&E;hou0-!=wBn;_Sf6TV`xCfHD zpRm}GJfgwj$9OmCeaqEv(|e;~E@3BpZXb^nVY3u-ug#Z7aPK`3Zs$~SXR2WVA#oVA z%ze=<=>vO1qfFRJGf_rq^5usK#>*R7%Gs@3N3Nt3Kr}x{8kd~`>Hm>PXrxf+{0|~> z|ML#37K3GB-&<{)y$7V36RPfCy2l2iqF3F|e+-?|9`AG+far#|MzB?X%o{wCn%yd} zCMqYtIF#$X`C`KlqF@UA?$|+iVm-jyFMD_wObbX5O^9m6LNj!)Bz|) zGwt{PB9)*KqDvA7CtxRSPs5F@;PccmnDNB=Dhj#IzcQ8!!@V@{7alv-w@Zr=(|PIc zaIC?jc$;WB8?r49$48#p@$1nF7`8RFEOV)qqdo@sXO*j}$#!rLx82ECpF$*Z%S|q5 zic=nzToM^emh`t0@oNJjDM|Q`zV6W8j;eFcb^33|LJn^E3R6xqt;OJAjOd>@kc6cK;f zSI{a+IQ|rXvtB*nXp2?WoObf3XEMP0Uthm`YdhI_%nWKn^H@O2Kqf*>pxO~^U4Qlx z8j>WlcVcniTMMzyh8Rf=!%}LM5m< zm7x^L?E34}UXWz*Ce)qBgVA+2yFSyOtY-y3j-MBl^iB*0$tBcoWZ^naBLC~-DjpSe z@}g%Y$7Dwvu|yc^1_^w|Hi~CCC{#183{|C`1IT4o@!MiTY?koWxRN%zhqoO3QxVv6 z8rYNrx_bu?k-{V1L8Jn(Z5Jw5&c=@p9-5F;rUl(;6vg29K$dEL5~mMMPW~H^4%<`o zL>Ox(fx3?US!#W|9;FBEC_8qXDp1y2lX(TyV(-BA5*E8T^$(3nPb77cQjPq5k3Xk1 zj&mCBh~$c60c;|O6WE`z{VNU=CC>zTkM{`rtpYfio)If#Ev2!s#&FDNl|&@+OHT^h zo}+jiVLv$LVtNNSTzMH>t*siIboPG){!M!P>PLHW@ZH1-tBnmRV|8 z02!@)_qZ|jA8xymYcG5x35zYDEuoF46(SiPYZ4a{{{^j^_=Elop5t zUKE4?KY#~m*wU>S_54sPIGQg17n7wJX`KY{c5+NsjF-t7?0V7hPw0mZpZla zSi_|E{MWu6cE<_{_#Nx#vy5ViL~0{Uoz90|h-x^$fR;>H+DDSaFX`&=H*)~Dnd2(?Py>{%*Qu9z;=OwjH$RhT5-gJlW#QBD(U~8`+IP}en>nKs-?tk2dYSq+Ma=OkSB)p7LWHMAj?9)b z6ie`JBNEB7xpYsNzn?<(0br&_MJi5T{SK9rM>H8;?JX7#_pwcp_W?kf4hcae*qR>(+R#f}Z zxl3^^woWTA{l#oqBzVuqw0wwZfjQI4KV#rexMdjECv}IB_FoU};Sc`!Cv#ftFQ+VW z8MJT(f@225t~E8wL!afUJ=ffq&y2~ zySMNE{^oYD0@X_a4u2cJwj{L}rb-P}zpqB9Vb zkBqYX4ov)wBrawTnH~Wjv|wdkwr{c5zGZ}r2Ozwd;%_nTYPansv@(oQ~d|bwoNT}&&qy_AVd;sZzT0uV8jUnxy*%SQQ7rUaqg;80h(NaZ!Q_L>XS?93aA5J_OvEjl8sp8gfR^&MZuug>-# zU3N5kn4f_*JeJb}>goRaq&lU@JK+WDl-o(<(~~~7hCR*l702wOq1GSEf#*?}%(rT5FdC(nFYz!8wr;Fa273y(A9C(@1w4O;4 zy9V=+z)4ejolC-xrDuEp zTU%DY-?%8U?iVnZ^^AEoDlmKTs()8vKWpYAKx>HEB{UQ6_RNq14)=R0=NokUQ;MSM zNI*g6RFr@A6gsta^s4s2sTFD4(6c(dE;QV*PZ2Wh=RY+l#;MniB4|5_7P+dQ)`^7; zisT2^*GynVK;%vZTj3RSt}LZj^{)o2&`_v#s6zlLHUT?iCEZOHk z`0E%b5!wi>T0Bp^$ItKdxz6=yXmD?V78MGn8R^YviK^)Z!R!%MLK{GViI7>S8fb`R z$RAv&5;wM8@Wu^&o79nrH6OWl)q9;P6ihzC?dR!i(tuDGt6-Yso#fHHPVsWRR}hj= zutwH3EM0h?o(52w!iRy13n^8>8r`F~{al@t@Elkx7Z`HR_A2fi<(;cVLA{#eM&iVt zj20YEu5Xg<9#FM!>N=LFgqnfG(8q?sV?cK+H*dtHhUqAyp_L;X&m}mXVyqA>+TH6H zjNWIipi}w;ykd-IJ-mQ$l&f1^%(`fm%r>?e^TuudR8X57Jl3%FCs9?Fb#qkMuT1e< z2*F@tPEyAyi0}zB0dQZMm!ZHY{(9V4gM7bo0%PEu9QP(-DLo62POfUKx;kl zr?;mp&aq@)s(1ixMe4v}&67LevfcHnu%@nA$9V+dIJ4;aqtbf*(zlJxL!sHd=6I1MO@bw89ME8E?O? z`zqn;rPK=8x{uBLj8T! z&uqm~TMGn(Gnk#SV%ngq6C+a7epve#t`)`_`u6|DrQL-)XRTen=>A=-o4a8{tBk#x z%ghX%4~AfZ%m=g==<4K&2WdaDH;WG@6%Bo77Mg5iIxkzHk^hDgQ7;gCM~E%^NfIz@ zGenU6GAt^><{YvfB$}_@dY3djv_KAb1&wsEoi=ENe|%T_yo0j&uA+vh^0b#>@v`7- zXGJ(SX$npgy3Hjae1f6#mGXYay+$>igZI)qXPm0)kY3y@%T{w~^|Ojhml+;h0z(#P z9za6bU(wY$5N%)~jyr8#@&ku6Mhg~PTS-pH&QAlrUi=G3_tf{%^glE8@FTn{L6g^j zdpTJX7BEubtGQ|ML#~sAf8{hYdJ_*)D%{zF(AV(Arsn(c!IkTL-6%COOU7YKxD>{a z89-lkOf#V(lp}cV-hO4l?=VCn+t^_n*vFsacr4*THoHVJ{%yC1dWYTY{nstw&f(1` zQy~b&1g{)^i;41xWcI|NgF2AEyKreX_-~^>hlnWo9OJN=Pv!sNMiB=fO6)QA;kuaY z+_X0yDO+l(cPgz@z$9}<3(6O%k))EW4wP)pk6>=yeZS{fJdg#5%&ITA;P3P#KqtKk zg}G^|>8u+CdD#`kwKMz0UyjBuj`eYDHM$H8``*v~yp}5R4?YXCDjb&p)X%_D&6xwY zZqhXE(Y;SOfli68rbPfgNf?{f%TR6)mt1Q{UC1d2F1)vYm{R`ZI|d()20HcXLQ-i4 zcbH;Y1V;Bg_=mQN+Wg~;2kJW!r#fxE#s}Y#=x+bD<-9h;7*sah%Im?3dk-AXX~p`P z2E$&2jh{tfZ@9JNPKhiVh$X^$cNoXxhd+NIta2Tt8aA+w8kh7{ zsA>h&kw91jJY+pwAF@qf$5P<|bM#ognS8ts+@v4ulTl#`PSR%DM^tjR$I)f|&_X*= z3f6gUiB6r72wXA=NeRCyLhDBRQA_r&G-6IupZ|zGXnT3sh%3tTK;n%7&u=#6zT16a zgf)exm6x(}wtp`u$iBF?*zRr4id)KRdncp9&JY1{2w$|$N=mYH=$ntytKPnkMo1gs^ zNZW=?y-$xj)kbE-DHBhb#9crH%>YP?b`Lc9A`23}+ zEob<5)yxWQ$k6Yr>zJnkda?h&SVp?nbS;>RqHI3rQ+nUH*!zC=t@4;EbL~bv4S6oF z$VH`#{O_sP&2gdS*ZyrPGh@B2N-8ChI8ix_C&`k&mSs0Z@}+V9`y6S%@G(KE*D zN-u|N1h^ZNP;o`kjkPNem4%m|`8U=~30*8F2B8|MUdQigMrqk@fzjvc=zr1V6T}`e z`!x0Yn;+vHW<3X-&WGk}ANm z{Lu<$G(v26`B`T zaZFzO>MA|H9kFQHE!dU)<<8NI!>n-;ar$A5kWcOU4IrjjWE#! z#<4}tM7Liwi7)Q*Eb4h(Z39i^&+9P6=HI{nCT=h(r`hCSJImmWV?*lU@um_wL~`sn z!Nqo>VQYzeyL^v@u)_ED72>MHeY(Lj@5QTeH@c#)w@DnL~2g7 zH#sDFCXU(OvUN$gE3VWE@wBP^y5G{lp>)%gD$idTtFxaG@rO>-zSm;-3K|J>)B2h( zrZ4I2f3Ia`QwXNU3pIZNSTzW) zH^KEwna~m;VbwAdOA$3QUdV^jfL3(bj)VIxS($6}tu_NSX_vd5+0rt16 zTt}FK7`Axh3T4a2F?h>yJe5c8N~vD%=H->BGdu|VmXDJJ~{ct1_;!w zE@rQR3*3KSrXElUvMbv$Y_-MQ-2>ylJR(PUGxHddNQu&Rkb-8+r04ZWI zT?9Nz+O%zP&JSxIieXzaHXR<)wVACgAH06@3NCiiPxUzp$F9B7c8lTQV@mL|^Q36h z%4xU%kF(+A \ No newline at end of file diff --git a/frontend/public/vercel.svg b/frontend/public/vercel.svg deleted file mode 100644 index 77053960..00000000 --- a/frontend/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/public/window.svg b/frontend/public/window.svg deleted file mode 100644 index b2b2a44f..00000000 --- a/frontend/public/window.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file From 2f3c14edde5b4bd39772c616d5073786b544c3ec Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Fri, 19 Dec 2025 18:50:32 +0800 Subject: [PATCH 007/366] docs: update README.md --- README.md | 242 ++++++++++++++++++----------------- README_zh.md | 291 ++++++++++++++++++++++++++++++++++++++++++ frontend/README.md | 2 + frontend/README_zh.md | 50 ++++++++ 4 files changed, 466 insertions(+), 119 deletions(-) create mode 100644 README_zh.md create mode 100644 frontend/README_zh.md diff --git a/README.md b/README.md index 81e5145f..486ceee0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # LINUX DO Credit -🚀 Linux Do 社区 Credit 积分服务平台 +🚀 Linux Do Community Credit Service Platform + +[中文](./README_zh.md) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Go Version](https://img.shields.io/badge/Go-1.24-blue.svg)](https://golang.org/) @@ -20,18 +22,19 @@ [![CodeQL](https://github.com/linux-do/credit/actions/workflows/codeql.yml/badge.svg)](https://github.com/linux-do/credit/actions/workflows/codeql.yml) [![ESLint](https://github.com/linux-do/credit/actions/workflows/eslint.yml/badge.svg)](https://github.com/linux-do/credit/actions/workflows/eslint.yml) -## 📖 项目简介 +## 📖 Introduction + +LINUX DO Credit is a credit service platform built for the Linux Do community, aimed at providing a series of credit-related services and offering a foundational framework for credit circulation for community developers. -LINUX DO Credit 是一个为 Linux Do 社区打造的积分服务平台,旨在提供一系列积分相关服务,为社区开发者提供积分流转基础框架。 -### ✨ 主要特性 +### ✨ Key Features -- 🔐 **OAuth2 认证** - 集成 Linux Do 社区账号系统 -- 🛡️ **风险控制** - 完善的信任等级和风险评估系统 -- 📊 **实时监控** - 详细的分发统计和用户行为分析 -- 🎨 **现代化界面** - 基于 Next.js 16 和 React 19 的响应式设计 -- ⚡ **高性能** - Go 后端 + Redis 缓存 + PostgreSQL 数据库 +- 🔐 **OAuth2 Authentication** - Integrated with Linux Do community account system +- 🛡️ **Risk Control** - Comprehensive trust level and risk assessment system +- 📊 **Real-time Monitoring** - Detailed distribution statistics and user behavior analysis +- 🎨 **Modern Interface** - Responsive design based on Next.js 16 and React 19 +- ⚡ **High Performance** - Go Backend + Redis Cache + PostgreSQL Database -## 🏗️ 架构概览 +## 🏗️ Architecture Overview ``` ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ @@ -40,247 +43,248 @@ LINUX DO Credit 是一个为 Linux Do 社区打造的积分服务平台,旨在 │ │ │ │ │ │ │ • React 19 │ │ • Gin Framework │ │ • PostgreSQL │ │ • TypeScript │ │ • OAuth2 │ │ • Redis Cache │ -│ • Tailwind CSS │ │ • OpenTelemetry │ │ • Session Store │ -│ • Shadcn UI │ │ • Swagger API │ │ │ +│ • Tailwind CSS │ │ • Session Store │ │ │ +│ • Shadcn UI │ │ • OpenTelemetry │ │ │ +│ │ │ • Swagger API │ │ │ └─────────────────┘ └─────────────────┘ └─────────────────┘ ``` -## 🛠️ 技术栈 +## 🛠️ Tech Stack -### 后端 -- **[Go 1.25.4](https://go.dev/doc)** - 主要开发语言 -- **[GIN](https://github.com/gin-gonic/gin)** - Web 框架 -- **[GORM](https://github.com/go-gorm/gorm)** - ORM 框架 -- **[Redis](https://github.com/redis/redis)** - 缓存和会话存储 -- **[PostgreSQL](https://www.postgresql.org)** - 主数据库 -- **[OpenTelemetry](https://opentelemetry.io)** - 可观测性 -- **[Swagger](https://github.com/swaggo/swag)** - API 文档 +### Backend +- **[Go 1.25.4](https://go.dev/doc)** - Primary development language +- **[GIN](https://github.com/gin-gonic/gin)** - Web Framework +- **[GORM](https://github.com/go-gorm/gorm)** - ORM Framework +- **[Redis](https://github.com/redis/redis)** - Cache and session store +- **[PostgreSQL]** - Primary Database +- **[OpenTelemetry](https://opentelemetry.io)** - Observability +- **[Swagger](https://github.com/swaggo/swag)** - API Documentation -### 前端 -- **[Next.js 16](https://github.com/vercel/next.js)** - React 框架 -- **[React 19](https://github.com/facebook/react)** - UI 库 -- **[TypeScript](https://github.com/microsoft/TypeScript)** - 类型安全 -- **[Tailwind CSS 4](https://github.com/tailwindlabs/tailwindcss)** - 样式框架 -- **[Shadcn UI](https://github.com/shadcn-ui/ui)** - 组件库 -- **[Lucide Icons](https://github.com/lucide-icons/lucide)** - 图标库 +### Frontend +- **[Next.js 16](https://github.com/vercel/next.js)** - React Framework +- **[React 19](https://github.com/facebook/react)** - UI Library +- **[TypeScript](https://github.com/microsoft/TypeScript)** - Type Safety +- **[Tailwind CSS 4](https://github.com/tailwindlabs/tailwindcss)** - Styling Framework +- **[Shadcn UI](https://github.com/shadcn-ui/ui)** - Component Library +- **[Lucide Icons](https://github.com/lucide-icons/lucide)** - Icon Library -## 📋 环境要求 +## 📋 Requirements - **Go** >= 1.25.4 - **Node.js** >= 18.0 - **PostgreSQL** >= 18 - **Redis** >= 6.0 -- **pnpm** >= 8.0 (推荐) +- **pnpm** >= 8.0 (Recommended) -## 🚀 快速开始 +## 🚀 Quick Start -### 1. 克隆项目 +### 1. Clone the Project ```bash git clone https://github.com/linux-do/credit.git cd credit ``` -### 2. 配置环境 +### 2. Configure Environment -复制配置文件并编辑: +Copy the configuration file and edit it: ```bash cp config.example.yaml config.yaml ``` -编辑 `config.yaml` 文件,配置数据库连接、Redis、OAuth2 等信息。 +Edit `config.yaml` to configure database connections, Redis, OAuth2, etc. -### 3. 初始化数据库 +### 3. Initialize Database ```bash -# 创建数据库 -createdb -h <主机> -p 5432 -U postgres linux_do_credit +# Create database +createdb -h -p 5432 -U postgres linux_do_credit -# 如果需要指定字符集,可使用 -# psql -h <主机> -p 5432 -U postgres -c "CREATE DATABASE linux_do_credit WITH ENCODING 'UTF8' LC_COLLATE='zh_CN.UTF-8' LC_CTYPE='zh_CN.UTF-8' TEMPLATE template0;" +# If you need to specify encoding, use: +# psql -h -p 5432 -U postgres -c "CREATE DATABASE linux_do_credit WITH ENCODING 'UTF8' LC_COLLATE='zh_CN.UTF-8' LC_CTYPE='zh_CN.UTF-8' TEMPLATE template0;" -# 运行迁移(启动后端时会自动执行) +# Run migrations (automatically executed when starting the backend) ``` -### 4. 启动后端 +### 4. Start Backend ```bash -# 安装 Go 依赖 +# Install Go dependencies go mod tidy -# 生成 API 文档 +# Generate API documentation make swagger -# 启动后端服务 +# Start backend service go run main.go api ``` -### 5. 启动前端 +### 5. Start Frontend ```bash cd frontend -# 安装依赖 +# Install dependencies pnpm install -# 启动开发服务器 +# Start development server pnpm dev ``` -### 6. 访问应用 +### 6. Access Application -- **前端界面**: http://localhost:3000 -- **API 文档**: http://localhost:8000/swagger/index.html -- **健康检查**: http://localhost:8000/api/health +- **Frontend Interface**: http://localhost:3000 +- **API Documentation**: http://localhost:8000/swagger/index.html +- **Health Check**: http://localhost:8000/api/health -## ⚙️ 配置说明 +## ⚙️ Configuration -### 主要配置项 +### Main Configuration Options -| 配置项 | 说明 | 示例 | -|--------|------|------| -| `app.addr` | 后端服务监听地址 | `:8000` | -| `oauth2.client_id` | OAuth2 客户端 ID | `your_client_id` | -| `database.host` | PostgreSQL 数据库地址 | `127.0.0.1` | -| `database.port` | PostgreSQL 数据库端口 | `5432` | -| `database.username` | PostgreSQL 数据库用户名 | `postgres` | -| `database.password` | PostgreSQL 数据库密码 | `password` | -| `database.database` | PostgreSQL 数据库名称 | `linux_do_credit` | -| `database.ssl_mode` | PostgreSQL SSL 模式 | `disable` | -| `database.application_name` | PostgreSQL 应用标识 | `credit-server` | -| `database.search_path` | PostgreSQL 搜索路径 | `public` | -| `database.default_query_exec_mode` | SQL 缓存模式 | `cache_statement` | -| `redis.host` | Redis 服务器地址 | `127.0.0.1` | +| Option | Description | Example | +|--------|-------------|---------| +| `app.addr` | Backend service listening address | `:8000` | +| `oauth2.client_id` | OAuth2 Client ID | `your_client_id` | +| `database.host` | PostgreSQL database host | `127.0.0.1` | +| `database.port` | PostgreSQL database port | `5432` | +| `database.username` | PostgreSQL database username | `postgres` | +| `database.password` | PostgreSQL database password | `password` | +| `database.database` | PostgreSQL database name | `linux_do_credit` | +| `database.ssl_mode` | PostgreSQL SSL mode | `disable` | +| `database.application_name` | PostgreSQL application name | `credit-server` | +| `database.search_path` | PostgreSQL search path | `public` | +| `database.default_query_exec_mode` | SQL cache mode | `cache_statement` | +| `redis.host` | Redis server address | `127.0.0.1` | -详细配置说明请参考 `config.example.yaml` 文件。 +For detailed configuration instructions, please refer to the `config.example.yaml` file. -## 🔧 开发指南 +## 🔧 Development Guide -### 后端开发 +### Backend Development ```bash -# 运行 API 服务器 +# Run API server go run main.go api -# 运行任务调度器 +# Run task scheduler go run main.go scheduler -# 运行工作队列 +# Run worker queue go run main.go worker -# 生成 Swagger 文档 +# Generate Swagger documentation make swagger -# 代码格式化和检查 +# Format and check code make tidy ``` -### 前端开发 +### Frontend Development ```bash cd frontend -# 开发模式(使用 Turbopack) +# Development mode (using Turbopack) pnpm dev -# 构建生产版本 +# Build production version pnpm build -# 启动生产服务 +# Start production service pnpm start -# 代码检查和格式化 +# Lint and format code pnpm lint pnpm format ``` -## 📚 API 文档 +## 📚 API Documentation -API 文档通过 Swagger 自动生成,启动后端服务后可访问: +API documentation is automatically generated by Swagger and can be accessed after starting the backend service: ``` http://localhost:8000/swagger/index.html ``` -### 主要 API 端点 +### Main API Endpoints -- `GET /api/health` - 健康检查 -- `GET /api/oauth2/login` - OAuth2 登录 -- `GET /api/projects` - 获取项目列表 -- `POST /api/projects` - 创建新项目 +- `GET /api/health` - Health Check +- `GET /api/oauth2/login` - OAuth2 Login +- `GET /api/projects` - Get Project List +- `POST /api/projects` - Create New Project -## 🧪 测试 +## 🧪 Testing ```bash -# 后端测试 +# Backend testing go test ./... -# 前端测试 +# Frontend testing cd frontend pnpm test ``` -## 🚀 部署 +## 🚀 Deployment -### Docker 部署 +### Docker Deployment ```bash -# 构建镜像 +# Build image docker build -t linux-do-credit . -# 运行容器 +# Run container docker run -d -p 8000:8000 linux-do-credit ``` -### 生产环境部署 +### Production Environment Deployment -1. 构建前端资源: +1. Build frontend resources: ```bash cd frontend && pnpm build ``` -2. 编译后端程序: +2. Compile backend program: ```bash go build -o credit main.go ``` -3. 配置生产环境的 `config.yaml` +3. Configure `config.yaml` for production -4. 启动服务: +4. Start service: ```bash ./credit api ``` -## 🤝 贡献指南 +## 🤝 Contribution Guidelines -我们欢迎社区贡献!请在提交代码前阅读: +We welcome community contributions! Please read the following before submitting code: -- [贡献指南](CONTRIBUTING.md) -- [行为准则](CODE_OF_CONDUCT.md) -- [贡献者许可协议](CLA.md) +- [Contribution Guidelines](CONTRIBUTING.md) +- [Code of Conduct](CODE_OF_CONDUCT.md) +- [Contributor License Agreement](CLA.md) -### 提交流程 +### Submission Process -1. Fork 本仓库 -2. 创建特性分支 (`git checkout -b feature/your-feature`) -3. 提交更改 (`git commit -am 'Add your feature'`) -4. 推送到分支 (`git push origin feature/your-feature`) -5. 创建 Pull Request +1. Fork this repository +2. Create a feature branch (`git checkout -b feature/your-feature`) +3. Commit changes (`git commit -am 'Add your feature'`) +4. Push to branch (`git push origin feature/your-feature`) +5. Create Pull Request -## 📄 许可证 +## 📄 License -本项目基于 [Apache2.0 许可证](LICENSE) 开源。 +This project is open source under the [Apache2.0 License](LICENSE). -## 🔗 相关链接 +## 🔗 Related Links -- [Linux Do 社区](https://linux.do) -- [问题反馈](https://github.com/linux-do/credit/issues) -- [功能请求](https://github.com/linux-do/credit/issues/new?template=feature_request.md) +- [Linux Do Community](https://linux.do) +- [Issue Reporting](https://github.com/linux-do/credit/issues) +- [Feature Request](https://github.com/linux-do/credit/issues/new?template=feature_request.md) -## ❤️ 致谢 +## ❤️ Acknowledgements -感谢所有为本项目做出贡献的开发者和 Linux Do 社区的支持! +Thanks to all developers who contributed to this project and the support of the Linux Do Community! ## 📈 Star History diff --git a/README_zh.md b/README_zh.md new file mode 100644 index 00000000..4e614162 --- /dev/null +++ b/README_zh.md @@ -0,0 +1,291 @@ +# LINUX DO Credit + +🚀 Linux Do 社区 Credit 积分服务平台 + +[English](./README.md) + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Go Version](https://img.shields.io/badge/Go-1.24-blue.svg)](https://golang.org/) +[![Next.js](https://img.shields.io/badge/Next.js-16-black.svg)](https://nextjs.org/) +[![React](https://img.shields.io/badge/React-19-blue.svg)](https://reactjs.org/) + +[![GitHub release](https://img.shields.io/github/v/release/linux-do/credit?include_prereleases)](https://github.com/linux-do/credit/releases) +[![GitHub stars](https://img.shields.io/github/stars/linux-do/credit)](https://github.com/linux-do/credit/stargazers) +[![GitHub forks](https://img.shields.io/github/forks/linux-do/credit)](https://github.com/linux-do/credit/network) +[![GitHub issues](https://img.shields.io/github/issues/linux-do/credit)](https://github.com/linux-do/credit/issues) +[![GitHub pull requests](https://img.shields.io/github/issues-pr/linux-do/credit)](https://github.com/linux-do/credit/pulls) +[![GitHub contributors](https://img.shields.io/github/contributors/linux-do/credit)](https://github.com/linux-do/credit/graphs/contributors) + +[![Backend Build](https://github.com/linux-do/credit/actions/workflows/build_backend.yml/badge.svg)](https://github.com/linux-do/credit/actions/workflows/build_backend.yml) +[![Frontend Build](https://github.com/linux-do/credit/actions/workflows/build_frontend.yml/badge.svg)](https://github.com/linux-do/credit/actions/workflows/build_frontend.yml) +[![Docker Build](https://github.com/linux-do/credit/actions/workflows/build_image.yml/badge.svg)](https://github.com/linux-do/credit/actions/workflows/build_image.yml) +[![CodeQL](https://github.com/linux-do/credit/actions/workflows/codeql.yml/badge.svg)](https://github.com/linux-do/credit/actions/workflows/codeql.yml) +[![ESLint](https://github.com/linux-do/credit/actions/workflows/eslint.yml/badge.svg)](https://github.com/linux-do/credit/actions/workflows/eslint.yml) + +## 📖 项目简介 + +LINUX DO Credit 是一个为 Linux Do 社区打造的积分服务平台,旨在提供一系列积分相关服务,为社区开发者提供积分流转基础框架。 + +### ✨ 主要特性 + +- 🔐 **OAuth2 认证** - 集成 Linux Do 社区账号系统 +- 🛡️ **风险控制** - 完善的信任等级和风险评估系统 +- 📊 **实时监控** - 详细的分发统计和用户行为分析 +- 🎨 **现代化界面** - 基于 Next.js 16 和 React 19 的响应式设计 +- ⚡ **高性能** - Go 后端 + Redis 缓存 + PostgreSQL 数据库 + +## 🏗️ 架构概览 + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Frontend │ │ Backend │ │ Database │ +│ (Next.js) │◄──►│ (Go) │◄──►│ (PostgreSQL) │ +│ │ │ │ │ │ +│ • React 19 │ │ • Gin Framework │ │ • PostgreSQL │ +│ • TypeScript │ │ • OAuth2 │ │ • Redis Cache │ +│ • Tailwind CSS │ │ • Session Store │ │ │ +│ • Shadcn UI │ │ • OpenTelemetry │ │ │ +│ │ │ • Swagger API │ │ │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +## 🛠️ 技术栈 + +### 后端 +- **[Go 1.25.4](https://go.dev/doc)** - 主要开发语言 +- **[GIN](https://github.com/gin-gonic/gin)** - Web 框架 +- **[GORM](https://github.com/go-gorm/gorm)** - ORM 框架 +- **[Redis](https://github.com/redis/redis)** - 缓存和会话存储 +- **[PostgreSQL]** - 主数据库 +- **[OpenTelemetry](https://opentelemetry.io)** - 可观测性 +- **[Swagger](https://github.com/swaggo/swag)** - API 文档 + +### 前端 +- **[Next.js 16](https://github.com/vercel/next.js)** - React 框架 +- **[React 19](https://github.com/facebook/react)** - UI 库 +- **[TypeScript](https://github.com/microsoft/TypeScript)** - 类型安全 +- **[Tailwind CSS 4](https://github.com/tailwindlabs/tailwindcss)** - 样式框架 +- **[Shadcn UI](https://github.com/shadcn-ui/ui)** - 组件库 +- **[Lucide Icons](https://github.com/lucide-icons/lucide)** - 图标库 + +## 📋 环境要求 + +- **Go** >= 1.25.4 +- **Node.js** >= 18.0 +- **PostgreSQL** >= 18 +- **Redis** >= 6.0 +- **pnpm** >= 8.0 (推荐) + +## 🚀 快速开始 + +### 1. 克隆项目 + +```bash +git clone https://github.com/linux-do/credit.git +cd credit +``` + +### 2. 配置环境 + +复制配置文件并编辑: + +```bash +cp config.example.yaml config.yaml +``` + +编辑 `config.yaml` 文件,配置数据库连接、Redis、OAuth2 等信息。 + +### 3. 初始化数据库 + +```bash +# 创建数据库 +createdb -h <主机> -p 5432 -U postgres linux_do_credit + +# 如果需要指定字符集,可使用 +# psql -h <主机> -p 5432 -U postgres -c "CREATE DATABASE linux_do_credit WITH ENCODING 'UTF8' LC_COLLATE='zh_CN.UTF-8' LC_CTYPE='zh_CN.UTF-8' TEMPLATE template0;" + +# 运行迁移(启动后端时会自动执行) +``` + +### 4. 启动后端 + +```bash +# 安装 Go 依赖 +go mod tidy + +# 生成 API 文档 +make swagger + +# 启动后端服务 +go run main.go api +``` + +### 5. 启动前端 + +```bash +cd frontend + +# 安装依赖 +pnpm install + +# 启动开发服务器 +pnpm dev +``` + +### 6. 访问应用 + +- **前端界面**: http://localhost:3000 +- **API 文档**: http://localhost:8000/swagger/index.html +- **健康检查**: http://localhost:8000/api/health + +## ⚙️ 配置说明 + +### 主要配置项 + +| 配置项 | 说明 | 示例 | +|--------|------|------| +| `app.addr` | 后端服务监听地址 | `:8000` | +| `oauth2.client_id` | OAuth2 客户端 ID | `your_client_id` | +| `database.host` | PostgreSQL 数据库地址 | `127.0.0.1` | +| `database.port` | PostgreSQL 数据库端口 | `5432` | +| `database.username` | PostgreSQL 数据库用户名 | `postgres` | +| `database.password` | PostgreSQL 数据库密码 | `password` | +| `database.database` | PostgreSQL 数据库名称 | `linux_do_credit` | +| `database.ssl_mode` | PostgreSQL SSL 模式 | `disable` | +| `database.application_name` | PostgreSQL 应用标识 | `credit-server` | +| `database.search_path` | PostgreSQL 搜索路径 | `public` | +| `database.default_query_exec_mode` | SQL 缓存模式 | `cache_statement` | +| `redis.host` | Redis 服务器地址 | `127.0.0.1` | + +详细配置说明请参考 `config.example.yaml` 文件。 + +## 🔧 开发指南 + +### 后端开发 + +```bash +# 运行 API 服务器 +go run main.go api + +# 运行任务调度器 +go run main.go scheduler + +# 运行工作队列 +go run main.go worker + +# 生成 Swagger 文档 +make swagger + +# 代码格式化和检查 +make tidy +``` + +### 前端开发 + +```bash +cd frontend + +# 开发模式(使用 Turbopack) +pnpm dev + +# 构建生产版本 +pnpm build + +# 启动生产服务 +pnpm start + +# 代码检查和格式化 +pnpm lint +pnpm format +``` + +## 📚 API 文档 + +API 文档通过 Swagger 自动生成,启动后端服务后可访问: + +``` +http://localhost:8000/swagger/index.html +``` + +### 主要 API 端点 + +- `GET /api/health` - 健康检查 +- `GET /api/oauth2/login` - OAuth2 登录 +- `GET /api/projects` - 获取项目列表 +- `POST /api/projects` - 创建新项目 + +## 🧪 测试 + +```bash +# 后端测试 +go test ./... + +# 前端测试 +cd frontend +pnpm test +``` + +## 🚀 部署 + +### Docker 部署 + +```bash +# 构建镜像 +docker build -t linux-do-credit . + +# 运行容器 +docker run -d -p 8000:8000 linux-do-credit +``` + +### 生产环境部署 + +1. 构建前端资源: + ```bash + cd frontend && pnpm build + ``` + +2. 编译后端程序: + ```bash + go build -o credit main.go + ``` + +3. 配置生产环境的 `config.yaml` + +4. 启动服务: + ```bash + ./credit api + ``` + +## 🤝 贡献指南 + +我们欢迎社区贡献!请在提交代码前阅读: + +- [贡献指南](CONTRIBUTING.md) +- [行为准则](CODE_OF_CONDUCT.md) +- [贡献者许可协议](CLA.md) + +### 提交流程 + +1. Fork 本仓库 +2. 创建特性分支 (`git checkout -b feature/your-feature`) +3. 提交更改 (`git commit -am 'Add your feature'`) +4. 推送到分支 (`git push origin feature/your-feature`) +5. 创建 Pull Request + +## 📄 许可证 + +本项目基于 [Apache2.0 许可证](LICENSE) 开源。 + +## 🔗 相关链接 + +- [Linux Do 社区](https://linux.do) +- [问题反馈](https://github.com/linux-do/credit/issues) +- [功能请求](https://github.com/linux-do/credit/issues/new?template=feature_request.md) + +## ❤️ 致谢 + +感谢所有为本项目做出贡献的开发者和 Linux Do 社区的支持! + +## 📈 Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=linux-do/credit&type=Date)](https://star-history.com/#linux-do/credit&Date) diff --git a/frontend/README.md b/frontend/README.md index 59bafdf3..412a6b4b 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -2,6 +2,8 @@ Modern frontend application for the Linux Do Credit system. +[中文](./README_zh.md) | English + ## Tech Stack - **Framework**: [Next.js 16](https://nextjs.org/) diff --git a/frontend/README_zh.md b/frontend/README_zh.md new file mode 100644 index 00000000..33b0b12c --- /dev/null +++ b/frontend/README_zh.md @@ -0,0 +1,50 @@ +# Linux Do Credit Frontend + +Linux Do Credit 系统的现代化前端应用。 + +[English](./README.md) | 中文 + +## 技术栈 + +- **框架**: [Next.js 16](https://nextjs.org/) +- **样式**: [Tailwind CSS 4](https://tailwindcss.com/) +- **UI 组件**: [Radix UI](https://www.radix-ui.com/) +- **图标**: [Lucide React](https://lucide.dev/) +- **包管理器**: [pnpm](https://pnpm.io/) + +## 快速开始 + +### 前置要求 + +- Node.js (建议使用最新的 LTS 版本) +- pnpm + +### 安装 + +1. 安装依赖: + ```bash + pnpm install + ``` + +2. 运行开发服务器: + ```bash + pnpm dev + ``` + + 在浏览器中打开 [http://localhost:3000](http://localhost:3000) (或终端中显示的端口) 查看结果。 + +## 项目结构 + +- `app/`: Next.js App Router 页面和布局 +- `components/`: 可复用 UI 组件 + - `ui/`: 基础 UI 组件 (按钮, 输入框等) + - `common/`: 共享业务组件 +- `lib/`: 工具函数和服务定义 +- `public/`: 静态资源 + +## 脚本 + +- `pnpm dev`: 启动开发服务器 (使用 Turbopack) +- `pnpm build`: 构建生产版本 +- `pnpm start`: 启动生产服务器 +- `pnpm lint`: 运行 ESLint From c67f49aa93fa3f6c7f2489714675a0513a37a9cf Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Fri, 19 Dec 2025 18:52:50 +0800 Subject: [PATCH 008/366] feat: update pull_request_template --- .github/pull_request_template.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e74543f0..75dfb279 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,9 +2,9 @@ -- [ ] 我已阅读并理解 [贡献者公约](https://github.com/linux-do/pay/blob/master/CODE_OF_CONDUCT.md), -- [ ] 我已阅读并同意 [贡献者许可协议 (CLA)](https://github.com/linux-do/pay/blob/master/CLA.md),确认我的贡献将根据项目的 MIT 许可证进行许可, -- [ ] 我知晓如果此 PR 并不做出实质性更改,或可被认为是*为了PR被合并而提交PR*的,则可能不会被合并, +- [ ] 我已阅读并理解 [贡献者公约](https://github.com/linux-do/pay/blob/master/CODE_OF_CONDUCT.md) +- [ ] 我已阅读并同意 [贡献者许可协议 (CLA)](https://github.com/linux-do/pay/blob/master/CLA.md),确认我的贡献将根据项目的 Apache2.0 许可证进行许可 +- [ ] 我知晓如果此 PR 并不做出实质性更改,或可被认为是*为了PR被合并而提交PR*的,则可能不会被合并 **关联信息** From 64d8f20df8dcb98b300404c515b68e4e573dd296 Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Fri, 19 Dec 2025 18:59:31 +0800 Subject: [PATCH 009/366] docs: update README_zh.md --- README_zh.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README_zh.md b/README_zh.md index 4e614162..cf2f9d5f 100644 --- a/README_zh.md +++ b/README_zh.md @@ -286,6 +286,6 @@ docker run -d -p 8000:8000 linux-do-credit 感谢所有为本项目做出贡献的开发者和 Linux Do 社区的支持! -## 📈 Star History +## 📈 项目趋势 [![Star History Chart](https://api.star-history.com/svg?repos=linux-do/credit&type=Date)](https://star-history.com/#linux-do/credit&Date) From 2b3876269b105678cd86d3bc238ad81df86a2757 Mon Sep 17 00:00:00 2001 From: lurk Date: Fri, 19 Dec 2025 21:32:14 +0800 Subject: [PATCH 010/366] chore(versions): update dependencies to specific versions --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 05ff55b8..fc3c892e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:latest +FROM alpine:3.23.2 # set the time zone to Beijing Time in the Eastern 8th Time Zone ARG TZ=Asia/Shanghai From c7adca03871ba114c3433a12b10a4ed7816d6289 Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Fri, 19 Dec 2025 22:34:29 +0800 Subject: [PATCH 011/366] chore(versions): update dependencies to specific versions --- .github/workflows/build_backend.yml | 7 ++++--- .github/workflows/build_frontend.yml | 10 +++++----- .github/workflows/build_image.yml | 9 ++++++++- .github/workflows/check_license.yml | 2 +- .github/workflows/close_ticket.yml | 2 +- .github/workflows/codeql.yml | 2 +- .github/workflows/copilot-setup-steps.yml | 7 ++++--- .github/workflows/eslint.yml | 2 +- .github/workflows/pr-template-check.yml | 2 +- Dockerfile | 2 +- README.md | 4 ++-- README_zh.md | 4 ++-- go.mod | 2 +- 13 files changed, 32 insertions(+), 23 deletions(-) diff --git a/.github/workflows/build_backend.yml b/.github/workflows/build_backend.yml index dd718b8f..44885366 100644 --- a/.github/workflows/build_backend.yml +++ b/.github/workflows/build_backend.yml @@ -10,7 +10,7 @@ on: jobs: go_build: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Checkout code uses: actions/checkout@v4 @@ -18,12 +18,13 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.25.4" + go-version: "1.25" + check-latest: true - name: Install dependencies run: | go mod download - go install github.com/swaggo/swag/cmd/swag@latest + go install github.com/swaggo/swag/cmd/swag@v1.16.6 - name: Make Swagger run: make swagger diff --git a/.github/workflows/build_frontend.yml b/.github/workflows/build_frontend.yml index eac227bb..22e8d969 100644 --- a/.github/workflows/build_frontend.yml +++ b/.github/workflows/build_frontend.yml @@ -10,27 +10,27 @@ on: jobs: nextjs_build: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Checkout code uses: actions/checkout@v4 - + - name: Install pnpm uses: pnpm/action-setup@v4 with: version: 10.10.0 - + - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: "20" cache: 'pnpm' cache-dependency-path: frontend/pnpm-lock.yaml - + - name: Install dependencies working-directory: frontend run: pnpm install --frozen-lockfile - + - name: Build working-directory: frontend run: pnpm build diff --git a/.github/workflows/build_image.yml b/.github/workflows/build_image.yml index 653183f5..1d07188c 100644 --- a/.github/workflows/build_image.yml +++ b/.github/workflows/build_image.yml @@ -18,12 +18,19 @@ permissions: jobs: build_image: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: # checkout code - name: Checkout repository uses: actions/checkout@v4 + # set up go + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.25" + check-latest: true + # download Go modules - name: Download Go modules run: go mod download diff --git a/.github/workflows/check_license.yml b/.github/workflows/check_license.yml index 25c0cc5e..b625d9a6 100644 --- a/.github/workflows/check_license.yml +++ b/.github/workflows/check_license.yml @@ -9,7 +9,7 @@ on: jobs: check_license: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: checkout code uses: actions/checkout@v4 diff --git a/.github/workflows/close_ticket.yml b/.github/workflows/close_ticket.yml index fcacf28b..4ebf1f4a 100644 --- a/.github/workflows/close_ticket.yml +++ b/.github/workflows/close_ticket.yml @@ -6,7 +6,7 @@ on: jobs: close_ticket: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: issues: write pull-requests: write diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 16edd5ec..7d76f9c8 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -11,7 +11,7 @@ on: jobs: analyze: name: Analyze (${{ matrix.language }}) - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: security-events: write packages: read diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 69b36659..9055760c 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -11,7 +11,7 @@ on: jobs: copilot-setup-steps: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: read @@ -39,9 +39,10 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.24" + go-version: "1.25" + check-latest: true - name: Install dependencies run: | go mod download - go install github.com/swaggo/swag/cmd/swag@latest + go install github.com/swaggo/swag/cmd/swag@v1.16.6 diff --git a/.github/workflows/eslint.yml b/.github/workflows/eslint.yml index 9f959cbe..f8865db9 100644 --- a/.github/workflows/eslint.yml +++ b/.github/workflows/eslint.yml @@ -10,7 +10,7 @@ on: jobs: lint: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/pr-template-check.yml b/.github/workflows/pr-template-check.yml index 16ea9467..e61365de 100644 --- a/.github/workflows/pr-template-check.yml +++ b/.github/workflows/pr-template-check.yml @@ -6,7 +6,7 @@ on: jobs: check-pr-template: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: check all checklist items are checked uses: actions/github-script@v7 diff --git a/Dockerfile b/Dockerfile index fc3c892e..32ed842f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:3.23.2 +FROM alpine:3.23 # set the time zone to Beijing Time in the Eastern 8th Time Zone ARG TZ=Asia/Shanghai diff --git a/README.md b/README.md index 486ceee0..fb5f5f3c 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ LINUX DO Credit is a credit service platform built for the Linux Do community, a ## 🛠️ Tech Stack ### Backend -- **[Go 1.25.4](https://go.dev/doc)** - Primary development language +- **[Go 1.25.5](https://go.dev/doc)** - Primary development language - **[GIN](https://github.com/gin-gonic/gin)** - Web Framework - **[GORM](https://github.com/go-gorm/gorm)** - ORM Framework - **[Redis](https://github.com/redis/redis)** - Cache and session store @@ -70,7 +70,7 @@ LINUX DO Credit is a credit service platform built for the Linux Do community, a ## 📋 Requirements -- **Go** >= 1.25.4 +- **Go** >= 1.25.5 - **Node.js** >= 18.0 - **PostgreSQL** >= 18 - **Redis** >= 6.0 diff --git a/README_zh.md b/README_zh.md index cf2f9d5f..18dac166 100644 --- a/README_zh.md +++ b/README_zh.md @@ -52,7 +52,7 @@ LINUX DO Credit 是一个为 Linux Do 社区打造的积分服务平台,旨在 ## 🛠️ 技术栈 ### 后端 -- **[Go 1.25.4](https://go.dev/doc)** - 主要开发语言 +- **[Go 1.25.5](https://go.dev/doc)** - 主要开发语言 - **[GIN](https://github.com/gin-gonic/gin)** - Web 框架 - **[GORM](https://github.com/go-gorm/gorm)** - ORM 框架 - **[Redis](https://github.com/redis/redis)** - 缓存和会话存储 @@ -70,7 +70,7 @@ LINUX DO Credit 是一个为 Linux Do 社区打造的积分服务平台,旨在 ## 📋 环境要求 -- **Go** >= 1.25.4 +- **Go** >= 1.25.5 - **Node.js** >= 18.0 - **PostgreSQL** >= 18 - **Redis** >= 6.0 diff --git a/go.mod b/go.mod index d81d8190..c4df6da3 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/linux-do/pay -go 1.25.4 +go 1.25.5 require ( github.com/gin-contrib/sessions v1.0.4 From 071f7dc0deaffcc1ff5ae408a0cfd3a1410523aa Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Fri, 19 Dec 2025 22:59:34 +0800 Subject: [PATCH 012/366] docs: add the official postgresql website link --- README.md | 2 +- README_zh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fb5f5f3c..784596fa 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ LINUX DO Credit is a credit service platform built for the Linux Do community, a - **[GIN](https://github.com/gin-gonic/gin)** - Web Framework - **[GORM](https://github.com/go-gorm/gorm)** - ORM Framework - **[Redis](https://github.com/redis/redis)** - Cache and session store -- **[PostgreSQL]** - Primary Database +- **[PostgreSQL](https://www.postgresql.org)** - Primary Database - **[OpenTelemetry](https://opentelemetry.io)** - Observability - **[Swagger](https://github.com/swaggo/swag)** - API Documentation diff --git a/README_zh.md b/README_zh.md index 18dac166..67c6e7ca 100644 --- a/README_zh.md +++ b/README_zh.md @@ -56,7 +56,7 @@ LINUX DO Credit 是一个为 Linux Do 社区打造的积分服务平台,旨在 - **[GIN](https://github.com/gin-gonic/gin)** - Web 框架 - **[GORM](https://github.com/go-gorm/gorm)** - ORM 框架 - **[Redis](https://github.com/redis/redis)** - 缓存和会话存储 -- **[PostgreSQL]** - 主数据库 +- **[PostgreSQL](https://www.postgresql.org)** - 主数据库 - **[OpenTelemetry](https://opentelemetry.io)** - 可观测性 - **[Swagger](https://github.com/swaggo/swag)** - API 文档 From 6259ab7fd7104e0e8c2460de5f5c57987ce411c9 Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Fri, 19 Dec 2025 23:56:24 +0800 Subject: [PATCH 013/366] feat: optimize UI details --- frontend/app/(main)/docs/api/page.tsx | 4 +- frontend/app/(main)/docs/how-to-use/page.tsx | 9 +- .../common/docs/markdown-renderer.tsx | 4 +- frontend/components/common/docs/toc.tsx | 6 +- frontend/components/common/trade/transfer.tsx | 2 +- frontend/components/layout/sidebar.tsx | 2 +- frontend/lib/utils/search-data.ts | 196 +++++++++--------- 7 files changed, 111 insertions(+), 112 deletions(-) diff --git a/frontend/app/(main)/docs/api/page.tsx b/frontend/app/(main)/docs/api/page.tsx index 4c20b145..b439bf53 100644 --- a/frontend/app/(main)/docs/api/page.tsx +++ b/frontend/app/(main)/docs/api/page.tsx @@ -156,13 +156,13 @@ curl -X POST https://credit.linux.do/epay/pay/submit.php \\ export default function ApiDocPage() { return ( -
+
diff --git a/frontend/app/(main)/docs/how-to-use/page.tsx b/frontend/app/(main)/docs/how-to-use/page.tsx index b571b154..53b919b4 100644 --- a/frontend/app/(main)/docs/how-to-use/page.tsx +++ b/frontend/app/(main)/docs/how-to-use/page.tsx @@ -4,10 +4,9 @@ import { MarkdownRenderer } from "@/components/common/docs/markdown-renderer" import { TableOfContents } from "@/components/common/docs/toc" const CONTENT = ` -> 为社区开发者与用户提供完整的平台使用说明 - ## 1. 快速开始 - +> 为社区开发者与用户提供完整的平台使用说明 + - 身份认证:基于 LINUX DO Connect (OAuth) - 认证方式:账户积分消耗认证 - 手续费:动态费率,由服务方承担 @@ -159,13 +158,13 @@ const CONTENT = ` export default function HowToUsePage() { return ( -
+
diff --git a/frontend/components/common/docs/markdown-renderer.tsx b/frontend/components/common/docs/markdown-renderer.tsx index a051f05c..c29d8f85 100644 --- a/frontend/components/common/docs/markdown-renderer.tsx +++ b/frontend/components/common/docs/markdown-renderer.tsx @@ -95,7 +95,7 @@ export const MarkdownRenderer: React.FC = ({ /> ), h1: ({ className, children, ...props }: React.HTMLAttributes) => ( -

+

{children}

), @@ -138,7 +138,7 @@ export const MarkdownRenderer: React.FC = ({ }; return ( -
+
在此页
- +
-
- - - - {apiKey.redirect_uri} - - - -
+ {apiKey.redirect_uri && ( +
+ + + + {apiKey.redirect_uri} + + + +
+ )}
diff --git a/frontend/components/common/merchant/merchant-online.tsx b/frontend/components/common/merchant/merchant-online.tsx index 01771948..b199968b 100644 --- a/frontend/components/common/merchant/merchant-online.tsx +++ b/frontend/components/common/merchant/merchant-online.tsx @@ -239,7 +239,7 @@ function MerchantOnlineContent({ apiKeys, loadAPIKeys }: MerchantOnlineContentPr /* 处理复制链接 */ const handleCopyLink = (token: string) => { - const url = `${ window.location.origin }/pay/${ token }` + const url = `${ window.location.origin }/paying/online?token=${ token }` navigator.clipboard.writeText(url) toast.success("在线积分流转服务链接已复制") } diff --git a/frontend/lib/services/merchant/types.ts b/frontend/lib/services/merchant/types.ts index bc553425..bd9c1345 100644 --- a/frontend/lib/services/merchant/types.ts +++ b/frontend/lib/services/merchant/types.ts @@ -17,7 +17,7 @@ export interface MerchantAPIKey { /** 应用描述 */ app_description: string; /** 重定向 URI */ - redirect_uri: string; + redirect_uri?: string; /** 通知 URL */ notify_url: string; /** 创建时间 */ @@ -38,8 +38,8 @@ export interface CreateAPIKeyRequest { app_homepage_url: string; /** 应用描述(最大100字符,可选) */ app_description?: string; - /** 重定向 URI(最大100字符,必须是有效的 URL) */ - redirect_uri: string; + /** 重定向 URI(最大100字符,必须是有效的 URL,可选) */ + redirect_uri?: string; /** 通知 URL(最大100字符,必须是有效的 URL) */ notify_url: string; } From 429cc1db43f645318ab98db09cbcf63f47a2b440 Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Sun, 21 Dec 2025 00:05:39 +0800 Subject: [PATCH 016/366] fix: createAPIKey --- frontend/contexts/merchant-context.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/contexts/merchant-context.tsx b/frontend/contexts/merchant-context.tsx index 626be09e..0d701adf 100644 --- a/frontend/contexts/merchant-context.tsx +++ b/frontend/contexts/merchant-context.tsx @@ -23,7 +23,7 @@ interface MerchantContextType extends MerchantDataState { createAPIKey: (data: { app_name: string app_homepage_url: string - redirect_uri: string + redirect_uri?: string notify_url: string }) => Promise updateAPIKey: (id: number, data: UpdateAPIKeyRequest) => Promise @@ -103,7 +103,7 @@ export function MerchantProvider({ children }: { children: React.ReactNode }) { const createAPIKey = useCallback(async (data: { app_name: string app_homepage_url: string - redirect_uri: string + redirect_uri?: string notify_url: string }): Promise => { const newKey = await services.merchant.createAPIKey(data) From 3875f3d90cc75d4bb247b40425ac9da97ffdf66b Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Sun, 21 Dec 2025 14:58:57 +0800 Subject: [PATCH 017/366] feat: add Snowflake distributed ID generator for sharding preparation --- config.example.yaml | 20 +++++++++-- go.mod | 3 +- go.sum | 2 ++ internal/config/model.go | 39 ++++++++++++++------ internal/db/idgen/snowflake.go | 45 ++++++++++++++++++++++++ internal/model/disputes.go | 12 ++++++- internal/model/merchant_api_keys.go | 10 +++++- internal/model/merchant_payment_links.go | 10 +++++- internal/model/orders.go | 12 +++++-- internal/model/users.go | 20 ++++++++--- 10 files changed, 149 insertions(+), 24 deletions(-) create mode 100644 internal/db/idgen/snowflake.go diff --git a/config.example.yaml b/config.example.yaml index eade8fc0..73712721 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -6,6 +6,7 @@ app: app_name: "linux-do-credit" env: "development" # development, testing, production addr: ":8000" + node_id: 1 # 分布式节点 ID (0-1023),不同实例必须不同 graceful_shutdown_timeout: 30 session_cookie_name: "linux_do_credit_session_id" # change this in local dev env session_secret: "" # you can't change this after first time start @@ -53,6 +54,20 @@ database: # - host: "replica2.db.local" # port: 5432 +# clickhouse +clickhouse: + enabled: false + hosts: + - "127.0.0.1:9000" + username: "default" + password: "" + database: "linux_do_credit" + max_idle_conn: 10 + max_open_conn: 100 + conn_max_lifetime: 3600 + dial_timeout: 5 + block_buffer_size: 10 + # Redis # 支持三种模式:Standalone(单节点)、Sentinel(高可用)、Cluster(水平扩展) redis: @@ -85,12 +100,13 @@ log: max_backups: 10 compress: true -# Schedule -schedule: +# Scheduler +scheduler: user_gamification_score_dispatch_interval_seconds: 3 update_user_gamification_scores_task_cron: "0 2 * * *" dispute_auto_refund_dispatch_interval_seconds: 3 auto_refund_expired_disputes_task_cron: "0 0 * * *" + sync_orders_to_clickhouse_task_cron: "10 0 * * *" # Worker worker: diff --git a/go.mod b/go.mod index 7b37795f..eaa349b1 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,8 @@ module github.com/linux-do/credit go 1.25.5 require ( + github.com/ClickHouse/clickhouse-go/v2 v2.37.2 + github.com/bwmarrin/snowflake v0.3.0 github.com/gin-contrib/sessions v1.0.4 github.com/gin-gonic/gin v1.11.0 github.com/google/uuid v1.6.0 @@ -33,7 +35,6 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/ClickHouse/ch-go v0.66.1 // indirect - github.com/ClickHouse/clickhouse-go/v2 v2.37.2 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/boj/redistore v1.4.1 // indirect diff --git a/go.sum b/go.sum index 9d742497..d3c87881 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0= +github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE= diff --git a/internal/config/model.go b/internal/config/model.go index 68b079a9..ca787ebf 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -17,15 +17,16 @@ limitations under the License. package config type configModel struct { - App appConfig `mapstructure:"app"` - OAuth2 OAuth2Config `mapstructure:"oauth2"` - Database databaseConfig `mapstructure:"database"` - Redis redisConfig `mapstructure:"redis"` - Log logConfig `mapstructure:"log"` - Schedule scheduleConfig `mapstructure:"schedule"` - Worker workerConfig `mapstructure:"worker"` - LinuxDo linuxDoConfig `mapstructure:"linuxdo"` - Otel otelConfig `mapstructure:"otel"` + App appConfig `mapstructure:"app"` + OAuth2 OAuth2Config `mapstructure:"oauth2"` + Database databaseConfig `mapstructure:"database"` + Redis redisConfig `mapstructure:"redis"` + Log logConfig `mapstructure:"log"` + Scheduler schedulerConfig `mapstructure:"scheduler"` + Worker workerConfig `mapstructure:"worker"` + ClickHouse clickHouseConfig `mapstructure:"clickhouse"` + LinuxDo linuxDoConfig `mapstructure:"linuxdo"` + Otel otelConfig `mapstructure:"otel"` } // appConfig 应用基本配置 @@ -33,6 +34,7 @@ type appConfig struct { AppName string `mapstructure:"app_name"` Env string `mapstructure:"env"` Addr string `mapstructure:"addr"` + NodeID int64 `mapstructure:"node_id"` APIPrefix string `mapstructure:"api_prefix"` GracefulShutdownTimeout int `mapstructure:"graceful_shutdown_timeout"` FrontendPayURL string `mapstructure:"frontend_pay_url"` @@ -85,6 +87,20 @@ type databaseReplicaConfig struct { Password string `mapstructure:"password"` } +// clickhouse 配置 +type clickHouseConfig struct { + Enabled bool `mapstructure:"enabled"` + Hosts []string `mapstructure:"hosts"` + Username string `mapstructure:"username"` + Password string `mapstructure:"password"` + Database string `mapstructure:"database"` + MaxIdleConn int `mapstructure:"max_idle_conn"` + MaxOpenConn int `mapstructure:"max_open_conn"` + ConnMaxLifetime int `mapstructure:"conn_max_lifetime"` + DialTimeout int `mapstructure:"dial_timeout"` + BlockBufferSize uint8 `mapstructure:"block_buffer_size"` +} + // redisConfig Redis配置 type redisConfig struct { Enabled bool `mapstructure:"enabled"` @@ -117,12 +133,13 @@ type logConfig struct { Compress bool `mapstructure:"compress"` } -// scheduleConfig 定时任务配置 -type scheduleConfig struct { +// schedulerConfig 定时任务配置 +type schedulerConfig struct { UserGamificationScoreDispatchIntervalSeconds int `mapstructure:"user_gamification_score_dispatch_interval_seconds"` UpdateUserGamificationScoresTaskCron string `mapstructure:"update_user_gamification_scores_task_cron"` DisputeAutoRefundDispatchIntervalSeconds int `mapstructure:"dispute_auto_refund_dispatch_interval_seconds"` AutoRefundExpiredDisputesTaskCron string `mapstructure:"auto_refund_expired_disputes_task_cron"` + SyncOrdersToClickHouseTaskCron string `mapstructure:"sync_orders_to_clickhouse_task_cron"` } // workerConfig 工作配置 diff --git a/internal/db/idgen/snowflake.go b/internal/db/idgen/snowflake.go new file mode 100644 index 00000000..1130fa41 --- /dev/null +++ b/internal/db/idgen/snowflake.go @@ -0,0 +1,45 @@ +/* +Copyright 2025 linux.do + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package idgen + +import ( + "log" + + "github.com/bwmarrin/snowflake" + "github.com/linux-do/credit/internal/config" +) + +// 2025-12-01 00:00:00 UTC 的毫秒时间戳 +const epoch int64 = 1764547200000 + +var node *snowflake.Node + +func init() { + snowflake.Epoch = epoch + + nodeID := config.Config.App.NodeID + var err error + node, err = snowflake.NewNode(nodeID) + if err != nil { + log.Fatalf("[Snowflake] init failed: %v\n", err) + } + log.Printf("[Snowflake] initialized with node ID: %d, epoch: 2025-12-01\n", nodeID) +} + +func NextUint64ID() uint64 { + return uint64(node.Generate().Int64()) +} diff --git a/internal/model/disputes.go b/internal/model/disputes.go index 87db363b..0903347b 100644 --- a/internal/model/disputes.go +++ b/internal/model/disputes.go @@ -18,6 +18,9 @@ package model import ( "time" + + "github.com/linux-do/credit/internal/db/idgen" + "gorm.io/gorm" ) type DisputeStatus string @@ -29,7 +32,7 @@ const ( ) type Dispute struct { - ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + ID uint64 `json:"id" gorm:"primaryKey"` OrderID uint64 `json:"order_id" gorm:"uniqueIndex:idx_dispute_order;index:idx_dispute_order_status,priority:1;not null"` InitiatorUserID uint64 `json:"initiator_user_id" gorm:"not null;index:idx_initiator_status_created,priority:1"` Reason string `json:"reason" gorm:"size:500;not null"` @@ -40,3 +43,10 @@ type Dispute struct { CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index:idx_initiator_status_created,priority:3"` UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` } + +func (d *Dispute) BeforeCreate(*gorm.DB) error { + if d.ID == 0 { + d.ID = idgen.NextUint64ID() + } + return nil +} diff --git a/internal/model/merchant_api_keys.go b/internal/model/merchant_api_keys.go index 275b3be2..0fdb5ee9 100644 --- a/internal/model/merchant_api_keys.go +++ b/internal/model/merchant_api_keys.go @@ -19,11 +19,12 @@ package model import ( "time" + "github.com/linux-do/credit/internal/db/idgen" "gorm.io/gorm" ) type MerchantAPIKey struct { - ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + ID uint64 `json:"id" gorm:"primaryKey"` UserID uint64 `json:"user_id" gorm:"not null;index:idx_merchant_api_keys_user_created,priority:1"` ClientID string `json:"client_id" gorm:"size:64;uniqueIndex;index:idx_client_credentials,priority:2;not null"` ClientSecret string `json:"client_secret" gorm:"size:64;index:idx_client_credentials,priority:1;not null"` @@ -46,3 +47,10 @@ func (m *MerchantAPIKey) GetByID(tx *gorm.DB, id uint64) error { func (m *MerchantAPIKey) GetByClientID(tx *gorm.DB, clientID string) error { return tx.Where("client_id = ?", clientID).First(m).Error } + +func (m *MerchantAPIKey) BeforeCreate(*gorm.DB) error { + if m.ID == 0 { + m.ID = idgen.NextUint64ID() + } + return nil +} diff --git a/internal/model/merchant_payment_links.go b/internal/model/merchant_payment_links.go index ead8e3a4..5d82d635 100644 --- a/internal/model/merchant_payment_links.go +++ b/internal/model/merchant_payment_links.go @@ -19,12 +19,13 @@ package model import ( "time" + "github.com/linux-do/credit/internal/db/idgen" "github.com/shopspring/decimal" "gorm.io/gorm" ) type MerchantPaymentLink struct { - ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + ID uint64 `json:"id" gorm:"primaryKey"` MerchantAPIKeyID uint64 `json:"merchant_api_key_id" gorm:"not null;index"` Token string `json:"token" gorm:"size:64;uniqueIndex;not null"` Amount decimal.Decimal `json:"amount" gorm:"type:numeric(20,2);not null"` @@ -38,3 +39,10 @@ type MerchantPaymentLink struct { func (m *MerchantPaymentLink) GetByToken(tx *gorm.DB, token string) error { return tx.Where("token = ?", token).First(m).Error } + +func (m *MerchantPaymentLink) BeforeCreate(*gorm.DB) error { + if m.ID == 0 { + m.ID = idgen.NextUint64ID() + } + return nil +} diff --git a/internal/model/orders.go b/internal/model/orders.go index f81075e7..395ba6d7 100644 --- a/internal/model/orders.go +++ b/internal/model/orders.go @@ -22,6 +22,7 @@ import ( "time" "github.com/linux-do/credit/internal/db" + "github.com/linux-do/credit/internal/db/idgen" "github.com/linux-do/credit/internal/logger" "github.com/shopspring/decimal" @@ -51,7 +52,7 @@ const ( ) type Order struct { - ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + ID uint64 `json:"id" gorm:"primaryKey"` OrderNo string `json:"order_no" gorm:"-"` OrderName string `json:"order_name" gorm:"size:64;not null"` MerchantOrderNo string `json:"merchant_order_no" gorm:"size:64;index"` @@ -68,7 +69,14 @@ type Order struct { TradeTime time.Time `json:"trade_time" gorm:"index:idx_orders_payer_status_type_trade,priority:4"` ExpiresAt time.Time `json:"expires_at" gorm:"not null"` CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index:idx_orders_payee_status_type_created,priority:4;index:idx_orders_payer_status_type_created,priority:4;index:idx_orders_client_status_created,priority:3"` - UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"` +} + +func (o *Order) BeforeCreate(*gorm.DB) error { + if o.ID == 0 { + o.ID = idgen.NextUint64ID() + } + return nil } // AfterFind 格式化 OrderNo diff --git a/internal/model/users.go b/internal/model/users.go index d0626d0d..eb32059a 100644 --- a/internal/model/users.go +++ b/internal/model/users.go @@ -31,7 +31,7 @@ import ( "github.com/linux-do/credit/internal/db" "github.com/linux-do/credit/internal/logger" "github.com/linux-do/credit/internal/task" - "github.com/linux-do/credit/internal/task/schedule" + "github.com/linux-do/credit/internal/task/scheduler" "github.com/linux-do/credit/internal/util" "github.com/shopspring/decimal" "gorm.io/gorm" @@ -144,15 +144,25 @@ func (u *User) CheckActive() error { } // EnqueueBadgeScoreTask 为用户下发积分计算任务 -func (u *User) EnqueueBadgeScoreTask(ctx context.Context) { +func (u *User) EnqueueBadgeScoreTask(ctx context.Context, delay time.Duration) error { payload, _ := json.Marshal(map[string]interface{}{ "user_id": u.ID, }) - if _, err := schedule.AsynqClient.Enqueue(asynq.NewTask(task.UpdateSingleUserGamificationScoreTask, payload), asynq.Queue(task.QueueWhitelistOnly)); err != nil { + + opts := []asynq.Option{ + asynq.Queue(task.QueueWhitelistOnly), + asynq.MaxRetry(5), + } + if delay > 0 { + opts = append(opts, asynq.ProcessIn(delay)) + } + + if _, err := scheduler.AsynqClient.Enqueue(asynq.NewTask(task.UpdateSingleUserGamificationScoreTask, payload), opts...); err != nil { logger.ErrorF(ctx, "下发用户[%s]积分计算任务失败: %v", u.Username, err) - } else { - logger.InfoF(ctx, "下发用户[%s]积分计算任务成功", u.Username) + return err } + logger.InfoF(ctx, "下发用户[%s]积分计算任务成功", u.Username) + return nil } // MarkAsDeactivatedAndCreateNew 将当前用户标记为已注销,并创建新用户 From e32cc4f9459ceb6e89bece6c7b2223271818935d Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Sun, 21 Dec 2025 14:59:12 +0800 Subject: [PATCH 018/366] feat: add ClickHouse support for order data analytics --- internal/apps/order/tasks.go | 116 ++++++++++++++++++++++++ internal/db/clickhouse.go | 73 +++++++++++++++ support-files/sql/create_clickhouse.sql | 45 +++++++++ 3 files changed, 234 insertions(+) create mode 100644 internal/apps/order/tasks.go create mode 100644 internal/db/clickhouse.go create mode 100644 support-files/sql/create_clickhouse.sql diff --git a/internal/apps/order/tasks.go b/internal/apps/order/tasks.go new file mode 100644 index 00000000..225c5a7c --- /dev/null +++ b/internal/apps/order/tasks.go @@ -0,0 +1,116 @@ +/* +Copyright 2025 linux.do + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package order + +import ( + "context" + "time" + + "github.com/hibiken/asynq" + "github.com/linux-do/credit/internal/config" + "github.com/linux-do/credit/internal/db" + "github.com/linux-do/credit/internal/logger" + "github.com/linux-do/credit/internal/model" +) + +// HandleSyncOrdersToClickHouse 同步订单数据 +func HandleSyncOrdersToClickHouse(ctx context.Context, t *asynq.Task) error { + if !config.Config.ClickHouse.Enabled { + logger.InfoF(ctx, "ClickHouse 未启用,跳过订单同步") + return nil + } + + // 计算昨天的时间范围 + now := time.Now() + yesterday := now.AddDate(0, 0, -1) + startOfDay := time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 0, 0, 0, 0, yesterday.Location()) + endOfDay := startOfDay.AddDate(0, 0, 1) + + logger.InfoF(ctx, "开始同步订单到 ClickHouse: %s ~ %s", startOfDay.Format("2006-01-02 15:04:05"), endOfDay.Format("2006-01-02 15:04:05")) + + pageSize := 10000 + lastID := uint64(0) + totalSynced := 0 + + for { + var orders []model.Order + if err := db.DB(ctx). + Where("updated_at >= ? AND updated_at < ? AND id > ?", startOfDay, endOfDay, lastID). + Order("id ASC"). + Limit(pageSize). + Find(&orders).Error; err != nil { + logger.ErrorF(ctx, "查询订单失败: %v", err) + return err + } + + if len(orders) == 0 { + break + } + + if err := batchInsertToClickHouse(ctx, orders); err != nil { + logger.ErrorF(ctx, "写入 ClickHouse 失败: %v", err) + return err + } + + totalSynced += len(orders) + lastID = orders[len(orders)-1].ID + + logger.InfoF(ctx, "已同步 %d 条订单,当前进度 ID: %d", len(orders), lastID) + } + + logger.InfoF(ctx, "订单同步完成,共同步 %d 条", totalSynced) + return nil +} + +// batchInsertToClickHouse 批量写入订单 +func batchInsertToClickHouse(ctx context.Context, orders []model.Order) error { + batch, err := db.ChConn.PrepareBatch(ctx, ` + INSERT INTO orders ( + id, order_name, merchant_order_no, client_id, + payer_user_id, payee_user_id, amount, + status, type, remark, payment_type, + trade_time, expires_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + if err != nil { + return err + } + + for _, order := range orders { + if err := batch.Append( + order.ID, + order.OrderName, + order.MerchantOrderNo, + order.ClientID, + order.PayerUserID, + order.PayeeUserID, + order.Amount, + string(order.Status), + string(order.Type), + order.Remark, + order.PaymentType, + order.TradeTime, + order.ExpiresAt, + order.CreatedAt, + order.UpdatedAt, + ); err != nil { + return err + } + } + + return batch.Send() +} diff --git a/internal/db/clickhouse.go b/internal/db/clickhouse.go new file mode 100644 index 00000000..2b0c8d80 --- /dev/null +++ b/internal/db/clickhouse.go @@ -0,0 +1,73 @@ +/* +Copyright 2025 linux.do + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package db + +import ( + "context" + "log" + "time" + + "github.com/ClickHouse/clickhouse-go/v2" + "github.com/ClickHouse/clickhouse-go/v2/lib/driver" + "github.com/linux-do/credit/internal/config" +) + +var ( + ChConn driver.Conn +) + +func init() { + if !config.Config.ClickHouse.Enabled { + return + } + + cfg := config.Config.ClickHouse + var err error + + // 配置 ClickHouse 连接 + ChConn, err = clickhouse.Open(&clickhouse.Options{ + Addr: cfg.Hosts, + Auth: clickhouse.Auth{ + Database: cfg.Database, + Username: cfg.Username, + Password: cfg.Password, + }, + Settings: clickhouse.Settings{ + "max_execution_time": 60, + }, + Compression: &clickhouse.Compression{ + Method: clickhouse.CompressionLZ4, + }, + DialTimeout: time.Duration(cfg.DialTimeout) * time.Second, + MaxOpenConns: cfg.MaxOpenConn, + MaxIdleConns: cfg.MaxIdleConn, + ConnMaxLifetime: time.Duration(cfg.ConnMaxLifetime) * time.Second, + ReadTimeout: time.Duration(cfg.DialTimeout*2) * time.Second, + BlockBufferSize: cfg.BlockBufferSize, + }) + + if err != nil { + log.Fatalf("[ClickHouse] init connection failed: %v\n", err) + } + + // 测试连接 + if err = ChConn.Ping(context.Background()); err != nil { + log.Fatalf("[ClickHouse] ping failed: %v\n", err) + } + + log.Println("[ClickHouse] connection established successfully") +} diff --git a/support-files/sql/create_clickhouse.sql b/support-files/sql/create_clickhouse.sql new file mode 100644 index 00000000..dd0eb9b6 --- /dev/null +++ b/support-files/sql/create_clickhouse.sql @@ -0,0 +1,45 @@ +CREATE DATABASE IF NOT EXISTS linux_do_credit; + +USE linux_do_credit; + +CREATE TABLE IF NOT EXISTS orders +( + id UInt64, + order_name String, + merchant_order_no String, + client_id String, + payer_user_id UInt64, + payee_user_id UInt64, + amount Decimal(20, 2), + status LowCardinality(String), + type LowCardinality(String), + remark String, + payment_type LowCardinality(String), + trade_time DateTime, + expires_at DateTime, + created_at DateTime, + updated_at DateTime +) + ENGINE = ReplacingMergeTree(updated_at) + PARTITION BY toYYYYMM(created_at) + ORDER BY (created_at, id) + SETTINGS index_granularity = 8192; + +-- ============================================================ +-- 常用查询示例 +-- ============================================================ + +-- 查询单个订单(加 FINAL 获取最新版本) +-- SELECT * FROM orders FINAL WHERE id = 1001; + +-- 按用户统计订单 +-- SELECT payer_user_id, count() AS cnt, sum(amount) AS total +-- FROM orders FINAL +-- WHERE status = 'success' +-- GROUP BY payer_user_id; + +-- 按月统计(利用分区裁剪) +-- SELECT toYYYYMM(created_at) AS month, count() AS orders, sum(amount) AS volume +-- FROM orders FINAL +-- GROUP BY month +-- ORDER BY month; From 961f49f76f3af7a56c87748481a4dc659290e131 Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Sun, 21 Dec 2025 14:59:25 +0800 Subject: [PATCH 019/366] refactor: rename schedule module to scheduler --- internal/cmd/scheduler.go | 4 ++-- internal/task/constants.go | 3 ++- .../schedule.go => scheduler/scheduler.go} | 18 +++++++++++++++--- internal/task/worker/worker.go | 2 ++ 4 files changed, 21 insertions(+), 6 deletions(-) rename internal/task/{schedule/schedule.go => scheduler/scheduler.go} (78%) diff --git a/internal/cmd/scheduler.go b/internal/cmd/scheduler.go index 6fea77ce..c15ce74d 100644 --- a/internal/cmd/scheduler.go +++ b/internal/cmd/scheduler.go @@ -19,7 +19,7 @@ package cmd import ( "log" - "github.com/linux-do/credit/internal/task/schedule" + "github.com/linux-do/credit/internal/task/scheduler" "github.com/spf13/cobra" ) @@ -29,7 +29,7 @@ var schedulerCmd = &cobra.Command{ Short: "credit Scheduler", Run: func(cmd *cobra.Command, args []string) { log.Println("[Scheduler] 启动定时任务调度服务") - if err := schedule.StartScheduler(); err != nil { + if err := scheduler.StartScheduler(); err != nil { log.Fatalf("[调度器] 启动失败: %v", err) } }, diff --git a/internal/task/constants.go b/internal/task/constants.go index 02d97923..753923f7 100644 --- a/internal/task/constants.go +++ b/internal/task/constants.go @@ -21,7 +21,8 @@ const ( UpdateSingleUserGamificationScoreTask = "user:gamification:update_single_score_task" AutoRefundExpiredDisputesTask = "dispute:auto_refund_expired" AutoRefundSingleDisputeTask = "dispute:auto_refund_single" - MerchantPaymentNotifyTask = "payment:merchant_notify" // 商户支付回调任务 + MerchantPaymentNotifyTask = "payment:merchant_notify" + SyncOrdersToClickHouseTask = "order:sync_to_clickhouse" ) const ( diff --git a/internal/task/schedule/schedule.go b/internal/task/scheduler/scheduler.go similarity index 78% rename from internal/task/schedule/schedule.go rename to internal/task/scheduler/scheduler.go index 743803cd..55c20b76 100644 --- a/internal/task/schedule/schedule.go +++ b/internal/task/scheduler/scheduler.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package schedule +package scheduler import ( "fmt" @@ -55,8 +55,9 @@ func StartScheduler() error { // 用户积分更新任务 if _, err = scheduler.Register( - config.Config.Schedule.UpdateUserGamificationScoresTaskCron, + config.Config.Scheduler.UpdateUserGamificationScoresTaskCron, asynq.NewTask(task.UpdateUserGamificationScoresTask, nil), + asynq.MaxRetry(5), asynq.Unique(23*time.Hour), ); err != nil { return @@ -64,8 +65,19 @@ func StartScheduler() error { // 争议自动退款任务 if _, err = scheduler.Register( - config.Config.Schedule.AutoRefundExpiredDisputesTaskCron, + config.Config.Scheduler.AutoRefundExpiredDisputesTaskCron, asynq.NewTask(task.AutoRefundExpiredDisputesTask, nil), + asynq.MaxRetry(5), + asynq.Unique(23*time.Hour), + ); err != nil { + return + } + + // 订单同步任务 + if _, err = scheduler.Register( + config.Config.Scheduler.SyncOrdersToClickHouseTaskCron, + asynq.NewTask(task.SyncOrdersToClickHouseTask, nil), + asynq.MaxRetry(10), asynq.Unique(23*time.Hour), ); err != nil { return diff --git a/internal/task/worker/worker.go b/internal/task/worker/worker.go index c0b4845a..03b20cb9 100644 --- a/internal/task/worker/worker.go +++ b/internal/task/worker/worker.go @@ -21,6 +21,7 @@ import ( "github.com/hibiken/asynq" "github.com/linux-do/credit/internal/apps/dispute" + "github.com/linux-do/credit/internal/apps/order" "github.com/linux-do/credit/internal/apps/payment" "github.com/linux-do/credit/internal/apps/user" "github.com/linux-do/credit/internal/config" @@ -47,6 +48,7 @@ func StartWorker() error { mux.HandleFunc(task.AutoRefundExpiredDisputesTask, dispute.HandleAutoRefundExpiredDisputes) mux.HandleFunc(task.AutoRefundSingleDisputeTask, dispute.HandleAutoRefundSingleDispute) mux.HandleFunc(task.MerchantPaymentNotifyTask, payment.HandleMerchantPaymentNotify) + mux.HandleFunc(task.SyncOrdersToClickHouseTask, order.HandleSyncOrdersToClickHouse) // 启动服务器 return asynqServer.Run(mux) } From 04ba9b79d51f6d623f87989137fe504bba99f953 Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Sun, 21 Dec 2025 14:59:56 +0800 Subject: [PATCH 020/366] chore: code formatting and minor fixes --- internal/apps/dispute/routers.go | 6 +++--- internal/apps/dispute/tasks.go | 12 ++++++------ internal/apps/merchant/link/routers.go | 6 +++--- internal/apps/oauth/logics.go | 4 ++-- internal/apps/payment/routers.go | 8 ++++---- internal/apps/payment/tasks.go | 14 +++----------- internal/apps/user/tasks.go | 17 ++++------------- 7 files changed, 25 insertions(+), 42 deletions(-) diff --git a/internal/apps/dispute/routers.go b/internal/apps/dispute/routers.go index 84f8f1e1..36c11406 100644 --- a/internal/apps/dispute/routers.go +++ b/internal/apps/dispute/routers.go @@ -335,7 +335,7 @@ func RefundReview(c *gin.Context) { if err := tx.Model(&model.Order{}). Where("id = ?", order.ID). - UpdateColumn("status", model.OrderStatusRefund).Error; err != nil { + Update("status", model.OrderStatusRefund).Error; err != nil { return err } } else if status == model.DisputeStatusClosed { @@ -353,7 +353,7 @@ func RefundReview(c *gin.Context) { if err := tx.Model(&model.Order{}). Where("id = ?", order.ID). - UpdateColumn("status", model.OrderStatusRefused).Error; err != nil { + Update("status", model.OrderStatusRefused).Error; err != nil { return err } } @@ -427,7 +427,7 @@ func CloseDispute(c *gin.Context) { if err := tx.Model(&model.Order{}). Where("id = ?", order.ID). - UpdateColumn("status", model.OrderStatusSuccess).Error; err != nil { + Update("status", model.OrderStatusSuccess).Error; err != nil { return err } diff --git a/internal/apps/dispute/tasks.go b/internal/apps/dispute/tasks.go index b5019ae0..7a564e9f 100644 --- a/internal/apps/dispute/tasks.go +++ b/internal/apps/dispute/tasks.go @@ -29,7 +29,7 @@ import ( "github.com/linux-do/credit/internal/logger" "github.com/linux-do/credit/internal/model" "github.com/linux-do/credit/internal/task" - "github.com/linux-do/credit/internal/task/schedule" + "github.com/linux-do/credit/internal/task/scheduler" "gorm.io/gorm" "gorm.io/gorm/clause" ) @@ -43,7 +43,7 @@ func HandleAutoRefundExpiredDisputes(ctx context.Context, t *asynq.Task) error { return errGet } - pageSize := 200 + pageSize := 1000 lastID := uint64(0) currentDelay := 0 * time.Second @@ -68,16 +68,16 @@ func HandleAutoRefundExpiredDisputes(ctx context.Context, t *asynq.Task) error { } for _, dispute := range disputes { - currentDelay += time.Duration(config.Config.Schedule.DisputeAutoRefundDispatchIntervalSeconds) * time.Second + currentDelay += time.Duration(config.Config.Scheduler.DisputeAutoRefundDispatchIntervalSeconds) * time.Second payload, _ := json.Marshal(map[string]interface{}{ "dispute_id": dispute.ID, }) - if _, errTask := schedule.AsynqClient.Enqueue( + if _, errTask := scheduler.AsynqClient.Enqueue( asynq.NewTask(task.AutoRefundSingleDisputeTask, payload), asynq.ProcessIn(currentDelay), - asynq.MaxRetry(3), + asynq.MaxRetry(5), ); errTask != nil { logger.ErrorF(ctx, "下发争议[ID:%d]自动退款任务失败: %v", dispute.ID, errTask) return errTask @@ -177,7 +177,7 @@ func HandleAutoRefundSingleDispute(ctx context.Context, t *asynq.Task) error { // 更新订单状态为已退款 if err := tx.Model(&model.Order{}). Where("id = ?", order.ID). - UpdateColumn("status", model.OrderStatusRefund).Error; err != nil { + Update("status", model.OrderStatusRefund).Error; err != nil { return fmt.Errorf("更新订单状态失败: %w", err) } diff --git a/internal/apps/merchant/link/routers.go b/internal/apps/merchant/link/routers.go index 0033a38b..f2db1c9f 100644 --- a/internal/apps/merchant/link/routers.go +++ b/internal/apps/merchant/link/routers.go @@ -32,7 +32,7 @@ import ( "github.com/linux-do/credit/internal/model" "github.com/linux-do/credit/internal/service" "github.com/linux-do/credit/internal/task" - "github.com/linux-do/credit/internal/task/schedule" + "github.com/linux-do/credit/internal/task/scheduler" "github.com/linux-do/credit/internal/util" "github.com/shopspring/decimal" "gorm.io/gorm" @@ -300,10 +300,10 @@ func PayByLink(c *gin.Context) { "order_id": order.ID, "client_id": merchantAPIKey.ClientID, }) - if _, errTask := schedule.AsynqClient.Enqueue( + if _, errTask := scheduler.AsynqClient.Enqueue( asynq.NewTask(task.MerchantPaymentNotifyTask, notifyPayload), asynq.Queue(task.QueueWebhook), - asynq.MaxRetry(5), + asynq.MaxRetry(10), asynq.Timeout(30*time.Second), ); errTask != nil { return fmt.Errorf("下发商户回调任务失败: %w", errTask) diff --git a/internal/apps/oauth/logics.go b/internal/apps/oauth/logics.go index 0d21fa88..28c9600f 100644 --- a/internal/apps/oauth/logics.go +++ b/internal/apps/oauth/logics.go @@ -118,7 +118,7 @@ func doOAuth(ctx context.Context, code string) (*model.User, error) { span.SetStatus(codes.Error, err.Error()) return nil, err } - user.EnqueueBadgeScoreTask(ctx) + user.EnqueueBadgeScoreTask(ctx, 0) } else { // query failed span.SetStatus(codes.Error, txByUsername.Error.Error()) @@ -131,7 +131,7 @@ func doOAuth(ctx context.Context, code string) (*model.User, error) { span.SetStatus(codes.Error, err.Error()) return nil, err } - user.EnqueueBadgeScoreTask(ctx) + user.EnqueueBadgeScoreTask(ctx, 0) } else { if err = user.CheckActive(); err != nil { span.SetStatus(codes.Error, err.Error()) diff --git a/internal/apps/payment/routers.go b/internal/apps/payment/routers.go index d2ad7f65..36c8bece 100644 --- a/internal/apps/payment/routers.go +++ b/internal/apps/payment/routers.go @@ -32,7 +32,7 @@ import ( "github.com/linux-do/credit/internal/config" "github.com/linux-do/credit/internal/service" "github.com/linux-do/credit/internal/task" - "github.com/linux-do/credit/internal/task/schedule" + "github.com/linux-do/credit/internal/task/scheduler" "github.com/gin-gonic/gin" "github.com/linux-do/credit/internal/db" @@ -316,7 +316,7 @@ func RefundMerchantOrder(c *gin.Context) { if err := tx.Model(&model.Order{}). Where("id = ?", order.ID). - UpdateColumn("status", model.OrderStatusRefund).Error; err != nil { + Update("status", model.OrderStatusRefund).Error; err != nil { return err } @@ -467,10 +467,10 @@ func PayMerchantOrder(c *gin.Context) { "order_id": order.ID, "client_id": order.ClientID, }) - if _, errTask := schedule.AsynqClient.Enqueue( + if _, errTask := scheduler.AsynqClient.Enqueue( asynq.NewTask(task.MerchantPaymentNotifyTask, notifyPayload), asynq.Queue(task.QueueWebhook), - asynq.MaxRetry(5), + asynq.MaxRetry(10), asynq.Timeout(30*time.Second), ); errTask != nil { return fmt.Errorf("下发商户回调任务失败: %w", errTask) diff --git a/internal/apps/payment/tasks.go b/internal/apps/payment/tasks.go index 49137bd2..8a8ed68a 100644 --- a/internal/apps/payment/tasks.go +++ b/internal/apps/payment/tasks.go @@ -81,17 +81,9 @@ func HandleMerchantPaymentNotify(ctx context.Context, t *asynq.Task) error { if err := sendCallbackRequest(ctx, apiKey.NotifyURL, callbackParams); err != nil { retried, _ := asynq.GetRetryCount(ctx) - maxRetry := 5 - - logger.ErrorF(ctx, "商户回调失败: 订单[ID:%d] 重试次数[%d/%d] 错误: %v", - payload.OrderID, retried+1, maxRetry, err) - - if retried >= maxRetry-1 { - logger.ErrorF(ctx, "商户回调达到最大重试次数,回调最终失败: 订单[ID:%d]", payload.OrderID) - return nil // 任务完成(虽然失败) - } - - return fmt.Errorf("商户回调失败: %w", err) + logger.ErrorF(ctx, "商户回调失败: 订单[ID:%d] 重试次数[%d] 错误: %v", + payload.OrderID, retried+1, err) + return err } logger.InfoF(ctx, "商户回调成功: 订单[ID:%d] ClientID[%s]", payload.OrderID, payload.ClientID) diff --git a/internal/apps/user/tasks.go b/internal/apps/user/tasks.go index e6cc399c..9859ee03 100644 --- a/internal/apps/user/tasks.go +++ b/internal/apps/user/tasks.go @@ -27,8 +27,6 @@ import ( "github.com/linux-do/credit/internal/db" "github.com/linux-do/credit/internal/logger" "github.com/linux-do/credit/internal/model" - "github.com/linux-do/credit/internal/task" - "github.com/linux-do/credit/internal/task/schedule" "github.com/shopspring/decimal" "gorm.io/gorm" ) @@ -36,7 +34,7 @@ import ( // HandleUpdateUserGamificationScores 处理所有用户积分更新任务 func HandleUpdateUserGamificationScores(ctx context.Context, t *asynq.Task) error { // 分页处理用户 - pageSize := 200 + pageSize := 1000 lastID := uint64(0) currentDelay := 0 * time.Second @@ -66,17 +64,10 @@ func HandleUpdateUserGamificationScores(ctx context.Context, t *asynq.Task) erro } for _, user := range users { - currentDelay += time.Duration(config.Config.Schedule.UserGamificationScoreDispatchIntervalSeconds) * time.Second + currentDelay += time.Duration(config.Config.Scheduler.UserGamificationScoreDispatchIntervalSeconds) * time.Second - payload, _ := json.Marshal(map[string]interface{}{ - "user_id": user.ID, - }) - - if _, errTask := schedule.AsynqClient.Enqueue(asynq.NewTask(task.UpdateSingleUserGamificationScoreTask, payload), asynq.Queue(task.QueueWhitelistOnly), asynq.ProcessIn(currentDelay), asynq.MaxRetry(3)); errTask != nil { - logger.ErrorF(ctx, "下发用户[%s]积分计算任务失败: %v", user.Username, errTask) - return errTask - } else { - logger.InfoF(ctx, "下发用户[%s]积分计算任务成功", user.Username) + if err := user.EnqueueBadgeScoreTask(ctx, currentDelay); err != nil { + return err } } From 6f698f05bee41641d22dd0daf9975fb5d4615a09 Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Sun, 21 Dec 2025 17:17:13 +0800 Subject: [PATCH 021/366] feat(dashboard): add daily stats and top customers APIs --- docs/docs.go | 71 ++++++++++++++- docs/swagger.json | 71 ++++++++++++++- docs/swagger.yaml | 47 +++++++++- internal/apps/dashboard/logic.go | 88 ++++++++++++++++++ internal/apps/dashboard/routers.go | 138 +++++++++++++++++++++++++++++ internal/router/router.go | 9 ++ main.go | 4 +- 7 files changed, 423 insertions(+), 5 deletions(-) create mode 100644 internal/apps/dashboard/logic.go create mode 100644 internal/apps/dashboard/routers.go diff --git a/docs/docs.go b/docs/docs.go index 466e3a03..40e756fa 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -391,6 +391,75 @@ const docTemplate = `{ } } }, + "/api/v1/dashboard/stats/daily": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard" + ], + "summary": "获取每日收支统计", + "parameters": [ + { + "type": "integer", + "description": "查询天数,最大7天", + "name": "days", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/dashboard/stats/top-customers": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard" + ], + "summary": "获取Top客户", + "parameters": [ + { + "type": "integer", + "description": "查询天数,最大7天", + "name": "days", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "返回数量,最大10", + "name": "limit", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/health": { "get": { "produces": [ @@ -1659,7 +1728,7 @@ const docTemplate = `{ // SwaggerInfo holds exported Swagger Info so clients can modify it var SwaggerInfo = &swag.Spec{ - Version: "0.1.0", + Version: "1.0.0", Host: "", BasePath: "", Schemes: []string{}, diff --git a/docs/swagger.json b/docs/swagger.json index 6d6c93c0..1f4391f1 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -3,7 +3,7 @@ "info": { "title": "LINUX DO Credit", "contact": {}, - "version": "0.1.0" + "version": "1.0.0" }, "paths": { "/api.php": { @@ -382,6 +382,75 @@ } } }, + "/api/v1/dashboard/stats/daily": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard" + ], + "summary": "获取每日收支统计", + "parameters": [ + { + "type": "integer", + "description": "查询天数,最大7天", + "name": "days", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/dashboard/stats/top-customers": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard" + ], + "summary": "获取Top客户", + "parameters": [ + { + "type": "integer", + "description": "查询天数,最大7天", + "name": "days", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "返回数量,最大10", + "name": "limit", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/health": { "get": { "produces": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index dd7284bf..27af16a1 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -365,7 +365,7 @@ definitions: info: contact: {} title: LINUX DO Credit - version: 0.1.0 + version: 1.0.0 paths: /api.php: get: @@ -605,6 +605,51 @@ paths: $ref: '#/definitions/util.ResponseAny' tags: - config + /api/v1/dashboard/stats/daily: + get: + consumes: + - application/json + parameters: + - description: 查询天数,最大7天 + in: query + name: days + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + summary: 获取每日收支统计 + tags: + - dashboard + /api/v1/dashboard/stats/top-customers: + get: + consumes: + - application/json + parameters: + - description: 查询天数,最大7天 + in: query + name: days + required: true + type: integer + - description: 返回数量,最大10 + in: query + name: limit + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + summary: 获取Top客户 + tags: + - dashboard /api/v1/health: get: produces: diff --git a/internal/apps/dashboard/logic.go b/internal/apps/dashboard/logic.go new file mode 100644 index 00000000..f58720de --- /dev/null +++ b/internal/apps/dashboard/logic.go @@ -0,0 +1,88 @@ +/* +Copyright 2025 linux.do + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package dashboard + +import ( + "context" + "time" + + "github.com/linux-do/credit/internal/db" + "github.com/linux-do/credit/internal/model" + "github.com/shopspring/decimal" +) + +// getDateRange 计算查询的时间范围 +func getDateRange(days int) (startDate, endDate time.Time) { + now := time.Now() + todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + return todayStart.AddDate(0, 0, -(days - 1)), todayStart.AddDate(0, 0, 1) +} + +// dailyAmountResult 每日金额查询结果 +type dailyAmountResult struct { + Date time.Time + Amount decimal.Decimal +} + +// queryDailyAmounts 查询每日金额 +// isIncome: true=收入(payee), false=支出(payer) +func queryDailyAmounts(ctx context.Context, userID uint64, isIncome bool, startDate, endDate time.Time) (map[string]decimal.Decimal, error) { + var userIDField string + if isIncome { + userIDField = "payee_user_id" + } else { + userIDField = "payer_user_id" + } + + var results []dailyAmountResult + err := db.DB(ctx).Model(&model.Order{}). + Select("DATE_TRUNC('day', created_at) as date, SUM(amount) as amount"). + Where(userIDField+" = ?", userID). + Where("status = ?", model.OrderStatusSuccess). + Where("created_at >= ? AND created_at < ?", startDate, endDate). + Group("DATE_TRUNC('day', created_at)"). + Scan(&results).Error + + if err != nil { + return nil, err + } + + // 转换为 map + statsMap := make(map[string]decimal.Decimal) + for _, r := range results { + dateStr := r.Date.Format("2006-01-02") + statsMap[dateStr] = r.Amount + } + + return statsMap, nil +} + +// mergeDailyStats 合并收入和支出统计,填充无数据的日期 +func mergeDailyStats(startDate time.Time, days int, incomeMap, expenseMap map[string]decimal.Decimal) []DailyStatsItem { + result := make([]DailyStatsItem, days) + + for i := range result { + dateStr := startDate.AddDate(0, 0, i).Format("2006-01-02") + result[i] = DailyStatsItem{ + Date: dateStr, + Income: incomeMap[dateStr], + Expense: expenseMap[dateStr], + } + } + + return result +} diff --git a/internal/apps/dashboard/routers.go b/internal/apps/dashboard/routers.go new file mode 100644 index 00000000..98bb8c09 --- /dev/null +++ b/internal/apps/dashboard/routers.go @@ -0,0 +1,138 @@ +/* +Copyright 2025 linux.do + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package dashboard + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/linux-do/credit/internal/apps/oauth" + "github.com/linux-do/credit/internal/db" + "github.com/linux-do/credit/internal/model" + "github.com/linux-do/credit/internal/util" + "github.com/shopspring/decimal" +) + +// DailyStatsRequest 每日统计请求参数 +type DailyStatsRequest struct { + Days int `form:"days" binding:"required,min=1,max=7"` +} + +// DailyStatsItem 每日统计项 +type DailyStatsItem struct { + Date string `json:"date"` + Income decimal.Decimal `json:"income"` + Expense decimal.Decimal `json:"expense"` +} + +// TopCustomersRequest Top客户请求参数 +type TopCustomersRequest struct { + Days int `form:"days" binding:"required,min=1,max=7"` + Limit int `form:"limit" binding:"required,min=1,max=10"` +} + +// TopCustomer Top客户项 +type TopCustomer struct { + UserID uint64 `json:"user_id"` + Username string `json:"username"` + TotalAmount decimal.Decimal `json:"total_amount"` + OrderCount int64 `json:"order_count"` +} + +// GetDailyStats 获取每日收支统计 +// @Summary 获取每日收支统计 +// @Tags dashboard +// @Accept json +// @Produce json +// @Param days query int true "查询天数,最大7天" +// @Success 200 {object} util.ResponseAny +// @Router /api/v1/dashboard/stats/daily [get] +func GetDailyStats(c *gin.Context) { + var req DailyStatsRequest + if err := c.ShouldBindQuery(&req); err != nil { + c.JSON(http.StatusBadRequest, util.Err(err.Error())) + return + } + + user, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey) + ctx := c.Request.Context() + startDate, endDate := getDateRange(req.Days) + + // 查询每日收入(用户作为收款方) + incomeStats, err := queryDailyAmounts(ctx, user.ID, true, startDate, endDate) + if err != nil { + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + + // 查询每日支出(用户作为付款方) + expenseStats, err := queryDailyAmounts(ctx, user.ID, false, startDate, endDate) + if err != nil { + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + + dailyStats := mergeDailyStats(startDate, req.Days, incomeStats, expenseStats) + + c.JSON(http.StatusOK, util.OK(dailyStats)) +} + +// GetTopCustomers 获取Top客户(向当前用户付款最多的客户) +// @Summary 获取Top客户 +// @Tags dashboard +// @Accept json +// @Produce json +// @Param days query int true "查询天数,最大7天" +// @Param limit query int true "返回数量,最大10" +// @Success 200 {object} util.ResponseAny +// @Router /api/v1/dashboard/stats/top-customers [get] +func GetTopCustomers(c *gin.Context) { + var req TopCustomersRequest + if err := c.ShouldBindQuery(&req); err != nil { + c.JSON(http.StatusBadRequest, util.Err(err.Error())) + return + } + + user, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey) + ctx := c.Request.Context() + startDate, endDate := getDateRange(req.Days) + + // 查询Top客户 + var customers []TopCustomer + err := db.DB(ctx).Model(&model.Order{}). + Select(` + orders.payer_user_id as user_id, + users.username, + SUM(orders.amount) as total_amount, + COUNT(*) as order_count + `). + Joins("LEFT JOIN users ON orders.payer_user_id = users.id"). + Where("orders.payee_user_id = ?", user.ID). + Where("orders.status = ?", model.OrderStatusSuccess). + Where("orders.created_at >= ? AND orders.created_at < ?", startDate, endDate). + Group("orders.payer_user_id, users.username"). + Order("total_amount DESC"). + Limit(req.Limit). + Scan(&customers).Error + + if err != nil { + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + + c.JSON(http.StatusOK, util.OK(customers)) +} diff --git a/internal/router/router.go b/internal/router/router.go index bae64f31..0b3dede9 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -42,6 +42,7 @@ import ( _ "github.com/linux-do/credit/docs" "github.com/linux-do/credit/internal/apps/admin/system_config" "github.com/linux-do/credit/internal/apps/admin/user_pay_config" + "github.com/linux-do/credit/internal/apps/dashboard" "github.com/linux-do/credit/internal/apps/health" "github.com/linux-do/credit/internal/apps/oauth" "github.com/linux-do/credit/internal/apps/order" @@ -138,6 +139,14 @@ func Serve() { userRouter.PUT("/pay-key", user.UpdatePayKey) } + // Dashboard + dashboardRouter := apiV1Router.Group("/dashboard") + dashboardRouter.Use(oauth.LoginRequired()) + { + dashboardRouter.GET("/stats/daily", dashboard.GetDailyStats) + dashboardRouter.GET("/stats/top-customers", dashboard.GetTopCustomers) + } + // Order orderRouter := apiV1Router.Group("/order") orderRouter.Use(oauth.LoginRequired()) diff --git a/main.go b/main.go index 8449028c..f3b13fd3 100644 --- a/main.go +++ b/main.go @@ -19,7 +19,7 @@ package main import "github.com/linux-do/credit/internal/cmd" // @title LINUX DO Credit -// @version 0.1.0 +// @version 1.0.0 func main() { - cmd.Execute() + cmd.Execute() } From 460a38429aee5bcc4d529462226faa8288ede18b Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Sun, 21 Dec 2025 17:31:56 +0800 Subject: [PATCH 022/366] fix(docs): correct license badge and Go version in README files --- README.md | 4 ++-- README_zh.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 784596fa..966cf244 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ [中文](./README_zh.md) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Go Version](https://img.shields.io/badge/Go-1.24-blue.svg)](https://golang.org/) +[![License: Apache2.0](https://img.shields.io/badge/License-Apache2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) +[![Go Version](https://img.shields.io/badge/Go-1.25.5-blue.svg)](https://golang.org/) [![Next.js](https://img.shields.io/badge/Next.js-16-black.svg)](https://nextjs.org/) [![React](https://img.shields.io/badge/React-19-blue.svg)](https://reactjs.org/) diff --git a/README_zh.md b/README_zh.md index 67c6e7ca..56c40075 100644 --- a/README_zh.md +++ b/README_zh.md @@ -4,8 +4,8 @@ [English](./README.md) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Go Version](https://img.shields.io/badge/Go-1.24-blue.svg)](https://golang.org/) +[![License: Apache2.0](https://img.shields.io/badge/License-Apache2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) +[![Go Version](https://img.shields.io/badge/Go-1.25.5-blue.svg)](https://golang.org/) [![Next.js](https://img.shields.io/badge/Next.js-16-black.svg)](https://nextjs.org/) [![React](https://img.shields.io/badge/React-19-blue.svg)](https://reactjs.org/) From 324532553dea8e77ac485c34f8bb152a503d5c18 Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Mon, 22 Dec 2025 09:12:34 +0800 Subject: [PATCH 023/366] feat: unified code standards --- .../common/balance/balance-main.tsx | 10 +- .../common/balance/balance-report.tsx | 4 +- .../common/balance/balance-summary.tsx | 11 +- .../common/balance/balance-table.tsx | 24 +- ...dispute-dialogs.tsx => dispute-dialog.tsx} | 4 +- .../components/common/general/table-data.tsx | 4 +- .../components/common/home/data-panel.tsx | 119 ++- .../components/common/home/dispute-dialog.tsx | 4 +- frontend/components/common/home/home-main.tsx | 5 +- .../components/common/home/overview-panel.tsx | 271 ++---- .../common/merchant/merchant-data.tsx | 202 ++--- .../common/merchant/merchant-dialog.tsx | 55 +- .../common/merchant/merchant-info.tsx | 48 +- .../common/merchant/merchant-main.tsx | 2 +- .../common/merchant/merchant-online.tsx | 60 +- .../common/merchant/merchant-selector.tsx | 10 +- .../components/common/trade/all-activity.tsx | 11 +- .../components/common/trade/trade-main.tsx | 31 +- .../components/common/trade/trade-table.tsx | 61 +- frontend/components/common/trade/transfer.tsx | 4 - frontend/contexts/admin-context.tsx | 49 +- frontend/contexts/merchant-context.tsx | 48 +- frontend/contexts/transaction-context.tsx | 26 +- frontend/contexts/user-context.tsx | 24 +- frontend/hooks/use-controlled-state.tsx | 9 + frontend/hooks/use-is-in-view.tsx | 8 + frontend/hooks/use-public-config.ts | 43 +- frontend/lib/services/README.md | 798 +++++++++--------- frontend/lib/services/admin/index.ts | 24 +- frontend/lib/services/auth/index.ts | 23 + frontend/lib/services/config/index.ts | 14 +- frontend/lib/services/core/base.service.ts | 41 +- .../lib/services/dispute/dispute.service.ts | 21 + frontend/lib/services/dispute/index.ts | 26 +- frontend/lib/services/dispute/types.ts | 10 + frontend/lib/services/index.ts | 2 +- .../lib/services/merchant/merchant.service.ts | 16 +- frontend/lib/services/transaction/index.ts | 29 +- .../transaction/transaction.service.ts | 12 +- frontend/lib/services/transaction/types.ts | 11 - frontend/lib/services/user/index.ts | 13 +- frontend/lib/services/user/user.service.ts | 4 +- frontend/lib/utils/error-handling.ts | 61 ++ 43 files changed, 1147 insertions(+), 1105 deletions(-) rename frontend/components/common/general/{dispute-dialogs.tsx => dispute-dialog.tsx} (99%) create mode 100644 frontend/lib/utils/error-handling.ts diff --git a/frontend/components/common/balance/balance-main.tsx b/frontend/components/common/balance/balance-main.tsx index c0f91890..d0887c91 100644 --- a/frontend/components/common/balance/balance-main.tsx +++ b/frontend/components/common/balance/balance-main.tsx @@ -34,18 +34,18 @@ export function BalanceMain() {
-
-
积分摘要
+
+

积分摘要

-
-
近期活动
+
+

近期活动

-
diff --git a/frontend/components/common/balance/balance-report.tsx b/frontend/components/common/balance/balance-report.tsx index 6e668ad3..e502f084 100644 --- a/frontend/components/common/balance/balance-report.tsx +++ b/frontend/components/common/balance/balance-report.tsx @@ -7,8 +7,8 @@ import * as React from "react" */ export function BalanceReport() { return ( -
-
报告
+
+

报告

暂无报告
) diff --git a/frontend/components/common/balance/balance-summary.tsx b/frontend/components/common/balance/balance-summary.tsx index 79758366..50163b87 100644 --- a/frontend/components/common/balance/balance-summary.tsx +++ b/frontend/components/common/balance/balance-summary.tsx @@ -24,6 +24,13 @@ function calculatePercentages(available: number, total: number) { } } +/** + * 安全转换数字,避免 NaN + */ +function safeNumber(value: number): number { + return typeof value === 'number' && !isNaN(value) ? value : 0 +} + /** * 积分余额摘要组件 * @@ -78,7 +85,7 @@ export function BalanceSummary() { {loading ? "-" : ( {loading ? "-" : ( { - setActiveTab(value as OrderType | "all") - }, []) - return (
setActiveTab(value as OrderType | "all")} className="w-full" > @@ -112,7 +108,7 @@ export function BalanceTable() { * * 负责获取和显示积分余额活动数据 */ -function TransactionList({ type }: { type?: OrderType }) { +const TransactionList = React.memo(function TransactionList({ type }: { type?: OrderType }) { const { transactions, total, @@ -136,16 +132,6 @@ function TransactionList({ type }: { type?: OrderType }) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [type]) - /** 处理加载更多积分余额活动数据 */ - const handleLoadMore = React.useCallback(() => { - loadMore() - }, [loadMore]) - - /** 处理重试加载积分余额活动数据 */ - const handleRetry = React.useCallback(() => { - fetchTransactions({ page: 1 }) - }, [fetchTransactions]) - return ( fetchTransactions({ page: 1 })} + onLoadMore={loadMore} /> ) -} +}) diff --git a/frontend/components/common/general/dispute-dialogs.tsx b/frontend/components/common/general/dispute-dialog.tsx similarity index 99% rename from frontend/components/common/general/dispute-dialogs.tsx rename to frontend/components/common/general/dispute-dialog.tsx index b120629e..106c333a 100644 --- a/frontend/components/common/general/dispute-dialogs.tsx +++ b/frontend/components/common/general/dispute-dialog.tsx @@ -18,7 +18,7 @@ import { DialogTitle, } from "@/components/ui/dialog" import { Label } from "@/components/ui/label" -import { TransactionService, DisputeService } from "@/lib/services" +import { DisputeService } from "@/lib/services" import { toast } from "sonner" import { useIsMobile } from "@/hooks/use-mobile" import { usePublicConfig } from "@/hooks/use-public-config" @@ -196,7 +196,7 @@ export function CreateDisputeDialog({ order, onSuccess }: { order: Order; onSucc try { setLoading(true) - await TransactionService.createDispute({ order_id: order.id, reason: reason.trim() }) + await DisputeService.createDispute({ order_id: order.id, reason: reason.trim() }) toast.success('争议已发起', { description: '请等待服务方处理' }) updateOrderStatus(order.id, { status: 'disputing' }) setOpen(false) diff --git a/frontend/components/common/general/table-data.tsx b/frontend/components/common/general/table-data.tsx index 4e8c0f1d..93b48250 100644 --- a/frontend/components/common/general/table-data.tsx +++ b/frontend/components/common/general/table-data.tsx @@ -20,7 +20,7 @@ import { CancelDisputeDialog, ViewDisputeHistoryDialog, RefundReviewDialog, -} from "./dispute-dialogs" +} from "./dispute-dialog" const ROW_HEIGHT = 36 @@ -325,4 +325,4 @@ export const TransactionTableList = React.memo(function TransactionTableList({ ) }) -export { RefundReviewDialog, CancelDisputeDialog } from "./dispute-dialogs" +export { RefundReviewDialog, CancelDisputeDialog } from "./dispute-dialog" diff --git a/frontend/components/common/home/data-panel.tsx b/frontend/components/common/home/data-panel.tsx index e59628ea..104e579b 100644 --- a/frontend/components/common/home/data-panel.tsx +++ b/frontend/components/common/home/data-panel.tsx @@ -36,74 +36,67 @@ export function DataPanel() { const remainQuota = parseFloat(user?.remain_quota || '0') return ( -
-
-
-

积分总额

+
+
+

积分总额

- - + + + + + + + + + + } + /> + - - - - - - - - - } - /> - - - -
+ activeDot={{ + fill: "hsl(217, 91%, 60%)", + stroke: "hsl(217, 91%, 60%)", + strokeWidth: 3, + r: 2, + }} + /> + + +
-
-
-
LINUX DO Credits
-
- {loading ? '-' : } -
+
+
+
LINUX DO Credits
+
+ {loading ? '-' : }
+
-
-
今日剩余额度
-
- {loading ? '-' : } -
+
+
今日剩余额度
+
+ {loading ? '-' : }
diff --git a/frontend/components/common/home/dispute-dialog.tsx b/frontend/components/common/home/dispute-dialog.tsx index c23c8edf..a8d5f70e 100644 --- a/frontend/components/common/home/dispute-dialog.tsx +++ b/frontend/components/common/home/dispute-dialog.tsx @@ -118,9 +118,7 @@ export function DisputeDialog({ mode, open, onOpenChange }: DisputeDialogProps) - - {dialogTitle} - + {dialogTitle} {dialogDescription} diff --git a/frontend/components/common/home/home-main.tsx b/frontend/components/common/home/home-main.tsx index c925f79b..e912a383 100644 --- a/frontend/components/common/home/home-main.tsx +++ b/frontend/components/common/home/home-main.tsx @@ -11,15 +11,14 @@ export function HomeMain() {
-

今天

+

今天

-

近期概览

+

近期概览

-
) diff --git a/frontend/components/common/home/overview-panel.tsx b/frontend/components/common/home/overview-panel.tsx index d85e4a07..60717549 100644 --- a/frontend/components/common/home/overview-panel.tsx +++ b/frontend/components/common/home/overview-panel.tsx @@ -50,16 +50,13 @@ const createDisputeOrder = (dispute: DisputeWithOrder, type: 'receive' | 'paymen const DisputeListSkeleton = () => (
{Array.from({ length: 5 }).map((_, index) => ( -
+
-
- +
+
))} @@ -72,15 +69,12 @@ const DisputeListSkeleton = () => ( const PaymentListSkeleton = () => (
{Array.from({ length: 5 }).map((_, index) => ( -
+
-
+
@@ -155,33 +149,20 @@ function PaymentCard({ onViewAll }: { onViewAll: () => void }) { fetchPayments() }, [fetchPayments]) - const handleRefresh = () => { - fetchPayments() - } - return ( -
+
活动

{loading ? '-' : }

- -
@@ -193,23 +174,14 @@ function PaymentCard({ onViewAll }: { onViewAll: () => void }) { ) : payments.length > 0 ? (
{payments.map((payment) => ( -
+
-

- {payment.order_name} -

-

- LDC {payment.amount} -

-
-
- - {getStatusText(payment.status)} - +

{payment.order_name}

+

LDC {payment.amount}

+ + {getStatusText(payment.status)} +
))}
@@ -226,15 +198,10 @@ function PaymentCard({ onViewAll }: { onViewAll: () => void }) {
)} - -
+ +
更新时间:{new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })} -
@@ -256,17 +223,9 @@ function TotalCard() { const fetchTotal = React.useCallback(async () => { try { setLoading(true) - const response = await TransactionService.getTransactions({ - page: 1, - page_size: 100, - status: 'success' - }) - - const totalAmount = response.orders.reduce((sum, order) => { - return sum + parseFloat(order.amount || '0') - }, 0) - - setTotal(totalAmount) + const response = await TransactionService.getTransactions({ page: 1, page_size: 100, status: 'success' }) + const total = response.orders.reduce((sum, order) => sum + parseFloat(order.amount || '0'), 0) + setTotal(total) } catch (error) { console.error('Failed to fetch total:', error) } finally { @@ -278,27 +237,21 @@ function TotalCard() { fetchTotal() }, [fetchTotal]) - const handleRefresh = () => { - fetchTotal() - } - return ( -
+
积分 -
-
-
- {loading ? ( - - ) : ( - <>LDC - )} -
+
+ {loading ? ( + + ) : ( + <>LDC + )}
@@ -306,10 +259,10 @@ function TotalCard() {

图表暂时不可用

- -
- 更新时间:{new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })} -
+ + + 更新时间:{new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })} + ) @@ -328,32 +281,12 @@ function NetVolumeCard() { const fetchNetVolume = React.useCallback(async () => { try { setLoading(true) - - /* 获取积分收益总额 */ - const receiveResponse = await TransactionService.getTransactions({ - page: 1, - page_size: 100, - type: 'receive', - status: 'success' - }) - - const receiveTotal = receiveResponse.orders.reduce((sum, order) => { - return sum + parseFloat(order.amount || '0') - }, 0) - - /* 获取积分消耗总额 */ - const paymentResponse = await TransactionService.getTransactions({ - page: 1, - page_size: 100, - type: 'payment', - status: 'success' - }) - - const paymentTotal = paymentResponse.orders.reduce((sum, order) => { - return sum + parseFloat(order.amount || '0') - }, 0) - - /* 计算净积分值 */ + const [receiveResponse, paymentResponse] = await Promise.all([ + TransactionService.getTransactions({ page: 1, page_size: 100, type: 'receive', status: 'success' }), + TransactionService.getTransactions({ page: 1, page_size: 100, type: 'payment', status: 'success' }) + ]) + const receiveTotal = receiveResponse.orders.reduce((sum, order) => sum + parseFloat(order.amount || '0'), 0) + const paymentTotal = paymentResponse.orders.reduce((sum, order) => sum + parseFloat(order.amount || '0'), 0) setNetAmount(receiveTotal - paymentTotal) } catch (error) { console.error('Failed to fetch net volume:', error) @@ -366,30 +299,24 @@ function NetVolumeCard() { fetchNetVolume() }, [fetchNetVolume]) - const handleRefresh = () => { - fetchNetVolume() - } - return ( -
+
净积分值 -
-
-
- {loading ? ( - - ) : ( - <> - LDC - {netAmount < 0 && (赤字)} - - )} -
+
+ {loading ? ( + + ) : ( + <> + LDC + {netAmount < 0 && (赤字)} + + )}
@@ -397,10 +324,10 @@ function NetVolumeCard() {

图表暂时不可用

- -
- 更新时间:{new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })} -
+ + + 更新时间:{new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })} + ) @@ -418,8 +345,8 @@ function TopCustomersCard() {
积分收益排行 -
@@ -430,10 +357,8 @@ function TopCustomersCard() {
- -
- 更新时间:上午12:29 -
+ + 更新时间:上午12:29
) @@ -453,13 +378,13 @@ function PendingDisputesCard({ onViewAll }: { onViewAll: () => void }) { return ( -
+
待处理的争议

{loading ? '-' : }

-
@@ -470,23 +395,13 @@ function PendingDisputesCard({ onViewAll }: { onViewAll: () => void }) { ) : disputes.list.length > 0 ? (
{disputes.list.map((dispute) => ( -
+
-

- {dispute.order_name} -

-

- {dispute.payee_username} -

+

{dispute.order_name}

+

{dispute.payee_username}

-
- +
+
))} @@ -498,15 +413,10 @@ function PendingDisputesCard({ onViewAll }: { onViewAll: () => void }) { )} - -
+ +
更新时间:{new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })} -
@@ -529,13 +439,13 @@ function MyDisputesCard({ onViewAll }: { onViewAll: () => void }) { return ( -
+
我发起的争议

{loading ? '-' : }

-
@@ -546,23 +456,13 @@ function MyDisputesCard({ onViewAll }: { onViewAll: () => void }) { ) : disputes.list.length > 0 ? (
{disputes.list.map((dispute) => ( -
+
-

- {dispute.order_name} -

-

- 积分活动发起者正在处理争议 -

+

{dispute.order_name}

+

积分活动发起者正在处理争议

-
- +
+
))} @@ -574,15 +474,10 @@ function MyDisputesCard({ onViewAll }: { onViewAll: () => void }) { )} - -
+ +
更新时间:{new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })} -
diff --git a/frontend/components/common/merchant/merchant-data.tsx b/frontend/components/common/merchant/merchant-data.tsx index 2513940b..49e12852 100644 --- a/frontend/components/common/merchant/merchant-data.tsx +++ b/frontend/components/common/merchant/merchant-data.tsx @@ -12,33 +12,31 @@ import { TransactionProvider, useTransaction } from "@/contexts/transaction-cont /** 集市功能列表 */ const MERCHANT_ACTIONS = [ - { - title: "处理争议", - description: "获取此应用的所有争议", - icon: Undo2, - color: "text-orange-600", - bgColor: "bg-orange-50 dark:bg-orange-950/20", - action: "refund", - }, - { - title: "所有活动", - description: "显示此应用的所有活动", - icon: FileText, - color: "text-blue-600", - bgColor: "bg-blue-50 dark:bg-blue-950/20", - action: "view-all", - }, - - { - title: "在线流转", - description: "创建在线积分流转活动", - icon: Link2, - color: "text-purple-600", - bgColor: "bg-purple-50 dark:bg-purple-950/20", - action: "online-payment", - }, + { title: "处理争议", description: "获取此应用的所有争议", icon: Undo2, color: "text-orange-600", bgColor: "bg-orange-50 dark:bg-orange-950/20", action: "refund" as const }, + { title: "所有活动", description: "显示此应用的所有活动", icon: FileText, color: "text-blue-600", bgColor: "bg-blue-50 dark:bg-blue-950/20", action: "view-all" as const }, + { title: "在线流转", description: "创建在线积分流转活动", icon: Link2, color: "text-purple-600", bgColor: "bg-purple-50 dark:bg-purple-950/20", action: "online-payment" as const }, ] +/** 格式化日期为本地时间字符串 */ +const formatLocalDate = (date: Date) => { + const y = date.getFullYear() + const m = String(date.getMonth() + 1).padStart(2, '0') + const d = String(date.getDate()).padStart(2, '0') + const h = String(date.getHours()).padStart(2, '0') + const min = String(date.getMinutes()).padStart(2, '0') + const s = String(date.getSeconds()).padStart(2, '0') + return `${ y }-${ m }-${ d }T${ h }:${ min }:${ s }+08:00` +} + +/** 获取默认日期范围(最近30天) */ +const getDefaultDateRange = () => { + const today = new Date() + today.setHours(0, 0, 0, 0) + const from = new Date(today) + from.setDate(from.getDate() - 29) + return { from, to: today } +} + interface MerchantDataProps { apiKey: MerchantAPIKey } @@ -48,39 +46,19 @@ interface MerchantDataProps { * 显示应用的活动数据和统计信息 */ export function MerchantData({ apiKey }: MerchantDataProps) { - const getLastMonthRange = () => { - const today = new Date() - today.setHours(0, 0, 0, 0) - const start = new Date(today) - start.setDate(start.getDate() - 29) - - const formatLocalDate = (date: Date) => { - const year = date.getFullYear() - const month = String(date.getMonth() + 1).padStart(2, '0') - const day = String(date.getDate()).padStart(2, '0') - const hours = String(date.getHours()).padStart(2, '0') - const minutes = String(date.getMinutes()).padStart(2, '0') - const seconds = String(date.getSeconds()).padStart(2, '0') - return `${ year }-${ month }-${ day }T${ hours }:${ minutes }:${ seconds }+08:00` - } - - const tomorrow = new Date(today) - tomorrow.setDate(tomorrow.getDate() + 1) - - return { - startTime: formatLocalDate(start), - endTime: formatLocalDate(tomorrow) - } - } - - const { startTime, endTime } = getLastMonthRange() + const start = new Date() + start.setHours(0, 0, 0, 0) + start.setDate(start.getDate() - 29) + const end = new Date() + end.setHours(0, 0, 0, 0) + end.setDate(end.getDate() + 1) return ( @@ -94,58 +72,26 @@ export function MerchantData({ apiKey }: MerchantDataProps) { */ function MerchantDataContent({ apiKey }: MerchantDataProps) { const router = useRouter() - const { - transactions, - total, - currentPage, - totalPages, - loading, - error, - fetchTransactions, - loadMore, - } = useTransaction() + const { transactions, total, currentPage, totalPages, loading, error, fetchTransactions, loadMore } = useTransaction() const [selectedTypes, setSelectedTypes] = React.useState([]) const [selectedStatuses, setSelectedStatuses] = React.useState([]) const [selectedQuickSelection, setSelectedQuickSelection] = React.useState("最近 1 个月") - const [dateRange, setDateRange] = React.useState<{ from: Date; to: Date } | null>(() => { - const today = new Date() - today.setHours(0, 0, 0, 0) - const from = new Date(today) - from.setDate(from.getDate() - 29) - return { from, to: today } - }) + const [dateRange, setDateRange] = React.useState<{ from: Date; to: Date } | null>(getDefaultDateRange) const clearAllFilters = () => { setSelectedTypes([]) setSelectedStatuses([]) - const today = new Date() - today.setHours(0, 0, 0, 0) - const from = new Date(today) - from.setDate(from.getDate() - 29) - setDateRange({ from, to: today }) + setDateRange(getDefaultDateRange()) setSelectedQuickSelection("最近 1 个月") } - /** - * 当筛选条件或 API key 改变时,重新加载数据 - */ useEffect(() => { - const formatLocalDate = (date: Date) => { - const year = date.getFullYear() - const month = String(date.getMonth() + 1).padStart(2, '0') - const day = String(date.getDate()).padStart(2, '0') - const hours = String(date.getHours()).padStart(2, '0') - const minutes = String(date.getMinutes()).padStart(2, '0') - const seconds = String(date.getSeconds()).padStart(2, '0') - return `${ year }-${ month }-${ day }T${ hours }:${ minutes }:${ seconds }+08:00` - } - const params = { page: 1, page_size: 20, - type: selectedTypes.length > 0 ? selectedTypes[0] as OrderType : undefined, - status: selectedStatuses.length > 0 ? selectedStatuses[0] as OrderStatus : undefined, + type: selectedTypes[0] as OrderType | undefined, + status: selectedStatuses[0] as OrderStatus | undefined, startTime: dateRange ? formatLocalDate(dateRange.from) : undefined, endTime: dateRange ? (() => { const endDate = new Date(dateRange.to) @@ -154,28 +100,19 @@ function MerchantDataContent({ apiKey }: MerchantDataProps) { })() : undefined, client_id: apiKey.client_id, } - fetchTransactions(params) }, [fetchTransactions, dateRange, selectedTypes, selectedStatuses, apiKey.client_id]) - const handleLoadMore = () => { - loadMore() - } - - const handleRefund = () => { - setSelectedStatuses(['disputing']) - - toast.success('待处理的争议', { - description: '显示此应用的所有待处理的争议' - }) - } - - const handleViewAllOrders = () => { - clearAllFilters() - - toast.success('全部活动', { - description: '显示此应用的所有积分活动' - }) + const actionHandlers = { + refund: () => { + setSelectedStatuses(['disputing']) + toast.success('待处理的争议', { description: '显示此应用的所有待处理的争议' }) + }, + 'view-all': () => { + clearAllFilters() + toast.success('全部活动', { description: '显示此应用的所有积分活动' }) + }, + 'online-payment': () => router.push(`/merchant/online-paying?apiKeyId=${ apiKey.id }`) } return ( @@ -183,36 +120,23 @@ function MerchantDataContent({ apiKey }: MerchantDataProps) {

应用服务

- {MERCHANT_ACTIONS.map((action, index) => { - const Icon = action.icon - - return ( - - ) - })} +
+ + ))}
@@ -244,7 +168,7 @@ function MerchantDataContent({ apiKey }: MerchantDataProps) { currentPage={currentPage} totalPages={totalPages} onRetry={() => fetchTransactions({ page: 1, client_id: apiKey.client_id })} - onLoadMore={handleLoadMore} + onLoadMore={loadMore} emptyDescription="未发现积分活动记录" />
diff --git a/frontend/components/common/merchant/merchant-dialog.tsx b/frontend/components/common/merchant/merchant-dialog.tsx index da33d589..531643f5 100644 --- a/frontend/components/common/merchant/merchant-dialog.tsx +++ b/frontend/components/common/merchant/merchant-dialog.tsx @@ -56,31 +56,38 @@ export function MerchantDialog({ }, []) /** - * 初始化表单数据 + * 获取初始表单数据 */ - useEffect(() => { + const getInitialFormData = React.useCallback((): CreateAPIKeyRequest | UpdateAPIKeyRequest => { if (mode === 'update' && apiKey) { - setFormData({ + return { app_name: apiKey.app_name, app_homepage_url: apiKey.app_homepage_url, app_description: apiKey.app_description, redirect_uri: apiKey.redirect_uri, notify_url: apiKey.notify_url, - }) - } else { - setFormData({ - app_name: '', - app_homepage_url: '', - app_description: '', - redirect_uri: '', - notify_url: '', - }) + } + } + return { + app_name: '', + app_homepage_url: '', + app_description: '', + redirect_uri: '', + notify_url: '', } }, [mode, apiKey]) + /** + * 初始化表单数据 + */ + useEffect(() => { + setFormData(getInitialFormData()) + }, [getInitialFormData]) + const isValidUrl = (url: string): boolean => { + if (!url || url.trim() === '') return false try { - const urlObj = new URL(url) + const urlObj = new URL(url.trim()) return urlObj.protocol === 'http:' || urlObj.protocol === 'https:' } catch { return false @@ -88,23 +95,7 @@ export function MerchantDialog({ } const resetForm = () => { - if (mode === 'update' && apiKey) { - setFormData({ - app_name: apiKey.app_name, - app_homepage_url: apiKey.app_homepage_url, - app_description: apiKey.app_description, - redirect_uri: apiKey.redirect_uri, - notify_url: apiKey.notify_url, - }) - } else { - setFormData({ - app_name: '', - app_homepage_url: '', - app_description: '', - redirect_uri: '', - notify_url: '', - }) - } + setFormData(getInitialFormData()) } const validateForm = (): { valid: boolean; error?: string } => { @@ -205,7 +196,7 @@ export function MerchantDialog({ {trigger || ( - )} @@ -298,7 +289,7 @@ export function MerchantDialog({ handleSubmit() }} disabled={processing} - className="bg-primary hover:bg-primary/90 h-8 text-xs" + className="h-8 text-xs" > {processing ? <> {mode === 'create' ? '创建中' : '更新中'} : (mode === 'create' ? '创建' : '更新')} diff --git a/frontend/components/common/merchant/merchant-info.tsx b/frontend/components/common/merchant/merchant-info.tsx index d2468e43..a736e8c3 100644 --- a/frontend/components/common/merchant/merchant-info.tsx +++ b/frontend/components/common/merchant/merchant-info.tsx @@ -39,12 +39,30 @@ export function MerchantInfo({ apiKey, onUpdate, onDelete, updateAPIKey }: Merch const [showClientSecret, setShowClientSecret] = useState(false) /* 复制到剪贴板 */ - const copyToClipboard = (text: string, label: string) => { - navigator.clipboard.writeText(text).then(() => { + const copyToClipboard = async (text: string, label: string) => { + try { + await navigator.clipboard.writeText(text) toast.success(`${ label } 已复制`) - }).catch(() => { - toast.error('复制失败') - }) + } catch { + try { + const textArea = document.createElement('textarea') + textArea.value = text + textArea.style.position = 'fixed' + textArea.style.opacity = '0' + document.body.appendChild(textArea) + textArea.select() + const successful = document.execCommand('copy') + document.body.removeChild(textArea) + + if (successful) { + toast.success(`${ label } 已复制`) + } else { + toast.error('复制失败') + } + } catch { + toast.error('复制失败') + } + } } const maskText = (text: string, showLength: number = 8) => { @@ -84,7 +102,7 @@ export function MerchantInfo({ apiKey, onUpdate, onDelete, updateAPIKey }: Merch {apiKey.app_homepage_url} - +
@@ -99,7 +117,7 @@ export function MerchantInfo({ apiKey, onUpdate, onDelete, updateAPIKey }: Merch {apiKey.redirect_uri} - +
)} @@ -114,7 +132,7 @@ export function MerchantInfo({ apiKey, onUpdate, onDelete, updateAPIKey }: Merch {apiKey.notify_url} - +
@@ -128,20 +146,20 @@ export function MerchantInfo({ apiKey, onUpdate, onDelete, updateAPIKey }: Merch 客户端标识
-
- +
+ {showClientId ? apiKey.client_id : maskText(apiKey.client_id, 8)}
-
+
{showClientSecret ? apiKey.client_secret : '•'.repeat(40)} diff --git a/frontend/components/common/merchant/merchant-main.tsx b/frontend/components/common/merchant/merchant-main.tsx index 2ec507c8..5056d947 100644 --- a/frontend/components/common/merchant/merchant-main.tsx +++ b/frontend/components/common/merchant/merchant-main.tsx @@ -65,7 +65,7 @@ export function MerchantMain() { return (
-
+

集市中心

{apiKeys.length > 0 && ( diff --git a/frontend/components/common/merchant/merchant-online.tsx b/frontend/components/common/merchant/merchant-online.tsx index b199968b..cad25be4 100644 --- a/frontend/components/common/merchant/merchant-online.tsx +++ b/frontend/components/common/merchant/merchant-online.tsx @@ -22,6 +22,31 @@ import { MerchantSelector } from "@/components/common/merchant/merchant-selector import { LoadingPage } from "@/components/layout/loading" import { TransactionTableList } from "@/components/common/general/table-data" +/** 设备预览配置 */ +const DEVICE_CONFIG = { + mobile: { + width: 375, + height: 812, + borderRadius: 50, + borderWidth: 14, + scale: 0.6 + }, + tablet: { + width: 768, + height: 1024, + borderRadius: 32, + borderWidth: 18, + scale: 0.45 + }, + desktop: { + width: 1200, + height: 750, + borderRadius: 16, + borderWidth: 8, + scale: 0.35 + } +} as const + export function MerchantOnline() { const searchParams = useSearchParams() const { apiKeys, loading: loadingKeys, loadAPIKeys } = useMerchant() @@ -35,7 +60,7 @@ export function MerchantOnline() { if (apiKeys.length === 0) { return (
-
+

在线流转

@@ -62,17 +87,16 @@ export function MerchantOnline() { client_id: selectedKey?.client_id }} > - + ) } interface MerchantOnlineContentProps { apiKeys: MerchantAPIKey[] - loadAPIKeys: () => Promise } -function MerchantOnlineContent({ apiKeys, loadAPIKeys }: MerchantOnlineContentProps) { +function MerchantOnlineContent({ apiKeys }: MerchantOnlineContentProps) { const router = useRouter() const searchParams = useSearchParams() const { @@ -87,12 +111,6 @@ function MerchantOnlineContent({ apiKeys, loadAPIKeys }: MerchantOnlineContentPr refresh } = useTransaction() - /* 加载 API Keys */ - useEffect(() => { - loadAPIKeys() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - /* 派生 selectedKey */ const apiKeyId = searchParams.get("apiKeyId") @@ -282,7 +300,7 @@ function MerchantOnlineContent({ apiKeys, loadAPIKeys }: MerchantOnlineContentPr return (
-
+

在线流转

{apiKeys.length > 0 && ( @@ -293,7 +311,7 @@ function MerchantOnlineContent({ apiKeys, loadAPIKeys }: MerchantOnlineContentPr /> )}
@@ -334,7 +352,7 @@ function MerchantOnlineContent({ apiKeys, loadAPIKeys }: MerchantOnlineContentPr className="rounded-lg p-4 border border-dashed hover:border-primary/50 shadow-none transition-all text-left group bg-background min-h-[100px] w-[180px] shrink-0 flex flex-col items-center justify-center gap-2" >
- +

创建服务

@@ -375,7 +393,7 @@ function MerchantOnlineContent({ apiKeys, loadAPIKeys }: MerchantOnlineContentPr

{link.remark || "无备注"}

- +
@@ -445,11 +463,11 @@ function MerchantOnlineContent({ apiKeys, loadAPIKeys }: MerchantOnlineContentPr layout initial={false} animate={{ - width: previewDevice === 'mobile' ? 375 : previewDevice === 'tablet' ? 768 : 1200, - height: previewDevice === 'mobile' ? 812 : previewDevice === 'tablet' ? 1024 : 750, - borderRadius: previewDevice === 'mobile' ? 50 : previewDevice === 'tablet' ? 32 : 16, - borderWidth: previewDevice === 'mobile' ? 14 : previewDevice === 'tablet' ? 18 : 8, - scale: previewDevice === 'mobile' ? 0.6 : previewDevice === 'tablet' ? 0.45 : 0.35, + width: DEVICE_CONFIG[previewDevice].width, + height: DEVICE_CONFIG[previewDevice].height, + borderRadius: DEVICE_CONFIG[previewDevice].borderRadius, + borderWidth: DEVICE_CONFIG[previewDevice].borderWidth, + scale: DEVICE_CONFIG[previewDevice].scale, }} transition={{ type: "spring", stiffness: 200, damping: 25 }} className="shrink-0 overflow-hidden shadow-2xl bg-black relative border-[#1f1f1f] origin-center rounded-[50px]" @@ -536,8 +554,8 @@ function MerchantOnlineContent({ apiKeys, loadAPIKeys }: MerchantOnlineContentPr -
diff --git a/frontend/components/common/merchant/merchant-selector.tsx b/frontend/components/common/merchant/merchant-selector.tsx index ddb503a7..774f28e9 100644 --- a/frontend/components/common/merchant/merchant-selector.tsx +++ b/frontend/components/common/merchant/merchant-selector.tsx @@ -29,16 +29,20 @@ export function MerchantSelector({ onValueChange={(value) => onSelect(Number(value))} disabled={loading || apiKeys.length === 0} > - + {apiKeys.map((apiKey) => ( - +
{apiKey.app_name} {apiKey.app_description && ( - + - {apiKey.app_description} )} diff --git a/frontend/components/common/trade/all-activity.tsx b/frontend/components/common/trade/all-activity.tsx index 03a1e040..c1be1b91 100644 --- a/frontend/components/common/trade/all-activity.tsx +++ b/frontend/components/common/trade/all-activity.tsx @@ -3,6 +3,13 @@ import { ArrowRightLeft, TrendingDown, TrendingUp, Users } from "lucide-react" import { CountingNumber } from "@/components/animate-ui/primitives/texts/counting-number" import { useUser } from "@/contexts/user-context" +/** + * 安全转换数字,避免 NaN + */ +function safeNumber(value: number): number { + return typeof value === 'number' && !isNaN(value) ? value : 0 +} + /** * 所有活动组件 * 显示用户的所有活动统计数据 @@ -52,7 +59,7 @@ export function AllActivity() { return (
-
数据概览
+

数据概览

{statCards.map((card) => { const IconComponent = card.icon @@ -69,7 +76,7 @@ export function AllActivity() {
{loading ? '-' : ( = { + receive: Receive, + payment: Payment, + transfer: Transfer, + community: Community, + online: Online, + all: AllActivity, +} + /** * 活动交易主页面组件 * @@ -54,22 +63,8 @@ export function TradeMain() { /* 渲染活动页面内容 */ const renderPageContent = () => { - switch (activeTab) { - case 'receive': - return - case 'payment': - return - case 'transfer': - return - case 'community': - return - case 'online': - return - case 'all': - return - default: - return null - } + const Component = PAGE_COMPONENTS[activeTab] + return Component ? : null } return ( @@ -97,7 +92,7 @@ export function TradeMain() { {renderPageContent()}
-
活动记录
+

活动记录

diff --git a/frontend/components/common/trade/trade-table.tsx b/frontend/components/common/trade/trade-table.tsx index 4b5650cc..98b1472d 100644 --- a/frontend/components/common/trade/trade-table.tsx +++ b/frontend/components/common/trade/trade-table.tsx @@ -5,29 +5,29 @@ import { TransactionProvider, useTransaction } from "@/contexts/transaction-cont import type { OrderStatus, OrderType, TransactionQueryParams } from "@/lib/services" import { formatLocalDate } from "@/lib/utils" +/** + * 获取默认日期范围(最近30天) + */ +function getDefaultDateRange() { + const today = new Date() + today.setHours(0, 0, 0, 0) + const from = new Date(today) + from.setDate(from.getDate() - 29) + const tomorrow = new Date(today) + tomorrow.setDate(tomorrow.getDate() + 1) + return { from, to: tomorrow } +} + /** * 活动表格组件 * * 支持类型、状态、时间范围筛选的活动记录显示(支持分页) */ export function TradeTable({ type }: { type?: OrderType }) { - /* 计算最近一个月的时间范围 */ - const getLastMonthRange = React.useCallback(() => { - const today = new Date() - today.setHours(0, 0, 0, 0) - const start = new Date(today) - start.setDate(start.getDate() - 29) - const tomorrow = new Date(today) - tomorrow.setDate(tomorrow.getDate() + 1) - - return { - startTime: formatLocalDate(start), - endTime: formatLocalDate(tomorrow) - } - }, []) - /* 获取时间范围 */ - const { startTime, endTime } = getLastMonthRange() + const { from, to: tomorrow } = getDefaultDateRange() + const startTime = formatLocalDate(from) + const endTime = formatLocalDate(tomorrow) return ( @@ -53,30 +53,16 @@ function TransactionList({ initialType }: { initialType?: OrderType }) { loadMore, } = useTransaction() - /* 筛选状态 */ const [selectedTypes, setSelectedTypes] = React.useState(initialType ? [initialType] : []) const [selectedStatuses, setSelectedStatuses] = React.useState([]) const [selectedQuickSelection, setSelectedQuickSelection] = React.useState("最近 1 个月") - const [dateRange, setDateRange] = React.useState<{ from: Date; to: Date } | null>(() => { - const today = new Date() - today.setHours(0, 0, 0, 0) - const from = new Date(today) - from.setDate(from.getDate() - 29) - const tomorrow = new Date(today) - tomorrow.setDate(tomorrow.getDate() + 1) - return { from, to: tomorrow } - }) + const [dateRange, setDateRange] = React.useState<{ from: Date; to: Date } | null>(getDefaultDateRange) /* 清空所有筛选 */ - const clearAllFilters = React.useCallback(() => { + const clearAllFilters = () => { setSelectedTypes(initialType ? [initialType] : []) setSelectedStatuses([]) - const today = new Date() - today.setHours(0, 0, 0, 0) - const from = new Date(today) - from.setDate(from.getDate() - 29) - const tomorrow = new Date(today) - tomorrow.setDate(tomorrow.getDate() + 1) + const { from, to: tomorrow } = getDefaultDateRange() setDateRange({ from, to: tomorrow }) setSelectedQuickSelection("最近 1 个月") @@ -88,7 +74,7 @@ function TransactionList({ initialType }: { initialType?: OrderType }) { startTime: formatLocalDate(from), endTime: formatLocalDate(tomorrow), }) - }, [initialType, fetchTransactions]) + } /* 当筛选条件改变时,重新加载数据 */ React.useEffect(() => { @@ -117,11 +103,6 @@ function TransactionList({ initialType }: { initialType?: OrderType }) { } }, [initialType]) - /* 加载更多 */ - const handleLoadMore = React.useCallback(() => { - loadMore() - }, [loadMore]) - return (
fetchTransactions({ page: 1 })} - onLoadMore={handleLoadMore} + onLoadMore={loadMore} />
) diff --git a/frontend/components/common/trade/transfer.tsx b/frontend/components/common/trade/transfer.tsx index 36bf5f28..32386a43 100644 --- a/frontend/components/common/trade/transfer.tsx +++ b/frontend/components/common/trade/transfer.tsx @@ -107,10 +107,6 @@ export function Transfer() { toast.error('转移失败', { description: errorMessage }) - - toast.error('转移失败', { - description: errorMessage - }) } finally { setLoading(false) } diff --git a/frontend/contexts/admin-context.tsx b/frontend/contexts/admin-context.tsx index 2b896db7..6e2d9741 100644 --- a/frontend/contexts/admin-context.tsx +++ b/frontend/contexts/admin-context.tsx @@ -3,24 +3,13 @@ import * as React from "react" import { createContext, useContext, useState, useRef, useCallback } from "react" -import services, { isCancelError } from "@/lib/services" +import services from "@/lib/services" import type { UpdateUserPayConfigRequest } from "@/lib/services/admin/types" import type { UserPayConfig, SystemConfig, UpdateSystemConfigRequest } from "@/lib/services" +import { handleContextError } from "@/lib/utils/error-handling" - - -/** - * Admin 上下文状态接口 - * 用于统一管理 admin 相关的数据状态 - * - * @example - * ```tsx - * - *
内容
- *
- * ``` - */ +/** Admin 上下文状态接口 */ export interface AdminContextState { userPayConfigs: UserPayConfig[] userPayConfigsLoading: boolean @@ -63,7 +52,7 @@ export function AdminProvider({ children }: { children: React.ReactNode }) { const userPayRequestIdRef = useRef(0) const systemRequestIdRef = useRef(0) - /* 获取用户支付配置列表 */ + /** 获取用户支付配置列表 */ const refetchUserPayConfigs = useCallback(async () => { const requestId = ++userPayRequestIdRef.current @@ -83,35 +72,31 @@ export function AdminProvider({ children }: { children: React.ReactNode }) { return } - if (!isCancelError(error)) { - console.error('加载支付配置失败:', error) - setUserPayConfigsError(error instanceof Error ? error : new Error('加载支付配置失败')) - } + const errorObject = handleContextError(error, '加载支付配置失败', { logError: true }) + setUserPayConfigsError(errorObject) setUserPayConfigsLoading(false) } }, []) - /* 更新用户支付配置 */ + /** 更新用户支付配置 */ const updateUserPayConfig = useCallback(async (id: number, data: UpdateUserPayConfigRequest) => { try { await services.admin.updateUserPayConfig(id, data) await refetchUserPayConfigs() } catch (error) { - if (isCancelError(error)) { - return - } + handleContextError(error, '更新支付配置失败') throw error } }, [refetchUserPayConfigs]) - /* 删除用户支付配置 */ + /** 删除用户支付配置 */ const deleteUserPayConfig = useCallback(async (id: number) => { await services.admin.deleteUserPayConfig(id) await refetchUserPayConfigs() }, [refetchUserPayConfigs]) - /* 获取系统配置列表 */ + /** 获取系统配置列表 */ const refetchSystemConfigs = useCallback(async () => { const requestId = ++systemRequestIdRef.current @@ -131,28 +116,24 @@ export function AdminProvider({ children }: { children: React.ReactNode }) { return } - if (!isCancelError(error)) { - console.error('加载系统配置失败:', error) - setSystemConfigsError(error instanceof Error ? error : new Error('加载系统配置失败')) - } + const errorObject = handleContextError(error, '加载系统配置失败', { logError: true }) + setSystemConfigsError(errorObject) setSystemConfigsLoading(false) } }, []) - /* 更新系统配置 */ + /** 更新系统配置 */ const updateSystemConfig = useCallback(async (key: string, data: UpdateSystemConfigRequest) => { try { await services.admin.updateSystemConfig(key, data) await refetchSystemConfigs() } catch (error) { - if (isCancelError(error)) { - return - } + handleContextError(error, '更新系统配置失败') throw error } }, [refetchSystemConfigs]) - /* 删除系统配置 */ + /** 删除系统配置 */ const deleteSystemConfig = useCallback(async (key: string) => { await services.admin.deleteSystemConfig(key) await refetchSystemConfigs() diff --git a/frontend/contexts/merchant-context.tsx b/frontend/contexts/merchant-context.tsx index 0d701adf..cf96253f 100644 --- a/frontend/contexts/merchant-context.tsx +++ b/frontend/contexts/merchant-context.tsx @@ -1,23 +1,19 @@ "use client" import { createContext, useContext, useState, useRef, useCallback, useEffect } from "react" -import { toast } from "sonner" -import services, { type MerchantAPIKey, type UpdateAPIKeyRequest, isCancelError } from "@/lib/services" +import services, { type MerchantAPIKey, type UpdateAPIKeyRequest } from "@/lib/services" +import { handleContextError } from "@/lib/utils/error-handling" -/** - * 商户数据状态接口 - */ +/** 商户数据状态接口 */ interface MerchantDataState { apiKeys: MerchantAPIKey[] loading: boolean error: string | null } -/** - * 商户 Context 接口 - */ +/** 商户 Context 接口 */ interface MerchantContextType extends MerchantDataState { loadAPIKeys: () => Promise createAPIKey: (data: { @@ -31,16 +27,10 @@ interface MerchantContextType extends MerchantDataState { refresh: () => Promise } -/** - * 商户 Context - * 提供全局商户数据状态管理,数据在页面切换间保持 - */ +/** 商户 Context(全局状态管理) */ const MerchantContext = createContext(undefined) -/** - * 商户 Provider Props - * @param {React.ReactNode} children - 商户 Provider 的子元素 - */ +/** 商户 Provider */ export function MerchantProvider({ children }: { children: React.ReactNode }) { const [state, setState] = useState({ apiKeys: [], @@ -48,15 +38,15 @@ export function MerchantProvider({ children }: { children: React.ReactNode }) { error: null, }) - /* 使用 ref 来标记是否已经加载过数据(全局级别) */ + /** 使用 ref 来标记是否已经加载过数据(全局级别) */ const hasLoadedRef = useRef(false) const isMountedRef = useRef(true) const apiKeysRef = useRef(state.apiKeys) - /* 同步更新 ref */ + /** 同步更新 ref */ apiKeysRef.current = state.apiKeys - /* 获取 API Keys */ + /** 获取 API Keys */ const loadAPIKeys = useCallback(async () => { if (hasLoadedRef.current && apiKeysRef.current.length > 0) { return @@ -80,26 +70,22 @@ export function MerchantProvider({ children }: { children: React.ReactNode }) { hasLoadedRef.current = true } catch (error) { - if (isCancelError(error)) { + const errorObject = handleContextError(error, '加载 API Keys 失败', { showToast: true }) + if (errorObject.message === '请求已取消') { return } if (!isMountedRef.current) return - const errorMessage = (error as Error).message || '无法加载 API Keys' setState(prev => ({ ...prev, loading: false, - error: errorMessage + error: errorObject.message })) - - toast.error('加载失败', { - description: errorMessage - }) } }, []) - /* 创建 API Key */ + /** 创建 API Key */ const createAPIKey = useCallback(async (data: { app_name: string app_homepage_url: string @@ -118,13 +104,13 @@ export function MerchantProvider({ children }: { children: React.ReactNode }) { return newKey }, []) - /* 更新 API Key */ + /** 更新 API Key */ const updateAPIKey = useCallback(async (id: number, data: UpdateAPIKeyRequest): Promise => { await services.merchant.updateAPIKey(id, data) if (!isMountedRef.current) return - /* 更新本地状态中的API Key */ + /** 更新本地状态中的API Key */ setState(prev => ({ ...prev, apiKeys: prev.apiKeys.map(key => @@ -133,7 +119,7 @@ export function MerchantProvider({ children }: { children: React.ReactNode }) { })) }, []) - /* 删除 API Key */ + /** 删除 API Key */ const deleteAPIKey = useCallback(async (id: number) => { await services.merchant.deleteAPIKey(id) @@ -145,7 +131,7 @@ export function MerchantProvider({ children }: { children: React.ReactNode }) { })) }, []) - /* 刷新数据 */ + /** 刷新数据 */ const refresh = useCallback(async () => { hasLoadedRef.current = false await loadAPIKeys() diff --git a/frontend/contexts/transaction-context.tsx b/frontend/contexts/transaction-context.tsx index b1d9078c..6c635746 100644 --- a/frontend/contexts/transaction-context.tsx +++ b/frontend/contexts/transaction-context.tsx @@ -8,7 +8,7 @@ import type { Order, TransactionQueryParams } from "@/lib/services" import { generateTransactionCacheKey } from "@/lib/utils" -/* 交易上下文状态接口 */ +/** 交易上下文状态接口 */ interface TransactionContextState { transactions: Order[] total: number @@ -28,10 +28,10 @@ interface TransactionContextState { const MAX_CACHE_SIZE = 50 const CACHE_DURATION = 5 * 60 * 1000 // 5分钟 -/* 交易上下文 */ +/** 交易上下文 */ const TransactionContext = createContext(null) -/* 交易 Provider Props 接口 */ +/** 交易 Provider Props 接口 */ interface TransactionProviderProps { children: React.ReactNode defaultParams?: Partial @@ -59,7 +59,7 @@ export function TransactionProvider({ children, defaultParams = {} }: Transactio const [error, setError] = useState(null) const [lastParams, setLastParams] = useState>(defaultParams) - /* 使用 useRef 存储缓存 */ + /** 使用 useRef 存储缓存 */ const cacheRef = useRef>({}) const latestRequestIdRef = useRef(0) @@ -85,7 +85,7 @@ export function TransactionProvider({ children, defaultParams = {} }: Transactio cacheRef.current[key] = value }, []) - /* 获取交易列表 */ + /** 获取交易列表 */ const fetchTransactions = useCallback(async (params: Partial) => { const queryParams: TransactionQueryParams = { page: params.page || 1, @@ -93,10 +93,10 @@ export function TransactionProvider({ children, defaultParams = {} }: Transactio ...params, } - /* 生成唯一的请求 ID */ + /** 生成唯一的请求 ID */ const requestId = ++latestRequestIdRef.current - /* 生成缓存key */ + /** 生成缓存key */ const cacheKey = generateTransactionCacheKey(queryParams) const cached = cacheRef.current[cacheKey] @@ -107,7 +107,7 @@ export function TransactionProvider({ children, defaultParams = {} }: Transactio return } - /* 使用缓存数据,同步更新状态 */ + /** 使用缓存数据,同步更新状态 */ setTransactions(cached.data) setTotal(cached.total) setCurrentPage(queryParams.page) @@ -118,7 +118,7 @@ export function TransactionProvider({ children, defaultParams = {} }: Transactio return } - /* 发起API请求 */ + /** 发起API请求 */ setLoading(true) setError(null) if (queryParams.page === 1) { @@ -167,7 +167,7 @@ export function TransactionProvider({ children, defaultParams = {} }: Transactio } }, [pageSize, addToCache, cleanExpiredCache]) - /* 加载更多 */ + /** 加载更多 */ const loadMore = useCallback(async () => { if (loading) return @@ -178,7 +178,7 @@ export function TransactionProvider({ children, defaultParams = {} }: Transactio }) }, [currentPage, fetchTransactions, lastParams, loading]) - /* 刷新当前页 */ + /** 刷新当前页 */ const refresh = useCallback(async () => { const cacheKey = generateTransactionCacheKey({ ...lastParams, page: 1, page_size: pageSize }) delete cacheRef.current[cacheKey] @@ -189,7 +189,7 @@ export function TransactionProvider({ children, defaultParams = {} }: Transactio }) }, [fetchTransactions, lastParams, pageSize]) - /* 乐观更新订单状态 */ + /** 乐观更新订单状态 */ const updateOrderStatus = useCallback((orderId: number, updates: Partial>) => { setTransactions(prev => prev.map(order => @@ -202,7 +202,7 @@ export function TransactionProvider({ children, defaultParams = {} }: Transactio cacheRef.current = {} }, []) - /* 重置状态 */ + /** 重置状态 */ const reset = useCallback(() => { setTransactions([]) setTotal(0) diff --git a/frontend/contexts/user-context.tsx b/frontend/contexts/user-context.tsx index f02332a3..f064b2c0 100644 --- a/frontend/contexts/user-context.tsx +++ b/frontend/contexts/user-context.tsx @@ -6,14 +6,14 @@ import services from '@/lib/services' import { User, TrustLevel, PayLevel } from '@/lib/services/auth/types' -/* 用户状态接口 */ +/** 用户状态接口 */ interface UserState { user: User | null loading: boolean error: string | null } -/* 用户上下文接口 */ +/** 用户上下文接口 */ interface UserContextValue extends UserState { refetch: () => Promise updatePayKey: (payKey: string) => Promise @@ -22,7 +22,7 @@ interface UserContextValue extends UserState { logout: () => Promise } -/* 信任等级映射 */ +/** 信任等级映射 */ const TRUST_LEVEL_LABELS: Record = { [TrustLevel.New]: '新用户', [TrustLevel.Basic]: '基本用户', @@ -31,7 +31,7 @@ const TRUST_LEVEL_LABELS: Record = { [TrustLevel.Leader]: '领导者', } -/* 支付等级映射 */ +/** 支付等级映射 */ const PAY_LEVEL_LABELS: Record = { [PayLevel.BlackGold]: '黑金', [PayLevel.WhiteGold]: '白金', @@ -39,7 +39,7 @@ const PAY_LEVEL_LABELS: Record = { [PayLevel.Platinum]: '铂金', } -/* 用户上下文 */ +/** 用户上下文 */ const UserContext = createContext(undefined) /** @@ -65,17 +65,17 @@ export function UserProvider({ children }: { children: ReactNode }) { const isMountedRef = useRef(true) - /* 获取信任等级标签 */ + /** 获取信任等级标签 */ const getTrustLevelLabel = useCallback((trustLevel: TrustLevel): string => { return TRUST_LEVEL_LABELS[trustLevel] || '未知' }, []) - /* 获取支付等级标签 */ + /** 获取支付等级标签 */ const getPayLevelLabel = useCallback((payLevel: PayLevel): string => { return PAY_LEVEL_LABELS[payLevel] || '未知' }, []) - /* 获取用户信息 */ + /** 获取用户信息 */ const fetchUser = useCallback(async () => { try { setState(prev => ({ ...prev, loading: true, error: null })) @@ -95,18 +95,18 @@ export function UserProvider({ children }: { children: ReactNode }) { } }, []) - /* 重新获取用户信息 */ + /** 重新获取用户信息 */ const refetch = useCallback(async () => { await fetchUser() }, [fetchUser]) - /* 更新支付密码 */ + /** 更新支付密码 */ const updatePayKey = useCallback(async (payKey: string) => { await services.user.updatePayKey(payKey) await fetchUser() }, [fetchUser]) - /* 用户登出 */ + /** 用户登出 */ const logout = useCallback(async () => { try { await services.auth.logout() @@ -125,7 +125,7 @@ export function UserProvider({ children }: { children: ReactNode }) { } }, []) - /* 组件挂载时获取用户信息 */ + /** 组件挂载时获取用户信息 */ useEffect(() => { isMountedRef.current = true fetchUser() diff --git a/frontend/hooks/use-controlled-state.tsx b/frontend/hooks/use-controlled-state.tsx index f806fadd..84605dbf 100644 --- a/frontend/hooks/use-controlled-state.tsx +++ b/frontend/hooks/use-controlled-state.tsx @@ -5,6 +5,15 @@ interface CommonControlledStateProps { defaultValue?: T; } +/** + * 受控状态 Hook + * 用于处理受控和非受控组件的状态管理 + * + * @example + * ```tsx + * const [value, setValue] = useControlledState({ value: propValue, onChange: console.log }) + * ``` + */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export function useControlledState( props: CommonControlledStateProps & { diff --git a/frontend/hooks/use-is-in-view.tsx b/frontend/hooks/use-is-in-view.tsx index 3738c569..465d5df3 100644 --- a/frontend/hooks/use-is-in-view.tsx +++ b/frontend/hooks/use-is-in-view.tsx @@ -7,6 +7,14 @@ interface UseIsInViewOptions { inViewMargin?: UseInViewOptions['margin']; } +/** + * 检测元素是否在视图中 + * + * @example + * ```tsx + * const { ref, isInView } = useIsInView({ inViewOnce: true }) + * ``` + */ function useIsInView( ref: React.Ref, options: UseIsInViewOptions = {}, diff --git a/frontend/hooks/use-public-config.ts b/frontend/hooks/use-public-config.ts index d464467d..53810d7b 100644 --- a/frontend/hooks/use-public-config.ts +++ b/frontend/hooks/use-public-config.ts @@ -1,34 +1,50 @@ import { useState, useEffect } from 'react' import { ConfigService, PublicConfigResponse } from '@/lib/services' +// 全局缓存 +let cachedConfig: PublicConfigResponse | null = null +let cachePromise: Promise | null = null +let cacheTime = 0 +const CACHE_DURATION = 5 * 60 * 1000 // 5分钟 + /** - * 使用公共配置 Hook - * 自动获取并缓存公共配置,避免重复请求 - * - * @returns 公共配置数据、加载状态和错误信息 - * + * 获取公共配置(带缓存) + * * @example * ```tsx * const { config, loading, error } = usePublicConfig() - * - * if (loading) return - * if (error) return - * - * console.log('争议时间窗口:', config.dispute_time_window_hours) * ``` */ export function usePublicConfig() { - const [config, setConfig] = useState(null) - const [loading, setLoading] = useState(true) + const [config, setConfig] = useState(cachedConfig) + const [loading, setLoading] = useState(!cachedConfig) const [error, setError] = useState(null) useEffect(() => { let mounted = true const fetchConfig = async () => { + const now = Date.now() + + // 使用有效缓存 + if (cachedConfig && (now - cacheTime) < CACHE_DURATION) { + if (mounted) { + setConfig(cachedConfig) + setLoading(false) + } + return + } + + // 共享同一个请求(请求去重) + if (!cachePromise) { + cachePromise = ConfigService.getPublicConfig() + } + try { - const data = await ConfigService.getPublicConfig() + const data = await cachePromise if (mounted) { + cachedConfig = data + cacheTime = now setConfig(data) setError(null) } @@ -37,6 +53,7 @@ export function usePublicConfig() { setError(err instanceof Error ? err : new Error('获取配置失败')) } } finally { + cachePromise = null if (mounted) { setLoading(false) } diff --git a/frontend/lib/services/README.md b/frontend/lib/services/README.md index 34d3a80d..1ad9c150 100644 --- a/frontend/lib/services/README.md +++ b/frontend/lib/services/README.md @@ -1,508 +1,524 @@ -# 服务层架构 +# 服务层架构文档 + +> **前端服务层** - 统一的 API 交互层,基于 TypeScript 和面向对象设计 + +## 架构设计 + +### 设计理念 + +服务层采用**分层架构**和**继承模式**,遵循以下核心原则: + +1. **单一职责** - 每个服务模块负责一个业务领域 +2. **类型安全** - 全量 TypeScript 类型定义,杜绝 `any` +3. **统一规范** - 所有服务遵循相同的设计模式 +4. **错误分类** - 细粒度错误类型,便于精确处理 +5. **可扩展性** - 基于继承的设计,易于添加新服务 + +--- + +## 架构分层 + +```mermaid +graph TB + subgraph UI["业务组件层"] + Components["React Components
Hooks & Contexts"] + end + + subgraph Services["服务层 (Services Layer)"] + direction LR + Auth["AuthService
认证服务"] + Admin["AdminService
管理员服务"] + Merchant["MerchantService
商户服务"] + Transaction["TransactionService
交易服务"] + Dispute["DisputeService
争议服务"] + User["UserService
用户服务"] + Config["ConfigService
配置服务"] + end + + subgraph BaseLayer["服务基类"] + BaseService["BaseService
• get() post() put() delete()
• rawGet() rawPost()
"] + end + + subgraph Core["核心层 (Core Layer)"] + direction LR + ApiClient["api-client.ts
• 请求/响应拦截
• 错误处理
• 请求去重
• 401重定向
"] + Errors["errors.ts
• ApiErrorBase
• NetworkError
• ValidationError
• 等7种错误
"] + Types["types.ts
• ApiResponse
• PaginationParams
• RequestConfig
"] + CoreConfig["config.ts
• baseURL
• timeout
• credentials
"] + end + + Backend["Backend API
(Go + Gin)"] + + Components -->|调用| Auth + Components -->|调用| Admin + Components -->|调用| Merchant + Components -->|调用| Transaction + Components -->|调用| Dispute + Components -->|调用| User + Components -->|调用| Config + + Auth -.->|继承| BaseService + Admin -.->|继承| BaseService + Merchant -.->|继承| BaseService + Transaction -.->|继承| BaseService + Dispute -.->|继承| BaseService + User -.->|继承| BaseService + Config -.->|继承| BaseService + + BaseService -->|使用| ApiClient + BaseService -->|使用| Errors + BaseService -->|使用| Types + ApiClient -->|读取| CoreConfig + + ApiClient ==>|HTTP 请求| Backend + + style UI fill:#e1f5ff + style Services fill:#fff9e6 + style BaseLayer fill:#f0f0f0 + style Core fill:#e8f5e9 + style Backend fill:#ffebee +``` -服务层是前端与 API 交互的统一入口,基于以下原则: -- **关注点分离** - 每个服务负责一个业务领域 -- **统一入口** - 通过 `services` 对象导出所有服务 -- **类型安全** - 所有请求和响应有明确类型定义 -- **错误分类** - 细粒度的错误类型 -- **可取消请求** - 支持请求取消机制 -- **环境配置** - 支持多环境配置 +--- ## 目录结构 ``` lib/services/ -├── core/ # 核心服务层 -│ ├── api-client.ts # Axios 客户端实例 -│ ├── base.service.ts # 服务基类 -│ ├── config.ts # API 配置 -│ ├── errors.ts # 错误类型定义 -│ ├── types.ts # 核心类型定义 -│ └── index.ts # 导出核心模块 -├── admin/ # 管理员服务 -│ ├── types.ts # 管理员相关类型 -│ ├── admin.service.ts # 管理员服务实现 -│ └── index.ts # 导出管理员服务 -├── auth/ # 认证服务 -│ ├── types.ts # 认证相关类型 -│ ├── auth.service.ts # 认证服务实现 -│ └── index.ts # 导出认证服务 -├── merchant/ # 商户服务 -│ ├── types.ts # 商户相关类型 -│ ├── merchant.service.ts # 商户服务实现 -│ └── index.ts # 导出商户服务 -├── transaction/ # 交易服务 -│ ├── types.ts # 交易相关类型 -│ ├── transaction.service.ts # 交易服务实现 -│ └── index.ts # 导出交易服务 -├── user/ # 用户服务 -│ ├── types.ts # 用户相关类型 -│ ├── user.service.ts # 用户服务实现 -│ └── index.ts # 导出用户服务 -├── index.ts # 服务层统一入口 -└── README.md # 文档 +├── core/ # 核心基础设施层 +│ ├── api-client.ts # HTTP 客户端(请求拦截、错误处理) +│ ├── base.service.ts # 服务基类(封装 CRUD 方法) +│ ├── config.ts # API 配置(环境变量、超时等) +│ ├── errors.ts # 错误类型定义(7种错误类型) +│ ├── types.ts # 核心类型(ApiResponse、分页等) +│ └── index.ts # 核心模块导出 +│ +├── auth/ # 认证服务模块 +│ ├── auth.service.ts # OAuth 认证、登录、登出 +│ ├── types.ts # User、OAuthLoginUrlResponse 等 +│ └── index.ts # 模块导出 + 文档 +│ +├── admin/ # 管理员服务模块 +│ ├── admin.service.ts # 系统配置、用户支付配置管理 +│ ├── types.ts # SystemConfig、UserPayConfig 等 +│ └── index.ts # 模块导出 + 文档 +│ +├── merchant/ # 商户服务模块 +│ ├── merchant.service.ts # API Key、支付链接、订单管理 +│ ├── types.ts # MerchantAPIKey、PaymentLink 等 +│ └── index.ts # 模块导出 + 文档 +│ +├── transaction/ # 交易服务模块 +│ ├── transaction.service.ts # 交易记录、转账 +│ ├── types.ts # Order、TransactionQueryParams 等 +│ └── index.ts # 模块导出 + 文档 +│ +├── dispute/ # 争议服务模块 +│ ├── dispute.service.ts # 创建争议、退款审核 +│ ├── types.ts # Dispute、DisputeWithOrder 等 +│ └── index.ts # 模块导出 + 文档 +│ +├── user/ # 用户服务模块 +│ ├── user.service.ts # 用户设置(支付密钥等) +│ ├── types.ts # UpdatePayKeyRequest 等 +│ └── index.ts # 模块导出 + 文档 +│ +├── config/ # 配置服务模块 +│ ├── config.service.ts # 获取公共配置 +│ ├── types.ts # PublicConfigResponse +│ └── index.ts # 模块导出 + 文档 +│ +├── index.ts # 统一导出入口 +└── README.md # 本文档 ``` -## 核心模块 +--- -### config.ts +## 核心模块详解 -提供 API 配置管理: -- 环境变量支持(`NEXT_PUBLIC_API_URL`) -- 统一的超时配置 -- 凭证配置 +### 1. BaseService - 服务基类 -### api-client.ts +**职责**:为所有业务服务提供统一的 HTTP 方法封装 -提供统一的 HTTP 客户端,包含: -- 请求/响应拦截器 -- 统一错误处理 -- 自动 401 重定向 -- 请求取消机制 -- 超时控制 +**关键设计**: +```typescript +export class BaseService { + protected static readonly basePath: string = ''; + + // 标准 RESTful 方法 + protected static async get(path: string, params?: Record): Promise + protected static async post(path: string, data?: unknown): Promise + protected static async put(path: string, data?: unknown): Promise + protected static async patch(path: string, data?: unknown): Promise + protected static async delete(path: string, params?: Record): Promise + + // 特殊端点支持(不遵循标准响应格式) + protected static async rawGet(url: string, params?: unknown): Promise + protected static async rawPost(url: string, data?: unknown): Promise +} +``` -### base.service.ts +**设计优势**: +- ✅ 子类只需设置 `basePath`,无需重复实现 HTTP 逻辑 +- ✅ 统一的响应解包(`response.data.data`) +- ✅ 类型安全的泛型设计 +- ✅ 支持特殊端点(如 `/api.php`) -提供服务基类,封装: -- GET、POST、PUT、PATCH、DELETE 方法 -- 统一的路径处理 -- 类型安全的响应解析 -- 支持额外的请求配置 +--- -### errors.ts +### 2. API Client - HTTP 客户端 -定义错误类型层级: -- `ApiErrorBase` - API 错误基类 -- `NetworkError` - 网络连接错误 -- `TimeoutError` - 请求超时错误 -- `UnauthorizedError` - 未授权错误 (401) -- `ForbiddenError` - 权限不足错误 (403) -- `NotFoundError` - 资源未找到错误 (404) -- `ValidationError` - 参数验证错误 (400) -- `ServerError` - 服务器错误 (5xx) +**职责**:提供全局唯一的 Axios 实例,处理所有 HTTP 请求 -### types.ts +**核心功能**: -定义核心类型: -- `ApiResponse` - API 响应结构 -- `ApiError` - 错误响应结构 -- `PaginationParams` - 分页请求参数 -- `PaginationResponse` - 分页响应结构 +#### 请求拦截器 +- 自动添加 Cancel Token(支持请求取消) +- 请求去重(避免重复请求) -## 使用方式 +#### 响应拦截器 +- **401 自动重定向** - 未授权时自动跳转登录页 +- **错误分类** - 将 HTTP 状态码映射为具体错误类型 +- **统一响应格式** - 解析 `ApiResponse` 结构 -### 1. 推荐:使用统一的 services 对象 +#### 错误处理映射 +| HTTP 状态码 | 错误类型 | 说明 | +|------------|---------|------| +| 400 | `ValidationError` | 参数验证失败 | +| 401 | 自动重定向 | 跳转到登录页 | +| 403 | `ForbiddenError` | 权限不足 | +| 404 | `NotFoundError` | 资源不存在 | +| 5xx | `ServerError` | 服务器错误 | +| 超时 | `TimeoutError` | 请求超时 | +| 网络 | `NetworkError` | 网络连接失败 | -```typescript -import services from '@/lib/services'; +--- -// 调用认证服务 -const user = await services.auth.getUserInfo(); -await services.auth.logout(); +### 3. 错误类型层级 -// 调用交易服务 -const transactions = await services.transaction.getTransactions({ - page: 1, - page_size: 20, - type: 'receive' -}); - -// 调用商户服务 -const apiKeys = await services.merchant.listAPIKeys(); -const newKey = await services.merchant.createAPIKey({ - app_name: '我的应用', - app_homepage_url: 'https://example.com', - redirect_uri: 'https://example.com/callback' -}); - -// 创建商户订单(需要商户凭证) -const order = await services.merchant.createMerchantOrder( - { - order_name: '商品购买', - amount: 99.99, - remark: '订单备注' - }, - 'client_id', - 'client_secret' -); - -// 查询商户订单信息 -const orderInfo = await services.merchant.getMerchantOrder({ order_no: 'order_no_123' }); -console.log('订单信息:', orderInfo.order); -console.log('支付配置:', orderInfo.user_pay_config); - -// 支付商户订单 -await services.merchant.payMerchantOrder({ order_no: 'order_no_123' }); - -// 调用管理员服务(需要管理员权限) -const systemConfigs = await services.admin.listSystemConfigs(); -const payConfigs = await services.admin.listUserPayConfigs(); - -// 调用用户服务 -await services.user.updatePayKey('123456'); +``` +ApiErrorBase (基类) +├── NetworkError (网络连接错误) +├── TimeoutError (请求超时) +├── UnauthorizedError (401 - 未授权) +├── ForbiddenError (403 - 权限不足) +├── NotFoundError (404 - 资源不存在) +├── ValidationError (400 - 参数验证失败) +└── ServerError (5xx - 服务器错误) ``` -### 2. 按需导入:直接导入特定服务 +**设计优势**: +- 支持 `instanceof` 类型判断 +- 携带详细错误信息(`error_code`、`details`) +- 便于前端精确处理不同错误场景 -```typescript -import { AdminService, UserService } from '@/lib/services'; +--- -const configs = await AdminService.listSystemConfigs(); -await UserService.updatePayKey('123456'); -``` +## 开发规范 -### 3. 错误处理 +### 1. 服务类规范 +#### 必须遵循 ```typescript -import services, { UnauthorizedError, ValidationError } from '@/lib/services'; - -try { - const user = await services.auth.login({ username, password }); - console.log('登录成功', user); -} catch (error) { - if (error instanceof UnauthorizedError) { - console.error('登录失败:用户名或密码错误'); - } else if (error instanceof ValidationError) { - console.error('登录失败:参数验证失败', error.details); - } else { - console.error('登录失败:', error.message); +export class SomeService extends BaseService { + // 1. 必须继承 BaseService + // 2. basePath 必须是 protected static readonly + protected static readonly basePath = '/api/v1/resource'; + + // 3. 方法必须是 static async + // 4. 返回类型必须明确(禁止 any) + static async getAll(): Promise { + return this.get('/'); + } + + // 5. 参数类型必须明确定义在 types.ts + static async create(request: CreateResourceRequest): Promise { + return this.post('/', request); } } ``` -### 4. 取消请求 - +#### 方法命名规范 +| 操作 | 命名 | 示例 | +|------|------|------| +| 获取列表 | `list*` 或 `getAll` | `listAPIKeys()` | +| 获取单个 | `get*` 或 `getById` | `getAPIKey(id)` | +| 创建 | `create*` | `createAPIKey(request)` | +| 更新 | `update*` | `updateAPIKey(id, request)` | +| 删除 | `delete*` | `deleteAPIKey(id)` | +| 特殊操作 | 动词开头 | `payMerchantOrder()` | + +#### 方法排序规范 ```typescript -import { cancelRequest, cancelAllRequests } from '@/lib/services'; - -// 取消特定请求 -cancelRequest('GET', '/api/v1/users'); - -// 取消所有请求(例如在组件卸载时) -useEffect(() => { - return () => { - cancelAllRequests(); - }; -}, []); +export class SomeService extends BaseService { + protected static readonly basePath = '/api/v1/resource'; + + // 1. CRUD 操作(按 Create → Read → Update → Delete) + static async create() { } + static async list() { } + static async get() { } + static async update() { } + static async delete() { } + + // 2. 其他业务方法(按业务逻辑分组) + static async someAction() { } +} ``` -### 5. 使用核心模块 +--- + +### 2. 类型定义规范 +#### types.ts 文件结构 ```typescript -import { apiClient, BaseService } from '@/lib/services/core'; +// 1. 类型别名(Type Aliases) +export type ResourceStatus = 'active' | 'inactive' | 'pending'; + +// 2. 枚举(Enums) - 使用 const enum 提升性能 +export const enum ResourceLevel { + Basic = 1, + Premium = 2, + Enterprise = 3, +} -// 直接使用 apiClient -const response = await apiClient.get('/api/v1/users'); +// 3. 接口(Interfaces) - 按业务逻辑分组 +/** + * 资源信息 + */ +export interface Resource { + /** 资源 ID */ + id: number; + /** 资源名称 */ + name: string; + /** 状态 */ + status: ResourceStatus; + /** 创建时间 */ + created_at: string; +} -// 继承 BaseService 创建新服务 -class UserService extends BaseService { - protected static readonly basePath = '/api/v1/users'; - - static async getAll() { - return this.get('/'); - } +/** + * 创建资源请求 + */ +export interface CreateResourceRequest { + /** 资源名称(最大 50 字符) */ + name: string; + /** 描述(可选,最大 200 字符) */ + description?: string; } ``` -## 服务模块说明 +#### 注释规范 +- ✅ 所有 interface 必须有 JSDoc 描述 +- ✅ 所有字段必须有行内注释 +- ✅ 包含约束信息(长度、范围、格式等) +- ✅ 可选字段使用 `?` 标记 -### AdminService - 管理员服务 +--- -处理系统配置和用户支付配置管理相关的 API 请求。 +### 3. JSDoc 注释规范 -**主要接口:** -- 系统配置 CRUD 操作 -- 用户支付配置 CRUD 操作 - -**权限要求:** 需要管理员权限 - -**使用示例:** +**标准模板**: ```typescript -import services from '@/lib/services'; - -// 获取系统配置列表 -const configs = await services.admin.listSystemConfigs(); - -// 创建系统配置 -await services.admin.createSystemConfig({ - key: 'app.version', - value: '1.0.0', - description: '应用版本' -}); - -// 更新系统配置 -await services.admin.updateSystemConfig('app.version', { - value: '1.1.0', - description: '更新版本' -}); - -// 删除系统配置 -await services.admin.deleteSystemConfig('app.version'); - -// 用户支付配置管理 -const payConfigs = await services.admin.listUserPayConfigs(); -const newConfig = await services.admin.createUserPayConfig({ - level: PayLevel.Premium, - min_score: 1000, - max_score: null, - daily_limit: 100000, - fee_rate: 0.01 -}); +/** + * [一句话描述方法功能] + * + * @param paramName - 参数说明 + * @returns 返回值说明 + * @throws {ErrorType} 错误条件说明 + * + * @example + * ```typescript + * // 使用示例 + * const result = await Service.method({ param: 'value' }); + * ``` + * + * @remarks [可选] + * - 业务规则或注意事项 + */ ``` -### UserService - 用户服务 - -处理用户个人设置相关的 API 请求。 +**示例**: +```typescript +/** + * 创建商户 API Key + * + * @param request - API Key 配置 + * @returns 创建的 API Key 信息 + * @throws {UnauthorizedError} 当未登录时 + * @throws {ValidationError} 当参数验证失败时 + * + * @example + * ```typescript + * const apiKey = await MerchantService.createAPIKey({ + * app_name: '我的应用', + * app_homepage_url: 'https://example.com' + * }); + * ``` + * + * @remarks + * - app_name 最大 20 字符 + * - 需要登录权限 + */ +static async createAPIKey(request: CreateAPIKeyRequest): Promise +``` -**主要接口:** -- 更新用户支付密钥 +--- -**权限要求:** 需要登录 +### 4. 模块 index.ts 规范 -**使用示例:** +**标准格式**: ```typescript -import services from '@/lib/services'; +/** + * [模块名] 服务模块 + * + * @description + * 提供 [业务领域] 相关的功能,包括: + * - 功能点1 + * - 功能点2 + * - 功能点3 + * + * @example + * ```typescript + * import { ServiceName } from '@/lib/services'; + * + * // 使用示例(展示最常用的1-2个方法) + * const result = await ServiceName.commonMethod(); + * ``` + * + * @remarks [可选] + * - 特殊说明(如权限要求等) + */ -// 更新支付密钥(6位数字) -await services.user.updatePayKey('123456'); +export { ServiceName } from './service-name.service'; +export type * from './types'; +// 或明确导出 +export type { + Type1, + Type2, +} from './types'; ``` -## 创建新服务 +--- -### 1. 创建目录结构 +## 创建新服务指南 +### Step 1: 创建目录和文件 ```bash -lib/services/新服务名/ - ├── types.ts # 类型定义 - ├── 服务名.service.ts # 服务实现 - └── index.ts # 导出服务 +mkdir lib/services/resource +touch lib/services/resource/types.ts +touch lib/services/resource/resource.service.ts +touch lib/services/resource/index.ts ``` -### 2. 定义类型(types.ts) - +### Step 2: 定义类型(types.ts) ```typescript -/** - * 用户信息 - */ -export interface User { - id: string; +export interface Resource { + id: number; name: string; - email: string; } -/** - * 创建用户请求 - */ -export interface CreateUserRequest { +export interface CreateResourceRequest { name: string; - email: string; } ``` -### 3. 实现服务类(服务名.service.ts) - +### Step 3: 实现服务(resource.service.ts) ```typescript import { BaseService } from '../core/base.service'; -import type { User, CreateUserRequest } from './types'; +import type { Resource, CreateResourceRequest } from './types'; -/** - * 用户服务 - * 处理用户相关的 API 请求 - */ -export class UserService extends BaseService { - protected static readonly basePath = '/api/v1/users'; - - /** - * 获取用户列表 - * @returns 用户列表 - */ - static async getAll(): Promise { - return this.get('/'); - } - - /** - * 获取单个用户 - * @param id - 用户 ID - * @returns 用户信息 - */ - static async getById(id: string): Promise { - return this.get(`/${id}`); - } +export class ResourceService extends BaseService { + protected static readonly basePath = '/api/v1/resources'; - /** - * 创建用户 - * @param request - 创建请求 - * @returns 创建的用户 - */ - static async create(request: CreateUserRequest): Promise { - return this.post('/', request); + static async list(): Promise { + return this.get('/'); } - /** - * 更新用户 - * @param id - 用户 ID - * @param request - 更新请求 - * @returns 更新后的用户 - */ - static async update(id: string, request: Partial): Promise { - return this.put(`/${id}`, request); - } - - /** - * 删除用户 - * @param id - 用户 ID - */ - static async delete(id: string): Promise { - return this.delete(`/${id}`); + static async create(request: CreateResourceRequest): Promise { + return this.post('/', request); } } ``` -### 4. 导出服务(index.ts) - +### Step 4: 导出模块(index.ts) ```typescript /** - * 用户服务模块 + * 资源服务模块 + * + * @description + * 提供资源管理相关的功能,包括: + * - 资源列表查询 + * - 资源创建 */ -export { UserService } from './user.service'; -export type { User, CreateUserRequest } from './types'; +export { ResourceService } from './resource.service'; +export type * from './types'; ``` -### 5. 注册到服务层(services/index.ts) - +### Step 5: 注册到统一入口(services/index.ts) ```typescript -import { AuthService } from './auth'; -import { UserService } from './user'; // 新增 +import { ResourceService } from './resource'; const services = { - auth: AuthService, - user: UserService, // 新增 + // ... existing services + resource: ResourceService, // 新增 }; export default services; - -// 导出新服务 -export { UserService } from './user'; -export type { User, CreateUserRequest } from './user'; +export { ResourceService } from './resource'; +export type * from './resource'; ``` -## 最佳实践 - -### 1. 类型安全 +--- -✅ **推荐**:明确类型定义 -```typescript -static async getUser(id: string): Promise { - return this.get(`/${id}`); -} -``` +## 使用示例 -❌ **避免**:使用 `any` 类型 ```typescript -static async getUser(id: string): Promise { - return this.get(`/${id}`); -} -``` +import services from '@/lib/services'; -### 2. 错误处理 +// 调用服务 +const user = await services.auth.getUserInfo(); +const transactions = await services.transaction.getTransactions({ + page: 1, + page_size: 20 +}); -✅ **推荐**:使用类型化错误处理 -```typescript -import { UnauthorizedError, ValidationError, NetworkError } from '@/lib/services'; +// 错误处理 +import { UnauthorizedError, ValidationError } from '@/lib/services'; try { - const user = await services.auth.getCurrentUser(); - setUser(user); + await services.merchant.createAPIKey(request); } catch (error) { if (error instanceof UnauthorizedError) { router.push('/login'); - } else if (error instanceof NetworkError) { - toast.error('网络连接失败,请检查您的网络'); } else if (error instanceof ValidationError) { toast.error(error.message); - console.log('验证错误详情:', error.details); - } else { - toast.error('操作失败,请稍后重试'); } } ``` -### 3. 请求取消 - -✅ **推荐**:在组件卸载时取消请求 -```typescript -import { useEffect } from 'react'; -import { cancelAllRequests } from '@/lib/services'; - -function MyComponent() { - useEffect(() => { - // 组件卸载时取消所有请求 - return () => { - cancelAllRequests(); - }; - }, []); -} -``` - -### 4. 参数校验 +--- -✅ **推荐**:使用 ValidationError 返回详细信息 -```typescript -static async createUser(data: CreateUserRequest): Promise { - if (!data.email) { - throw new ValidationError('邮箱不能为空', { field: 'email' }); - } - return this.post('/', data); -} -``` - -### 5. 文档注释 - -✅ **推荐**:使用 JSDoc 注释 -```typescript -/** - * 获取用户信息 - * @param id - 用户 ID - * @returns 用户信息 - * @throws {NotFoundError} 当用户不存在时 - * @throws {UnauthorizedError} 当未登录时 - */ -static async getUser(id: string): Promise { - return this.get(`/${id}`); -} -``` - -### 6. 使用统一的 services 对象 - -✅ **推荐**:统一风格 -```typescript -import services from '@/lib/services'; +## 注意事项 -// 所有服务调用都通过 services 对象 -const user = await services.auth.getCurrentUser(); -const orders = await services.order.getAll(); -``` +### 禁止事项 +- ❌ 使用 `any` 类型 +- ❌ 直接使用 `apiClient`(除非在 `BaseService` 内部) +- ❌ 绕过 BaseService 实现 HTTP 请求 +- ❌ 在业务组件中直接导入 axios -❌ **避免**:混用两种方式 -```typescript -import services, { AdminService } from '@/lib/services'; +### 必须遵循 +- ✅ 所有服务继承 `BaseService` +- ✅ 使用 `protected static readonly basePath` +- ✅ 方法必须有完整的 JSDoc 注释 +- ✅ 类型定义必须在 `types.ts` 中 +- ✅ 通过 `services` 对象调用服务 -// 不要混用 -const configs = await services.admin.listSystemConfigs(); -const profile = await AdminService.getSystemConfig('app.name'); // 不推荐 -``` +--- -## 注意事项 +## 相关文档 -1. **禁止使用 `any` 类型** - 所有类型必须明确定义 -2. **使用 `unknown` 必须断言** - 需要类型断言或类型守卫 -3. **继承 BaseService** - 新服务必须继承 `BaseService` 类 -4. **设置 basePath** - 每个服务必须使用 `readonly` 设置 `basePath` -5. **导出类型** - 在 `index.ts` 中导出所有类型定义 -6. **统一错误处理** - 使用 `errors.ts` 中定义的错误类型 -7. **环境变量** - 使用 `NEXT_PUBLIC_API_URL` 配置 API 地址 -8. **请求取消** - 在适当的时机取消不需要的请求 -9. **服务完整性** - 前端服务层已完整实现所有业务接口 - - `GET /api/v1/health` - 健康检查(通常不需要前端调用) +- [TypeScript 官方文档](https://www.typescriptlang.org/) +- [Axios 文档](https://axios-http.com/) +- [JSDoc 规范](https://jsdoc.app/) diff --git a/frontend/lib/services/admin/index.ts b/frontend/lib/services/admin/index.ts index f62a9094..370e310f 100644 --- a/frontend/lib/services/admin/index.ts +++ b/frontend/lib/services/admin/index.ts @@ -1,8 +1,30 @@ /** * 管理员服务模块 * - * 提供系统配置和用户支付配置管理功能 + * @description + * 提供系统配置和用户支付配置管理功能,包括: + * - 系统配置管理(创建、查询、更新、删除) + * - 用户支付配置管理(创建、查询、更新、删除) + * + * @remarks * 所有接口都需要管理员权限 + * + * @example + * ```typescript + * import { AdminService } from '@/lib/services'; + * + * // 获取系统配置列表 + * const configs = await AdminService.listSystemConfigs(); + * + * // 创建用户支付配置 + * await AdminService.createUserPayConfig({ + * level: 1, + * min_score: 0, + * max_score: 999, + * daily_limit: 10000, + * fee_rate: 0.01 + * }); + * ``` */ export { AdminService } from './admin.service'; diff --git a/frontend/lib/services/auth/index.ts b/frontend/lib/services/auth/index.ts index 00d6f375..4dc3cf63 100644 --- a/frontend/lib/services/auth/index.ts +++ b/frontend/lib/services/auth/index.ts @@ -1,5 +1,28 @@ /** * 认证服务模块 + * + * @description + * 提供 OAuth 认证相关的功能,包括: + * - 获取登录 URL + * - 处理 OAuth 回调 + * - 获取用户信息 + * - 用户登出 + * - 发起登录流程 + * + * @example + * ```typescript + * import { AuthService } from '@/lib/services'; + * + * // 发起登录 + * await AuthService.initiateLogin(); + * + * // 获取用户信息 + * const user = await AuthService.getUserInfo(); + * console.log('当前用户:', user.username); + * + * // 登出 + * await AuthService.logout(); + * ``` */ export { AuthService } from './auth.service'; diff --git a/frontend/lib/services/config/index.ts b/frontend/lib/services/config/index.ts index 8775df2d..460e6362 100644 --- a/frontend/lib/services/config/index.ts +++ b/frontend/lib/services/config/index.ts @@ -1,6 +1,18 @@ /** * 配置服务模块 - * 导出配置相关的服务和类型 + * + * @description + * 提供系统公共配置相关的功能,包括: + * - 获取公共配置(争议时间窗口等) + * + * @example + * ```typescript + * import { ConfigService } from '@/lib/services'; + * + * // 获取公共配置 + * const config = await ConfigService.getPublicConfig(); + * console.log('争议时间窗口:', config.dispute_time_window_hours, '小时'); + * ``` */ export { ConfigService } from './config.service'; diff --git a/frontend/lib/services/core/base.service.ts b/frontend/lib/services/core/base.service.ts index b2864818..b1ea363d 100644 --- a/frontend/lib/services/core/base.service.ts +++ b/frontend/lib/services/core/base.service.ts @@ -30,7 +30,7 @@ export class BaseService { * @returns 完整路径 */ protected static getFullPath(path: string): string { - return `${this.basePath}${path}`; + return `${ this.basePath }${ path }`; } /** @@ -143,5 +143,44 @@ export class BaseService { ); return response.data.data; } + + /** + * 原始 GET 请求(用于特殊 API 端点) + * @template T - 响应数据类型 + * @param url - 完整 URL + * @param params - 查询参数 + * @returns 响应数据(不经过 response.data.data 解包) + * + * @remarks + * 仅用于不遵循标准响应格式的特殊端点(如 /api.php) + */ + protected static async rawGet( + url: string, + params?: unknown, + ): Promise { + const response = await apiClient.get( + url, + { params } as InternalAxiosRequestConfig, + ); + return response.data; + } + + /** + * 原始 POST 请求(用于特殊 API 端点) + * @template T - 响应数据类型 + * @param url - 完整 URL + * @param data - 请求数据 + * @returns 响应数据(不经过 response.data.data 解包) + * + * @remarks + * 仅用于不遵循标准响应格式的特殊端点(如 /api.php) + */ + protected static async rawPost( + url: string, + data?: unknown, + ): Promise { + const response = await apiClient.post(url, data); + return response.data; + } } diff --git a/frontend/lib/services/dispute/dispute.service.ts b/frontend/lib/services/dispute/dispute.service.ts index dba0cfe1..b41b95a5 100644 --- a/frontend/lib/services/dispute/dispute.service.ts +++ b/frontend/lib/services/dispute/dispute.service.ts @@ -4,6 +4,7 @@ import type { ListDisputesResponse, RefundReviewRequest, CloseDisputeRequest, + CreateDisputeRequest, } from './types'; /** @@ -13,6 +14,26 @@ import type { export class DisputeService extends BaseService { protected static readonly basePath = '/api/v1/order'; + /** + * 创建争议 + * @param data - 争议信息 + * @returns void + * @throws {UnauthorizedError} 当未登录时 + * @throws {NotFoundError} 当订单不存在或不符合争议条件时 + * @throws {ValidationError} 当参数验证失败时 + * + * @example + * ```typescript + * await DisputeService.createDispute({ + * order_id: 123, + * reason: '商品质量问题' + * }); + * ``` + */ + static async createDispute(data: CreateDisputeRequest): Promise { + return this.post('/dispute', data); + } + /** * 查询用户发起的争议列表 * @param params - 查询参数 diff --git a/frontend/lib/services/dispute/index.ts b/frontend/lib/services/dispute/index.ts index b5b0bf31..325ca6d5 100644 --- a/frontend/lib/services/dispute/index.ts +++ b/frontend/lib/services/dispute/index.ts @@ -1,6 +1,30 @@ /** * 争议服务模块 - * 导出争议相关的服务和类型 + * + * @description + * 提供争议处理相关的功能,包括: + * - 创建争议 + * - 查询用户发起的争议列表 + * - 查询商户的争议列表 + * - 退款审核(商户) + * - 关闭争议(用户) + * + * @example + * ```typescript + * import { DisputeService } from '@/lib/services'; + * + * // 创建争议 + * await DisputeService.createDispute({ + * order_id: 123, + * reason: '商品质量问题' + * }); + * + * // 查询争议列表 + * const disputes = await DisputeService.listDisputes({ + * page: 1, + * page_size: 20 + * }); + * ``` */ export { DisputeService } from './dispute.service'; diff --git a/frontend/lib/services/dispute/types.ts b/frontend/lib/services/dispute/types.ts index 9cd7efa9..062dc6c7 100644 --- a/frontend/lib/services/dispute/types.ts +++ b/frontend/lib/services/dispute/types.ts @@ -88,3 +88,13 @@ export interface CloseDisputeRequest { /** 争议 ID */ dispute_id: number; } + +/** + * 创建争议请求 + */ +export interface CreateDisputeRequest { + /** 订单 ID */ + order_id: number; + /** 争议原因(最大 100 字符) */ + reason: string; +} diff --git a/frontend/lib/services/index.ts b/frontend/lib/services/index.ts index 667de2ab..b77c2e69 100644 --- a/frontend/lib/services/index.ts +++ b/frontend/lib/services/index.ts @@ -102,7 +102,6 @@ export type { OrderStatus, TransactionQueryParams, TransactionListResponse, - CreateDisputeRequest, TransferRequest, TransferResponse, } from './transaction'; @@ -118,6 +117,7 @@ export type { ListDisputesResponse, RefundReviewRequest, CloseDisputeRequest, + CreateDisputeRequest, } from './dispute'; // 配置服务 diff --git a/frontend/lib/services/merchant/merchant.service.ts b/frontend/lib/services/merchant/merchant.service.ts index b8fd866b..374db592 100644 --- a/frontend/lib/services/merchant/merchant.service.ts +++ b/frontend/lib/services/merchant/merchant.service.ts @@ -1,6 +1,4 @@ import { BaseService } from '../core/base.service'; -import apiClient from '../core/api-client'; -import type { InternalAxiosRequestConfig } from 'axios'; import type { MerchantAPIKey, CreateAPIKeyRequest, @@ -362,12 +360,7 @@ export class MerchantService extends BaseService { static async queryMerchantOrder( params: QueryMerchantOrderRequest ): Promise { - // 直接使用 apiClient,因为这个接口不在标准 RESTful 路径下 - const response = await apiClient.get( - '/epay/api.php', - { params } as InternalAxiosRequestConfig - ); - return response.data; + return this.rawGet('/epay/api.php', params); } /** @@ -408,12 +401,7 @@ export class MerchantService extends BaseService { static async refundMerchantOrder( params: RefundMerchantOrderRequest ): Promise { - // 直接使用 apiClient,因为这个接口不在标准 RESTful 路径下 - const response = await apiClient.post( - '/epay/api.php', - params - ); - return response.data; + return this.rawPost('/epay/api.php', params); } } diff --git a/frontend/lib/services/transaction/index.ts b/frontend/lib/services/transaction/index.ts index 707192a9..c9387822 100644 --- a/frontend/lib/services/transaction/index.ts +++ b/frontend/lib/services/transaction/index.ts @@ -1,6 +1,32 @@ /** * 交易服务模块 - * 导出交易相关的服务和类型 + * + * @description + * 提供交易相关的功能,包括: + * - 查询交易记录列表(分页) + * - 用户转账 + * + * @example + * ```typescript + * import { TransactionService } from '@/lib/services'; + * + * // 查询交易记录 + * const result = await TransactionService.getTransactions({ + * page: 1, + * page_size: 20, + * type: 'receive', + * status: 'success' + * }); + * + * // 用户转账 + * await TransactionService.transfer({ + * recipient_id: 123, + * recipient_username: 'user123', + * amount: 100.50, + * pay_key: '123456', + * remark: '转账备注' + * }); + * ``` */ export { TransactionService } from './transaction.service'; @@ -10,7 +36,6 @@ export type { OrderStatus, TransactionQueryParams, TransactionListResponse, - CreateDisputeRequest, TransferRequest, TransferResponse, } from './types'; diff --git a/frontend/lib/services/transaction/transaction.service.ts b/frontend/lib/services/transaction/transaction.service.ts index 1468c0e0..6cae7710 100644 --- a/frontend/lib/services/transaction/transaction.service.ts +++ b/frontend/lib/services/transaction/transaction.service.ts @@ -1,7 +1,7 @@ import { BaseService } from '../core/base.service'; import apiClient from '../core/api-client'; import type { ApiResponse } from '../core/types'; -import type { TransactionQueryParams, TransactionListResponse, CreateDisputeRequest, TransferRequest, TransferResponse } from './types'; +import type { TransactionQueryParams, TransactionListResponse, TransferRequest, TransferResponse } from './types'; /** * 交易服务 @@ -30,16 +30,6 @@ export class TransactionService extends BaseService { static async getTransactions(params: TransactionQueryParams): Promise { return this.post('/transactions', params); } - /** - * 创建争议 - * @param data - 争议信息 - * @throws {UnauthorizedError} 当未登录时 - * @throws {NotFoundError} 当订单不存在或不符合争议条件时 - * @throws {ValidationError} 当参数验证失败时 - */ - static async createDispute(data: CreateDisputeRequest): Promise { - return this.post('/dispute', data); - } /** * 用户转账 diff --git a/frontend/lib/services/transaction/types.ts b/frontend/lib/services/transaction/types.ts index 7b271dbb..96ddce52 100644 --- a/frontend/lib/services/transaction/types.ts +++ b/frontend/lib/services/transaction/types.ts @@ -94,17 +94,6 @@ export interface TransactionListResponse { orders: Order[]; } - -/** - * 创建争议请求 - */ -export interface CreateDisputeRequest { - /** 订单 ID */ - order_id: number; - /** 争议原因(最大 100 字符) */ - reason: string; -} - /** * 用户转账请求 */ diff --git a/frontend/lib/services/user/index.ts b/frontend/lib/services/user/index.ts index 34051885..09462dd2 100644 --- a/frontend/lib/services/user/index.ts +++ b/frontend/lib/services/user/index.ts @@ -1,6 +1,17 @@ /** * 用户服务模块 - * 导出用户相关的服务和类型 + * + * @description + * 提供用户个人设置相关的功能,包括: + * - 更新支付密钥 + * + * @example + * ```typescript + * import { UserService } from '@/lib/services'; + * + * // 更新支付密钥 + * await UserService.updatePayKey('123456'); + * ``` */ export { UserService } from './user.service'; diff --git a/frontend/lib/services/user/user.service.ts b/frontend/lib/services/user/user.service.ts index c45c198b..aeed82de 100644 --- a/frontend/lib/services/user/user.service.ts +++ b/frontend/lib/services/user/user.service.ts @@ -1,5 +1,4 @@ import { BaseService } from '../core/base.service'; -import type { UpdatePayKeyRequest } from './types'; /** * 用户服务 @@ -25,8 +24,7 @@ export class UserService extends BaseService { * - 只能更新当前登录用户的支付密钥 */ static async updatePayKey(payKey: string): Promise { - const request: UpdatePayKeyRequest = { pay_key: payKey }; - return this.put('/pay-key', request); + return this.put('/pay-key', { pay_key: payKey }); } } diff --git a/frontend/lib/utils/error-handling.ts b/frontend/lib/utils/error-handling.ts new file mode 100644 index 00000000..b6e0e54a --- /dev/null +++ b/frontend/lib/utils/error-handling.ts @@ -0,0 +1,61 @@ +import { isCancelError } from '@/lib/services' +import { toast } from 'sonner' + +/** + * 错误处理选项 + */ +interface HandleContextErrorOptions { + /** 是否显示 toast 提示 */ + showToast?: boolean + /** 是否在控制台记录错误 */ + logError?: boolean +} + +/** + * 统一处理 Context 中的错误 + * + * @param error - 捕获的错误对象 + * @param defaultMessage - 默认错误消息 + * @param options - 错误处理选项 + * @returns 标准化的 Error 对象 + * + * @example + * ```typescript + * try { + * await fetchData() + * } catch (error) { + * const errorObject = handleContextError(error, '加载数据失败', { + * showToast: true, + * logError: true + * }) + * setError(errorObject) + * } + * ``` + */ +export function handleContextError( + error: unknown, + defaultMessage: string, + options: HandleContextErrorOptions = {} +): Error { + const { showToast = false, logError = true } = options + + // 取消的请求不算错误 + if (isCancelError(error)) { + return new Error('请求已取消') + } + + const errorMessage = error instanceof Error ? error.message : defaultMessage + const errorObject = error instanceof Error ? error : new Error(defaultMessage) + + if (logError) { + console.error(defaultMessage, error) + } + + if (showToast) { + toast.error(defaultMessage, { + description: errorMessage + }) + } + + return errorObject +} From 31982499904f36a8088323e0ea376fc8203fe781 Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Mon, 22 Dec 2025 09:15:51 +0800 Subject: [PATCH 024/366] fix: loadAPIKeys is assigned a value but never used --- frontend/components/common/merchant/merchant-online.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/common/merchant/merchant-online.tsx b/frontend/components/common/merchant/merchant-online.tsx index cad25be4..9f36d2a7 100644 --- a/frontend/components/common/merchant/merchant-online.tsx +++ b/frontend/components/common/merchant/merchant-online.tsx @@ -49,7 +49,7 @@ const DEVICE_CONFIG = { export function MerchantOnline() { const searchParams = useSearchParams() - const { apiKeys, loading: loadingKeys, loadAPIKeys } = useMerchant() + const { apiKeys, loading: loadingKeys } = useMerchant() const apiKeyId = searchParams.get("apiKeyId") const selectedKey = apiKeys.find(k => k.id.toString() === apiKeyId) || null From 732f727edb68cf049dbfe765e694cd7f06e099ef Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Mon, 22 Dec 2025 11:06:53 +0800 Subject: [PATCH 025/366] fix(dashboard): add top customers API with order type filtering --- internal/apps/dashboard/routers.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/apps/dashboard/routers.go b/internal/apps/dashboard/routers.go index 98bb8c09..ef1eee7f 100644 --- a/internal/apps/dashboard/routers.go +++ b/internal/apps/dashboard/routers.go @@ -123,6 +123,7 @@ func GetTopCustomers(c *gin.Context) { Joins("LEFT JOIN users ON orders.payer_user_id = users.id"). Where("orders.payee_user_id = ?", user.ID). Where("orders.status = ?", model.OrderStatusSuccess). + Where("orders.type in ?", []model.OrderType{model.OrderTypePayment, model.OrderTypeOnline}). Where("orders.created_at >= ? AND orders.created_at < ?", startDate, endDate). Group("orders.payer_user_id, users.username"). Order("total_amount DESC"). From debdb3332f744271277f904da879c8d5db3f00ec Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Mon, 22 Dec 2025 11:07:15 +0800 Subject: [PATCH 026/366] feat(merchant): simplify UpdateAPIKey to update all fields directly --- internal/apps/merchant/api_key/routers.go | 26 ++++++----------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/internal/apps/merchant/api_key/routers.go b/internal/apps/merchant/api_key/routers.go index 93af24ac..410f8aef 100644 --- a/internal/apps/merchant/api_key/routers.go +++ b/internal/apps/merchant/api_key/routers.go @@ -131,26 +131,12 @@ func UpdateAPIKey(c *gin.Context) { apiKey, _ := util.GetFromContext[*model.MerchantAPIKey](c, merchant.APIKeyObjKey) - updates := make(map[string]interface{}) - if req.AppName != "" { - updates["app_name"] = req.AppName - } - if req.AppHomepageURL != "" { - updates["app_homepage_url"] = req.AppHomepageURL - } - if req.AppDescription != "" { - updates["app_description"] = req.AppDescription - } - if req.RedirectURI != "" { - updates["redirect_uri"] = req.RedirectURI - } - if req.NotifyURL != "" { - updates["notify_url"] = req.NotifyURL - } - - if len(updates) == 0 { - c.JSON(http.StatusBadRequest, util.Err(NoFieldsToUpdate)) - return + updates := map[string]interface{}{ + "app_name": req.AppName, + "app_homepage_url": req.AppHomepageURL, + "app_description": req.AppDescription, + "redirect_uri": req.RedirectURI, + "notify_url": req.NotifyURL, } if err := db.DB(c.Request.Context()). From f34e0a0e9456ee7d553596ed76ff050115b68eeb Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Mon, 22 Dec 2025 13:02:36 +0800 Subject: [PATCH 027/366] feat: public payment configuration --- internal/router/router.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/router/router.go b/internal/router/router.go index 0b3dede9..726bdee2 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -170,6 +170,7 @@ func Serve() { configRouter := apiV1Router.Group("/config") { configRouter.GET("/public", publicconfig.GetPublicConfig) + configRouter.GET("/user-pay", system_config.ListSystemConfigs) } // MerchantAPIKey From 5f035f5b7c357f8a3039412ddb88539a7b564eaf Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Mon, 22 Dec 2025 13:57:39 +0800 Subject: [PATCH 028/366] fix: public payment configuration --- internal/router/router.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/router/router.go b/internal/router/router.go index 726bdee2..090c4ecf 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -170,7 +170,7 @@ func Serve() { configRouter := apiV1Router.Group("/config") { configRouter.GET("/public", publicconfig.GetPublicConfig) - configRouter.GET("/user-pay", system_config.ListSystemConfigs) + configRouter.GET("/user-pay", user_pay_config.ListUserPayConfigs) } // MerchantAPIKey From edc3a1358bdb8368238b0af2904b85fbdc03eb61 Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Mon, 22 Dec 2025 14:21:16 +0800 Subject: [PATCH 029/366] feat: some minor optimizations --- frontend/app/(main)/admin/user_pay/page.tsx | 6 +- frontend/components/auth/login-form.tsx | 158 ++-- frontend/components/auth/login-page.tsx | 4 +- .../common/admin/user-pay-configs.tsx | 59 +- .../components/common/home/data-panel.tsx | 232 ++++-- .../components/common/home/overview-panel.tsx | 673 +++++++++++------- .../common/merchant/merchant-online.tsx | 69 +- .../common/pay/paying/paying-main.tsx | 6 +- .../components/common/settings/profile.tsx | 115 ++- frontend/components/home/footer-section.tsx | 4 +- frontend/components/layout/header.tsx | 7 +- frontend/components/ui/progress.tsx | 31 + frontend/contexts/admin-context.tsx | 10 +- frontend/hooks/use-public-config.ts | 6 +- frontend/lib/services/README.md | 2 +- frontend/lib/services/admin/admin.service.ts | 26 +- frontend/lib/services/admin/index.ts | 7 +- frontend/lib/services/admin/types.ts | 28 +- .../lib/services/config/config.service.ts | 20 + frontend/lib/services/config/index.ts | 5 + .../services/dashboard/dashboard.service.ts | 69 ++ frontend/lib/services/dashboard/index.ts | 29 + frontend/lib/services/dashboard/types.ts | 57 ++ frontend/lib/services/index.ts | 16 +- .../lib/services/merchant/merchant.service.ts | 8 +- frontend/lib/services/merchant/types.ts | 8 +- frontend/package.json | 1 + frontend/pnpm-lock.yaml | 26 + frontend/proxy.ts | 71 ++ 29 files changed, 1250 insertions(+), 503 deletions(-) create mode 100644 frontend/components/ui/progress.tsx create mode 100644 frontend/lib/services/dashboard/dashboard.service.ts create mode 100644 frontend/lib/services/dashboard/index.ts create mode 100644 frontend/lib/services/dashboard/types.ts create mode 100644 frontend/proxy.ts diff --git a/frontend/app/(main)/admin/user_pay/page.tsx b/frontend/app/(main)/admin/user_pay/page.tsx index 38a4eb32..8d0895b3 100644 --- a/frontend/app/(main)/admin/user_pay/page.tsx +++ b/frontend/app/(main)/admin/user_pay/page.tsx @@ -9,7 +9,7 @@ import { useUser } from "@/contexts/user-context" import { AdminProvider, useAdmin } from "@/contexts/admin-context" -/* 用户支付配置页面 */ +/* 用户积分配置页面 */ export default function UserPayConfigPage() { return ( @@ -18,7 +18,7 @@ export default function UserPayConfigPage() { ) } -/* 用户支付配置页面内容 */ +/* 用户积分配置页面内容 */ function UserPayConfigPageContent() { const { user, loading } = useUser() const { refetchUserPayConfigs } = useAdmin() @@ -31,7 +31,7 @@ function UserPayConfigPageContent() { /* 等待用户信息加载完成 */ if (loading) { - return + return } /* 权限检查:只有管理员才能访问 */ diff --git a/frontend/components/auth/login-form.tsx b/frontend/components/auth/login-form.tsx index 41de0a49..8a9ffdfa 100644 --- a/frontend/components/auth/login-form.tsx +++ b/frontend/components/auth/login-form.tsx @@ -1,9 +1,11 @@ "use client" -import { useState } from "react" +import { useState, useEffect } from "react" import { toast } from "sonner" import { Spinner } from "@/components/ui/spinner" import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { motion, useAnimation } from "motion/react" import { Dialog, DialogContent, @@ -42,9 +44,39 @@ export function LoginForm({ ...props }: React.ComponentProps<"div">) { const [isLoading, setIsLoading] = useState(false) + const [hasAgreed, setHasAgreed] = useState(false) + const controls = useAnimation() + + + useEffect(() => { + const agreed = localStorage.getItem("loginPromptAgreed") === "true" + if (agreed) { + setHasAgreed(true) + } + }, []) + + const handleAgreementChange = (checked: boolean | string) => { + const isChecked = checked === true + setHasAgreed(isChecked) + if (isChecked) { + localStorage.setItem("loginPromptAgreed", "true") + } else { + localStorage.removeItem("loginPromptAgreed") + } + } /* 处理登录 */ const handleLogin = async () => { + if (!hasAgreed) { + toast.error("请先阅读并勾选服务条款和隐私政策") + controls.start({ + x: [0, -4, 4, -4, 4, 0], + color: ["#ef4444", "inherit"], + transition: { duration: 0.5 } + }) + return + } + setIsLoading(true) try { await services.auth.initiateLogin() @@ -67,69 +99,83 @@ export function LoginForm({ -
-
- 登录即代表您同意我们的{" "} - - - - - - - 服务条款 - 请仔细阅读以下条款,使用本服务即表示您接受。 - - - {termsSections.map((section) => ( - - {section.title} - {section.content} - - ))} - - - - {" "}及{" "} - - - - - - - 隐私政策 - 我们重视您的隐私,以下说明信息如何收集与使用。 - - - {privacySections.map((section) => ( - - {section.title} - {section.content} - - ))} - - - - . -
+ + + +
) } diff --git a/frontend/components/auth/login-page.tsx b/frontend/components/auth/login-page.tsx index ba90671e..ccb1a37b 100644 --- a/frontend/components/auth/login-page.tsx +++ b/frontend/components/auth/login-page.tsx @@ -221,14 +221,14 @@ export function LoginPage() { setSetupStep('password') setConfirmPayKey('') }} - className="flex-1 h-10 rounded-full tracking-wide text-sm font-bold transition-all active:scale-95" + className="flex-1 h-9 rounded-full tracking-wide text-sm font-bold transition-all active:scale-95" > 返回 )}
+
+ +
+ { + const value = e.target.value + if (value === '') { + onEditDataChange('score_rate', '0') + return + } + + const numValue = parseInt(value) + if (isNaN(numValue)) { + return + } + + if (numValue >= 0 && numValue <= 100) { + onEditDataChange('score_rate', (numValue / 100).toString()) + } + }} + className="text-xs text-right h-4 w-16 px-0 mr-3 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-[12px]" + /> +

%

+
+
+
@@ -214,13 +250,13 @@ function PayConfigDetailPanel({ /** - * 用户支付配置管理组件 + * 用户积分配置管理组件 * * @example * ```tsx * * ``` - * @returns {React.ReactNode} 用户支付配置管理组件 + * @returns {React.ReactNode} 用户积分配置管理组件 */ export function UserPayConfigs() { const { @@ -237,6 +273,7 @@ export function UserPayConfigs() { max_score: config.max_score, daily_limit: config.daily_limit, fee_rate: config.fee_rate.toString(), + score_rate: config.score_rate.toString(), }) const handleSave = async (config: UserPayConfig, editData: Partial) => { @@ -247,6 +284,7 @@ export function UserPayConfigs() { max_score: editData.max_score, daily_limit: editData.daily_limit, fee_rate: editData.fee_rate?.toString() ?? config.fee_rate.toString(), + score_rate: editData.score_rate?.toString() ?? config.score_rate.toString(), }) await refetchUserPayConfigs() } @@ -257,7 +295,7 @@ export function UserPayConfigs() { return ( - title="支付配置" + title="积分配置" data={configs} loading={loading} error={error} @@ -266,14 +304,15 @@ export function UserPayConfigs() { onSave={handleSave} onDelete={handleDelete} getId={(config) => config.id} - emptyDescription="未发现支付配置" + emptyDescription="未发现积分配置" loadingDescription="配置加载中" columns={[ { header: "等级", cell: (item) => Level {item.level}, width: "min-w-[80px]", align: "left" }, { header: "最低分", cell: (item) => item.min_score, width: "min-w-[200px]", align: "left" }, { header: "最高分", cell: (item) => item.max_score || "无限制", width: "min-w-[200px]", align: "left" }, - { header: "每日限额", cell: (item) => item.daily_limit ? `LDC ${item.daily_limit.toLocaleString()}` : "无限制", width: "min-w-[200px]", align: "left" }, - { header: "费率", cell: (item) => `${(Number(item.fee_rate) * 100).toFixed(2)}%`, width: "min-w-[200px]", align: "left" }, + { header: "每日限额", cell: (item) => item.daily_limit ? `LDC ${ item.daily_limit.toLocaleString() }` : "无限制", width: "min-w-[200px]", align: "left" }, + { header: "费率", cell: (item) => `${ (Number(item.fee_rate) * 100).toFixed(2) }%`, width: "min-w-[200px]", align: "left" }, + { header: "分数转化率", cell: (item) => `${ (Number(item.score_rate) * 100).toFixed(2) }%`, width: "min-w-[200px]", align: "left" }, { header: "更新时间", cell: (item) => {formatDateTime(item.updated_at)}, width: "min-w-[200px]", align: "left" }, ]} renderDetail={({ selected, hovered, editData, onEditDataChange, onSave, saving }) => ( diff --git a/frontend/components/common/home/data-panel.tsx b/frontend/components/common/home/data-panel.tsx index 104e579b..4d6c4e40 100644 --- a/frontend/components/common/home/data-panel.tsx +++ b/frontend/components/common/home/data-panel.tsx @@ -1,27 +1,28 @@ -import { Area, AreaChart, XAxis, YAxis } from "recharts" +import * as React from "react" +import { Area, AreaChart, XAxis } from "recharts" import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart" -import { CountingNumber } from '@/components/animate-ui/primitives/texts/counting-number'; +import { CountingNumber } from '@/components/animate-ui/primitives/texts/counting-number' +import { Skeleton } from "@/components/ui/skeleton" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" +import { Info } from "lucide-react" import { useUser } from "@/contexts/user-context" - - -const chartData = [ - { date: "10-28", total: 0 }, - { date: "10-29", total: 99 }, - { date: "10-30", total: 20 }, - { date: "10-31", total: 90 }, - { date: "11-01", total: 20 }, - { date: "11-02", total: 100 }, - { date: "11-03", total: 200 }, - { date: "11-04", total: 0.01 }, - { date: "11-05", total: 0 }, -] +import { DashboardService } from "@/lib/services" +import type { DailyStatsItem } from "@/lib/services" const chartConfig = { total: { - label: "积分值", + label: "积分总额", color: "hsl(217, 91%, 60%)", }, + income: { + label: "收入", + color: "#10b981", // emerald-500 + }, + expense: { + label: "支出", + color: "#f43f5e", // rose-500 + }, } satisfies ChartConfig /** @@ -30,73 +31,176 @@ const chartConfig = { * @returns {React.ReactNode} 数据面板组件 */ export function DataPanel() { - const { user, loading } = useUser() + const { user, loading: userLoading } = useUser() + const [dailyStats, setDailyStats] = React.useState([]) + const [loading, setLoading] = React.useState(true) const availableBalance = parseFloat(user?.available_balance || '0') const remainQuota = parseFloat(user?.remain_quota || '0') + /* 获取每日统计数据 */ + React.useEffect(() => { + const fetchDailyStats = async () => { + setLoading(true) + const data = await DashboardService.getDailyStats(7) + setDailyStats(data) + setLoading(false) + } + + if (!userLoading && user) { + fetchDailyStats() + } + }, [userLoading, user]) + + /* 根据当前余额和每日收支反推历史积分总额 */ + const chartData = React.useMemo(() => { + if (!dailyStats.length || userLoading) return [] + + const sortedStats = [...dailyStats].sort((a, b) => + new Date(b.date).getTime() - new Date(a.date).getTime() + ) + + let currentBalance = availableBalance + const balanceHistory: Array<{ date: string; total: number; income: number; expense: number }> = [] + + for (let i = 0; i < sortedStats.length; i++) { + const stat = sortedStats[i] + const income = parseFloat(stat.income || '0') + const expense = parseFloat(stat.expense || '0') + + balanceHistory.unshift({ + date: new Date(stat.date).toLocaleDateString('zh-CN', { + month: 'numeric', + day: 'numeric' + }), + total: Math.max(0, currentBalance), + income, + expense + }) + + currentBalance = currentBalance - income + expense + } + + return balanceHistory + }, [dailyStats, availableBalance, userLoading]) + return (
-

积分总额

- - - - - - - - - - - - } - /> - 积分趋势 + + {loading || userLoading ? ( +
+ +
+ ) : chartData.length > 0 ? ( + + - - + > + + + + + + + + + + + + + + + + } + /> + + + +
+
+ ) : ( +
+

暂无统计数据

+
+ )}
LINUX DO Credits
- {loading ? '-' : } + {userLoading ? '-' : }
-
今日剩余额度
+
+ 今日剩余额度 + + + + + + +

每日积分消耗额度限制,每日 0 点自动重置

+
+
+
+
- {loading ? '-' : } + {userLoading ? '-' : }
diff --git a/frontend/components/common/home/overview-panel.tsx b/frontend/components/common/home/overview-panel.tsx index 60717549..bb742d5f 100644 --- a/frontend/components/common/home/overview-panel.tsx +++ b/frontend/components/common/home/overview-panel.tsx @@ -2,16 +2,95 @@ import * as React from "react" import { Button } from "@/components/ui/button" import { Skeleton } from "@/components/ui/skeleton" import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card" -import { Eye, EyeOff, RefreshCcw } from "lucide-react" +import { Eye, EyeOff } from "lucide-react" import { ScrollArea } from "@/components/ui/scroll-area" -import { DisputeService, TransactionService } from "@/lib/services" -import type { DisputeWithOrder, Order } from "@/lib/services" +import { DisputeService, TransactionService, DashboardService } from "@/lib/services" +import type { DisputeWithOrder, Order, DailyStatsItem, TopCustomer } from "@/lib/services" import { RefundReviewDialog, CancelDisputeDialog } from "@/components/common/general/table-data" import { CountingNumber } from '@/components/animate-ui/primitives/texts/counting-number' import { useDisputeData } from "@/hooks/use-dispute" import { DisputeDialog } from "@/components/common/home/dispute-dialog" import { useRouter } from "next/navigation" import { Badge } from "@/components/ui/badge" +import { cn } from "@/lib/utils" +import { Progress } from "@/components/ui/progress" + +import { Spinner } from "@/components/ui/spinner" + +function AnimatedProgressBar({ value, className }: { value: number, className?: string }) { + const [progress, setProgress] = React.useState(0) + + React.useEffect(() => { + const timer = setTimeout(() => setProgress(value), 100) + return () => clearTimeout(timer) + }, [value]) + + return ( + + ) +} + +interface OverviewCardProps { + title: string + titleExtra?: React.ReactNode + headerExtra?: React.ReactNode + action?: React.ReactNode + loading?: boolean + onRefresh?: () => void + onViewAll?: () => void + children: React.ReactNode + className?: string +} + +function OverviewCard({ + title, + titleExtra, + headerExtra, + action, + loading, + onRefresh, + onViewAll, + children, + className +}: OverviewCardProps) { + return ( + + +
+
+ {title} + {titleExtra &&
{titleExtra}
} +
+
+ {action} + {onRefresh && ( + + )} +
+
+ {headerExtra &&
{headerExtra}
} +
+ + {children} + + +
+ 更新时间:{new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })} + {onViewAll && ( + + )} +
+
+
+ ) +} /** * 创建争议对象 @@ -49,7 +128,7 @@ const createDisputeOrder = (dispute: DisputeWithOrder, type: 'receive' | 'paymen */ const DisputeListSkeleton = () => (
- {Array.from({ length: 5 }).map((_, index) => ( + {Array.from({ length: 7 }).map((_, index) => (
@@ -68,7 +147,7 @@ const DisputeListSkeleton = () => ( */ const PaymentListSkeleton = () => (
- {Array.from({ length: 5 }).map((_, index) => ( + {Array.from({ length: 7 }).map((_, index) => (
@@ -150,217 +229,349 @@ function PaymentCard({ onViewAll }: { onViewAll: () => void }) { }, [fetchPayments]) return ( - - -
-
- 活动 -

{loading ? '-' : }

-
-
- - -
-
-
- - - {loading ? ( - - ) : payments.length > 0 ? ( -
- {payments.map((payment) => ( -
-
-

{payment.order_name}

-

LDC {payment.amount}

-
- - {getStatusText(payment.status)} - -
- ))} -
- ) : ( -
-

暂无积分活动记录

-
- )} -
- {isHidden && ( -
- + } + loading={loading} + onRefresh={fetchPayments} + onViewAll={onViewAll} + action={ + + } + > + + {loading ? ( + + ) : payments.length > 0 ? ( +
+ {payments.map((payment) => ( +
+
+

{payment.order_name}

+

LDC {payment.amount}

+
+ + {getStatusText(payment.status)} + +
+ ))} +
+ ) : ( +
+

暂无积分活动记录

)} - - -
- 更新时间:{new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })} - + + + {isHidden && ( +
+
- - + )} + ) } /** - * 积分总额卡片 - * 用于显示积分总额数据(所有成功活动的积分总额) + * 收入统计卡片 + * 用于显示每日收入统计 * - * @returns 积分总额列表卡片 + * @returns 收入统计卡片 */ -function TotalCard() { - const [total, setTotal] = React.useState(0) +function IncomeStatsCard() { + const [stats, setStats] = React.useState([]) const [loading, setLoading] = React.useState(true) + const [days] = React.useState(7) - const fetchTotal = React.useCallback(async () => { + const fetchDailyStats = React.useCallback(async () => { try { setLoading(true) - const response = await TransactionService.getTransactions({ page: 1, page_size: 100, status: 'success' }) - const total = response.orders.reduce((sum, order) => sum + parseFloat(order.amount || '0'), 0) - setTotal(total) + const data = await DashboardService.getDailyStats(days) + setStats(data) } catch (error) { - console.error('Failed to fetch total:', error) + console.error('Failed to fetch daily stats:', error) } finally { setLoading(false) } - }, []) + }, [days]) React.useEffect(() => { - fetchTotal() - }, [fetchTotal]) + fetchDailyStats() + }, [fetchDailyStats]) + + // 计算总收入 + const totalIncome = stats.reduce((sum, stat) => sum + parseFloat(stat.income || '0'), 0) + + // 找出最大收入值用于比例计算 + const maxIncome = Math.max(...stats.map(s => parseFloat(s.income || '0'))) return ( - - -
- 积分 - -
+ {loading ? ( ) : ( - <>LDC + <>LDC )}
- - -
-

图表暂时不可用

+ } + loading={loading} + onRefresh={fetchDailyStats} + > + {loading ? ( + +
+ {Array.from({ length: 7 }).map((_, index) => ( +
+
+ + +
+ +
+ ))} +
+
+ ) : stats.length > 0 ? ( + +
+ {stats.slice().reverse().map((stat, index) => { + const income = parseFloat(stat.income || '0') + const incomeWidth = maxIncome > 0 ? (income / maxIncome) * 100 : 0 + + return ( +
+
+ + {new Date(stat.date).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' })} + + + +{income.toFixed(2)} + +
+
+ +
+
+ ) + })} +
+
+ ) : ( +
+

暂无统计数据

- - - - 更新时间:{new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })} - - - + )} + ) } /** - * 净积分值卡片 - * 用于显示净积分值数据(积分收益 - 积分消耗) + * 支出统计卡片 + * 用于显示每日支出统计 * - * @returns 净积分值列表卡片 + * @returns 支出统计卡片 */ -function NetVolumeCard() { - const [netAmount, setNetAmount] = React.useState(0) +function ExpenseStatsCard() { + const [stats, setStats] = React.useState([]) const [loading, setLoading] = React.useState(true) + const [days] = React.useState(7) - const fetchNetVolume = React.useCallback(async () => { + const fetchDailyStats = React.useCallback(async () => { try { setLoading(true) - const [receiveResponse, paymentResponse] = await Promise.all([ - TransactionService.getTransactions({ page: 1, page_size: 100, type: 'receive', status: 'success' }), - TransactionService.getTransactions({ page: 1, page_size: 100, type: 'payment', status: 'success' }) - ]) - const receiveTotal = receiveResponse.orders.reduce((sum, order) => sum + parseFloat(order.amount || '0'), 0) - const paymentTotal = paymentResponse.orders.reduce((sum, order) => sum + parseFloat(order.amount || '0'), 0) - setNetAmount(receiveTotal - paymentTotal) + const data = await DashboardService.getDailyStats(days) + setStats(data) } catch (error) { - console.error('Failed to fetch net volume:', error) + console.error('Failed to fetch daily stats:', error) } finally { setLoading(false) } - }, []) + }, [days]) React.useEffect(() => { - fetchNetVolume() - }, [fetchNetVolume]) + fetchDailyStats() + }, [fetchDailyStats]) + + /* 计算总支出 */ + const totalExpense = stats.reduce((sum, stat) => sum + parseFloat(stat.expense || '0'), 0) + + /* 找出最大支出值用于比例计算 */ + const maxExpense = Math.max(...stats.map(s => parseFloat(s.expense || '0'))) return ( - - -
- 净积分值 - -
+ {loading ? ( ) : ( - <> - LDC - {netAmount < 0 && (赤字)} - + <>LDC )}
- - -
-

图表暂时不可用

+ } + loading={loading} + onRefresh={fetchDailyStats} + > + {loading ? ( + +
+ {Array.from({ length: 7 }).map((_, index) => ( +
+
+ + +
+ +
+ ))} +
+
+ ) : stats.length > 0 ? ( + +
+ {stats.slice().reverse().map((stat, index) => { + const expense = parseFloat(stat.expense || '0') + const expenseWidth = maxExpense > 0 ? (expense / maxExpense) * 100 : 0 + + return ( +
+
+ + {new Date(stat.date).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' })} + + + -{expense.toFixed(2)} + +
+
+ +
+
+ ) + })} +
+
+ ) : ( +
+

暂无统计数据

- - - - 更新时间:{new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })} - - - + )} + ) } + + /** - * 积分消耗最多的佬友卡片 - * 用于显示积分消耗最多的佬友数据 + * Top 客户卡片 + * 用于显示向当前用户付款最多的客户排行 * - * @returns 积分消耗最多的佬友卡片 + * @returns Top 客户卡片 */ function TopCustomersCard() { + const [customers, setCustomers] = React.useState([]) + const [loading, setLoading] = React.useState(true) + const [days] = React.useState(7) + const [limit] = React.useState(5) + + const fetchTopCustomers = React.useCallback(async () => { + try { + setLoading(true) + const data = await DashboardService.getTopCustomers(days, limit) + setCustomers(data) + } catch (error) { + console.error('Failed to fetch top customers:', error) + } finally { + setLoading(false) + } + }, [days, limit]) + + React.useEffect(() => { + fetchTopCustomers() + }, [fetchTopCustomers]) + + /* 找出最大金额用于显示进度条 */ + const maxAmount = customers && customers.length > 0 + ? Math.max(...customers.map(c => parseFloat(c.total_amount || '0'))) + : 0 + return ( - - -
- 积分收益排行 - -
-
- -
- - - -
-
- - 更新时间:上午12:29 - -
+ + + {loading ? ( +
+ {Array.from({ length: 5 }).map((_, index) => ( +
+
+
+ + +
+ +
+
+ + +
+
+ ))} +
+ ) : customers && customers.length > 0 ? ( +
+ {customers.map((customer, index) => { + const amount = parseFloat(customer.total_amount || '0') + const percentage = maxAmount > 0 ? (amount / maxAmount) * 100 : 0 + + return ( +
+
+
+ #{index + 1} + {customer.username} +
+
+ {amount.toFixed(2)} LDC +
+
+
+
+ +
+ + {customer.order_count}单 + +
+
+ ) + })} +
+ ) : ( +
+

暂无客户数据

+
+ )} +
+
) } @@ -376,52 +587,37 @@ function PendingDisputesCard({ onViewAll }: { onViewAll: () => void }) { }) return ( - - -
-
- 待处理的争议 -

{loading ? '-' : }

-
- -
-
- - - {loading ? ( - - ) : disputes.list.length > 0 ? ( -
- {disputes.list.map((dispute) => ( -
-
-

{dispute.order_name}

-

{dispute.payee_username}

-
-
- -
+ } + loading={loading} + onRefresh={handleRefresh} + onViewAll={onViewAll} + > + + {loading ? ( + + ) : disputes.list.length > 0 ? ( +
+ {disputes.list.map((dispute) => ( +
+
+

{dispute.order_name}

+

{dispute.payee_username}

- ))} -
- ) : ( -
-

暂无待处理的积分活动争议

-
- )} - - - -
- 更新时间:{new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })} - -
-
- +
+ +
+
+ ))} +
+ ) : ( +
+

暂无待处理的积分活动争议

+
+ )} + + ) } @@ -437,52 +633,37 @@ function MyDisputesCard({ onViewAll }: { onViewAll: () => void }) { }) return ( - - -
-
- 我发起的争议 -

{loading ? '-' : }

-
- -
-
- - - {loading ? ( - - ) : disputes.list.length > 0 ? ( -
- {disputes.list.map((dispute) => ( -
-
-

{dispute.order_name}

-

积分活动发起者正在处理争议

-
-
- -
+ } + loading={loading} + onRefresh={handleRefresh} + onViewAll={onViewAll} + > + + {loading ? ( + + ) : disputes.list.length > 0 ? ( +
+ {disputes.list.map((dispute) => ( +
+
+

{dispute.order_name}

+

积分活动发起者正在处理争议

- ))} -
- ) : ( -
- 暂无我发起的积分活动争议 -
- )} - - - -
- 更新时间:{new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })} - -
-
- +
+ +
+
+ ))} +
+ ) : ( +
+

暂无我发起的积分活动争议

+
+ )} + + ) } @@ -516,8 +697,8 @@ export function OverviewPanel() {
- - + + diff --git a/frontend/components/common/merchant/merchant-online.tsx b/frontend/components/common/merchant/merchant-online.tsx index 9f36d2a7..90a14dcf 100644 --- a/frontend/components/common/merchant/merchant-online.tsx +++ b/frontend/components/common/merchant/merchant-online.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useCallback } from "react" import { useRouter, useSearchParams } from "next/navigation" import { motion, AnimatePresence } from "motion/react" -import { Key, Smartphone, Tablet, Monitor, RotateCw, Plus, Trash2, Copy, ExternalLink, CreditCard, ArrowLeft, Loader2, Eye, EyeOff, Expand, Minimize, Sun, Moon } from "lucide-react" +import { Key, Smartphone, Tablet, Monitor, RotateCw, Plus, Trash2, Copy, ExternalLink, CreditCard, Store, Loader2, Eye, EyeOff, Expand, Minimize, Sun, Moon } from "lucide-react" import { toast } from "sonner" import { Button } from "@/components/ui/button" @@ -20,6 +20,7 @@ import { PayingInfo } from "@/components/common/pay/paying/paying-info" import { PayingNow } from "@/components/common/pay/paying/paying-now" import { MerchantSelector } from "@/components/common/merchant/merchant-selector" import { LoadingPage } from "@/components/layout/loading" +import { EmptyState } from "@/components/layout/empty" import { TransactionTableList } from "@/components/common/general/table-data" /** 设备预览配置 */ @@ -48,8 +49,13 @@ const DEVICE_CONFIG = { } as const export function MerchantOnline() { + const router = useRouter() const searchParams = useSearchParams() - const { apiKeys, loading: loadingKeys } = useMerchant() + const { apiKeys, loading: loadingKeys, loadAPIKeys } = useMerchant() + + useEffect(() => { + loadAPIKeys() + }, [loadAPIKeys]) const apiKeyId = searchParams.get("apiKeyId") const selectedKey = apiKeys.find(k => k.id.toString() === apiKeyId) || null @@ -59,22 +65,20 @@ export function MerchantOnline() { if (apiKeys.length === 0) { return ( -
-
+
+

在线流转

-
-
-
- -
-

暂未创建应用

-

- 您还没有创建任何应用。请前往集市中心创建应用,即可开始使用在线积分流转服务。 -

-
+
+ +
) } @@ -191,12 +195,6 @@ function MerchantOnlineContent({ apiKeys }: MerchantOnlineContentProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedKey?.client_id]) - - /* 处理返回 */ - const handleBack = () => { - router.back() - } - /* 处理创建 */ const handleCreate = async () => { if (!selectedKey) return @@ -299,8 +297,8 @@ function MerchantOnlineContent({ apiKeys }: MerchantOnlineContentProps) { } return ( -
-
+
+

在线流转

{apiKeys.length > 0 && ( @@ -310,30 +308,21 @@ function MerchantOnlineContent({ apiKeys }: MerchantOnlineContentProps) { onSelect={handleMerchantSelect} /> )} -
{!selectedKey ? ( -
-
- -
-

- {apiKeys.length === 0 ? "暂未创建应用" : "未选择应用"} -

-

- {apiKeys.length === 0 +

+ - {apiKeys.length === 0 && ( - - )} + icon={Key} + />
) : (
diff --git a/frontend/components/common/pay/paying/paying-main.tsx b/frontend/components/common/pay/paying/paying-main.tsx index 1210c0be..4982cb12 100644 --- a/frontend/components/common/pay/paying/paying-main.tsx +++ b/frontend/components/common/pay/paying/paying-main.tsx @@ -1,7 +1,7 @@ "use client" import { useState, useEffect, useRef } from "react" -import { useSearchParams } from "next/navigation" +import { useSearchParams, useRouter } from "next/navigation" import { toast } from "sonner" import { motion } from "motion/react" import { PayingNow } from "@/components/common/pay/paying/paying-now" @@ -18,6 +18,7 @@ import type { GetMerchantOrderResponse } from "@/lib/services" export function PayingMain() { /** 获取URL参数中的认证编号 */ const searchParams = useSearchParams() + const router = useRouter() const encryptedOrderNo = searchParams.get('order_no') /** 组件状态管理 */ @@ -67,7 +68,6 @@ export function PayingMain() { errorMessage.includes(key) )?.[1] || errorMessage - // Use a consistent ID for error toasts to avoid duplicates in Strict Mode toast.error(userMessage, { id: 'payment-error' }) } @@ -159,7 +159,7 @@ export function PayingMain() { return } - window.location.reload() + router.push('/home') }, 5000) } catch (error: unknown) { handleServiceError(error, "认证") diff --git a/frontend/components/common/settings/profile.tsx b/frontend/components/common/settings/profile.tsx index 7fb460d0..2ce33412 100644 --- a/frontend/components/common/settings/profile.tsx +++ b/frontend/components/common/settings/profile.tsx @@ -9,6 +9,8 @@ import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbP import { cn } from "@/lib/utils" import { PayLevel, User } from "@/lib/services/auth" import { useUser } from "@/contexts/user-context" +import services from "@/lib/services" +import type { UserPayConfig } from "@/lib/services/admin" /** @@ -23,15 +25,17 @@ interface LevelConfig { gradient: string textColor: string metalEffect?: boolean + dailyLimit: number | null + feeRate: number | string + scoreRate: number | string } -const LEVEL_CONFIGS: LevelConfig[] = [ +/** 等级视觉样式配置(不包含业务数据) */ +const LEVEL_STYLES = [ { level: PayLevel.BlackGold, name: "黑金会员", nameEn: "BLACK MEMBER", - minScore: 0, - maxScore: 1999, gradient: "from-slate-800 via-gray-900 to-black", textColor: "text-white", metalEffect: true, @@ -40,8 +44,6 @@ const LEVEL_CONFIGS: LevelConfig[] = [ level: PayLevel.WhiteGold, name: "白金会员", nameEn: "PLATINUM MEMBER", - minScore: 2000, - maxScore: 9999, gradient: "from-slate-400 via-slate-300 to-slate-400", textColor: "text-slate-900", }, @@ -49,8 +51,6 @@ const LEVEL_CONFIGS: LevelConfig[] = [ level: PayLevel.Gold, name: "黄金会员", nameEn: "GOLD MEMBER", - minScore: 10000, - maxScore: 49999, gradient: "from-[#F3E3B3] via-[#E6D4A3] via-[#F3E3B3] to-[#D4AF37]", textColor: "text-amber-950", }, @@ -58,20 +58,58 @@ const LEVEL_CONFIGS: LevelConfig[] = [ level: PayLevel.Platinum, name: "铂金会员", nameEn: "DIAMOND MEMBER", - minScore: 50000, - maxScore: null, gradient: "from-purple-600 via-pink-500 to-purple-600", textColor: "text-white", }, -] +] as const + +/** 合并 API 数据和视觉样式 */ +function mergeLevelConfigs(apiConfigs: UserPayConfig[]): LevelConfig[] { + return LEVEL_STYLES.map(style => { + const apiConfig = apiConfigs.find(c => c.level === style.level) + if (apiConfig) { + return { + ...style, + minScore: apiConfig.min_score, + maxScore: apiConfig.max_score, + dailyLimit: apiConfig.daily_limit, + feeRate: apiConfig.fee_rate, + scoreRate: apiConfig.score_rate, + } + } + return { + ...style, + minScore: 0, + maxScore: null, + dailyLimit: null, + feeRate: 0, + scoreRate: 0, + } + }) +} -function getLevelConfig(score: number): LevelConfig { - for (let i = LEVEL_CONFIGS.length - 1; i >= 0; i--) { - if (score >= LEVEL_CONFIGS[i].minScore) { - return LEVEL_CONFIGS[i] +function getLevelConfig(score: number, configs: LevelConfig[]): LevelConfig { + if (configs.length === 0) { + return { + level: PayLevel.BlackGold, + name: "加载中...", + nameEn: "LOADING...", + gradient: "from-slate-800 via-gray-900 to-black", + textColor: "text-white", + minScore: 0, + maxScore: null, + dailyLimit: null, + feeRate: 0, + scoreRate: 0, } } - return LEVEL_CONFIGS[0] + + for (let i = configs.length - 1; i >= 0; i--) { + if (score >= configs[i].minScore) { + return configs[i] + } + } + return configs[0] } function MembershipCard({ @@ -80,14 +118,16 @@ function MembershipCard({ score, currentLevel, isActive, + levelConfigs, }: { config: LevelConfig user: User score: number currentLevel: LevelConfig isActive: boolean + levelConfigs: LevelConfig[] }) { - const isAccessible = LEVEL_CONFIGS.findIndex(l => l.level === config.level) <= LEVEL_CONFIGS.findIndex(l => l.level === currentLevel.level) + const isAccessible = levelConfigs.findIndex(l => l.level === config.level) <= levelConfigs.findIndex(l => l.level === currentLevel.level) const isDarkCard = config.level === PayLevel.BlackGold || config.level === PayLevel.Platinum return ( @@ -195,9 +235,18 @@ export function ProfileMain() { const { user, loading, getTrustLevelLabel } = useUser() const [api, setApi] = React.useState() const [current, setCurrent] = React.useState(0) + const [levelConfigs, setLevelConfigs] = React.useState([]) const score = user?.pay_score ?? 0 - const currentLevel = getLevelConfig(score) + const currentLevel = getLevelConfig(score, levelConfigs) + + React.useEffect(() => { + async function fetchPayConfigs() { + const configs = await services.config.getUserPayConfigs() + setLevelConfigs(mergeLevelConfigs(configs)) + } + fetchPayConfigs() + }, []) React.useEffect(() => { if (!api) return @@ -208,12 +257,12 @@ export function ProfileMain() { React.useEffect(() => { if (user && api) { - const currentLevelIndex = LEVEL_CONFIGS.findIndex( + const currentLevelIndex = levelConfigs.findIndex( (config) => config.level === currentLevel.level ) api.scrollTo(currentLevelIndex, false) } - }, [user, currentLevel.level, api]) + }, [user, currentLevel.level, api, levelConfigs]) if (loading) { return ( @@ -265,7 +314,7 @@ export function ProfileMain() {
- {LEVEL_CONFIGS.map((config, index) => ( + {levelConfigs.map((config, index) => ( ))} @@ -324,6 +374,31 @@ export function ProfileMain() {
平台分数
{user.pay_score.toLocaleString()}
+ +
+
每日流转限额
+
+ {currentLevel.dailyLimit ? `${ currentLevel.dailyLimit.toLocaleString() } 积分` : '无限额'} +
+
+ +
+
集市手续费率
+
+ {typeof currentLevel.feeRate === 'number' + ? `${ (currentLevel.feeRate * 100).toFixed(2) }%` + : `${ (parseFloat(currentLevel.feeRate as string) * 100).toFixed(2) }%`} +
+
+ +
+
平台分数转化率
+
+ {typeof currentLevel.scoreRate === 'number' + ? `${ (currentLevel.scoreRate * 100).toFixed(2) }%` + : `${ (parseFloat(currentLevel.scoreRate as string) * 100).toFixed(2) }%`} +
+
diff --git a/frontend/components/home/footer-section.tsx b/frontend/components/home/footer-section.tsx index 8d743748..4e5203b7 100644 --- a/frontend/components/home/footer-section.tsx +++ b/frontend/components/home/footer-section.tsx @@ -22,8 +22,8 @@ export const FooterSection = React.memo(function FooterSection({ className }: Fo
-
- LDP +
+ LDC
LINUX DO Credit diff --git a/frontend/components/layout/header.tsx b/frontend/components/layout/header.tsx index b83ee1cd..6fd8d988 100644 --- a/frontend/components/layout/header.tsx +++ b/frontend/components/layout/header.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react" import { Button } from "@/components/ui/button" -import { Bell, Plus, Settings, Search, Grid3x3, Moon, Sun } from "lucide-react" +import { Bell, Plus, Settings, Search, Moon, Sun } from "lucide-react" import { useUser } from "@/contexts/user-context" import { SidebarTrigger } from "@/components/ui/sidebar" import { useTheme } from "next-themes" @@ -32,7 +32,6 @@ export function SiteHeader() { return (
- {/* Mobile Layout */}
@@ -68,10 +67,6 @@ export function SiteHeader() {
- -
- -
- - {String(children).replace(/\n$/, "")} - -
-
- ); -} diff --git a/frontend/components/common/docs/how-to-use.tsx b/frontend/components/common/docs/how-to-use.tsx new file mode 100644 index 00000000..1a31fdf7 --- /dev/null +++ b/frontend/components/common/docs/how-to-use.tsx @@ -0,0 +1,330 @@ +import { type PolicySection } from "./types" +import { CodeBlock } from "@/components/ui/code-block" +import { + DocsTable, + DocsTableHeader, + DocsTableBody, + DocsTableHead, + DocsTableRow, + DocsTableCell, +} from "@/components/ui/docs-table" + +/** + * ------------------------------------------------------------------ + * 使用指南 (How To Use) + * ------------------------------------------------------------------ + */ +export const howToUseSections: PolicySection[] = [ + { + value: "quick-start", + title: "1. 快速开始", + content: ( +
+
+

为社区开发者与用户提供完整的平台使用说明

+
+
    +
  • 身份认证:基于 LINUX DO Connect (OAuth)
  • +
  • 认证方式:账户积分消耗认证
  • +
  • 手续费:动态费率,由服务方承担
  • +
  • 争议处理:支持服务方与消费方的双方争议处理
  • +
+
+ ) + }, + { + value: "roles", + title: "2. 角色说明", + content: ( +
+
    +
  • 服务方:最终积分流转的转入方
  • +
  • 消费方:最终积分流转的转出方
  • +
  • 认证平台:LINUX DO Credit 系统本身
  • +
+
+ ) + }, + { + value: "integration", + title: "3. 接入积分服务", + content: ( +
+

3.1 使用 API 接口

+
    +
  1. + 创建应用 +
      +
    • 前往 集市中心
    • +
    • 点击顶部右侧 创建应用 按钮
    • +
    • 填写必要信息:应用名称、应用主页、回调地址、通知地址
    • +
    +
  2. +
  3. + 获取 API 凭证 +
      +
    • 在集市中心顶部右侧选择器中选择您的应用
    • +
    • API 配置 面板中获取: +
        +
      • Client ID:客户端ID,用于标识您的身份
      • +
      • Client Secret:客户端密钥,用于签名验证(请妥善保管,切勿泄露
      • +
      +
    • +
    +
  4. +
  5. + 使用 API 接口 +
      +
    • 使用 API 接口创建积分流转服务
    • +
    • 参考文档:API 接口文档
    • +
    +
  6. +
+ +

3.2 使用在线服务

+
    +
  • 适用场景:无代码开发基础,或只用于简单的积分服务。
  • +
  • 操作步骤: +
      +
    1. 前往 集市中心 创建应用,获取 API 凭证
    2. +
    3. 选择应用,点击 在线收款 功能
    4. +
    5. 创建在线积分服务
    6. +
    7. 获取唯一积分服务链接
    8. +
    9. 发送给您所服务的客户使用
    10. +
    +
  • +
+ +

3.3 快速集成 New API

+
    +
  • 适用场景:New API 站点,LINUX DO Credit 兼容 EasyPay 协议,公益站站长可直接集成。
  • +
  • 操作步骤: +
      +
    1. + 前往 集市中心,点击 创建应用,填写 New API 站点信息: +
      + + + + 字段 + + + + + + 应用名称 + 您的应用名称 + + + 应用主页 + https://{"{您的 New API 域名}"} + + + 回调地址 + https://{"{您的 New API 域名}"}/console/log + + + 通知地址 + https://{"{您的 New API 域名}"}/api/user/epay/notify + + + +
      +
    2. +
    3. 前往 New API 站点的系统设置,找到 支付设置
    4. +
    5. + 配置 LINUX DO Credit 平台参数: +
      + + + + 参数 + + + + + + 支付地址 + http://credit.linux.do/epay/pay + + + 易支付商户ID + 您的 Client ID + + + 易支付商户密钥 + 您的 Client Secret + + + 回调地址 + https://{"{您的 New API 域名}"} + + + +
      +
    6. +
    7. + 配置充值方式(JSON 格式): +
      + +
      +
    8. +
    +
  • +
+
+ ), + children: [ + { value: "3-1-api", title: "3.1 使用 API 接口" }, + { value: "3-2-online", title: "3.2 使用在线服务" }, + { value: "3-3-new-api", title: "3.3 快速集成 New API" }, + ] + }, + { + value: "usage", + title: "4. 使用 LINUX DO Credit 积分", + content: ( +
+

您可以在任意支持 LINUX DO Credit 的平台下使用积分。在其他平台点击使用 LINUX DO Credit 积分时,会自动跳转到 LINUX DO Credit 的积分流转服务页面,您只需要确认积分流转信息无误,并选择使用 LINUX DO Credit 进行账户认证,即可完成整个交易服务。

+
+ ) + }, + { + value: "fees", + title: "5. 服务(手续)费", + content: ( +
+

5.1 规则说明

+

为了更好的维持 LINUX DO Credit 平台的积分服务机制,保证社区积分的生态可持续发展,我们会按照规范进行不同程度的服务(手续)费用收取。

+
    +
  • 承担方:服务(手续)费默认由服务方承担
  • +
  • 消费方使用:不会产生额外费用
  • +
  • 服务方实收:订单金额 - 服务(手续)费
  • +
+
+

计算公式:

+ +
+ +

5.2 动态费率

+

费率并非固定不变,会根据以下因素动态调整:

+
    +
  • 服务方平台等级
  • +
  • 服务方平台积分
  • +
  • LINUX DO Credit 平台活动
  • +
+
+ ), + children: [ + { value: "5-1-rules", title: "5.1 规则说明" }, + { value: "5-2-dynamic", title: "5.2 动态费率" }, + ] + }, + { + value: "dispute", + title: "6. 争议处理", + content: ( +
+

为了保障服务方与消费方的合法权益,当积分服务出现纠纷时,可使用争议功能。

+
    +
  • 作为服务方,您需要及时响应消费方的争议请求: +
      +
    1. 在集市中心或通知中查看到 待处理的争议
    2. +
    3. 查看消费方理由,选择操作: +
        +
      • 同意:认可消费方诉求,积分原路退回给消费方
      • +
      • 拒绝:如果您认为已履约,请提交相关证据
      • +
      +
    4. +
    +
  • +
+
+

+ 重要:建议服务方与消费方优先沟通解决。长时间未处理的争议会由 LINUX DO Credit 平台介入仲裁,这可能会影响您的服务方信誉。 +

+
+
+ ) + }, + { + value: "transfer", + title: "7. 积分转移", + content: ( +
+
    +
  • 适用场景:向 LINUX DO Credit 平台内的其他用户进行积分转移。
  • +
  • 操作步骤: +
      +
    1. 进入 活动 页面,选择 积分转移
    2. +
    3. 填写接收方信息: +
        +
      • 接收方账户:对方的用户名
      • +
      • 接收方 ID:对方的用户ID,确保积分去向准确
      • +
      • 积分数量:转移的积分数量
      • +
      +
    4. +
    5. 验证安全密码后,积分将实时到账
    6. +
    +
  • +
+
+

+ 重要:请勿滥用积分转移进行刷号、交易等违规行为,积分审计系统发现此类行为会立即封禁相关账户! +

+
+
+ ) + }, + { + value: "community-balance", + title: "8. 社区积分", + content: ( +
+

您的 LINUX DO Credit 平台基础积分主要由 社区积分 (Community Balance) 划转而来。

+
    +
  • 基本获取方式:通过在 LINUX DO 社区的活跃行为获得: +
      +
    • 发帖、回复、点赞等社区贡献
    • +
    • 详情可见 LINUX DO 社区
    • +
    +
  • +
  • 划转规则: +
      +
    • 划转时间:社区积分每日凌晨 00:00 自动划转至可用余额
    • +
    • 限制说明:划转前不可用于任何积分服务
    • +
    • 服务费用:目前不收取任何划转 服务费
    • +
    +
  • +
+
+ ) + }, + { + value: "settings", + title: "9. 账户设置", + content: ( +
+

您可以在 设置 (Settings) 页面管理您的账户信息。

+

功能列表

+
    +
  • 个人资料:查看当前的账户信息和会员等级
  • +
  • 安全设置:修改认证密码
  • +
  • 通知设置:暂未上线
  • +
  • 外观设置:切换页面主题、界面外观
  • +
+
+ ) + } +] diff --git a/frontend/components/auth/legal-page-layout.tsx b/frontend/components/common/docs/legal-page-layout.tsx similarity index 61% rename from frontend/components/auth/legal-page-layout.tsx rename to frontend/components/common/docs/legal-page-layout.tsx index 4701c2cb..7acefce1 100644 --- a/frontend/components/auth/legal-page-layout.tsx +++ b/frontend/components/common/docs/legal-page-layout.tsx @@ -6,7 +6,7 @@ import { ArrowLeft, Calendar, FileText } from "lucide-react" import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" import { motion, useScroll, useSpring } from "motion/react" -import type { PolicySection } from "@/components/auth/login-policies" +import type { PolicySection } from "./types" interface LegalPageLayoutProps { title: string @@ -29,6 +29,13 @@ export function LegalPageLayout({ restDelta: 0.001 }) + const allSectionIds = React.useMemo(() => { + return sections.flatMap(section => [ + section.value, + ...(section.children?.map(child => child.value) || []) + ]) + }, [sections]) + React.useEffect(() => { const observer = new IntersectionObserver( (entries) => { @@ -41,13 +48,13 @@ export function LegalPageLayout({ { rootMargin: "-20% 0px -35% 0px" } ) - sections.forEach((section) => { - const element = document.getElementById(section.value) + allSectionIds.forEach((id) => { + const element = document.getElementById(id) if (element) observer.observe(element) }) return () => observer.disconnect() - }, [sections]) + }, [sections, allSectionIds]) const scrollToSection = (e: React.MouseEvent, id: string) => { e.preventDefault() @@ -95,17 +102,17 @@ export function LegalPageLayout({ animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5 }} > -

+

{title}

- - 最后更新:{lastUpdated} + + {sections.length} 个内容 - - {sections.length} 个条款 + + 最后更新:{lastUpdated}
{description && ( @@ -124,31 +131,51 @@ export function LegalPageLayout({
目录
-
+
{sections.map((section, index) => ( -
- +
+ {index + 1}

@@ -168,11 +195,13 @@ export function LegalPageLayout({

-
+
{section.content}
@@ -181,15 +210,19 @@ export function LegalPageLayout({

- 对这些条款有疑问? + 对这些内容有疑问?

- - + + + + + +
diff --git a/frontend/components/common/docs/markdown-renderer.tsx b/frontend/components/common/docs/markdown-renderer.tsx deleted file mode 100644 index c29d8f85..00000000 --- a/frontend/components/common/docs/markdown-renderer.tsx +++ /dev/null @@ -1,151 +0,0 @@ -"use client"; - -import React from "react"; -import ReactMarkdown, { Components } from "react-markdown"; -import rehypeSlug from "rehype-slug"; -import remarkGfm from "remark-gfm"; -import { CodeBlock } from "@/components/common/docs/code-block"; -import { cn } from "@/lib/utils"; -import { Hash } from "lucide-react"; - -interface MarkdownRendererProps { - content: string; -} - -export const MarkdownRenderer: React.FC = ({ - content, -}) => { - const components: Partial = { - code: ({ className, children, ...props }: React.HTMLAttributes) => { - const match = /language-(\w+)/.exec(className || ""); - const language = match ? match[1] : undefined; - const isInline = !match && !String(children).includes("\n"); - - if (isInline) { - return ( - - {children} - - ); - } - - return ( - - {children} - - ); - }, - blockquote: ({ className, children, ...props }: React.BlockquoteHTMLAttributes) => { - return ( -
- {children} -
- ); - }, - table: ({ className, ...props }: React.TableHTMLAttributes) => ( -
-
- - - - ), - thead: ({ className, ...props }: React.HTMLAttributes) => ( - - ), - tr: ({ className, ...props }: React.HTMLAttributes) => ( - - ), - th: ({ className, ...props }: React.ThHTMLAttributes) => ( -
- ), - td: ({ className, ...props }: React.TdHTMLAttributes) => ( - - ), - h1: ({ className, children, ...props }: React.HTMLAttributes) => ( -

- {children} -

- ), - h2: ({ className, children, id, ...props }: React.HTMLAttributes) => ( -

- {children} - -

- ), - h3: ({ className, children, id, ...props }: React.HTMLAttributes) => ( -

- {children} - -

- ), - h4: ({ className, ...props }: React.HTMLAttributes) => ( -

- ), - p: ({ className, ...props }: React.HTMLAttributes) => ( -

- ), - ul: ({ className, ...props }: React.HTMLAttributes) => ( -

    li]:mt-2 marker:text-muted-foreground/60", className)} {...props} /> - ), - ol: ({ className, ...props }: React.HTMLAttributes) => ( -
      li]:mt-2 marker:text-muted-foreground/60", className)} {...props} /> - ), - li: ({ className, ...props }: React.LiHTMLAttributes) => ( -
    1. - ), - hr: ({ className, ...props }: React.HTMLAttributes) => ( -
      - ), - a: ({ className, ...props }: React.AnchorHTMLAttributes) => ( - - ), - }; - - return ( -
      - - {content} - -
      - ); -}; diff --git a/frontend/components/common/docs/privacy.tsx b/frontend/components/common/docs/privacy.tsx new file mode 100644 index 00000000..b63d4cba --- /dev/null +++ b/frontend/components/common/docs/privacy.tsx @@ -0,0 +1,110 @@ +import { type PolicySection } from "./types" + +/** + * ------------------------------------------------------------------ + * 隐私政策 (PRIVACY POLICY) + * ------------------------------------------------------------------ + */ +export const privacySections: PolicySection[] = [ + { + value: "collection-details", + title: "1. 详细信息收集说明", + content: ( +
      +

      我们仅遵循合法、正当、必要的原则,收集为您提供服务所必需的信息。我们的数据收集范围严格限制如下:

      +
      +
      + 1.1 身份鉴权信息: +

      当您通过 LINUX DO Connect 登录时,我们会获取您的社区 OpenID(唯一标识符)、加密后的用户名及头像 URL。我们不收集您的手机号、真实姓名或身份证件信息。

      +
      +
      + 1.2 服务日志信息: +

      为保障系统运行安全及满足法律合规要求,我们会自动收集您的操作日志,包括 IP 地址、访问日期和时间、API 调用记录、User-Agent(浏览器/设备类型)。

      +
      +
      + 1.3 交易与资产信息: +

      若您使用支付或转账功能,我们将记录您的商户订单号、交易金额、交易时间、交易状态摘要。这些信息是账务核对的必要依据。

      +
      +
      +
      + ), + }, + { + value: "storage-security", + title: "2. 数据存储与安全保护", + content: ( +
      +

      我们深知数据安全的重要性,并采取业界领先的技术措施保护您的数据:

      +
        +
      • 存储地点:依照法规要求,我们收集和产生的用户个人信息,存储在独立的服务器上。我们不会将您的数据传输至境外管辖区。
      • +
      • 加密技术:敏感数据(如 支付密码)在数据库中均采用高强度加密算法存储。数据传输全链路采用 SSL/TLS 1.3 协议进行加密,防止网络嗅探。
      • +
      • 隔离机制:本平台数据与外部网络物理隔离,且独立于 LINUX DO 社区论坛主数据库,确保单一系统故障不会波及全局数据安全。
      • +
      • 访问控制:我们实行严格的最小权限原则(Least Privilege),仅有核心运维人员经授权后方可访问必要的维护数据,且所有操作均有审计日志留存。
      • +
      +
      + ), + }, + { + value: "usage-rules", + title: "3. 信息使用规范", + content: ( +
      +

      我们收集的信息将仅用于以下目的:

      +
        +
      • 身份识别:用于确认您的社区身份,展示您的个人中心数据。
      • +
      • 业务功能:处理您的支付指令、API 请求、回调通知及账单生成。
      • +
      • 安全风控:利用 IP 及行为日志进行反作弊、反欺诈分析,识别恶意攻击行为,保护平台及其他用户的安全。
      • +
      • 客户支持:在您发起工单或申诉时,查询相关日志以协助您解决问题。
      • +
      +

      禁止用途:我们承诺绝不利用您的数据进行用户画像分析、个性化广告推送或商业营销。

      +
      + ), + }, + { + value: "sharing-disclosure", + title: "4. 信息共享与对外披露", + content: ( +
      +

      4.1 共享原则:我们坚持数据零共享策略。除以下极端情况外,我们不会向任何第三方(包括且不限于关联公司、支付宝、微信、银行、广告商)共享您的个人信息:

      +
        +
      • 事先获得您的明确授权或同意;
      • +
      • 根据适用的法律法规、法律程序的要求、强制性的行政或司法要求所必须的情况下进行提供。
      • +
      +

      4.2 转让与公开披露:我们不会将您的个人信息转让给任何公司、组织和个人。我们仅在法律法规强制要求,或为了保护平台及用户与公众的人身财产安全免受侵害时,才会公开披露您的信息。

      +
      + ), + }, + { + value: "user-rights", + title: "5. 您的权利与数据管理", + content: ( +
      +

      依照《中华人民共和国个人信息保护法》,您对您的个人信息享有完整的控制权:

      +
      +
      + 5.1 查阅与复制权: +

      您可以随时登录开发者后台,查阅您的概览信息、API Key 状态及历史交易账单。您可以通过“导出账单”功能获取您的数据副本。

      +
      +
      + 5.2 删除与遗忘权: +

      若您决定停止使用本服务,在结清所有应付费用及余额后,您可以申请注销账户。注销后,我们将立即删除您的所有敏感信息或进行匿名化处理,法律法规规定需保留的日志除外。

      +
      +
      + 5.3 纠正与更正权: +

      若您发现您的信息有误,您有权要求我们更正或补充。您可以在设置页面直接修改您的信息,或通过客服提交工单。

      +
      +
      +
      + ), + }, + { + value: "policy-update", + title: "6. 政策更新与通知", + content: ( +
      +

      随着业务的发展或法律法规的变动,我们可能会适时修订本《隐私政策》。

      +

      当条款发生重大变更时(例如收集范围扩大、使用目的改变),我们会通过站内信、公告或弹窗等显著方式通知您。若您在政策更新后继续使用本服务,即表示您同意接受更新后的隐私政策约束。

      +
      + ), + }, +] diff --git a/frontend/components/auth/login-policies.tsx b/frontend/components/common/docs/terms.tsx similarity index 57% rename from frontend/components/auth/login-policies.tsx rename to frontend/components/common/docs/terms.tsx index 96d7de0e..12d041db 100644 --- a/frontend/components/auth/login-policies.tsx +++ b/frontend/components/common/docs/terms.tsx @@ -1,12 +1,6 @@ -import { type ReactNode } from "react" +import { type PolicySection } from "./types" -export const LAST_UPDATED = "2025-12-16" - -export type PolicySection = { - value: string - title: string - content: ReactNode -} +export const TERMS_LAST_UPDATED = "2025-12-22" /** * ------------------------------------------------------------------ @@ -119,112 +113,3 @@ export const termsSections: PolicySection[] = [ ), }, ] - -/** - * ------------------------------------------------------------------ - * 隐私政策 (PRIVACY POLICY) - 增强版 - * ------------------------------------------------------------------ - */ -export const privacySections: PolicySection[] = [ - { - value: "collection-details", - title: "1. 详细信息收集说明", - content: ( -
      -

      我们仅遵循合法、正当、必要的原则,收集为您提供服务所必需的信息。我们的数据收集范围严格限制如下:

      -
      -
      - 1.1 身份鉴权信息: -

      当您通过 LINUX DO Connect 登录时,我们会获取您的社区 OpenID(唯一标识符)、加密后的用户名及头像 URL。我们不收集您的手机号、真实姓名或身份证件信息。

      -
      -
      - 1.2 服务日志信息: -

      为保障系统运行安全及满足法律合规要求,我们会自动收集您的操作日志,包括 IP 地址、访问日期和时间、API 调用记录、User-Agent(浏览器/设备类型)。

      -
      -
      - 1.3 交易与资产信息: -

      若您使用支付或转账功能,我们将记录您的商户订单号、交易金额、交易时间、交易状态摘要。这些信息是账务核对的必要依据。

      -
      -
      -
      - ), - }, - { - value: "storage-security", - title: "2. 数据存储与安全保护", - content: ( -
      -

      我们深知数据安全的重要性,并采取业界领先的技术措施保护您的数据:

      -
        -
      • 存储地点:依照法规要求,我们收集和产生的用户个人信息,存储在独立的服务器上。我们不会将您的数据传输至境外管辖区。
      • -
      • 加密技术:敏感数据(如 支付密码)在数据库中均采用高强度加密算法存储。数据传输全链路采用 SSL/TLS 1.3 协议进行加密,防止网络嗅探。
      • -
      • 隔离机制:本平台数据与外部网络物理隔离,且独立于 LINUX DO 社区论坛主数据库,确保单一系统故障不会波及全局数据安全。
      • -
      • 访问控制:我们实行严格的最小权限原则(Least Privilege),仅有核心运维人员经授权后方可访问必要的维护数据,且所有操作均有审计日志留存。
      • -
      -
      - ), - }, - { - value: "usage-rules", - title: "3. 信息使用规范", - content: ( -
      -

      我们收集的信息将仅用于以下目的:

      -
        -
      • 身份识别:用于确认您的社区身份,展示您的个人中心数据。
      • -
      • 业务功能:处理您的支付指令、API 请求、回调通知及账单生成。
      • -
      • 安全风控:利用 IP 及行为日志进行反作弊、反欺诈分析,识别恶意攻击行为,保护平台及其他用户的安全。
      • -
      • 客户支持:在您发起工单或申诉时,查询相关日志以协助您解决问题。
      • -
      -

      禁止用途:我们承诺绝不利用您的数据进行用户画像分析、个性化广告推送或商业营销。

      -
      - ), - }, - { - value: "sharing-disclosure", - title: "4. 信息共享与对外披露", - content: ( -
      -

      4.1 共享原则:我们坚持数据零共享策略。除以下极端情况外,我们不会向任何第三方(包括且不限于关联公司、支付宝、微信、银行、广告商)共享您的个人信息:

      -
        -
      • 事先获得您的明确授权或同意;
      • -
      • 根据适用的法律法规、法律程序的要求、强制性的行政或司法要求所必须的情况下进行提供。
      • -
      -

      4.2 转让与公开披露:我们不会将您的个人信息转让给任何公司、组织和个人。我们仅在法律法规强制要求,或为了保护平台及用户与公众的人身财产安全免受侵害时,才会公开披露您的信息。

      -
      - ), - }, - { - value: "user-rights", - title: "5. 您的权利与数据管理", - content: ( -
      -

      依照《中华人民共和国个人信息保护法》,您对您的个人信息享有完整的控制权:

      -
      -
      - 5.1 查阅与复制权: -

      您可以随时登录开发者后台,查阅您的概览信息、API Key 状态及历史交易账单。您可以通过“导出账单”功能获取您的数据副本。

      -
      -
      - 5.2 删除与遗忘权: -

      若您决定停止使用本服务,在结清所有应付费用及余额后,您可以申请注销账户。注销后,我们将立即删除您的所有敏感信息或进行匿名化处理,法律法规规定需保留的日志除外。

      -
      -
      - 5.3 纠正与更正权: -

      若您发现您的信息有误,您有权要求我们更正或补充。您可以在设置页面直接修改您的信息,或通过客服提交工单。

      -
      -
      -
      - ), - }, - { - value: "policy-update", - title: "6. 政策更新与通知", - content: ( -
      -

      随着业务的发展或法律法规的变动,我们可能会适时修订本《隐私政策》。

      -

      当条款发生重大变更时(例如收集范围扩大、使用目的改变),我们会通过站内信、公告或弹窗等显著方式通知您。若您在政策更新后继续使用本服务,即表示您同意接受更新后的隐私政策约束。

      -
      - ), - }, -] diff --git a/frontend/components/common/docs/toc.tsx b/frontend/components/common/docs/toc.tsx deleted file mode 100644 index 1df31609..00000000 --- a/frontend/components/common/docs/toc.tsx +++ /dev/null @@ -1,160 +0,0 @@ -"use client"; - -import React, { useEffect, useState, useMemo, useRef } from "react"; -import { cn } from "@/lib/utils"; -import { motion } from "motion/react"; -import { ScrollArea } from "@/components/ui/scroll-area"; - -interface TocItem { - id: string; - level: number; - text: string; -} - -interface TableOfContentsProps { - content: string; - className?: string; -} - -function slugify(text: string): string { - return text - .toLowerCase() - .trim() - .replace(/<[^>]+>/g, '') - .replace(/[\s\t\n]+/g, '-') - .replace(/[^\p{L}\p{N}\-_]/gu, '') - .replace(/^-+|-+$/g, ''); -} - -export function TableOfContents({ content, className }: TableOfContentsProps) { - const [activeId, setActiveId] = useState(""); - const scrollAreaRef = useRef(null); - - const toc = useMemo(() => { - const items: TocItem[] = []; - const slugCount = new Map(); - const lines = content.split('\n'); - - lines.forEach((line) => { - const match = line.match(/^(#{2,4})\s+(.+)$/); - if (match) { - const level = match[1].length; - const text = match[2].trim(); - let slug = slugify(text); - - const count = slugCount.get(slug); - if (count !== undefined) { - slugCount.set(slug, count + 1); - slug = `${ slug }-${ count + 1 }`; - } else { - slugCount.set(slug, 0); - } - - items.push({ id: slug, level, text }); - } - }); - - return items; - }, [content]); - - useEffect(() => { - if (!activeId || !scrollAreaRef.current) return; - - const activeElement = scrollAreaRef.current.querySelector(`[data-toc-id="${ activeId }"]`); - if (activeElement) { - activeElement.scrollIntoView({ - behavior: 'smooth', - block: 'nearest', - }); - } - }, [activeId]); - - useEffect(() => { - if (toc.length === 0) return; - - const observer = new IntersectionObserver( - (entries) => { - const intersecting = entries - .filter(entry => entry.isIntersecting) - .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top); - - if (intersecting.length > 0) { - setActiveId(intersecting[0].target.id); - } - }, - { - rootMargin: '-20px 0px -80% 0px', - threshold: [0, 1] - } - ); - - const elements: Element[] = []; - toc.forEach(item => { - const el = document.getElementById(item.id); - if (el) { - elements.push(el); - observer.observe(el); - } - }); - - return () => { - observer.disconnect(); - }; - }, [toc]); - - const handleClick = (e: React.MouseEvent, id: string) => { - e.preventDefault(); - const element = document.getElementById(id); - if (element) { - const offset = 20; - const bodyRect = document.body.getBoundingClientRect().top; - const elementRect = element.getBoundingClientRect().top; - const elementPosition = elementRect - bodyRect; - const offsetPosition = elementPosition - offset; - - window.scrollTo({ - top: offsetPosition, - behavior: "smooth" - }); - setActiveId(id); - } - }; - - if (toc.length === 0) return null; - - return ( -
      - ); -} diff --git a/frontend/components/common/docs/types.ts b/frontend/components/common/docs/types.ts new file mode 100644 index 00000000..1e5c24a8 --- /dev/null +++ b/frontend/components/common/docs/types.ts @@ -0,0 +1,11 @@ +import { type ReactNode } from "react" + +export type PolicySection = { + value: string + title: string + content: ReactNode + children?: { + value: string + title: string + }[] +} diff --git a/frontend/components/home/developer-section.tsx b/frontend/components/home/developer-section.tsx index 71bf2bad..c5dd4db8 100644 --- a/frontend/components/home/developer-section.tsx +++ b/frontend/components/home/developer-section.tsx @@ -23,7 +23,11 @@ export const DeveloperSection = React.memo(function DeveloperSection({ className return (
      -
      +
      +
      +
      +
      +
                       
      -                  curl https://api.linux.do/credit/v1/flows \{'\n'}
      +                  curl https://credit.linux.do/epay/submit.php \{'\n'}
                         {'  '}-u sk_live_...: \{'\n'}
                         {'  '}-d amount=1000 \{'\n'}
                         {'  '}-d currency="cny" \{'\n'}
      diff --git a/frontend/components/home/footer-section.tsx b/frontend/components/home/footer-section.tsx
      index 4e5203b7..933c7097 100644
      --- a/frontend/components/home/footer-section.tsx
      +++ b/frontend/components/home/footer-section.tsx
      @@ -51,10 +51,10 @@ export const FooterSection = React.memo(function FooterSection({ className }: Fo
                 

      资源

        +
      • 最新动态
      • 帮助文档
      • 社区动态
      • -
      • 状态监控
      • -
      • 开源贡献
      • +
      • 开源贡献
      diff --git a/frontend/components/home/hero-section.tsx b/frontend/components/home/hero-section.tsx index ec2f9da7..d2d451ea 100644 --- a/frontend/components/home/hero-section.tsx +++ b/frontend/components/home/hero-section.tsx @@ -71,7 +71,7 @@ export const HeroSection = React.memo(function HeroSection({ className }: HeroSe - + +
      +
      +
      +          
      +            {code}
      +          
      +        
      +
      +
      + ) +} diff --git a/frontend/components/ui/docs-table.tsx b/frontend/components/ui/docs-table.tsx new file mode 100644 index 00000000..ed666ec5 --- /dev/null +++ b/frontend/components/ui/docs-table.tsx @@ -0,0 +1,84 @@ +import * as React from "react" +import { cn } from "@/lib/utils" + +const DocsTable = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
      + + +)) +DocsTable.displayName = "DocsTable" + +const DocsTableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +DocsTableHeader.displayName = "DocsTableHeader" + +const DocsTableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +DocsTableBody.displayName = "DocsTableBody" + +const DocsTableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +DocsTableRow.displayName = "DocsTableRow" + +const DocsTableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
      +)) +DocsTableHead.displayName = "DocsTableHead" + +const DocsTableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +DocsTableCell.displayName = "DocsTableCell" + +export { + DocsTable, + DocsTableHeader, + DocsTableBody, + DocsTableHead, + DocsTableRow, + DocsTableCell, +} From 9b3cb02bc152e53d3a2316067341fcb82e8833da Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Mon, 22 Dec 2025 16:21:24 +0800 Subject: [PATCH 031/366] feat: lint format --- frontend/app/(docs)/docs/api/page.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/app/(docs)/docs/api/page.tsx b/frontend/app/(docs)/docs/api/page.tsx index a3e97493..8ba38086 100644 --- a/frontend/app/(docs)/docs/api/page.tsx +++ b/frontend/app/(docs)/docs/api/page.tsx @@ -12,6 +12,7 @@ export default function ApiDocPage() {

      LINUX DO Credit 提供简单、强大的 API 接口,支持多种编程语言和开发环境。通过标准化的 HTTP 接口,您可以轻松将积分服务集成到您的应用中。

      + } /> ); } From c422d644bf8fc6f2e6b742618a080ca429905f1c Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Tue, 23 Dec 2025 09:27:00 +0800 Subject: [PATCH 032/366] feat: optimize proxy middleware service --- .../components/common/home/data-panel.tsx | 8 ++--- frontend/components/layout/sidebar.tsx | 27 ++++++++++++++--- frontend/contexts/user-context.tsx | 8 +++-- frontend/lib/services/auth/auth.service.ts | 11 ++++++- frontend/lib/services/core/api-client.ts | 1 - frontend/lib/services/core/config.ts | 12 +++----- frontend/next.config.ts | 8 ++--- frontend/proxy.ts | 30 ++++++++----------- 8 files changed, 63 insertions(+), 42 deletions(-) diff --git a/frontend/components/common/home/data-panel.tsx b/frontend/components/common/home/data-panel.tsx index 4d6c4e40..f7e67923 100644 --- a/frontend/components/common/home/data-panel.tsx +++ b/frontend/components/common/home/data-panel.tsx @@ -12,16 +12,16 @@ import type { DailyStatsItem } from "@/lib/services" const chartConfig = { total: { - label: "积分总额", + label: "总额", color: "hsl(217, 91%, 60%)", }, income: { label: "收入", - color: "#10b981", // emerald-500 + color: "#10b981", }, expense: { label: "支出", - color: "#f43f5e", // rose-500 + color: "#f43f5e", }, } satisfies ChartConfig @@ -94,7 +94,7 @@ export function DataPanel() { ) : chartData.length > 0 ? ( - + ) { const { toggleSidebar, state } = useSidebar() const { user, getTrustLevelLabel, logout } = useUser() const [showLogoutDialog, setShowLogoutDialog] = React.useState(false) + const [isLoggingOut, setIsLoggingOut] = React.useState(false) const pathname = usePathname() const router = useRouter() + const handleLogout = async () => { + setIsLoggingOut(true) + try { + await logout() + setShowLogoutDialog(false) + } catch (error) { + toast.error("登出失败", { + description: error instanceof Error ? error.message : "登出时发生错误,请重试" + }) + setIsLoggingOut(false) + } + } + return ( <> @@ -279,17 +295,20 @@ export function AppSidebar({ ...props }: React.ComponentProps) { - + !isLoggingOut && setShowLogoutDialog(open)}> 确认登出 - 您确定要登出当前账户吗? + {isLoggingOut ? '正在登出,请稍候...' : '您确定要登出当前账户吗?'} - 取消 - 确认登出 + 取消 + + {isLoggingOut && } + {isLoggingOut ? '登出中...' : '确认登出'} + diff --git a/frontend/contexts/user-context.tsx b/frontend/contexts/user-context.tsx index f064b2c0..7ea63e7b 100644 --- a/frontend/contexts/user-context.tsx +++ b/frontend/contexts/user-context.tsx @@ -116,12 +116,16 @@ export function UserProvider({ children }: { children: ReactNode }) { setState({ user: null, loading: false, error: null }) window.location.href = '/login' } catch (error) { - if (!isMountedRef.current) return + if (!isMountedRef.current) { + throw error + } + const errorMessage = error instanceof Error ? error.message : '登出失败' setState(prev => ({ ...prev, - error: error instanceof Error ? error.message : '登出失败', + error: errorMessage, })) + throw new Error(errorMessage) } }, []) diff --git a/frontend/lib/services/auth/auth.service.ts b/frontend/lib/services/auth/auth.service.ts index 1379de78..e42d493b 100644 --- a/frontend/lib/services/auth/auth.service.ts +++ b/frontend/lib/services/auth/auth.service.ts @@ -85,7 +85,7 @@ export class AuthService extends BaseService { * ``` */ static async logout(): Promise { - return this.get('/logout'); + await this.get('/logout'); } /** @@ -99,6 +99,15 @@ export class AuthService extends BaseService { * ``` */ static async initiateLogin(): Promise { + if (typeof window !== 'undefined') { + const params = new URLSearchParams(window.location.search); + const callbackUrl = params.get('callbackUrl'); + + if (callbackUrl) { + sessionStorage.setItem('redirect_after_login', callbackUrl); + } + } + const url = await this.getLoginUrl(); if (typeof window !== 'undefined' && url) { window.location.href = url; diff --git a/frontend/lib/services/core/api-client.ts b/frontend/lib/services/core/api-client.ts index a5d71c3c..efbc31b5 100644 --- a/frontend/lib/services/core/api-client.ts +++ b/frontend/lib/services/core/api-client.ts @@ -80,7 +80,6 @@ function initiateLogin(currentPath: string): Promise { if (!currentPath.startsWith('/login') && !currentPath.startsWith('/callback')) { if (typeof window !== 'undefined') { sessionStorage.setItem('redirect_after_login', currentPath); - // Ensure we preserve the query parameters if needed, but for now just the path const loginUrl = new URL('/login', window.location.origin); loginUrl.searchParams.set('callbackUrl', currentPath); window.location.href = loginUrl.toString(); diff --git a/frontend/lib/services/core/config.ts b/frontend/lib/services/core/config.ts index fe4b92dc..4f08ee06 100644 --- a/frontend/lib/services/core/config.ts +++ b/frontend/lib/services/core/config.ts @@ -7,22 +7,18 @@ * @returns API 基础 URL */ export function getApiBaseUrl(): string { - if (typeof window !== 'undefined') { - return process.env.NEXT_PUBLIC_LINUX_DO_PAY_BACKEND_URL || ''; - } - // 服务端环境,使用 Next.js rewrites 代理时为空 - return process.env.LINUX_DO_PAY_BACKEND_URL || ''; + return process.env.NEXT_PUBLIC_LINUX_DO_CREDIT_BACKEND_URL || ''; } /** * API 配置选项 */ export const apiConfig = { - /** 基础 URL - 为空时使用相对路径,走 Next.js 代理 */ + /** Basic URL */ baseURL: getApiBaseUrl(), - /** 请求超时时间(毫秒) */ + /** 超时时间(毫秒) */ timeout: 15000, - /** 是否携带凭证 */ + /** 携带凭证 */ withCredentials: true, } as const; diff --git a/frontend/next.config.ts b/frontend/next.config.ts index a2b72784..29a6ddf4 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -5,22 +5,22 @@ const nextConfig: NextConfig = { experimental: { }, async rewrites() { - const backendUrl = process.env.LINUX_DO_PAY_BACKEND_URL || 'http://localhost:8000'; + const backendUrl = process.env.NEXT_PUBLIC_LINUX_DO_CREDIT_BACKEND_URL || 'http://localhost:8000'; return [ // 易支付兼容接口 - 创建订单 { source: '/epay/pay/:path*', - destination: `${backendUrl}/pay/:path*`, + destination: `${ backendUrl }/pay/:path*`, }, // 易支付兼容接口 - 查询订单和退款 { source: '/epay/api.php', - destination: `${backendUrl}/api.php`, + destination: `${ backendUrl }/api.php`, }, // 标准 RESTful API 接口 { source: '/api/:path*', - destination: `${backendUrl}/api/:path*`, + destination: `${ backendUrl }/api/:path*`, }, ]; }, diff --git a/frontend/proxy.ts b/frontend/proxy.ts index c0e47f5e..675248c8 100644 --- a/frontend/proxy.ts +++ b/frontend/proxy.ts @@ -15,7 +15,7 @@ export function proxy(request: NextRequest) { const { pathname, search } = request.nextUrl const sessionCookie = request.cookies.get('linux_do_credit_session_id') - // 定义不需要身份验证的公共路由 + /* 定义公共路由 */ const publicRoutes = [ '/', '/login', @@ -25,25 +25,21 @@ export function proxy(request: NextRequest) { '/docs', ] - // 定义公共路径前缀 + /* 定义公共路径前缀(易支付等外部接口) */ const publicPrefixes = [ - '/pay/', // 无需认证即可访问的支付页面 - '/_next/', // Next.js 内部文件 - '/api/', // API 路由 + '/epay/', // 易支付 API 兼容接口 ] - // 检查当前路径是否为公共路径 + /* 检查当前路径是否为公共路径 */ const isPublicRoute = publicRoutes.includes(pathname) || publicPrefixes.some(prefix => pathname.startsWith(prefix)) - // 如果是公共路由,允许通过 if (isPublicRoute) { return NextResponse.next() } - // 受保护路由:检查会话 cookie if (!sessionCookie) { - const callbackUrl = encodeURIComponent(pathname + search) + const callbackUrl = pathname + search const loginUrl = new URL('/login', request.url) loginUrl.searchParams.set('callbackUrl', callbackUrl) @@ -55,17 +51,15 @@ export function proxy(request: NextRequest) { /** * 配置代理应在哪些路由上运行 - * 使用匹配器排除静态文件以获得更好的性能 + * + * 使用 negative lookahead 排除不需要代理的路径: + * - _next/* (Next.js 内部资源,包括 webpack-hmr WebSocket、static、image 等) + * - api/* (API 路由) + * - favicon.ico, robots.txt, sitemap.xml (元数据文件) + * - 静态资源文件(图片等) */ export const config = { matcher: [ - /* - * 匹配所有请求路径,但排除: - * - _next/static (静态文件) - * - _next/image (图片优化) - * - favicon.ico, robots.txt, sitemap.xml (元文件) - * - 图片和其他静态资源 - */ - '/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:jpg|jpeg|gif|png|svg|ico|webp)).*)', + '/((?!_next|api|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:jpg|jpeg|gif|png|svg|ico|webp)).*)', ], } From f4db88ee66eabede8100dcc83de17f1ad9725c8e Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Tue, 23 Dec 2025 10:05:10 +0800 Subject: [PATCH 033/366] feat(session): Clean up the front-end session --- internal/apps/oauth/routers.go | 1 + internal/router/router.go | 11 ++--------- internal/util/session.go | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 9 deletions(-) create mode 100644 internal/util/session.go diff --git a/internal/apps/oauth/routers.go b/internal/apps/oauth/routers.go index 965efb30..1b8183db 100644 --- a/internal/apps/oauth/routers.go +++ b/internal/apps/oauth/routers.go @@ -166,6 +166,7 @@ func UserInfo(c *gin.Context) { // @Router /api/v1/oauth/logout [get] func Logout(c *gin.Context) { session := sessions.Default(c) + session.Options(util.GetSessionOptions(-1)) session.Clear() if err := session.Save(); err != nil { c.JSON(http.StatusInternalServerError, util.Err(err.Error())) diff --git a/internal/router/router.go b/internal/router/router.go index 090c4ecf..57b252e9 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -33,6 +33,7 @@ import ( "github.com/linux-do/credit/internal/apps/merchant/api_key" "github.com/linux-do/credit/internal/apps/merchant/link" "github.com/linux-do/credit/internal/listener" + "github.com/linux-do/credit/internal/util" "github.com/linux-do/credit/internal/apps/payment" @@ -91,15 +92,7 @@ func Serve() { } } - sessionStore.Options( - sessions.Options{ - Path: "/", - Domain: config.Config.App.SessionDomain, - MaxAge: config.Config.App.SessionAge, - HttpOnly: config.Config.App.SessionHttpOnly, - Secure: config.Config.App.SessionSecure, - }, - ) + sessionStore.Options(util.GetSessionOptions(config.Config.App.SessionAge)) r.Use(sessions.Sessions(config.Config.App.SessionCookieName, sessionStore)) diff --git a/internal/util/session.go b/internal/util/session.go new file mode 100644 index 00000000..418cc0f2 --- /dev/null +++ b/internal/util/session.go @@ -0,0 +1,32 @@ +/* +Copyright 2025 linux.do + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "github.com/gin-contrib/sessions" + "github.com/linux-do/credit/internal/config" +) + +func GetSessionOptions(maxAge int) sessions.Options { + return sessions.Options{ + Path: "/", + Domain: config.Config.App.SessionDomain, + MaxAge: maxAge, + HttpOnly: config.Config.App.SessionHttpOnly, + Secure: config.Config.App.SessionSecure, + } +} From 2cdb50955bd19a63df25f32a75a07fd36c6a7acc Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Tue, 23 Dec 2025 10:35:22 +0800 Subject: [PATCH 034/366] feat: optimize proxy middleware service --- frontend/.env.example | 12 ++++++++++-- frontend/next.config.ts | 2 +- frontend/proxy.ts | 11 ++++++----- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/frontend/.env.example b/frontend/.env.example index 81dd197b..a855cd4f 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,3 +1,11 @@ -LINUX_DO_PAY_BACKEND_URL=http://localhost:8010 -LINUX_DO_PAY_FRONTEND_URL=http://localhost:3010 +# 前端 URL +LINUX_DO_CREDIT_FRONTEND_URL=http://localhost:3000 + +# 后端 URL +LINUX_DO_CREDIT_BACKEND_URL=http://localhost:8000 + +# Session Cookie +LINUX_DO_CREDIT_SESSION_COOKIE_NAME=linux_do_credit_session_id + +# 环境 NODE_ENV=production \ No newline at end of file diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 29a6ddf4..24cfc33b 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -5,7 +5,7 @@ const nextConfig: NextConfig = { experimental: { }, async rewrites() { - const backendUrl = process.env.NEXT_PUBLIC_LINUX_DO_CREDIT_BACKEND_URL || 'http://localhost:8000'; + const backendUrl = process.env.LINUX_DO_CREDIT_BACKEND_URL || 'http://localhost:8000'; return [ // 易支付兼容接口 - 创建订单 { diff --git a/frontend/proxy.ts b/frontend/proxy.ts index 675248c8..df249b15 100644 --- a/frontend/proxy.ts +++ b/frontend/proxy.ts @@ -2,7 +2,6 @@ import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' /** - * Next.js 16 代理 * 在渲染页面之前通过会话 cookie 执行快速身份验证检查 * * 流程: @@ -13,7 +12,9 @@ import type { NextRequest } from 'next/server' */ export function proxy(request: NextRequest) { const { pathname, search } = request.nextUrl - const sessionCookie = request.cookies.get('linux_do_credit_session_id') + + const sessionCookieName = process.env.LINUX_DO_CREDIT_SESSION_COOKIE_NAME || 'linux_do_credit_session_id' + const sessionCookie = request.cookies.get(sessionCookieName) /* 定义公共路由 */ const publicRoutes = [ @@ -22,12 +23,12 @@ export function proxy(request: NextRequest) { '/callback', '/privacy', '/terms', - '/docs', ] - /* 定义公共路径前缀(易支付等外部接口) */ + /* 定义公共路径前缀 */ const publicPrefixes = [ - '/epay/', // 易支付 API 兼容接口 + '/docs/', // 文档页面 + '/epay/', // 易支付 API 兼容接口 ] /* 检查当前路径是否为公共路径 */ From c14187dd6b76bf56f782d99ca4b711e41ed9b3e0 Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Tue, 23 Dec 2025 11:29:38 +0800 Subject: [PATCH 035/366] feat: correct some text --- frontend/components/home/footer-section.tsx | 4 ++-- frontend/components/home/hero-section.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/components/home/footer-section.tsx b/frontend/components/home/footer-section.tsx index 933c7097..051711d2 100644 --- a/frontend/components/home/footer-section.tsx +++ b/frontend/components/home/footer-section.tsx @@ -81,8 +81,8 @@ export const FooterSection = React.memo(function FooterSection({ className }: Fo

      © 2025 LINUX DO Credit. All rights reserved. Made With AI By @Chenyme

      - 隐私政策 - 服务条款 + 隐私政策 + 服务条款 Cookie设置
      diff --git a/frontend/components/home/hero-section.tsx b/frontend/components/home/hero-section.tsx index d2d451ea..dd20b458 100644 --- a/frontend/components/home/hero-section.tsx +++ b/frontend/components/home/hero-section.tsx @@ -49,9 +49,9 @@ export const HeroSection = React.memo(function HeroSection({ className }: HeroSe transition={{ duration: 0.6, delay: 0.2, ease: [0.16, 1, 0.3, 1] }} className="text-sm md:text-base text-muted-foreground max-w-xl leading-relaxed mb-10" > - 专为社区开发者打造的积分流通基础设施 + 专为 LINUX DO 社区打造的积分流通基础设施
      - 快速集成、全球覆盖、安全可靠,助您轻松使用积分流通。 + 快速集成、全球覆盖、安全可靠,助您轻松流通积分。 Date: Tue, 23 Dec 2025 14:01:55 +0800 Subject: [PATCH 036/366] feat: Account initialization amount --- internal/apps/oauth/logics.go | 20 ++------- internal/db/migrator/migrator.go | 5 +++ internal/model/system_configs.go | 19 +++++++++ internal/model/users.go | 70 ++++++++++++++++++++++---------- 4 files changed, 76 insertions(+), 38 deletions(-) diff --git a/internal/apps/oauth/logics.go b/internal/apps/oauth/logics.go index 28c9600f..55b61a70 100644 --- a/internal/apps/oauth/logics.go +++ b/internal/apps/oauth/logics.go @@ -21,7 +21,6 @@ import ( "encoding/json" "errors" "io" - "time" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" @@ -30,7 +29,6 @@ import ( "github.com/linux-do/credit/internal/db" "github.com/linux-do/credit/internal/model" "github.com/linux-do/credit/internal/otel_trace" - "github.com/linux-do/credit/internal/util" "go.opentelemetry.io/otel/codes" "gorm.io/gorm" ) @@ -104,21 +102,12 @@ func doOAuth(ctx context.Context, code string) (*model.User, error) { return nil, err } } else if errors.Is(txByUsername.Error, gorm.ErrRecordNotFound) { - user = model.User{ - ID: userInfo.Id, - Username: userInfo.Username, - Nickname: userInfo.Name, - AvatarUrl: userInfo.AvatarUrl, - IsActive: userInfo.Active, - TrustLevel: userInfo.TrustLevel, - SignKey: util.GenerateUniqueIDSimple(), - LastLoginAt: time.Now(), - } - if err = db.DB(ctx).Create(&user).Error; err != nil { + // ID 和 username 都不存在(全新用户) + user = model.User{} + if err = user.CreateWithInitialCredit(ctx, &userInfo); err != nil { span.SetStatus(codes.Error, err.Error()) return nil, err } - user.EnqueueBadgeScoreTask(ctx, 0) } else { // query failed span.SetStatus(codes.Error, txByUsername.Error.Error()) @@ -127,11 +116,10 @@ func doOAuth(ctx context.Context, code string) (*model.User, error) { } else { if user.ID != userInfo.Id { // username 相同但 ID 不同(账户注销后被新用户占用) - if _, err = user.MarkAsDeactivatedAndCreateNew(ctx, &userInfo); err != nil { + if err = user.CreateWithInitialCredit(ctx, &userInfo); err != nil { span.SetStatus(codes.Error, err.Error()) return nil, err } - user.EnqueueBadgeScoreTask(ctx, 0) } else { if err = user.CheckActive(); err != nil { span.SetStatus(codes.Error, err.Error()) diff --git a/internal/db/migrator/migrator.go b/internal/db/migrator/migrator.go index a56e784f..c7ff99bc 100644 --- a/internal/db/migrator/migrator.go +++ b/internal/db/migrator/migrator.go @@ -82,6 +82,11 @@ func initSystemConfigs() { Value: "168", Description: "商家争议时间窗口(小时)", }, + { + Key: model.ConfigKeyNewUserInitialCredit, + Value: "0", + Description: "新用户注册初始积分", + }, } if err := tx.Create(&defaultConfigs).Error; err != nil { diff --git a/internal/model/system_configs.go b/internal/model/system_configs.go index cd9a1010..5eb228d1 100644 --- a/internal/model/system_configs.go +++ b/internal/model/system_configs.go @@ -24,6 +24,7 @@ import ( "time" "github.com/redis/go-redis/v9" + "github.com/shopspring/decimal" "github.com/linux-do/credit/internal/db" ) @@ -33,6 +34,7 @@ const ( ConfigKeyMerchantOrderExpireMinutes = "merchant_order_expire_minutes" // 商家订单过期时间(分钟) ConfigKeyWebsiteOrderExpireMinutes = "website_order_expire_minutes" // 网站订单过期时间(分钟) ConfigKeyDisputeTimeWindowHours = "dispute_time_window_hours" // 商家争议时间窗口(小时) + ConfigKeyNewUserInitialCredit = "new_user_initial_credit" // 新用户注册初始积分 ) const ( @@ -82,3 +84,20 @@ func GetIntByKey(ctx context.Context, key string) (int, error) { return value, nil } + +// GetDecimalByKey 通过 key 查询配置并转换为 decimal.Decimal 类型 +// precision 指定保留的小数位数,多余的小数会被裁剪 +func GetDecimalByKey(ctx context.Context, key string, precision int32) (decimal.Decimal, error) { + var sc SystemConfig + if err := sc.GetByKey(ctx, key); err != nil { + return decimal.Zero, err + } + + value, err := decimal.NewFromString(sc.Value) + if err != nil { + return decimal.Zero, fmt.Errorf("配置 %s 的值 '%s' 无法转换为decimal: %w", key, sc.Value, err) + } + + // 裁剪到指定小数位数 + return value.Truncate(precision), nil +} diff --git a/internal/model/users.go b/internal/model/users.go index eb32059a..973f37b4 100644 --- a/internal/model/users.go +++ b/internal/model/users.go @@ -31,7 +31,7 @@ import ( "github.com/linux-do/credit/internal/db" "github.com/linux-do/credit/internal/logger" "github.com/linux-do/credit/internal/task" - "github.com/linux-do/credit/internal/task/scheduler" + "github.com/linux-do/credit/internal/task/scheduler" "github.com/linux-do/credit/internal/util" "github.com/shopspring/decimal" "gorm.io/gorm" @@ -165,34 +165,60 @@ func (u *User) EnqueueBadgeScoreTask(ctx context.Context, delay time.Duration) e return nil } -// MarkAsDeactivatedAndCreateNew 将当前用户标记为已注销,并创建新用户 -func (u *User) MarkAsDeactivatedAndCreateNew(ctx context.Context, oauthInfo *OAuthUserInfo) (*User, error) { - err := db.DB(ctx).Transaction(func(tx *gorm.DB) error { - // 将旧用户名修改为注销状态 - oldUsername := fmt.Sprintf("%s已注销: %s", u.Username, uuid.NewString()) - if err := tx.Model(u).Updates(map[string]interface{}{ - "username": oldUsername, - "is_active": false, - }).Error; err != nil { +// CreateWithInitialCredit 创建新用户并初始化积分、订单 +// 如果u不为空(u.ID != 0),会先将当前用户标记为已注销,然后创建新用户 +func (u *User) CreateWithInitialCredit(ctx context.Context, oauthInfo *OAuthUserInfo) error { + return db.DB(ctx).Transaction(func(tx *gorm.DB) error { + // 如果当前用户不为空,先注销当前用户 + if u.ID != 0 { + oldUsername := fmt.Sprintf("%s已注销: %s", u.Username, uuid.NewString()) + if err := tx.Model(u).Updates(map[string]interface{}{ + "username": oldUsername, + "is_active": false, + }).Error; err != nil { + return err + } + } + + newUserInitialCredit, err := GetDecimalByKey(ctx, ConfigKeyNewUserInitialCredit, 2) + if err != nil { return err } - // 创建新用户 + now := time.Now() newUser := User{ - ID: oauthInfo.Id, - Username: oauthInfo.Username, - Nickname: oauthInfo.Name, - AvatarUrl: oauthInfo.AvatarUrl, - IsActive: oauthInfo.Active, - TrustLevel: oauthInfo.TrustLevel, - SignKey: util.GenerateUniqueIDSimple(), - LastLoginAt: time.Now(), + ID: oauthInfo.Id, + Username: oauthInfo.Username, + Nickname: oauthInfo.Name, + AvatarUrl: oauthInfo.AvatarUrl, + IsActive: oauthInfo.Active, + TrustLevel: oauthInfo.TrustLevel, + SignKey: util.GenerateUniqueIDSimple(), + TotalReceive: newUserInitialCredit, + AvailableBalance: newUserInitialCredit, + LastLoginAt: now, + } + if err = tx.Create(&newUser).Error; err != nil { + return err } - if err := tx.Create(&newUser).Error; err != nil { + + order := Order{ + OrderName: "新用户注册奖励", + PayerUserID: 0, + PayeeUserID: newUser.ID, + Amount: newUserInitialCredit, + Status: OrderStatusSuccess, + Type: OrderTypeCommunity, + Remark: fmt.Sprintf("新用户 %s 注册赠送初始积分 %s", newUser.Username, newUserInitialCredit.String()), + TradeTime: now, + ExpiresAt: now, + } + if err = tx.Create(&order).Error; err != nil { return err } + *u = newUser - return nil + + return u.EnqueueBadgeScoreTask(ctx, 0) }) - return u, err } From 12d1ed5ba18d4f3b2d65f66c937e11f8d44bbf47 Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Tue, 23 Dec 2025 14:24:08 +0800 Subject: [PATCH 037/366] fix: bugs reported during the closed beta test --- .../primitives/texts/counting-number.tsx | 8 +-- frontend/components/auth/login-page.tsx | 4 +- .../common/balance/balance-table.tsx | 2 +- frontend/components/common/docs/api.tsx | 40 +++++++------- .../components/common/docs/how-to-use.tsx | 52 +++++++++---------- .../common/docs/legal-page-layout.tsx | 22 ++++---- frontend/components/common/docs/privacy.tsx | 6 +-- frontend/components/common/docs/terms.tsx | 10 ++-- .../components/common/home/data-panel.tsx | 16 +++--- .../components/common/settings/profile.tsx | 38 +++++++------- frontend/components/common/trade/online.tsx | 2 +- .../components/common/trade/trade-main.tsx | 2 +- frontend/components/common/trade/transfer.tsx | 2 - frontend/components/home/hero-section.tsx | 4 +- frontend/components/ui/code-block.tsx | 2 +- frontend/components/ui/docs-table.tsx | 4 +- 16 files changed, 107 insertions(+), 107 deletions(-) diff --git a/frontend/components/animate-ui/primitives/texts/counting-number.tsx b/frontend/components/animate-ui/primitives/texts/counting-number.tsx index 756b626b..3dac66c9 100644 --- a/frontend/components/animate-ui/primitives/texts/counting-number.tsx +++ b/frontend/components/animate-ui/primitives/texts/counting-number.tsx @@ -28,7 +28,7 @@ function CountingNumber({ inViewMargin = '0px', inViewOnce = true, decimalSeparator = '.', - transition = { stiffness: 90, damping: 50 }, + transition = { stiffness: 200, damping: 30 }, decimalPlaces = 0, delay = 0, initiallyStable = false, @@ -79,7 +79,7 @@ function CountingNumber({ const [intPart, fracPart] = formatted.split(decimalSeparator); const paddedInt = intPart?.padStart(finalIntLength, '0') ?? ''; formatted = fracPart - ? `${paddedInt}${decimalSeparator}${fracPart}` + ? `${ paddedInt }${ decimalSeparator }${ fracPart }` : paddedInt; } @@ -97,14 +97,14 @@ function CountingNumber({ if (padStart) { const [intPart, fracPart] = out.split(decimalSeparator); const paddedInt = (intPart ?? '').padStart(finalIntLength, '0'); - out = fracPart ? `${paddedInt}${decimalSeparator}${fracPart}` : paddedInt; + out = fracPart ? `${ paddedInt }${ decimalSeparator }${ fracPart }` : paddedInt; } return out; }; const zeroText = padStart ? '0'.padStart(finalIntLength, '0') + - (decimals > 0 ? decimalSeparator + '0'.repeat(decimals) : '') + (decimals > 0 ? decimalSeparator + '0'.repeat(decimals) : '') : '0' + (decimals > 0 ? decimalSeparator + '0'.repeat(decimals) : ''); const initialText = initiallyStable ? formatValue(number) : zeroText; diff --git a/frontend/components/auth/login-page.tsx b/frontend/components/auth/login-page.tsx index ccb1a37b..f02f63d0 100644 --- a/frontend/components/auth/login-page.tsx +++ b/frontend/components/auth/login-page.tsx @@ -180,7 +180,7 @@ export function LoginPage() { > {[0, 1, 2, 3, 4, 5].map((i) => ( - + ))} @@ -316,7 +316,7 @@ export function LoginPage() { )} -
      +
      © {new Date().getFullYear()} LINUX DO Credit. 版权所有
      diff --git a/frontend/components/common/balance/balance-table.tsx b/frontend/components/common/balance/balance-table.tsx index c4a35bf8..793851ac 100644 --- a/frontend/components/common/balance/balance-table.tsx +++ b/frontend/components/common/balance/balance-table.tsx @@ -73,7 +73,7 @@ export function BalanceTable() { onValueChange={(value) => setActiveTab(value as OrderType | "all")} className="w-full" > - + {TABS.map((tab) => (
      -

      官方服务接口暂未上限,敬请期待

      +

      官方服务接口暂未上线,敬请期待

      ), @@ -37,16 +37,16 @@ export const apiSections: PolicySection[] = [

      兼容易支付、CodePay、VPay 等支付协议

      -

      2.1 概览

      -
        +

        2.1 概览

        +
        • 协议:EasyPay / CodePay / VPay 兼容协议
        • 服务类型:仅支持 type=epay
        • 网关基址:https://credit.linux.do/epay
        • 订单有效期:取系统配置 merchant_order_expire_minutes(平台端设置)
        -

        2.2 常见错误

        -
          +

          2.2 常见错误

          +
          • 不支持的请求类型type 仅允许 epay
          • 签名验证失败:参与签名字段与请求体需一致,密钥直接拼接
          • 金额必须大于0 / 积分小数位数不能超过2位
          • @@ -55,8 +55,8 @@ export const apiSections: PolicySection[] = [
          • 余额不足:余额退回时用户积分不足
          -

          2.3 对接流程

          -
            +

            2.3 对接流程

            +
            1. 控制台创建 API Key,记录 pidkey,配置回调地址
            2. 按“签名算法”生成 sign,调用 /pay/submit.php 创建积分流转服务并跳转认证界面
            3. 可通过 /api.php 轮询结果,或等待异步回调
            4. @@ -64,17 +64,17 @@ export const apiSections: PolicySection[] = [
            5. 回调验签通过后返回 success 完成闭环
            -

            2.4 鉴权与签名

            -

            2.4.1 API Key

            -
              +

              2.4 鉴权与签名

              +

              2.4.1 API Key

              +
              • pid:Client ID
              • key:Client Secret(妥善保管)
              • notify_url:回调地址, 使用创建应用时设置的 notify_url;请求体中的 notify_url 仅参与签名,不会覆盖创建应用时设置的 notify_url。
              -

              2.4.2 签名算法

              +

              2.4.2 签名算法

              -
                +
                1. 取所有非空字段(排除 signsign_type 字段)
                2. 将上述字段按 ASCII 升序,依次拼成 k1=v1&k2=v2
                3. 在末尾追加应用密钥:k1=v1&k2=v2{"{secret}"}
                4. @@ -87,8 +87,8 @@ sign=$(echo -n "\${payload}\${SECRET}" | md5) # 输出小写`} />
              -

              2.5 积分流转服务

              -
                +

                2.5 积分流转服务

                +
                • 方法:POST /pay/submit.php
                • 编码:application/x-www-form-urlencoded
                • 成功:验签通过后,平台自动创建积分流转服务,并跳转到认证界面(Location=https://credit.linux.do/paying?order_no=...
                • @@ -171,8 +171,8 @@ sign=$(echo -n "\${payload}\${SECRET}" | md5) # 输出小写`} language="bash" /> -

                  2.6 订单查询

                  -
                    +

                    2.6 订单查询

                    +
                    • 方法:GET /api.php
                    • 认证:pid + key
                    • 说明:trade_no 必填;out_trade_no 可选;act 可传 order,后端不强校验。
                    • @@ -234,8 +234,8 @@ sign=$(echo -n "\${payload}\${SECRET}" | md5) # 输出小写`} />

                      补充:status 1=成功,0=失败/处理中;不存在会返回 HTTP 404 且 {`{"code":-1,"msg":"服务不存在或已完成"}`}

                      -

                      2.7 订单退款

                      -
                        +

                        2.7 订单退款

                        +
                        • 方法:POST /api.php
                        • 编码:application/jsonapplication/x-www-form-urlencoded
                        • 限制:仅支持对已成功的积分流转服务进行积分的全额退回
                        • @@ -285,8 +285,8 @@ sign=$(echo -n "\${payload}\${SECRET}" | md5) # 输出小写`}

                          常见失败:服务不存在/未认证、金额不合法(<=0 或小数超过 2 位)。

                          -

                          2.8 异步通知(认证成功)

                          -
                            +

                            2.8 异步通知(认证成功)

                            +
                            • 触发:认证成功后;失败自动重试,最多 5 次(单次 30s 超时)
                            • 目标:创建应用时设置的 notify_url
                            • 方式:HTTP GET
                            • diff --git a/frontend/components/common/docs/how-to-use.tsx b/frontend/components/common/docs/how-to-use.tsx index 1a31fdf7..986f2431 100644 --- a/frontend/components/common/docs/how-to-use.tsx +++ b/frontend/components/common/docs/how-to-use.tsx @@ -50,11 +50,11 @@ export const howToUseSections: PolicySection[] = [ title: "3. 接入积分服务", content: (
                              -

                              3.1 使用 API 接口

                              -
                                +

                                3.1 使用 API 接口

                                +
                                1. 创建应用 -
                                    +
                                    • 前往 集市中心
                                    • 点击顶部右侧 创建应用 按钮
                                    • 填写必要信息:应用名称、应用主页、回调地址、通知地址
                                    • @@ -62,10 +62,10 @@ export const howToUseSections: PolicySection[] = [
                                    • 获取 API 凭证 -
                                        +
                                        • 在集市中心顶部右侧选择器中选择您的应用
                                        • API 配置 面板中获取: -
                                            +
                                            • Client ID:客户端ID,用于标识您的身份
                                            • Client Secret:客户端密钥,用于签名验证(请妥善保管,切勿泄露
                                            @@ -74,18 +74,18 @@ export const howToUseSections: PolicySection[] = [
                                          • 使用 API 接口 -
                                -

                                3.2 使用在线服务

                                -
                                  +

                                  3.2 使用在线服务

                                  +
                                  • 适用场景:无代码开发基础,或只用于简单的积分服务。
                                  • 操作步骤: -
                                      +
                                      1. 前往 集市中心 创建应用,获取 API 凭证
                                      2. 选择应用,点击 在线收款 功能
                                      3. 创建在线积分服务
                                      4. @@ -95,11 +95,11 @@ export const howToUseSections: PolicySection[] = [
                                  -

                                  3.3 快速集成 New API

                                  -
                                    +

                                    3.3 快速集成 New API

                                    +
                                    • 适用场景:New API 站点,LINUX DO Credit 兼容 EasyPay 协议,公益站站长可直接集成。
                                    • 操作步骤: -
                                        +
                                        1. 前往 集市中心,点击 创建应用,填写 New API 站点信息:
                                          @@ -145,7 +145,7 @@ export const howToUseSections: PolicySection[] = [ 支付地址 - http://credit.linux.do/epay/pay + https://credit.linux.do/epay/pay 易支付商户ID @@ -201,9 +201,9 @@ export const howToUseSections: PolicySection[] = [ title: "5. 服务(手续)费", content: (
                                          -

                                          5.1 规则说明

                                          +

                                          5.1 规则说明

                                          为了更好的维持 LINUX DO Credit 平台的积分服务机制,保证社区积分的生态可持续发展,我们会按照规范进行不同程度的服务(手续)费用收取。

                                          -
                                            +
                                            • 承担方:服务(手续)费默认由服务方承担
                                            • 消费方使用:不会产生额外费用
                                            • 服务方实收:订单金额 - 服务(手续)费
                                            • @@ -217,9 +217,9 @@ export const howToUseSections: PolicySection[] = [ />
                                          -

                                          5.2 动态费率

                                          +

                                          5.2 动态费率

                                          费率并非固定不变,会根据以下因素动态调整:

                                          -
                                            +
                                            • 服务方平台等级
                                            • 服务方平台积分
                                            • LINUX DO Credit 平台活动
                                            • @@ -237,9 +237,9 @@ export const howToUseSections: PolicySection[] = [ content: (

                                              为了保障服务方与消费方的合法权益,当积分服务出现纠纷时,可使用争议功能。

                                              -
                                                +
                                                • 作为服务方,您需要及时响应消费方的争议请求: -
                                                    +
                                                    1. 在集市中心或通知中查看到 待处理的争议
                                                    2. 查看消费方理由,选择操作:
                                                        @@ -263,10 +263,10 @@ export const howToUseSections: PolicySection[] = [ title: "7. 积分转移", content: (
                                                        -
                                                          +
                                                          • 适用场景:向 LINUX DO Credit 平台内的其他用户进行积分转移。
                                                          • 操作步骤: -
                                                              +
                                                              1. 进入 活动 页面,选择 积分转移
                                                              2. 填写接收方信息:
                                                                  @@ -293,15 +293,15 @@ export const howToUseSections: PolicySection[] = [ content: (

                                                                  您的 LINUX DO Credit 平台基础积分主要由 社区积分 (Community Balance) 划转而来。

                                                                  -
                                                                    +
                                                                    • 基本获取方式:通过在 LINUX DO 社区的活跃行为获得: -
                                                                        +
                                                                      • 划转规则: -
                                                                          +
                                                                          • 划转时间:社区积分每日凌晨 00:00 自动划转至可用余额
                                                                          • 限制说明:划转前不可用于任何积分服务
                                                                          • 服务费用:目前不收取任何划转 服务费
                                                                          • @@ -317,8 +317,8 @@ export const howToUseSections: PolicySection[] = [ content: (

                                                                            您可以在 设置 (Settings) 页面管理您的账户信息。

                                                                            -

                                                                            功能列表

                                                                            -
                                                                              +

                                                                              功能列表

                                                                              +
                                                                              • 个人资料:查看当前的账户信息和会员等级
                                                                              • 安全设置:修改认证密码
                                                                              • 通知设置:暂未上线
                                                                              • diff --git a/frontend/components/common/docs/legal-page-layout.tsx b/frontend/components/common/docs/legal-page-layout.tsx index 7acefce1..42e49dd1 100644 --- a/frontend/components/common/docs/legal-page-layout.tsx +++ b/frontend/components/common/docs/legal-page-layout.tsx @@ -74,7 +74,7 @@ export function LegalPageLayout({ } return ( -
                                                                                +
                                                                                -
                                                                                +
                                                                                -
                                                                                +
                                                                                -
                                                                                +
                                                                                {sections.map((section, index) => ( {index + 1} -

                                                                                +

                                                                                {section.title.replace(/^\d+\.\s*/, '')}

                                                                                {section.content}
                                                                                diff --git a/frontend/components/common/docs/privacy.tsx b/frontend/components/common/docs/privacy.tsx index b63d4cba..e257b8a7 100644 --- a/frontend/components/common/docs/privacy.tsx +++ b/frontend/components/common/docs/privacy.tsx @@ -35,7 +35,7 @@ export const privacySections: PolicySection[] = [ content: (

                                                                                我们深知数据安全的重要性,并采取业界领先的技术措施保护您的数据:

                                                                                -
                                                                                  +
                                                                                  • 存储地点:依照法规要求,我们收集和产生的用户个人信息,存储在独立的服务器上。我们不会将您的数据传输至境外管辖区。
                                                                                  • 加密技术:敏感数据(如 支付密码)在数据库中均采用高强度加密算法存储。数据传输全链路采用 SSL/TLS 1.3 协议进行加密,防止网络嗅探。
                                                                                  • 隔离机制:本平台数据与外部网络物理隔离,且独立于 LINUX DO 社区论坛主数据库,确保单一系统故障不会波及全局数据安全。
                                                                                  • @@ -50,7 +50,7 @@ export const privacySections: PolicySection[] = [ content: (

                                                                                    我们收集的信息将仅用于以下目的:

                                                                                    -
                                                                                      +
                                                                                      • 身份识别:用于确认您的社区身份,展示您的个人中心数据。
                                                                                      • 业务功能:处理您的支付指令、API 请求、回调通知及账单生成。
                                                                                      • 安全风控:利用 IP 及行为日志进行反作弊、反欺诈分析,识别恶意攻击行为,保护平台及其他用户的安全。
                                                                                      • @@ -66,7 +66,7 @@ export const privacySections: PolicySection[] = [ content: (

                                                                                        4.1 共享原则:我们坚持数据零共享策略。除以下极端情况外,我们不会向任何第三方(包括且不限于关联公司、支付宝、微信、银行、广告商)共享您的个人信息:

                                                                                        -
                                                                                          +
                                                                                          • 事先获得您的明确授权或同意;
                                                                                          • 根据适用的法律法规、法律程序的要求、强制性的行政或司法要求所必须的情况下进行提供。
                                                                                          diff --git a/frontend/components/common/docs/terms.tsx b/frontend/components/common/docs/terms.tsx index 12d041db..9409257c 100644 --- a/frontend/components/common/docs/terms.tsx +++ b/frontend/components/common/docs/terms.tsx @@ -26,7 +26,7 @@ export const termsSections: PolicySection[] = [

                                                                                          2.1 社区技术服务:LINUX DO Credit 是基于 LINUX DO 社区生态构建的独立价值交换协议与技术系统。我们仅提供 API 接口调用、数据路由、账单管理等纯技术服务

                                                                                          2.2 非金融机构申明:

                                                                                          -
                                                                                            +
                                                                                            • 非银行机构:我们不是商业银行、持牌支付机构(如支付宝、微信支付、银联)或清算机构。
                                                                                            • 不提供资金沉淀:平台不设立资金池,不提供真实法币存取款、转账汇款或支付结算服务。所有涉及资金流转的行为均发生于社区用户与社区支付渠道之间,不涉及真实货币。
                                                                                            • 不提供金融服务:平台不提供任何金融服务,包括但不限于贷款、融资、投资、理财、保险等金融服务。
                                                                                            • @@ -44,7 +44,7 @@ export const termsSections: PolicySection[] = [

                                                                                              3.1 账号体系:本平台采用 LINUX DO Connect (OAuth) 授权登录体系。您必须拥有合法、有效的 LINUX DO 社区账号方可使用本服务。您的平台账号权益(包括但不限于信誉分、等级)与社区账号严格绑定。

                                                                                              3.2 匿名性与真实性:

                                                                                              -
                                                                                                +
                                                                                                • 无需实名:我们尊重您的隐私,不强制要求您提供居民身份证、护照或营业执照进行实名认证。
                                                                                                • 操作真实性:您承诺注册和使用的账号是您本人操作。严禁恶意注册、挂机脚本、自动化程序注册等破坏平台公平性的行为。
                                                                                                @@ -58,8 +58,8 @@ export const termsSections: PolicySection[] = [ content: (

                                                                                                您在使用本服务时,必须严格遵守《中华人民共和国网络安全法》、《计算机信息网络国际联网安全保护管理办法》等法律法规。严禁利用本平台从事以下活动(“红线条款”):

                                                                                                -
                                                                                                -
                                                                                                  +
                                                                                                  +
                                                                                                  • 危害国家安全:反对宪法所确定的基本原则、危害国家安全、泄露国家秘密、颠覆国家政权、破坏国家统一的;
                                                                                                  • 非法信息服务:黑客攻击工具、DDoS 攻击服务、服务器爆破等其他非法信息服务平台;
                                                                                                  • 黄赌毒关联:制作、复制、发布、传播淫秽、色情、赌博、暴力、凶杀、恐怖或者教唆犯罪的;
                                                                                                  • @@ -90,7 +90,7 @@ export const termsSections: PolicySection[] = [

                                                                                                    6.1 基础免责:本平台服务按“现状”(As-Is)及“现有”(As-Available)状态提供。我们不保证服务一定能满足您的要求,也不保证服务不会中断,对服务的及时性、安全性、准确性都不作担保。

                                                                                                    6.2 不可抗力:对于因以下原因导致的服务中断、数据丢失或账号损失,平台不承担赔偿责任:

                                                                                                    -
                                                                                                      +
                                                                                                      • 自然灾害(台风、地震、海啸、洪水等);
                                                                                                      • 政府行为、法律法规或政策调整、行政命令;
                                                                                                      • 电信部门技术调整、通讯线路中断、海底光缆故障;
                                                                                                      • diff --git a/frontend/components/common/home/data-panel.tsx b/frontend/components/common/home/data-panel.tsx index f7e67923..500849dc 100644 --- a/frontend/components/common/home/data-panel.tsx +++ b/frontend/components/common/home/data-panel.tsx @@ -85,8 +85,9 @@ export function DataPanel() { }, [dailyStats, availableBalance, userLoading]) return ( -
                                                                                                        -
                                                                                                        +
                                                                                                        + {/* 积分趋势图表区域 - 移动端在上方,桌面端占左侧 2/3 */} +

                                                                                                        积分趋势

                                                                                                        {loading || userLoading ? ( @@ -177,16 +178,17 @@ export function DataPanel() { )}
                                                                                                        -
                                                                                                        -
                                                                                                        -
                                                                                                        LINUX DO Credits
                                                                                                        + {/* 统计数据区域 - 移动端在下方并排展示,桌面端在右侧垂直展示 */} +
                                                                                                        +
                                                                                                        +
                                                                                                        LINUX DO Credits
                                                                                                        {userLoading ? '-' : }
                                                                                                        -
                                                                                                        -
                                                                                                        +
                                                                                                        +
                                                                                                        今日剩余额度 diff --git a/frontend/components/common/settings/profile.tsx b/frontend/components/common/settings/profile.tsx index 2ce33412..ba33f899 100644 --- a/frontend/components/common/settings/profile.tsx +++ b/frontend/components/common/settings/profile.tsx @@ -157,43 +157,43 @@ function MembershipCard({ )} -
                                                                                                        +
                                                                                                        -
                                                                                                        +
                                                                                                        {config.nameEn}
                                                                                                        -
                                                                                                        +
                                                                                                        {config.name}
                                                                                                        -
                                                                                                        -
                                                                                                        平台分数
                                                                                                        -
                                                                                                        +
                                                                                                        +
                                                                                                        平台分数
                                                                                                        +
                                                                                                        {score.toLocaleString()}
                                                                                                        -
                                                                                                        -
                                                                                                        +
                                                                                                        +
                                                                                                        LINUX DO Credit
                                                                                                        -
                                                                                                        分数范围
                                                                                                        -
                                                                                                        +
                                                                                                        分数范围
                                                                                                        +
                                                                                                        {config.minScore.toLocaleString()} - {config.maxScore?.toLocaleString() || "∞"}
                                                                                                        -
                                                                                                        +
                                                                                                        {isAccessible ? ( - + @@ -203,7 +203,7 @@ function MembershipCard({ ) : (
                                                                                                        @@ -335,16 +335,16 @@ export function ProfileMain() {

                                                                                                        基本信息

                                                                                                        -
                                                                                                        - +
                                                                                                        + - + {(user.nickname || user.username).slice(0, 2)} -
                                                                                                        -
                                                                                                        +
                                                                                                        +
                                                                                                        账户
                                                                                                        @{user.username}
                                                                                                        diff --git a/frontend/components/common/trade/online.tsx b/frontend/components/common/trade/online.tsx index 32ad036c..8da2b945 100644 --- a/frontend/components/common/trade/online.tsx +++ b/frontend/components/common/trade/online.tsx @@ -15,7 +15,7 @@ export function Online() {

                                                                                                        创建和管理您的在线积分流转服务,无需编写代码即可快速开启个人服务。

                                                                                                        - + diff --git a/frontend/components/common/trade/trade-main.tsx b/frontend/components/common/trade/trade-main.tsx index 27bcd807..db35643e 100644 --- a/frontend/components/common/trade/trade-main.tsx +++ b/frontend/components/common/trade/trade-main.tsx @@ -78,7 +78,7 @@ export function TradeMain() { className="w-full" >
                                                                                                        - + 积分收益 积分消耗 积分转移 diff --git a/frontend/components/common/trade/transfer.tsx b/frontend/components/common/trade/transfer.tsx index 32386a43..e258e4d5 100644 --- a/frontend/components/common/trade/transfer.tsx +++ b/frontend/components/common/trade/transfer.tsx @@ -173,14 +173,12 @@ export function Transfer() {
                                                                                                        - LDC setAmount(e.target.value)} - className="pl-12 font-mono" disabled={loading} />
                                                                                                        diff --git a/frontend/components/home/hero-section.tsx b/frontend/components/home/hero-section.tsx index dd20b458..5a02e3e8 100644 --- a/frontend/components/home/hero-section.tsx +++ b/frontend/components/home/hero-section.tsx @@ -61,7 +61,7 @@ export const HeroSection = React.memo(function HeroSection({ className }: HeroSe transition={{ duration: 0.6, delay: 0.3, ease: [0.16, 1, 0.3, 1] }} className="flex flex-col sm:flex-row items-center gap-4" > - +
                                                                                                        -
                                                                                                        +        
                                                                                                                   
                                                                                                                     {code}
                                                                                                                   
                                                                                                        diff --git a/frontend/components/ui/docs-table.tsx b/frontend/components/ui/docs-table.tsx
                                                                                                        index ed666ec5..cf4dbc55 100644
                                                                                                        --- a/frontend/components/ui/docs-table.tsx
                                                                                                        +++ b/frontend/components/ui/docs-table.tsx
                                                                                                        @@ -5,10 +5,10 @@ const DocsTable = React.forwardRef<
                                                                                                           HTMLTableElement,
                                                                                                           React.HTMLAttributes
                                                                                                         >(({ className, ...props }, ref) => (
                                                                                                        -  
                                                                                                        +
                                                                                                        From 20372a9836101bdbf7e654af2eca50c1a5a7f913 Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Tue, 23 Dec 2025 17:57:45 +0800 Subject: [PATCH 038/366] fix: correction of inaccurate copywriting --- frontend/components/common/docs/terms.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/components/common/docs/terms.tsx b/frontend/components/common/docs/terms.tsx index 9409257c..87e23150 100644 --- a/frontend/components/common/docs/terms.tsx +++ b/frontend/components/common/docs/terms.tsx @@ -77,9 +77,9 @@ export const termsSections: PolicySection[] = [ title: "5. 虚拟资产与交易规则", content: (
                                                                                                        -

                                                                                                        5.1 资产性质:平台内流转的“余额”、“积分”等均为社区虚拟资产,仅代表您在社区生态内的技术贡献或权益凭证,不具有法定货币等同的法律地位,原则上不支持反向兑换为法定货币。

                                                                                                        -

                                                                                                        5.2 交易不可逆:鉴于区块链及网络技术的特性,一旦虚拟资产转移指令被执行,该操作即不可撤销。请您在确认支付或转账前,务必仔细核对收款方信息。

                                                                                                        -

                                                                                                        5.3 费用说明:为营造良好的交易环境和货币系统,平台有权向商户收取服务费、手续费(均为虚拟资产,不涉及真实货币),具体费率以控制台公示为准。平台保留根据金融审计结果调整费率的权利。

                                                                                                        +

                                                                                                        5.1 资产性质:平台内流转的“余额”、“积分”等均为社区虚拟积分,仅代表您在社区生态内的活跃度或贡献值。它们不具有任何货币属性,严禁兑换为法定货币,也不可用于任何非平台许可的商业交易。

                                                                                                        +

                                                                                                        5.2 交易不可逆:鉴于网络技术的实时性,一旦积分转移指令被执行,该操作即不可撤销。请您在确认消耗或转移积分前,务必仔细核对服务方信息。

                                                                                                        +

                                                                                                        5.3 规则说明:为营造良好的社区积分环境,平台有权收取一定的服务费(以积分为结算单位)进行调控,具体规则以控制台公示为准。平台保留根据社区运营状况调整积分规则的权利。

                                                                                                        ), }, From e1421220d8aebe51eacd621e7d61011d5400a429 Mon Sep 17 00:00:00 2001 From: cattie <2237829695@qq.com> Date: Tue, 23 Dec 2025 18:50:26 +0800 Subject: [PATCH 039/366] feat: autoclose sidebar when menu item is clicked in mobile view. --- frontend/components/layout/sidebar.tsx | 31 +++++++++++++++++++------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/frontend/components/layout/sidebar.tsx b/frontend/components/layout/sidebar.tsx index 16240178..8504a308 100644 --- a/frontend/components/layout/sidebar.tsx +++ b/frontend/components/layout/sidebar.tsx @@ -91,13 +91,19 @@ const data = { * @returns {React.ReactNode} 应用侧边栏组件 */ export function AppSidebar({ ...props }: React.ComponentProps) { - const { toggleSidebar, state } = useSidebar() + const { toggleSidebar, state, isMobile, setOpenMobile } = useSidebar() const { user, getTrustLevelLabel, logout } = useUser() const [showLogoutDialog, setShowLogoutDialog] = React.useState(false) const [isLoggingOut, setIsLoggingOut] = React.useState(false) const pathname = usePathname() const router = useRouter() + const handleCloseSidebar = React.useCallback(() => { + if (isMobile) { + setOpenMobile(false) + } + }, [isMobile, setOpenMobile]) + const handleLogout = async () => { setIsLoggingOut(true) try { @@ -179,16 +185,25 @@ export function AppSidebar({ ...props }: React.ComponentProps) { - router.push("/settings/profile")}> + { + router.push("/settings/profile") + handleCloseSidebar() + }}> 我的资料 - router.push("/settings")}> + { + router.push("/settings") + handleCloseSidebar() + }}> 设置 - router.push("/docs/how-to-use")}> + { + router.push("/docs/how-to-use") + handleCloseSidebar() + }}> 使用帮助 @@ -210,7 +225,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { isActive={pathname === item.url} asChild > - + {item.icon && } {item.title} @@ -235,7 +250,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { isActive={pathname === item.url} asChild > - + {item.icon && } {item.title} @@ -259,7 +274,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { tooltip={item.title} asChild > - + {item.icon && } {item.title} @@ -282,7 +297,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { tooltip={item.title} asChild > - + {item.icon && } {item.title} From ab52a7d1444f6c144d35ea99ef92743c5e9e3a0a Mon Sep 17 00:00:00 2001 From: chenhy <1545510953@qq.com> Date: Wed, 24 Dec 2025 10:43:13 +0800 Subject: [PATCH 040/366] feat: implement OIDC authentication with fallback support --- config.example.yaml | 9 +-- go.mod | 1 + internal/apps/oauth/config.go | 70 ++++++++++++++++++++-- internal/apps/oauth/logics.go | 109 +++++++++++++++++++++++++++------- internal/config/model.go | 5 +- internal/router/router.go | 7 +++ 6 files changed, 170 insertions(+), 31 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 73712721..848b07fb 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -17,14 +17,15 @@ app: api_prefix: "/api" frontend_pay_url: "http://localhost:3000/paying" -# OAuth2 +# OAuth2/OIDC(优先) oauth2: client_id: "" client_secret: "" redirect_uri: "" - authorization_endpoint: "https://connect.linux.do/oauth2/authorize" - token_endpoint: "https://connect.linux.do/oauth2/token" - user_endpoint: "https://connect.linux.do/api/user" + issuer: "https://connect.linux.do/" # OIDC Issuer URL,用于自动发现端点 + authorization_endpoint: "https://connect.linux.do/oauth2/authorize" # 回退用 + token_endpoint: "https://connect.linux.do/oauth2/token" # 回退用 + user_endpoint: "https://connect.linux.do/api/user" # 回退用 # DB # 支持两种模式:Standalone(单节点)、Primary-Replica(读写分离) diff --git a/go.mod b/go.mod index eaa349b1..1863eb4a 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.5 require ( github.com/ClickHouse/clickhouse-go/v2 v2.37.2 github.com/bwmarrin/snowflake v0.3.0 + github.com/coreos/go-oidc/v3 v3.11.0 github.com/gin-contrib/sessions v1.0.4 github.com/gin-gonic/gin v1.11.0 github.com/google/uuid v1.6.0 diff --git a/internal/apps/oauth/config.go b/internal/apps/oauth/config.go index 41e28ce2..fc95a166 100644 --- a/internal/apps/oauth/config.go +++ b/internal/apps/oauth/config.go @@ -17,22 +17,84 @@ limitations under the License. package oauth import ( + "context" + "sync" + + "github.com/coreos/go-oidc/v3/oidc" "github.com/linux-do/credit/internal/config" + "github.com/linux-do/credit/internal/logger" "golang.org/x/oauth2" ) -var oauthConf *oauth2.Config +var ( + oauthConf *oauth2.Config + oidcProvider *oidc.Provider + oidcVerifier *oidc.IDTokenVerifier + initOnce sync.Once + initErr error +) + +// InitOIDC 初始化 OIDC Provider 和 Verifier +func InitOIDC(ctx context.Context) error { + initOnce.Do(func() { + issuer := config.Config.OAuth2.Issuer + if issuer == "" { + issuer = "https://connect.linux.do/" + } + + // 创建 OIDC Provider + oidcProvider, initErr = oidc.NewProvider(ctx, issuer) + if initErr != nil { + logger.ErrorF(ctx, "Failed to create OIDC provider: %v", initErr) + return + } + + // 创建 ID Token 验证器 + oidcVerifier = oidcProvider.Verifier(&oidc.Config{ + ClientID: config.Config.OAuth2.ClientID, + }) + + // 使用 OIDC Provider 的端点配置 OAuth2 + oauthConf = &oauth2.Config{ + ClientID: config.Config.OAuth2.ClientID, + ClientSecret: config.Config.OAuth2.ClientSecret, + RedirectURL: config.Config.OAuth2.RedirectURI, + Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, + Endpoint: oidcProvider.Endpoint(), + } + + logger.InfoF(ctx, "OIDC provider initialized successfully with issuer: %s", issuer) + }) + + return initErr +} + +// GetOIDCVerifier 获取 OIDC ID Token 验证器 +func GetOIDCVerifier() *oidc.IDTokenVerifier { + return oidcVerifier +} + +// GetOIDCProvider 获取 OIDC Provider +func GetOIDCProvider() *oidc.Provider { + return oidcProvider +} + +// GetOAuthConfig 获取 OAuth2 配置 +func GetOAuthConfig() *oauth2.Config { + return oauthConf +} -func init() { +// InitOAuthFallback 初始化 OAuth2 (不使用 OIDC,作为回退方案) +func InitOAuthFallback() { oauthConf = &oauth2.Config{ ClientID: config.Config.OAuth2.ClientID, ClientSecret: config.Config.OAuth2.ClientSecret, RedirectURL: config.Config.OAuth2.RedirectURI, - Scopes: []string{"user:email:profile"}, + Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, Endpoint: oauth2.Endpoint{ AuthURL: config.Config.OAuth2.AuthorizationEndpoint, TokenURL: config.Config.OAuth2.TokenEndpoint, - AuthStyle: 0, + AuthStyle: oauth2.AuthStyleAutoDetect, }, } } diff --git a/internal/apps/oauth/logics.go b/internal/apps/oauth/logics.go index 55b61a70..6598e08f 100644 --- a/internal/apps/oauth/logics.go +++ b/internal/apps/oauth/logics.go @@ -21,12 +21,14 @@ import ( "encoding/json" "errors" "io" + "strconv" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" "github.com/linux-do/credit/internal/common" "github.com/linux-do/credit/internal/config" "github.com/linux-do/credit/internal/db" + "github.com/linux-do/credit/internal/logger" "github.com/linux-do/credit/internal/model" "github.com/linux-do/credit/internal/otel_trace" "go.opentelemetry.io/otel/codes" @@ -46,6 +48,43 @@ func GetUserIDFromContext(c *gin.Context) uint64 { return GetUserIDFromSession(session) } +// OIDCClaims OIDC ID Token 中的 Claims +type OIDCClaims struct { + Sub string `json:"sub"` + Username string `json:"username"` + Login string `json:"login"` + Name string `json:"name"` + Email string `json:"email"` + AvatarURL string `json:"avatar_url"` + Active bool `json:"active"` + TrustLevel int `json:"trust_level"` + Silenced bool `json:"silenced"` +} + +// ToOAuthUserInfo 将 OIDC Claims 转换为 OAuthUserInfo +func (c *OIDCClaims) ToOAuthUserInfo() (*model.OAuthUserInfo, error) { + // 解析 sub 为 uint64 + id, err := strconv.ParseUint(c.Sub, 10, 64) + if err != nil { + return nil, errors.New("invalid sub claim: " + c.Sub) + } + + // 使用 username,如果为空则使用 login + username := c.Username + if username == "" { + username = c.Login + } + + return &model.OAuthUserInfo{ + Id: id, + Username: username, + Name: c.Name, + Active: c.Active, + AvatarUrl: c.AvatarURL, + TrustLevel: model.TrustLevel(c.TrustLevel), + }, nil +} + func doOAuth(ctx context.Context, code string) (*model.User, error) { // init trace ctx, span := otel_trace.Start(ctx, "OAuth") @@ -58,25 +97,53 @@ func doOAuth(ctx context.Context, code string) (*model.User, error) { return nil, err } - // get user info - client := oauthConf.Client(ctx, token) - resp, err := client.Get(config.Config.OAuth2.UserEndpoint) - if err != nil { - span.SetStatus(codes.Error, err.Error()) - return nil, err - } - defer func(body io.ReadCloser) { _ = resp.Body.Close() }(resp.Body) + var userInfo *model.OAuthUserInfo - // load user info - responseData, err := io.ReadAll(resp.Body) - if err != nil { - span.SetStatus(codes.Error, err.Error()) - return nil, err + // 先尝试用 OIDC ID Token + if rawIDToken, ok := token.Extra("id_token").(string); ok && oidcVerifier != nil { + idToken, err := oidcVerifier.Verify(ctx, rawIDToken) + if err != nil { + logger.WarnF(ctx, "Failed to verify ID token, falling back to UserEndpoint: %v", err) + } else { + var claims OIDCClaims + if err := idToken.Claims(&claims); err != nil { + logger.WarnF(ctx, "Failed to parse ID token claims, falling back to UserEndpoint: %v", err) + } else { + // 转换为 OAuthUserInfo + userInfo, err = claims.ToOAuthUserInfo() + if err != nil { + logger.WarnF(ctx, "Failed to convert OIDC claims, falling back to UserEndpoint: %v", err) + userInfo = nil + } else { + logger.InfoF(ctx, "Successfully authenticated user via OIDC: %s (ID: %d)", userInfo.Username, userInfo.Id) + } + } + } } - var userInfo model.OAuthUserInfo - if err = json.Unmarshal(responseData, &userInfo); err != nil { - span.SetStatus(codes.Error, err.Error()) - return nil, err + + // 如果 OIDC 不行,回退到 UserEndpoint 方式 + if userInfo == nil { + logger.InfoF(ctx, "Using UserEndpoint fallback for authentication") + client := oauthConf.Client(ctx, token) + resp, err := client.Get(config.Config.OAuth2.UserEndpoint) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + return nil, err + } + defer func(body io.ReadCloser) { _ = resp.Body.Close() }(resp.Body) + + // load user info + responseData, err := io.ReadAll(resp.Body) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + return nil, err + } + var fallbackUserInfo model.OAuthUserInfo + if err = json.Unmarshal(responseData, &fallbackUserInfo); err != nil { + span.SetStatus(codes.Error, err.Error()) + return nil, err + } + userInfo = &fallbackUserInfo } if !userInfo.Active { err = errors.New(common.BannedAccount) @@ -96,7 +163,7 @@ func doOAuth(ctx context.Context, code string) (*model.User, error) { span.SetStatus(codes.Error, err.Error()) return nil, err } - user.UpdateFromOAuthInfo(&userInfo) + user.UpdateFromOAuthInfo(userInfo) if err = db.DB(ctx).Save(&user).Error; err != nil { span.SetStatus(codes.Error, err.Error()) return nil, err @@ -104,7 +171,7 @@ func doOAuth(ctx context.Context, code string) (*model.User, error) { } else if errors.Is(txByUsername.Error, gorm.ErrRecordNotFound) { // ID 和 username 都不存在(全新用户) user = model.User{} - if err = user.CreateWithInitialCredit(ctx, &userInfo); err != nil { + if err = user.CreateWithInitialCredit(ctx, userInfo); err != nil { span.SetStatus(codes.Error, err.Error()) return nil, err } @@ -116,7 +183,7 @@ func doOAuth(ctx context.Context, code string) (*model.User, error) { } else { if user.ID != userInfo.Id { // username 相同但 ID 不同(账户注销后被新用户占用) - if err = user.CreateWithInitialCredit(ctx, &userInfo); err != nil { + if err = user.CreateWithInitialCredit(ctx, userInfo); err != nil { span.SetStatus(codes.Error, err.Error()) return nil, err } @@ -125,7 +192,7 @@ func doOAuth(ctx context.Context, code string) (*model.User, error) { span.SetStatus(codes.Error, err.Error()) return nil, err } - user.UpdateFromOAuthInfo(&userInfo) + user.UpdateFromOAuthInfo(userInfo) if err = db.DB(ctx).Save(&user).Error; err != nil { span.SetStatus(codes.Error, err.Error()) return nil, err diff --git a/internal/config/model.go b/internal/config/model.go index ca787ebf..d657a6b7 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -46,14 +46,15 @@ type appConfig struct { SessionSecure bool `mapstructure:"session_secure"` } -// OAuth2Config OAuth2认证配置 +// OAuth2Config OAuth2/OIDC认证配置 type OAuth2Config struct { ClientID string `mapstructure:"client_id"` ClientSecret string `mapstructure:"client_secret"` RedirectURI string `mapstructure:"redirect_uri"` + Issuer string `mapstructure:"issuer"` // OIDC Issuer URL,用于自动发现端点 AuthorizationEndpoint string `mapstructure:"authorization_endpoint"` TokenEndpoint string `mapstructure:"token_endpoint"` - UserEndpoint string `mapstructure:"user_endpoint"` + UserEndpoint string `mapstructure:"user_endpoint"` // 保留用于回退 } // databaseConfig 数据库配置 diff --git a/internal/router/router.go b/internal/router/router.go index 57b252e9..7226dce9 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -56,6 +56,13 @@ import ( ) func Serve() { + // 初始化 OIDC Provider + ctx := context.Background() + if err := oauth.InitOIDC(ctx); err != nil { + log.Printf("[API] Warning: Failed to initialize OIDC provider, falling back to OAuth2: %v\n", err) + oauth.InitOAuthFallback() + } + // 运行模式 if config.Config.App.Env == "production" { gin.SetMode(gin.ReleaseMode) From f2882ee28c1e16acce6682dd2d3751cb75bf5214 Mon Sep 17 00:00:00 2001 From: chenhy <1545510953@qq.com> Date: Wed, 24 Dec 2025 14:31:23 +0800 Subject: [PATCH 041/366] feat: implement OIDC authentication with fallback support --- go.mod | 1 + go.sum | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/go.mod b/go.mod index 1863eb4a..6ed5d00d 100644 --- a/go.mod +++ b/go.mod @@ -51,6 +51,7 @@ require ( github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-faster/city v1.0.1 // indirect github.com/go-faster/errors v0.7.1 // indirect + github.com/go-jose/go-jose/v4 v4.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.22.1 // indirect diff --git a/go.sum b/go.sum index d3c87881..e94816f8 100644 --- a/go.sum +++ b/go.sum @@ -28,6 +28,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI= +github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -52,6 +54,8 @@ github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E= +github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= From 2ce3922af9814a0522b513493b83b1dd6e4cf835 Mon Sep 17 00:00:00 2001 From: chenhy <1545510953@qq.com> Date: Wed, 24 Dec 2025 16:54:57 +0800 Subject: [PATCH 042/366] refactor: simplify OIDC implementation --- internal/apps/oauth/config.go | 73 ++++++++++------------------------- internal/apps/oauth/logics.go | 49 +++-------------------- internal/model/users.go | 14 +++++++ internal/router/router.go | 7 ---- 4 files changed, 40 insertions(+), 103 deletions(-) diff --git a/internal/apps/oauth/config.go b/internal/apps/oauth/config.go index fc95a166..facc4b8b 100644 --- a/internal/apps/oauth/config.go +++ b/internal/apps/oauth/config.go @@ -18,83 +18,52 @@ package oauth import ( "context" - "sync" "github.com/coreos/go-oidc/v3/oidc" "github.com/linux-do/credit/internal/config" - "github.com/linux-do/credit/internal/logger" "golang.org/x/oauth2" ) var ( oauthConf *oauth2.Config - oidcProvider *oidc.Provider oidcVerifier *oidc.IDTokenVerifier - initOnce sync.Once - initErr error ) -// InitOIDC 初始化 OIDC Provider 和 Verifier -func InitOIDC(ctx context.Context) error { - initOnce.Do(func() { - issuer := config.Config.OAuth2.Issuer - if issuer == "" { - issuer = "https://connect.linux.do/" - } - - // 创建 OIDC Provider - oidcProvider, initErr = oidc.NewProvider(ctx, issuer) - if initErr != nil { - logger.ErrorF(ctx, "Failed to create OIDC provider: %v", initErr) - return - } - - // 创建 ID Token 验证器 - oidcVerifier = oidcProvider.Verifier(&oidc.Config{ - ClientID: config.Config.OAuth2.ClientID, - }) +func init() { + ctx := context.Background() + issuer := config.Config.OAuth2.Issuer + if issuer == "" { + issuer = "https://connect.linux.do/" + } - // 使用 OIDC Provider 的端点配置 OAuth2 + // 创建 OIDC Provider + oidcProvider, err := oidc.NewProvider(ctx, issuer) + if err != nil { + // OIDC 初始化失败,使用 OAuth2 配置 oauthConf = &oauth2.Config{ ClientID: config.Config.OAuth2.ClientID, ClientSecret: config.Config.OAuth2.ClientSecret, RedirectURL: config.Config.OAuth2.RedirectURI, Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, - Endpoint: oidcProvider.Endpoint(), + Endpoint: oauth2.Endpoint{ + AuthURL: config.Config.OAuth2.AuthorizationEndpoint, + TokenURL: config.Config.OAuth2.TokenEndpoint, + AuthStyle: oauth2.AuthStyleAutoDetect, + }, } + return + } - logger.InfoF(ctx, "OIDC provider initialized successfully with issuer: %s", issuer) + // OIDC 初始化成功 + oidcVerifier = oidcProvider.Verifier(&oidc.Config{ + ClientID: config.Config.OAuth2.ClientID, }) - return initErr -} - -// GetOIDCVerifier 获取 OIDC ID Token 验证器 -func GetOIDCVerifier() *oidc.IDTokenVerifier { - return oidcVerifier -} - -// GetOIDCProvider 获取 OIDC Provider -func GetOIDCProvider() *oidc.Provider { - return oidcProvider -} - -// GetOAuthConfig 获取 OAuth2 配置 -func GetOAuthConfig() *oauth2.Config { - return oauthConf -} - -// InitOAuthFallback 初始化 OAuth2 (不使用 OIDC,作为回退方案) -func InitOAuthFallback() { oauthConf = &oauth2.Config{ ClientID: config.Config.OAuth2.ClientID, ClientSecret: config.Config.OAuth2.ClientSecret, RedirectURL: config.Config.OAuth2.RedirectURI, Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, - Endpoint: oauth2.Endpoint{ - AuthURL: config.Config.OAuth2.AuthorizationEndpoint, - TokenURL: config.Config.OAuth2.TokenEndpoint, - AuthStyle: oauth2.AuthStyleAutoDetect, - }, + Endpoint: oidcProvider.Endpoint(), } } diff --git a/internal/apps/oauth/logics.go b/internal/apps/oauth/logics.go index 6598e08f..fa3ecad5 100644 --- a/internal/apps/oauth/logics.go +++ b/internal/apps/oauth/logics.go @@ -21,7 +21,6 @@ import ( "encoding/json" "errors" "io" - "strconv" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" @@ -48,43 +47,6 @@ func GetUserIDFromContext(c *gin.Context) uint64 { return GetUserIDFromSession(session) } -// OIDCClaims OIDC ID Token 中的 Claims -type OIDCClaims struct { - Sub string `json:"sub"` - Username string `json:"username"` - Login string `json:"login"` - Name string `json:"name"` - Email string `json:"email"` - AvatarURL string `json:"avatar_url"` - Active bool `json:"active"` - TrustLevel int `json:"trust_level"` - Silenced bool `json:"silenced"` -} - -// ToOAuthUserInfo 将 OIDC Claims 转换为 OAuthUserInfo -func (c *OIDCClaims) ToOAuthUserInfo() (*model.OAuthUserInfo, error) { - // 解析 sub 为 uint64 - id, err := strconv.ParseUint(c.Sub, 10, 64) - if err != nil { - return nil, errors.New("invalid sub claim: " + c.Sub) - } - - // 使用 username,如果为空则使用 login - username := c.Username - if username == "" { - username = c.Login - } - - return &model.OAuthUserInfo{ - Id: id, - Username: username, - Name: c.Name, - Active: c.Active, - AvatarUrl: c.AvatarURL, - TrustLevel: model.TrustLevel(c.TrustLevel), - }, nil -} - func doOAuth(ctx context.Context, code string) (*model.User, error) { // init trace ctx, span := otel_trace.Start(ctx, "OAuth") @@ -105,16 +67,15 @@ func doOAuth(ctx context.Context, code string) (*model.User, error) { if err != nil { logger.WarnF(ctx, "Failed to verify ID token, falling back to UserEndpoint: %v", err) } else { - var claims OIDCClaims + var claims model.OAuthUserInfo if err := idToken.Claims(&claims); err != nil { logger.WarnF(ctx, "Failed to parse ID token claims, falling back to UserEndpoint: %v", err) } else { - // 转换为 OAuthUserInfo - userInfo, err = claims.ToOAuthUserInfo() - if err != nil { - logger.WarnF(ctx, "Failed to convert OIDC claims, falling back to UserEndpoint: %v", err) - userInfo = nil + // 从 sub 填充 ID + if err := claims.FillIdFromSub(); err != nil { + logger.WarnF(ctx, "Failed to parse sub claim, falling back to UserEndpoint: %v", err) } else { + userInfo = &claims logger.InfoF(ctx, "Successfully authenticated user via OIDC: %s (ID: %d)", userInfo.Username, userInfo.Id) } } diff --git a/internal/model/users.go b/internal/model/users.go index 973f37b4..2cbe879b 100644 --- a/internal/model/users.go +++ b/internal/model/users.go @@ -23,6 +23,7 @@ import ( "errors" "fmt" "net/http" + "strconv" "time" "github.com/google/uuid" @@ -49,6 +50,7 @@ const ( type OAuthUserInfo struct { Id uint64 `json:"id"` + Sub string `json:"sub"` Username string `json:"username"` Name string `json:"name"` Active bool `json:"active"` @@ -56,6 +58,18 @@ type OAuthUserInfo struct { TrustLevel TrustLevel `json:"trust_level"` } +// FillIdFromSub 从 OIDC sub claim 填充 Id 字段 +func (o *OAuthUserInfo) FillIdFromSub() error { + if o.Id == 0 && o.Sub != "" { + id, err := strconv.ParseUint(o.Sub, 10, 64) + if err != nil { + return err + } + o.Id = id + } + return nil +} + // UserGamificationScoreResponse API响应 type UserGamificationScoreResponse struct { User struct { diff --git a/internal/router/router.go b/internal/router/router.go index 7226dce9..57b252e9 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -56,13 +56,6 @@ import ( ) func Serve() { - // 初始化 OIDC Provider - ctx := context.Background() - if err := oauth.InitOIDC(ctx); err != nil { - log.Printf("[API] Warning: Failed to initialize OIDC provider, falling back to OAuth2: %v\n", err) - oauth.InitOAuthFallback() - } - // 运行模式 if config.Config.App.Env == "production" { gin.SetMode(gin.ReleaseMode) From ba18d88dc832c953f7c2c76baa1b16d239af5db3 Mon Sep 17 00:00:00 2001 From: dean <1006393151@qq.com> Date: Wed, 24 Dec 2025 17:19:45 +0800 Subject: [PATCH 043/366] fix: Unauthorized --- internal/apps/order/routers.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/internal/apps/order/routers.go b/internal/apps/order/routers.go index 25e246e4..7568cf1c 100644 --- a/internal/apps/order/routers.go +++ b/internal/apps/order/routers.go @@ -88,10 +88,21 @@ func ListTransactions(c *gin.Context) { // community 类型:查询当前用户作为收款方的 community 订单 baseQuery = baseQuery.Where("orders.type = ? AND orders.payee_user_id = ?", orderType, user.ID) case model.OrderTypeOnline: - // 不为空查询商家的 online 订单,否则按照 online 查询 if req.ClientID != "" { - baseQuery = baseQuery.Where("orders.type = ? AND orders.client_id = ?", orderType, req.ClientID) clientIDHandled = true + // 验证 client_id 是否属于当前用户 + var count int64 + if err := db.DB(c.Request.Context()).Model(&model.MerchantAPIKey{}). + Where("client_id = ? AND user_id = ?", req.ClientID, user.ID). + Count(&count).Error; err != nil { + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + if count > 0 { + baseQuery = baseQuery.Where("orders.type = ? AND orders.client_id = ?", orderType, req.ClientID) + } else { + baseQuery = baseQuery.Where("orders.type = ? AND orders.client_id = ? AND (orders.payer_user_id = ? OR orders.payee_user_id = ?)", orderType, req.ClientID, user.ID, user.ID) + } } else { baseQuery = baseQuery.Where("orders.type = ? AND (orders.payer_user_id = ? OR orders.payee_user_id = ?)", orderType, user.ID, user.ID) } From 79621591e83897bdfe94f8c3edd219082d365595 Mon Sep 17 00:00:00 2001 From: dean <1006393151@qq.com> Date: Wed, 24 Dec 2025 18:04:31 +0800 Subject: [PATCH 044/366] fix: Unauthorized --- internal/apps/order/routers.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/apps/order/routers.go b/internal/apps/order/routers.go index 7568cf1c..e4415a0d 100644 --- a/internal/apps/order/routers.go +++ b/internal/apps/order/routers.go @@ -88,9 +88,9 @@ func ListTransactions(c *gin.Context) { // community 类型:查询当前用户作为收款方的 community 订单 baseQuery = baseQuery.Where("orders.type = ? AND orders.payee_user_id = ?", orderType, user.ID) case model.OrderTypeOnline: + // online 类型:商家可查看自己 client_id 的所有订单,普通用户只能查看与自己相关的订单 if req.ClientID != "" { clientIDHandled = true - // 验证 client_id 是否属于当前用户 var count int64 if err := db.DB(c.Request.Context()).Model(&model.MerchantAPIKey{}). Where("client_id = ? AND user_id = ?", req.ClientID, user.ID). From 6edcf145cda1065cae18eb5aba05c7f331b29523 Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Wed, 24 Dec 2025 18:11:36 +0800 Subject: [PATCH 045/366] feat: optimize some details & add fun holiday interactions --- .../components/common/docs/how-to-use.tsx | 2 +- .../components/common/settings/profile.tsx | 53 +++++---- frontend/components/layout/sidebar.tsx | 112 ++++++++++++++---- frontend/components/ui/snow-effect.tsx | 99 ++++++++++++++++ frontend/contexts/user-context.tsx | 2 +- frontend/lib/services/auth/types.ts | 14 +-- 6 files changed, 227 insertions(+), 55 deletions(-) create mode 100644 frontend/components/ui/snow-effect.tsx diff --git a/frontend/components/common/docs/how-to-use.tsx b/frontend/components/common/docs/how-to-use.tsx index 986f2431..0d742ba7 100644 --- a/frontend/components/common/docs/how-to-use.tsx +++ b/frontend/components/common/docs/how-to-use.tsx @@ -296,7 +296,7 @@ export const howToUseSections: PolicySection[] = [
                                                                                                        • 基本获取方式:通过在 LINUX DO 社区的活跃行为获得:
                                                                                                            -
                                                                                                          • 发帖、回复、点赞等社区贡献
                                                                                                          • +
                                                                                                          • 点赞、阅读、解决问题等社区贡献
                                                                                                          • 详情可见 LINUX DO 社区
                                                                                                        • diff --git a/frontend/components/common/settings/profile.tsx b/frontend/components/common/settings/profile.tsx index ba33f899..487bb2ae 100644 --- a/frontend/components/common/settings/profile.tsx +++ b/frontend/components/common/settings/profile.tsx @@ -33,33 +33,35 @@ interface LevelConfig { /** 等级视觉样式配置(不包含业务数据) */ const LEVEL_STYLES = [ { - level: PayLevel.BlackGold, - name: "黑金会员", - nameEn: "BLACK MEMBER", - gradient: "from-slate-800 via-gray-900 to-black", - textColor: "text-white", + level: PayLevel.Ordinary, + name: "普通会员", + nameEn: "ORDINARY MEMBER", + gradient: "from-[#1e3a8a] via-[#2563eb] to-[#1d4ed8] shadow-lg shadow-blue-900/10", + textColor: "text-white/95", + }, + { + level: PayLevel.Gold, + name: "黄金会员", + nameEn: "GOLD MEMBER", + gradient: "from-[#FDFCF0] via-[#FCE38A] to-[#B45309] shadow-lg shadow-amber-500/10", + textColor: "text-[#854D0E]", metalEffect: true, }, { level: PayLevel.WhiteGold, name: "白金会员", nameEn: "PLATINUM MEMBER", - gradient: "from-slate-400 via-slate-300 to-slate-400", - textColor: "text-slate-900", - }, - { - level: PayLevel.Gold, - name: "黄金会员", - nameEn: "GOLD MEMBER", - gradient: "from-[#F3E3B3] via-[#E6D4A3] via-[#F3E3B3] to-[#D4AF37]", - textColor: "text-amber-950", + gradient: "from-[#F8FAFC] via-[#E2E8F0] to-[#94A3B8] shadow-lg shadow-slate-500/10", + textColor: "text-slate-800", + metalEffect: true, }, { - level: PayLevel.Platinum, - name: "铂金会员", - nameEn: "DIAMOND MEMBER", - gradient: "from-purple-600 via-pink-500 to-purple-600", - textColor: "text-white", + level: PayLevel.BlackGold, + name: "黑金会员", + nameEn: "BLACK MEMBER", + gradient: "from-[#020617] via-[#1e293b] to-[#020617] shadow-lg shadow-black/20", + textColor: "text-[#FFE5B4]", + metalEffect: true, }, ] as const @@ -91,10 +93,10 @@ function mergeLevelConfigs(apiConfigs: UserPayConfig[]): LevelConfig[] { function getLevelConfig(score: number, configs: LevelConfig[]): LevelConfig { if (configs.length === 0) { return { - level: PayLevel.BlackGold, + level: PayLevel.Ordinary, name: "加载中...", nameEn: "LOADING...", - gradient: "from-slate-800 via-gray-900 to-black", + gradient: "from-blue-600 via-blue-500 to-blue-700", textColor: "text-white", minScore: 0, maxScore: null, @@ -128,7 +130,7 @@ function MembershipCard({ levelConfigs: LevelConfig[] }) { const isAccessible = levelConfigs.findIndex(l => l.level === config.level) <= levelConfigs.findIndex(l => l.level === currentLevel.level) - const isDarkCard = config.level === PayLevel.BlackGold || config.level === PayLevel.Platinum + const isDarkCard = config.level === PayLevel.BlackGold || config.level === PayLevel.Ordinary return (
                                                                                                          +
                                                                                                          {config.metalEffect && ( <> @@ -224,8 +227,8 @@ function getPayLevelLabel(level: PayLevel): string { return "白金会员" case PayLevel.Gold: return "黄金会员" - case PayLevel.Platinum: - return "铂金会员" + case PayLevel.Ordinary: + return "普通会员" default: return "未知等级" } @@ -344,7 +347,7 @@ export function ProfileMain() {
                                                                                                          -
                                                                                                          +
                                                                                                          账户
                                                                                                          @{user.username}
                                                                                                          diff --git a/frontend/components/layout/sidebar.tsx b/frontend/components/layout/sidebar.tsx index 8504a308..26958bc2 100644 --- a/frontend/components/layout/sidebar.tsx +++ b/frontend/components/layout/sidebar.tsx @@ -5,6 +5,7 @@ import Link from "next/link" import { usePathname, useRouter } from "next/navigation" import { toast } from "sonner" import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" import { Spinner } from "@/components/ui/spinner" import { AnimateIcon } from "@/components/animate-ui/icons/icon" import { ChevronLeft } from "@/components/animate-ui/icons/chevron-left" @@ -18,6 +19,7 @@ import { DropdownMenuLabel, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu" +import { SnowEffect } from "@/components/ui/snow-effect" import { Sidebar, SidebarContent, @@ -80,6 +82,54 @@ const data = { ], } +const ChristmasTree = ({ className }: { className?: string }) => ( + + {/* 树干 */} + + {/* 树叶层 - 从上到下 */} + + + + {/* 装饰球 */} + + + + + + {/* 顶部星星 */} + + +) + +const ChristmasHat = ({ className }: { className?: string }) => ( + + {/* 稍微倾斜的红色帽身 */} + + {/* 顶部白色绒球 */} + + {/* 底部白色绒边 - 使用圆角矩形模拟 */} + + {/* 添加一些简单的纹理/阴影细节 (可选,保持简洁) */} + +) + /** * 应用侧边栏组件 * 显示应用侧边栏 @@ -98,6 +148,8 @@ export function AppSidebar({ ...props }: React.ComponentProps) { const pathname = usePathname() const router = useRouter() + const [showChristmasTree, setShowChristmasTree] = React.useState(false) + const handleCloseSidebar = React.useCallback(() => { if (isMobile) { setOpenMobile(false) @@ -119,6 +171,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { return ( <> + {showChristmasTree && }
                                                                                                          diff --git a/frontend/components/common/pay/paying/paying-now.tsx b/frontend/components/common/pay/paying/paying-now.tsx index e5c380fc..6cf06994 100644 --- a/frontend/components/common/pay/paying/paying-now.tsx +++ b/frontend/components/common/pay/paying/paying-now.tsx @@ -351,7 +351,7 @@ function Footer({ loading }: { loading?: boolean }) {
                                                                                                          ) : (
                                                                                                          - + 由 LINUX DO Credit 提供安全认证
                                                                                                          ) From 81a53aeea2aa5302c59a76852688235901012874 Mon Sep 17 00:00:00 2001 From: zfb132 Date: Wed, 24 Dec 2025 21:35:57 +0800 Subject: [PATCH 051/366] chore: ignore .pnpm-store --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ae7deaa3..f02cfa3e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .idea/* !.idea/icon.png .vscode +.pnpm-store/ # logs logs From cba02dec150810c32c4068338a61e1cca155a27d Mon Sep 17 00:00:00 2001 From: zfb132 Date: Wed, 24 Dec 2025 21:37:21 +0800 Subject: [PATCH 052/366] fix: resolve vertical scrolling issue in table --- frontend/components/common/general/table-data.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/common/general/table-data.tsx b/frontend/components/common/general/table-data.tsx index 93b48250..0a0d4c70 100644 --- a/frontend/components/common/general/table-data.tsx +++ b/frontend/components/common/general/table-data.tsx @@ -55,7 +55,7 @@ export const TransactionDataTable = React.memo(function TransactionDataTable({
                                                                                                        From 52034def6e98368426128de668e07708c118db5f Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Wed, 24 Dec 2025 22:37:57 +0800 Subject: [PATCH 053/366] feat: optimize callbacks and loading --- .../common/balance/balance-table.tsx | 9 +++- .../common/merchant/merchant-online.tsx | 38 +++++++++++++---- .../common/pay/paying/paying-online.tsx | 41 +++++++++---------- frontend/components/home/footer-section.tsx | 2 +- frontend/contexts/transaction-context.tsx | 5 ++- frontend/lib/services/index.ts | 1 + .../lib/services/merchant/merchant.service.ts | 9 ++-- 7 files changed, 69 insertions(+), 36 deletions(-) diff --git a/frontend/components/common/balance/balance-table.tsx b/frontend/components/common/balance/balance-table.tsx index 793851ac..9a7fe482 100644 --- a/frontend/components/common/balance/balance-table.tsx +++ b/frontend/components/common/balance/balance-table.tsx @@ -63,8 +63,15 @@ function getTimeRange() { */ export function BalanceTable() { const [activeTab, setActiveTab] = React.useState("all") + const [timeRange, setTimeRange] = React.useState<{ startTime: string; endTime: string } | null>(null) - const timeRange = getTimeRange() + React.useEffect(() => { + setTimeRange(getTimeRange()) + }, []) + + if (!timeRange) { + return null // or a loading skeleton + } return (
                                                                                                        diff --git a/frontend/components/common/merchant/merchant-online.tsx b/frontend/components/common/merchant/merchant-online.tsx index 90a14dcf..8fd06f09 100644 --- a/frontend/components/common/merchant/merchant-online.tsx +++ b/frontend/components/common/merchant/merchant-online.tsx @@ -15,7 +15,8 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFo import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area" import { useMerchant } from "@/contexts/merchant-context" import { TransactionProvider, useTransaction } from "@/contexts/transaction-context" -import { MerchantService, type PaymentLink, type GetMerchantOrderResponse, type MerchantAPIKey } from "@/lib/services" +import { useUser } from "@/contexts/user-context" +import { MerchantService, ConfigService, type PaymentLink, type GetMerchantOrderResponse, type MerchantAPIKey, type UserPayConfig } from "@/lib/services" import { PayingInfo } from "@/components/common/pay/paying/paying-info" import { PayingNow } from "@/components/common/pay/paying/paying-now" import { MerchantSelector } from "@/components/common/merchant/merchant-selector" @@ -260,7 +261,25 @@ function MerchantOnlineContent({ apiKeys }: MerchantOnlineContentProps) { toast.success("在线积分流转服务链接已复制") } + /* 获取用户支付配置 */ + const [userPayConfigs, setUserPayConfigs] = useState([]) + const { user } = useUser() + + useEffect(() => { + const loadConfigs = async () => { + try { + const configs = await ConfigService.getUserPayConfigs() + setUserPayConfigs(configs) + } catch (error) { + console.error("Failed to load user pay configs:", error) + } + } + loadConfigs() + }, []) + /* 预览订单信息 */ + const currentUserConfig = userPayConfigs.find(c => user?.pay_level !== undefined && c.level === user.pay_level) + const previewOrderInfo: GetMerchantOrderResponse = { merchant: { app_name: selectedKey?.app_name || "服务名称", @@ -284,9 +303,12 @@ function MerchantOnlineContent({ apiKeys }: MerchantOnlineContentProps) { created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }, - user_pay_config: { + user_pay_config: currentUserConfig ? { + ...currentUserConfig, + fee_rate: String(currentUserConfig.fee_rate) + } : { id: 0, - level: 1, + level: user?.pay_level ?? 0, min_score: 0, max_score: null, daily_limit: null, @@ -416,12 +438,12 @@ function MerchantOnlineContent({ apiKeys }: MerchantOnlineContentProps) {
                                                                                                        -
                                                                                                        +

                                                                                                        {isFullscreen ? '全屏预览' : '实时预览'}

                                                                                                        -
                                                                                                        -
                                                                                                        +
                                                                                                        +
                                                                                                        -

                                                                                                        © 2025 LINUX DO Credit. All rights reserved. Made With AI By @Chenyme

                                                                                                        +

                                                                                                        © 2025 LINUX DO Credit. All rights reserved.

                                                                                                        隐私政策 服务条款 diff --git a/frontend/contexts/transaction-context.tsx b/frontend/contexts/transaction-context.tsx index 6c635746..df857fe8 100644 --- a/frontend/contexts/transaction-context.tsx +++ b/frontend/contexts/transaction-context.tsx @@ -146,8 +146,8 @@ export function TransactionProvider({ children, defaultParams = {} }: Transactio } setTotal(result.total) - setCurrentPage(result.page) - setPageSize(result.page_size) + setCurrentPage(queryParams.page) + setPageSize(queryParams.page_size) setLastParams(prev => ({ ...prev, ...params })) } catch (err) { if (err instanceof Error && err.message === '请求已被取消') { @@ -172,6 +172,7 @@ export function TransactionProvider({ children, defaultParams = {} }: Transactio if (loading) return const nextPage = currentPage + 1 + console.log('[TransactionContext] loadMore: nextPage =', nextPage) await fetchTransactions({ ...lastParams, page: nextPage, diff --git a/frontend/lib/services/index.ts b/frontend/lib/services/index.ts index e2654f24..8e3d6ac3 100644 --- a/frontend/lib/services/index.ts +++ b/frontend/lib/services/index.ts @@ -144,6 +144,7 @@ export type { QueryMerchantOrderResponse, RefundMerchantOrderRequest, RefundMerchantOrderResponse, + GetPaymentLinkInfoResponse, } from './merchant'; // 管理员服务 diff --git a/frontend/lib/services/merchant/merchant.service.ts b/frontend/lib/services/merchant/merchant.service.ts index 2fe93fe9..bfa5d13e 100644 --- a/frontend/lib/services/merchant/merchant.service.ts +++ b/frontend/lib/services/merchant/merchant.service.ts @@ -13,6 +13,7 @@ import type { QueryMerchantOrderResponse, RefundMerchantOrderRequest, RefundMerchantOrderResponse, + GetPaymentLinkInfoResponse, } from './types'; /** @@ -197,12 +198,12 @@ export class MerchantService extends BaseService { * @example * ```typescript * const linkInfo = await MerchantService.getPaymentLinkByToken('abc123'); - * console.log('商品名称:', linkInfo.product_name); - * console.log('金额:', linkInfo.amount); + * console.log('商品名称:', linkInfo.payment_link.product_name); + * console.log('金额:', linkInfo.payment_link.amount); * ``` */ - static async getPaymentLinkByToken(token: string): Promise { - return this.get(`/payment-links/${ token }`); + static async getPaymentLinkByToken(token: string): Promise { + return this.get(`/payment-links/${ token }`); } /** From e7b09313321438f16b067564a498bee7b07fa354 Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Wed, 24 Dec 2025 23:40:39 +0800 Subject: [PATCH 054/366] fix: online service failed to be obtained --- .../common/pay/paying/paying-online.tsx | 56 +++++++++++++++---- .../lib/services/merchant/merchant.service.ts | 5 +- 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/frontend/components/common/pay/paying/paying-online.tsx b/frontend/components/common/pay/paying/paying-online.tsx index 3de9a4bb..e787dd15 100644 --- a/frontend/components/common/pay/paying/paying-online.tsx +++ b/frontend/components/common/pay/paying/paying-online.tsx @@ -9,7 +9,8 @@ import { PayingInfo } from "@/components/common/pay/paying/paying-info" import { useUser } from "@/contexts/user-context" import services from "@/lib/services" -import type { PaymentLink, GetMerchantOrderResponse } from "@/lib/services" +import { ConfigService } from "@/lib/services" +import type { PaymentLink, GetMerchantOrderResponse, UserPayConfig } from "@/lib/services" /** @@ -47,6 +48,21 @@ export function PayingOnline() { } }, []) + /* 获取用户支付配置 */ + const [userPayConfigs, setUserPayConfigs] = useState([]) + + useEffect(() => { + const loadConfigs = async () => { + try { + const configs = await ConfigService.getUserPayConfigs() + setUserPayConfigs(configs) + } catch (error) { + console.error("Failed to load user pay configs:", error) + } + } + loadConfigs() + }, []) + /* 统一的服务错误处理函数 */ const handleServiceError = (error: unknown, operation: string) => { console.error(`${ operation }失败:`, error) @@ -82,7 +98,7 @@ export function PayingOnline() { setError(true) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [token]) + }, [token, userPayConfigs.length, user?.pay_level]) /* 查询支付链接信息 */ const handleQueryPaymentLink = async (queryToken?: string) => { @@ -92,36 +108,52 @@ export function PayingOnline() { return } + setLoading(true) setLoading(true) try { const data = await services.merchant.getPaymentLinkByToken(targetToken) - setPaymentLink(data.payment_link) + setPaymentLink(data) /* 将支付链接信息转换为订单信息格式,以复用PayingInfo和PayingNow组件 */ const mockOrderInfo: GetMerchantOrderResponse = { merchant: { - app_name: data.merchant.app_name || "商户", - redirect_uri: data.merchant.redirect_uri || "", + app_name: data.app_name || "商户", + redirect_uri: "", // data.redirect_uri? }, order: { id: 0, - order_no: `LINK-${ data.payment_link.id }`, - order_name: data.payment_link.product_name, + order_no: `LINK-${ data.id }`, + order_name: data.product_name, payer_username: user?.username || "", payee_username: "", - amount: data.payment_link.amount, + amount: data.amount, status: "pending", type: "payment", payment_type: "link", - remark: data.payment_link.remark || "", + remark: data.remark || "", client_id: "", return_url: "", notify_url: "", trade_time: null, - created_at: data.payment_link.created_at, - updated_at: data.payment_link.updated_at, + created_at: data.created_at, + updated_at: data.updated_at, }, - user_pay_config: data.user_pay_config, + user_pay_config: (() => { + const currentUserConfig = userPayConfigs.find(c => user?.pay_level !== undefined && c.level === user.pay_level) + return currentUserConfig ? { + ...currentUserConfig, + fee_rate: String(currentUserConfig.fee_rate) + } : { + id: 0, + level: user?.pay_level ?? 0, + min_score: 0, + max_score: null, + daily_limit: null, + fee_rate: "0.00", + created_at: "", + updated_at: "" + } + })(), } setOrderInfo(mockOrderInfo) diff --git a/frontend/lib/services/merchant/merchant.service.ts b/frontend/lib/services/merchant/merchant.service.ts index bfa5d13e..c6938490 100644 --- a/frontend/lib/services/merchant/merchant.service.ts +++ b/frontend/lib/services/merchant/merchant.service.ts @@ -13,7 +13,6 @@ import type { QueryMerchantOrderResponse, RefundMerchantOrderRequest, RefundMerchantOrderResponse, - GetPaymentLinkInfoResponse, } from './types'; /** @@ -202,8 +201,8 @@ export class MerchantService extends BaseService { * console.log('金额:', linkInfo.payment_link.amount); * ``` */ - static async getPaymentLinkByToken(token: string): Promise { - return this.get(`/payment-links/${ token }`); + static async getPaymentLinkByToken(token: string): Promise { + return this.get(`/payment-links/${ token }`); } /** From 6ef62e83bee80fb88340525985a0aa6871403006 Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Thu, 25 Dec 2025 11:37:30 +0800 Subject: [PATCH 055/366] feat: add retryDelayFunc --- internal/task/worker/worker.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/internal/task/worker/worker.go b/internal/task/worker/worker.go index 03b20cb9..c943a633 100644 --- a/internal/task/worker/worker.go +++ b/internal/task/worker/worker.go @@ -17,6 +17,8 @@ limitations under the License. package worker import ( + "math/rand" + "strings" "time" "github.com/hibiken/asynq" @@ -37,6 +39,29 @@ func StartWorker() error { ShutdownTimeout: 3 * time.Minute, Queues: buildQueuesFromConfig(), StrictPriority: config.Config.Worker.StrictPriority, + RetryDelayFunc: func(n int, err error, t *asynq.Task) time.Duration { + // 针对积分更新任务使用更长的重试间隔 + 随机抖动 + if t.Type() == task.UpdateSingleUserGamificationScoreTask { + var baseDelay time.Duration + if err != nil && strings.Contains(err.Error(), "429") { + baseDelay = 120 * time.Second + } else { + baseDelay = 60 * time.Second + } + // 指数退避 + delay := baseDelay * time.Duration(1< 30*time.Minute { + delay = 30 * time.Minute + } + // delay ~ 2*delay + if delay > 0 { + jitter := time.Duration(rand.Int63n(int64(delay))) + delay += jitter + } + return delay + } + return asynq.DefaultRetryDelayFunc(n, err, t) + }, }, ) From 405f40b36232e3827beedaecfdc3b55625ceb94f Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Thu, 25 Dec 2025 12:52:26 +0800 Subject: [PATCH 056/366] feat: add log --- internal/task/worker/worker.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/task/worker/worker.go b/internal/task/worker/worker.go index c943a633..79d3ace1 100644 --- a/internal/task/worker/worker.go +++ b/internal/task/worker/worker.go @@ -17,6 +17,7 @@ limitations under the License. package worker import ( + "log" "math/rand" "strings" "time" @@ -40,6 +41,8 @@ func StartWorker() error { Queues: buildQueuesFromConfig(), StrictPriority: config.Config.Worker.StrictPriority, RetryDelayFunc: func(n int, err error, t *asynq.Task) time.Duration { + log.Printf("[RetryDelayFunc] 任务类型: %s, 重试次数: %d, 错误: %v", t.Type(), n, err) + // 针对积分更新任务使用更长的重试间隔 + 随机抖动 if t.Type() == task.UpdateSingleUserGamificationScoreTask { var baseDelay time.Duration @@ -48,8 +51,8 @@ func StartWorker() error { } else { baseDelay = 60 * time.Second } - // 指数退避 - delay := baseDelay * time.Duration(1< 30*time.Minute { delay = 30 * time.Minute } From b7cc08097ad2887d6cec92788dd2d43738aeebb3 Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Thu, 25 Dec 2025 14:51:27 +0800 Subject: [PATCH 057/366] feat: add configuration print --- internal/config/config.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index b683d86a..3a034de2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,7 @@ limitations under the License. package config import ( + "encoding/json" "log" "os" @@ -49,4 +50,17 @@ func init() { // 设置全局配置 Config = &c + + // 打印配置 + printConfig(&c) +} + +// printConfig 打印配置内容 +func printConfig(c *configModel) { + configJSON, err := json.MarshalIndent(c, "", " ") + if err != nil { + log.Printf("[Config] failed to marshal config: %v\n", err) + return + } + log.Printf("[Config] loaded configuration:\n%s\n", string(configJSON)) } From ae38296cf64fd25b7b5fe1255f44b556cdc2efda Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Thu, 25 Dec 2025 14:52:12 +0800 Subject: [PATCH 058/366] feat: add a unique identifier --- internal/model/users.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/model/users.go b/internal/model/users.go index 2617ff5b..82fc96ca 100644 --- a/internal/model/users.go +++ b/internal/model/users.go @@ -169,6 +169,7 @@ func (u *User) EnqueueBadgeScoreTask(ctx context.Context, delay time.Duration) e opts := []asynq.Option{ asynq.Queue(task.QueueWhitelistOnly), asynq.MaxRetry(5), + asynq.TaskID(fmt.Sprintf("user_gamification_score_%d", u.ID)), } if delay > 0 { opts = append(opts, asynq.ProcessIn(delay)) @@ -178,7 +179,6 @@ func (u *User) EnqueueBadgeScoreTask(ctx context.Context, delay time.Duration) e logger.ErrorF(ctx, "下发用户[%s]积分计算任务失败: %v", u.Username, err) return err } - logger.InfoF(ctx, "下发用户[%s]积分计算任务成功", u.Username) return nil } From 42810089ec8d4a7d1a26edcbf3b909673447fc22 Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Thu, 25 Dec 2025 16:26:24 +0800 Subject: [PATCH 059/366] feat: Add a task rate limiting token --- go.mod | 1 + go.sum | 2 ++ internal/apps/user/constants.go | 22 ++++++++++++++++ internal/apps/user/tasks.go | 46 ++++++++++++++++++++++++++++++--- 4 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 internal/apps/user/constants.go diff --git a/go.mod b/go.mod index fc881e1f..982822f2 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/coreos/go-oidc/v3 v3.17.0 github.com/gin-contrib/sessions v1.0.4 github.com/gin-gonic/gin v1.11.0 + github.com/go-redis/redis_rate/v10 v10.0.1 github.com/google/uuid v1.6.0 github.com/hibiken/asynq v0.25.1 github.com/redis/go-redis/extra/redisotel/v9 v9.16.0 diff --git a/go.sum b/go.sum index ee773063..75da9da9 100644 --- a/go.sum +++ b/go.sum @@ -92,6 +92,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= +github.com/go-redis/redis_rate/v10 v10.0.1 h1:calPxi7tVlxojKunJwQ72kwfozdy25RjA0bCj1h0MUo= +github.com/go-redis/redis_rate/v10 v10.0.1/go.mod h1:EMiuO9+cjRkR7UvdvwMO7vbgqJkltQHtwbdIQvaBKIU= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= diff --git a/internal/apps/user/constants.go b/internal/apps/user/constants.go new file mode 100644 index 00000000..b1e60dbd --- /dev/null +++ b/internal/apps/user/constants.go @@ -0,0 +1,22 @@ +/* +Copyright 2025 linux.do + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package user + +const ( + // linuxDoAPIRateLimitKey Redis 限流 Key + linuxDoAPIRateLimitKey = "linux_do:api:rate_limit" +) diff --git a/internal/apps/user/tasks.go b/internal/apps/user/tasks.go index 9859ee03..cf250bd2 100644 --- a/internal/apps/user/tasks.go +++ b/internal/apps/user/tasks.go @@ -22,6 +22,7 @@ import ( "fmt" "time" + "github.com/go-redis/redis_rate/v10" "github.com/hibiken/asynq" "github.com/linux-do/credit/internal/config" "github.com/linux-do/credit/internal/db" @@ -31,12 +32,41 @@ import ( "gorm.io/gorm" ) +var ( + linuxDoRateLimiter *redis_rate.Limiter + rateLimitKey string +) + +func init() { + linuxDoRateLimiter = redis_rate.NewLimiter(db.Redis) + rateLimitKey = db.PrefixedKey(linuxDoAPIRateLimitKey) +} + +// waitForRateLimit 等待获取限流令牌(阻塞直到获取到令牌) +func waitForRateLimit(ctx context.Context, key string, limit redis_rate.Limit) error { + for { + res, err := linuxDoRateLimiter.Allow(ctx, key, limit) + if err != nil { + return fmt.Errorf("redis 限流器错误: %w", err) + } + if res.Allowed > 0 { + return nil // 获取到令牌 + } + // 未获取到令牌,等待后重试 + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(res.RetryAfter): + // 继续重试 + } + } +} + // HandleUpdateUserGamificationScores 处理所有用户积分更新任务 func HandleUpdateUserGamificationScores(ctx context.Context, t *asynq.Task) error { // 分页处理用户 pageSize := 1000 lastID := uint64(0) - currentDelay := 0 * time.Second // 计算一周前日期 now := time.Now() @@ -64,9 +94,19 @@ func HandleUpdateUserGamificationScores(ctx context.Context, t *asynq.Task) erro } for _, user := range users { - currentDelay += time.Duration(config.Config.Scheduler.UserGamificationScoreDispatchIntervalSeconds) * time.Second + interval := config.Config.Scheduler.UserGamificationScoreDispatchIntervalSeconds + limit := redis_rate.Limit{ + Rate: 1, + Burst: 1, + Period: time.Duration(interval) * time.Second, + } + if err := waitForRateLimit(ctx, rateLimitKey, limit); err != nil { + logger.ErrorF(ctx, "速率限制等待失败: %v", err) + return err + } - if err := user.EnqueueBadgeScoreTask(ctx, currentDelay); err != nil { + logger.InfoF(ctx, "下发用户[%s]积分计算任务", user.Username) + if err := user.EnqueueBadgeScoreTask(ctx, 0); err != nil { return err } } From 7ff555f65cecdec5a8439124edd44190398bf3df Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Thu, 25 Dec 2025 17:17:15 +0800 Subject: [PATCH 060/366] feat: add log --- internal/apps/user/tasks.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/internal/apps/user/tasks.go b/internal/apps/user/tasks.go index cf250bd2..29752ace 100644 --- a/internal/apps/user/tasks.go +++ b/internal/apps/user/tasks.go @@ -64,9 +64,13 @@ func waitForRateLimit(ctx context.Context, key string, limit redis_rate.Limit) e // HandleUpdateUserGamificationScores 处理所有用户积分更新任务 func HandleUpdateUserGamificationScores(ctx context.Context, t *asynq.Task) error { + fmt.Println("handle user gamification scores") + logger.InfoF(ctx, "[调度] 开始处理用户积分更新主任务") + // 分页处理用户 pageSize := 1000 lastID := uint64(0) + totalUsers := 0 // 计算一周前日期 now := time.Now() @@ -76,6 +80,7 @@ func HandleUpdateUserGamificationScores(ctx context.Context, t *asynq.Task) erro sessionAgeDays = 7 } oneWeekAgo := today.AddDate(0, 0, -sessionAgeDays) + logger.InfoF(ctx, "[调度] 查询条件: 最后登录 >= %s, sessionAgeDays=%d", oneWeekAgo.Format("2006-01-02"), sessionAgeDays) for { var users []model.User @@ -88,6 +93,8 @@ func HandleUpdateUserGamificationScores(ctx context.Context, t *asynq.Task) erro return err } + logger.InfoF(ctx, "[调度] 本批次查询到 %d 个用户, lastID=%d", len(users), lastID) + // 没有用户,退出循环 if len(users) == 0 { break @@ -100,12 +107,14 @@ func HandleUpdateUserGamificationScores(ctx context.Context, t *asynq.Task) erro Burst: 1, Period: time.Duration(interval) * time.Second, } + logger.InfoF(ctx, "[调度] 等待限流令牌, interval=%ds", interval) if err := waitForRateLimit(ctx, rateLimitKey, limit); err != nil { logger.ErrorF(ctx, "速率限制等待失败: %v", err) return err } - logger.InfoF(ctx, "下发用户[%s]积分计算任务", user.Username) + totalUsers++ + logger.InfoF(ctx, "[调度] 下发用户[%s]积分计算任务, 已下发=%d", user.Username, totalUsers) if err := user.EnqueueBadgeScoreTask(ctx, 0); err != nil { return err } @@ -113,6 +122,7 @@ func HandleUpdateUserGamificationScores(ctx context.Context, t *asynq.Task) erro lastID = users[len(users)-1].ID } + logger.InfoF(ctx, "[调度] 用户积分更新主任务完成, 共下发 %d 个任务", totalUsers) return nil } From 19c655a763477f200c6f0789521cb190c3e47b96 Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Thu, 25 Dec 2025 17:24:31 +0800 Subject: [PATCH 061/366] feat: add log --- internal/logger/logger.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/logger/logger.go b/internal/logger/logger.go index c08f690d..ca936036 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -43,7 +43,8 @@ func init() { zapLogger, otelzap.WithMinLevel(zapLogger.Level()), ) - logger.Level() + + fmt.Printf("[Logger] %s\n", logger.Level()) } func DebugF(ctx context.Context, format string, args ...interface{}) { From 4fdee257e29cb0b996ced971c2a57501d9b94934 Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Thu, 25 Dec 2025 18:36:56 +0800 Subject: [PATCH 062/366] feat: Add an integral update rate limit configuration --- config.example.yaml | 5 ++++- internal/apps/user/tasks.go | 16 +++------------- internal/config/model.go | 22 ++++++++++++++-------- 3 files changed, 21 insertions(+), 22 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 6fac3d56..af36a527 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -103,7 +103,6 @@ log: # Scheduler scheduler: - user_gamification_score_dispatch_interval_seconds: 3 update_user_gamification_scores_task_cron: "0 2 * * *" dispute_auto_refund_dispatch_interval_seconds: 3 auto_refund_expired_disputes_task_cron: "0 0 * * *" @@ -120,6 +119,10 @@ worker: priority: 5 - name: default priority: 3 + # 积分更新速率限制:rate 次/period 秒 + gamification_score_rate_limit: + rate: 1 # 允许的请求次数 + period: 3 # 时间周期(秒) # linuxDo linuxDo: diff --git a/internal/apps/user/tasks.go b/internal/apps/user/tasks.go index 29752ace..9da3f6ae 100644 --- a/internal/apps/user/tasks.go +++ b/internal/apps/user/tasks.go @@ -64,13 +64,9 @@ func waitForRateLimit(ctx context.Context, key string, limit redis_rate.Limit) e // HandleUpdateUserGamificationScores 处理所有用户积分更新任务 func HandleUpdateUserGamificationScores(ctx context.Context, t *asynq.Task) error { - fmt.Println("handle user gamification scores") - logger.InfoF(ctx, "[调度] 开始处理用户积分更新主任务") - // 分页处理用户 pageSize := 1000 lastID := uint64(0) - totalUsers := 0 // 计算一周前日期 now := time.Now() @@ -93,28 +89,23 @@ func HandleUpdateUserGamificationScores(ctx context.Context, t *asynq.Task) erro return err } - logger.InfoF(ctx, "[调度] 本批次查询到 %d 个用户, lastID=%d", len(users), lastID) - // 没有用户,退出循环 if len(users) == 0 { break } for _, user := range users { - interval := config.Config.Scheduler.UserGamificationScoreDispatchIntervalSeconds + rateLimit := config.Config.Worker.GamificationScoreRateLimit limit := redis_rate.Limit{ - Rate: 1, + Rate: rateLimit.Rate, Burst: 1, - Period: time.Duration(interval) * time.Second, + Period: time.Duration(rateLimit.Period) * time.Second, } - logger.InfoF(ctx, "[调度] 等待限流令牌, interval=%ds", interval) if err := waitForRateLimit(ctx, rateLimitKey, limit); err != nil { logger.ErrorF(ctx, "速率限制等待失败: %v", err) return err } - totalUsers++ - logger.InfoF(ctx, "[调度] 下发用户[%s]积分计算任务, 已下发=%d", user.Username, totalUsers) if err := user.EnqueueBadgeScoreTask(ctx, 0); err != nil { return err } @@ -122,7 +113,6 @@ func HandleUpdateUserGamificationScores(ctx context.Context, t *asynq.Task) erro lastID = users[len(users)-1].ID } - logger.InfoF(ctx, "[调度] 用户积分更新主任务完成, 共下发 %d 个任务", totalUsers) return nil } diff --git a/internal/config/model.go b/internal/config/model.go index 1b714183..c1e8b621 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -136,18 +136,18 @@ type logConfig struct { // schedulerConfig 定时任务配置 type schedulerConfig struct { - UserGamificationScoreDispatchIntervalSeconds int `mapstructure:"user_gamification_score_dispatch_interval_seconds"` - UpdateUserGamificationScoresTaskCron string `mapstructure:"update_user_gamification_scores_task_cron"` - DisputeAutoRefundDispatchIntervalSeconds int `mapstructure:"dispute_auto_refund_dispatch_interval_seconds"` - AutoRefundExpiredDisputesTaskCron string `mapstructure:"auto_refund_expired_disputes_task_cron"` - SyncOrdersToClickHouseTaskCron string `mapstructure:"sync_orders_to_clickhouse_task_cron"` + UpdateUserGamificationScoresTaskCron string `mapstructure:"update_user_gamification_scores_task_cron"` + DisputeAutoRefundDispatchIntervalSeconds int `mapstructure:"dispute_auto_refund_dispatch_interval_seconds"` + AutoRefundExpiredDisputesTaskCron string `mapstructure:"auto_refund_expired_disputes_task_cron"` + SyncOrdersToClickHouseTaskCron string `mapstructure:"sync_orders_to_clickhouse_task_cron"` } // workerConfig 工作配置 type workerConfig struct { - Concurrency int `mapstructure:"concurrency"` - StrictPriority bool `mapstructure:"strict_priority"` - Queues []QueueConfig `mapstructure:"queues"` + Concurrency int `mapstructure:"concurrency"` + StrictPriority bool `mapstructure:"strict_priority"` + Queues []QueueConfig `mapstructure:"queues"` + GamificationScoreRateLimit RateLimitConfig `mapstructure:"gamification_score_rate_limit"` } // QueueConfig 队列配置 @@ -156,6 +156,12 @@ type QueueConfig struct { Priority int `mapstructure:"priority"` } +// RateLimitConfig 速率限制配置(N次/M秒) +type RateLimitConfig struct { + Rate int `mapstructure:"rate"` // 允许的请求次数 + Period int `mapstructure:"period"` // 时间周期(秒) +} + // linuxDoConfig type linuxDoConfig struct { ApiKey string `mapstructure:"api_key"` From 499a485ca2fcf65e9dc362a3af3ee165eb5758bc Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Thu, 25 Dec 2025 19:28:08 +0800 Subject: [PATCH 063/366] feat: The number of protection days for new users --- internal/apps/user/tasks.go | 13 +++++++++++++ internal/db/migrator/migrator.go | 5 +++++ internal/model/system_configs.go | 1 + 3 files changed, 19 insertions(+) diff --git a/internal/apps/user/tasks.go b/internal/apps/user/tasks.go index 9da3f6ae..2ea42d6f 100644 --- a/internal/apps/user/tasks.go +++ b/internal/apps/user/tasks.go @@ -157,6 +157,19 @@ func HandleUpdateSingleUserGamificationScore(ctx context.Context, t *asynq.Task) // 计算差值 diff := newCommunityBalance.Sub(user.CommunityBalance) + + if diff.IsNegative() { + protectionDays, _ := model.GetIntByKey(ctx, model.ConfigKeyNewUserProtectionDays) + if protectionDays > 0 { + registeredDays := int(time.Since(user.CreatedAt).Hours() / 24) + if registeredDays < protectionDays { + logger.InfoF(ctx, "用户[%s]在保护期内(注册%d天,保护期%d天),积分下降%s,跳过扣分", + user.Username, registeredDays, protectionDays, diff.Abs().String()) + return nil + } + } + } + oldCommunityBalance := user.CommunityBalance now := time.Now() diff --git a/internal/db/migrator/migrator.go b/internal/db/migrator/migrator.go index c7ff99bc..03405f05 100644 --- a/internal/db/migrator/migrator.go +++ b/internal/db/migrator/migrator.go @@ -87,6 +87,11 @@ func initSystemConfigs() { Value: "0", Description: "新用户注册初始积分", }, + { + Key: model.ConfigKeyNewUserProtectionDays, + Value: "30", + Description: "新用户保护期天数,期内积分下降不扣分", + }, } if err := tx.Create(&defaultConfigs).Error; err != nil { diff --git a/internal/model/system_configs.go b/internal/model/system_configs.go index 5eb228d1..1fc6c3ce 100644 --- a/internal/model/system_configs.go +++ b/internal/model/system_configs.go @@ -35,6 +35,7 @@ const ( ConfigKeyWebsiteOrderExpireMinutes = "website_order_expire_minutes" // 网站订单过期时间(分钟) ConfigKeyDisputeTimeWindowHours = "dispute_time_window_hours" // 商家争议时间窗口(小时) ConfigKeyNewUserInitialCredit = "new_user_initial_credit" // 新用户注册初始积分 + ConfigKeyNewUserProtectionDays = "new_user_protection_days" // 新用户保护期天数(期内不扣分) ) const ( From 7f09d0379a71efaee3d5331326f44d452d90754c Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Thu, 25 Dec 2025 23:11:07 +0800 Subject: [PATCH 064/366] fix(tasks): Synchronization issues for new users during the protection period --- internal/apps/user/tasks.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/apps/user/tasks.go b/internal/apps/user/tasks.go index 2ea42d6f..dfd19405 100644 --- a/internal/apps/user/tasks.go +++ b/internal/apps/user/tasks.go @@ -163,6 +163,12 @@ func HandleUpdateSingleUserGamificationScore(ctx context.Context, t *asynq.Task) if protectionDays > 0 { registeredDays := int(time.Since(user.CreatedAt).Hours() / 24) if registeredDays < protectionDays { + if err := db.DB(ctx).Model(&user).UpdateColumns(map[string]interface{}{ + "community_balance": newCommunityBalance, + }).Error; err != nil { + return fmt.Errorf("更新用户[%s]积分失败: %w", user.Username, err) + } + logger.InfoF(ctx, "用户[%s]在保护期内(注册%d天,保护期%d天),积分下降%s,跳过扣分", user.Username, registeredDays, protectionDays, diff.Abs().String()) return nil From 1c002c7b061207bf50e1d992ec57339915fecc02 Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Fri, 26 Dec 2025 11:48:10 +0800 Subject: [PATCH 065/366] feat: New way of handling points --- internal/apps/user/tasks.go | 264 ++++++++++++++++++++++-------------- internal/common/errs.go | 4 + internal/model/users.go | 41 ++++++ 3 files changed, 206 insertions(+), 103 deletions(-) diff --git a/internal/apps/user/tasks.go b/internal/apps/user/tasks.go index dfd19405..2c7aacd4 100644 --- a/internal/apps/user/tasks.go +++ b/internal/apps/user/tasks.go @@ -24,10 +24,13 @@ import ( "github.com/go-redis/redis_rate/v10" "github.com/hibiken/asynq" + "github.com/linux-do/credit/internal/common" "github.com/linux-do/credit/internal/config" "github.com/linux-do/credit/internal/db" "github.com/linux-do/credit/internal/logger" "github.com/linux-do/credit/internal/model" + "github.com/linux-do/credit/internal/task" + "github.com/linux-do/credit/internal/task/scheduler" "github.com/shopspring/decimal" "gorm.io/gorm" ) @@ -64,150 +67,205 @@ func waitForRateLimit(ctx context.Context, key string, limit redis_rate.Limit) e // HandleUpdateUserGamificationScores 处理所有用户积分更新任务 func HandleUpdateUserGamificationScores(ctx context.Context, t *asynq.Task) error { - // 分页处理用户 - pageSize := 1000 - lastID := uint64(0) - - // 计算一周前日期 - now := time.Now() - today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) - sessionAgeDays := config.Config.App.SessionAge / 86400 - if sessionAgeDays < 7 { - sessionAgeDays = 7 + rateLimit := config.Config.Worker.GamificationScoreRateLimit + limit := redis_rate.Limit{ + Rate: rateLimit.Rate, + Burst: rateLimit.Rate, + Period: time.Duration(rateLimit.Period) * time.Second, } - oneWeekAgo := today.AddDate(0, 0, -sessionAgeDays) - logger.InfoF(ctx, "[调度] 查询条件: 最后登录 >= %s, sessionAgeDays=%d", oneWeekAgo.Format("2006-01-02"), sessionAgeDays) + + page := 0 + totalProcessed := 0 for { - var users []model.User - if err := db.DB(ctx).Where("id > ? AND last_login_at >= ? AND is_active = ?", lastID, oneWeekAgo, true). - Select("id, username"). - Order("id ASC"). - Limit(pageSize). - Find(&users).Error; err != nil { - logger.ErrorF(ctx, "查询用户失败: %v", err) + if err := waitForRateLimit(ctx, rateLimitKey, limit); err != nil { + logger.ErrorF(ctx, "速率限制等待失败: %v", err) return err } - // 没有用户,退出循环 - if len(users) == 0 { - break + leaderboard, err := model.GetLeaderboard(ctx, page) + if err != nil { + logger.ErrorF(ctx, "获取排行榜第 %d 页失败: %v", page, err) + return err } - for _, user := range users { - rateLimit := config.Config.Worker.GamificationScoreRateLimit - limit := redis_rate.Limit{ - Rate: rateLimit.Rate, - Burst: 1, - Period: time.Duration(rateLimit.Period) * time.Second, - } - if err := waitForRateLimit(ctx, rateLimitKey, limit); err != nil { - logger.ErrorF(ctx, "速率限制等待失败: %v", err) - return err - } + if len(leaderboard.Users) == 0 { + logger.InfoF(ctx, "[调度] 排行榜数据处理完成,共处理 %d 个用户", totalProcessed) + break + } - if err := user.EnqueueBadgeScoreTask(ctx, 0); err != nil { - return err - } + if err = enqueueBatchScoreTask(ctx, leaderboard.Users); err != nil { + logger.ErrorF(ctx, "下发第 %d 页批量任务失败: %v", page, err) + return err } - lastID = users[len(users)-1].ID + totalProcessed += len(leaderboard.Users) + logger.InfoF(ctx, "[调度] 已处理排行榜第 %d 页,本页 %d 个用户,累计 %d 个用户", + page, len(leaderboard.Users), totalProcessed) + + page++ } + return nil } -// HandleUpdateSingleUserGamificationScore 处理单个用户积分更新任务 -func HandleUpdateSingleUserGamificationScore(ctx context.Context, t *asynq.Task) error { - // 解析任务参数 - var payload struct { - UserID uint64 `json:"user_id"` - } - if err := json.Unmarshal(t.Payload(), &payload); err != nil { - return fmt.Errorf("解析任务参数失败: %w", err) +// enqueueBatchScoreTask 下发批量积分更新任务 +func enqueueBatchScoreTask(ctx context.Context, userScores []model.LeaderboardUser) error { + payload, _ := json.Marshal(map[string]interface{}{ + "user_scores": userScores, + }) + + opts := []asynq.Option{ + asynq.Queue(task.QueueWhitelistOnly), + asynq.MaxRetry(5), } - var user model.User - if err := user.GetByID(db.DB(ctx), payload.UserID); err != nil { - return fmt.Errorf("查询用户ID[%d]失败: %w", payload.UserID, err) + if _, err := scheduler.AsynqClient.Enqueue(asynq.NewTask(task.UpdateSingleUserGamificationScoreTask, payload), opts...); err != nil { + logger.ErrorF(ctx, "下发批量积分任务失败: %v", err) + return err } + return nil +} - // 获取用户积分 - response, errGet := user.GetUserGamificationScore(ctx) - if errGet != nil { - logger.ErrorF(ctx, "处理用户[%s]失败: %v", user.Username, errGet) - return errGet +// batchUpdateUserScores 批量更新用户积分 +func batchUpdateUserScores(ctx context.Context, userScores []model.LeaderboardUser) error { + if len(userScores) == 0 { + return nil } - newCommunityBalance := decimal.NewFromInt(response.User.GamificationScore) + userIDs := make([]uint64, len(userScores)) + scoreMap := make(map[uint64]int64, len(userScores)) + for i, u := range userScores { + userIDs[i] = u.ID + scoreMap[u.ID] = u.TotalScore + } - if user.CommunityBalance.IsZero() && user.TotalCommunity.IsZero() { - if err := db.DB(ctx).Model(&user).UpdateColumns(map[string]interface{}{ - "community_balance": newCommunityBalance, - }).Error; err != nil { - return fmt.Errorf("初始化用户[%s]社区积分失败: %w", user.Username, err) - } - logger.InfoF(ctx, "用户[%s]首次同步社区积分: %s", user.Username, newCommunityBalance.String()) - return nil + users, err := model.GetByIDs(db.DB(ctx), userIDs) + if err != nil { + return fmt.Errorf("批量查询用户失败: %w", err) } - if newCommunityBalance.Equal(user.CommunityBalance) { - logger.InfoF(ctx, "用户[%s]积分未变化,跳过更新", user.Username) - return nil + protectionDays, err := model.GetIntByKey(ctx, model.ConfigKeyNewUserProtectionDays) + if err != nil { + return fmt.Errorf("%s: %w", common.GetProtectionDaysFailed, err) } - // 计算差值 - diff := newCommunityBalance.Sub(user.CommunityBalance) + now := time.Now() - if diff.IsNegative() { - protectionDays, _ := model.GetIntByKey(ctx, model.ConfigKeyNewUserProtectionDays) - if protectionDays > 0 { - registeredDays := int(time.Since(user.CreatedAt).Hours() / 24) - if registeredDays < protectionDays { - if err := db.DB(ctx).Model(&user).UpdateColumns(map[string]interface{}{ + return db.DB(ctx).Transaction(func(tx *gorm.DB) error { + for _, user := range users { + newScore, exists := scoreMap[user.ID] + if !exists { + continue + } + + newCommunityBalance := decimal.NewFromInt(newScore) + + // 首次同步 + if user.CommunityBalance.IsZero() && user.TotalCommunity.IsZero() { + if err = tx.Model(&user).UpdateColumns(map[string]interface{}{ "community_balance": newCommunityBalance, }).Error; err != nil { - return fmt.Errorf("更新用户[%s]积分失败: %w", user.Username, err) + return fmt.Errorf("初始化用户[%s]社区积分失败: %w", user.Username, err) } + logger.InfoF(ctx, "用户[%s]首次同步社区积分: %s", user.Username, newCommunityBalance.String()) + continue + } + + // 积分未变化,跳过 + if newCommunityBalance.Equal(user.CommunityBalance) { + continue + } + + diff := newCommunityBalance.Sub(user.CommunityBalance) + oldCommunityBalance := user.CommunityBalance - logger.InfoF(ctx, "用户[%s]在保护期内(注册%d天,保护期%d天),积分下降%s,跳过扣分", - user.Username, registeredDays, protectionDays, diff.Abs().String()) + createOrder := func(amount decimal.Decimal, remark string) error { + order := model.Order{ + OrderName: "社区积分更新", + PayerUserID: 0, + PayeeUserID: user.ID, + Amount: amount, + Status: model.OrderStatusSuccess, + Type: model.OrderTypeCommunity, + Remark: remark, + TradeTime: now, + ExpiresAt: now, + } + if err = tx.Create(&order).Error; err != nil { + return fmt.Errorf("创建用户[%s]订单失败: %w", user.Username, err) + } return nil } + + // 新用户保护期检查 + if diff.IsNegative() && protectionDays > 0 { + registeredDays := int(time.Since(user.CreatedAt).Hours() / 24) + if registeredDays < protectionDays { + if err = tx.Model(&user).UpdateColumns(map[string]interface{}{ + "community_balance": newCommunityBalance, + }).Error; err != nil { + return fmt.Errorf("更新用户[%s]积分失败: %w", user.Username, err) + } + remark := fmt.Sprintf("社区积分从 %s 更新到 %s,变化 %s(保护期内,跳过扣分)", + oldCommunityBalance.String(), newCommunityBalance.String(), diff.String()) + if err = createOrder(decimal.Zero, remark); err != nil { + return err + } + logger.InfoF(ctx, "用户[%s]在保护期内,积分下降%s,跳过扣分", user.Username, diff.Abs().String()) + continue + } + } + + // 更新用户积分 + if err = tx.Model(&user).UpdateColumns(map[string]interface{}{ + "community_balance": newCommunityBalance, + "total_community": gorm.Expr("total_community + ?", diff), + "total_receive": gorm.Expr("total_receive + ?", diff), + "available_balance": gorm.Expr("available_balance + ?", diff), + }).Error; err != nil { + return fmt.Errorf("更新用户[%s]积分失败: %w", user.Username, err) + } + + remark := fmt.Sprintf("社区积分从 %s 更新到 %s,变化 %s", + oldCommunityBalance.String(), newCommunityBalance.String(), diff.String()) + if err = createOrder(diff, remark); err != nil { + return err + } } + return nil + }) +} + +// HandleUpdateSingleUserGamificationScore 处理用户积分更新任务 +func HandleUpdateSingleUserGamificationScore(ctx context.Context, t *asynq.Task) error { + var payload struct { + UserID uint64 `json:"user_id"` + UserScores []model.LeaderboardUser `json:"user_scores"` + } + if err := json.Unmarshal(t.Payload(), &payload); err != nil { + return fmt.Errorf("解析任务参数失败: %w", err) } - oldCommunityBalance := user.CommunityBalance - now := time.Now() + if len(payload.UserScores) > 0 { + return batchUpdateUserScores(ctx, payload.UserScores) + } - if err := db.DB(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Model(&user).UpdateColumns(map[string]interface{}{ - "community_balance": newCommunityBalance, - "total_community": gorm.Expr("total_community + ?", diff), - "total_receive": gorm.Expr("total_receive + ?", diff), - "available_balance": gorm.Expr("available_balance + ?", diff), - }).Error; err != nil { - return fmt.Errorf("更新用户[%s]积分失败: %w", user.Username, err) + if payload.UserID > 0 { + var user model.User + if err := user.GetByID(db.DB(ctx), payload.UserID); err != nil { + return fmt.Errorf("查询用户[%d]失败: %w", payload.UserID, err) } - order := model.Order{ - OrderName: "社区积分更新", - PayerUserID: 0, - PayeeUserID: user.ID, - Amount: diff, - Status: model.OrderStatusSuccess, - Type: model.OrderTypeCommunity, - Remark: fmt.Sprintf("社区积分从 %s 更新到 %s,变化 %s", oldCommunityBalance.String(), newCommunityBalance.String(), diff.String()), - TradeTime: now, - ExpiresAt: now, - } - if err := tx.Create(&order).Error; err != nil { - return fmt.Errorf("创建用户[%s]社区积分订单失败: %w", user.Username, err) + response, errGet := user.GetUserGamificationScore(ctx) + if errGet != nil { + logger.ErrorF(ctx, "处理用户[%s]失败: %v", user.Username, errGet) + return errGet } - return nil - }); err != nil { - logger.ErrorF(ctx, "处理用户[%s]积分更新失败: %v", user.Username, err) - return err + return batchUpdateUserScores(ctx, []model.LeaderboardUser{ + {ID: payload.UserID, TotalScore: response.User.GamificationScore}, + }) } return nil diff --git a/internal/common/errs.go b/internal/common/errs.go index 0004f41d..f53c2928 100644 --- a/internal/common/errs.go +++ b/internal/common/errs.go @@ -25,3 +25,7 @@ const ( PayKeyIncorrect = "支付密钥错误" CannotPaySelf = "不能给自己付款" ) + +const ( + GetProtectionDaysFailed = "获取新用户保护期配置失败" +) diff --git a/internal/model/users.go b/internal/model/users.go index 82fc96ca..6c43be86 100644 --- a/internal/model/users.go +++ b/internal/model/users.go @@ -80,6 +80,18 @@ type UserGamificationScoreResponse struct { } `json:"user"` } +// LeaderboardResponse 排行榜 API 响应 +type LeaderboardResponse struct { + Users []LeaderboardUser `json:"users"` +} + +// LeaderboardUser 排行榜用户信息 +type LeaderboardUser struct { + ID uint64 `json:"id"` + Username string `json:"username"` + TotalScore int64 `json:"total_score"` +} + type User struct { ID uint64 `json:"id" gorm:"primaryKey"` Username string `json:"username" gorm:"size:64;uniqueIndex"` @@ -109,6 +121,15 @@ func (u *User) GetByID(tx *gorm.DB, id uint64) error { return nil } +// GetByIDs 批量查询用户 +func GetByIDs(tx *gorm.DB, ids []uint64) ([]User, error) { + var users []User + if err := tx.Where("id IN ?", ids).Find(&users).Error; err != nil { + return nil, err + } + return users, nil +} + // VerifyPayKey 验证用户支付密码 // 使用用户的 SignKey 解密存储的加密密码,然后与输入的明文密码比较 func (u *User) VerifyPayKey(inputPayKey string) bool { @@ -142,6 +163,26 @@ func (u *User) GetUserGamificationScore(ctx context.Context) (*UserGamificationS return &response, nil } +// GetLeaderboard 获取排行榜数据 +func GetLeaderboard(ctx context.Context, page int) (*LeaderboardResponse, error) { + url := fmt.Sprintf("https://linux.do/leaderboard/1.json?period=all_time&page=%d", page) + resp, err := util.Request(ctx, http.MethodGet, url, nil, nil, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("获取排行榜失败,状态码: %d", resp.StatusCode) + } + + var response LeaderboardResponse + if err = json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("解析排行榜响应失败: %w", err) + } + return &response, nil +} + // UpdateFromOAuthInfo 根据 OAuth 信息更新用户数据 func (u *User) UpdateFromOAuthInfo(oauthInfo *OAuthUserInfo) { u.Username = oauthInfo.Username From d422329b30be27a0b59b783cbcc67e6b9b2fff5b Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Fri, 26 Dec 2025 14:02:44 +0800 Subject: [PATCH 066/366] feat: Points unchanged, add order record --- internal/apps/user/tasks.go | 38 +++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/internal/apps/user/tasks.go b/internal/apps/user/tasks.go index 2c7aacd4..c4b79b64 100644 --- a/internal/apps/user/tasks.go +++ b/internal/apps/user/tasks.go @@ -160,25 +160,8 @@ func batchUpdateUserScores(ctx context.Context, userScores []model.LeaderboardUs } newCommunityBalance := decimal.NewFromInt(newScore) - - // 首次同步 - if user.CommunityBalance.IsZero() && user.TotalCommunity.IsZero() { - if err = tx.Model(&user).UpdateColumns(map[string]interface{}{ - "community_balance": newCommunityBalance, - }).Error; err != nil { - return fmt.Errorf("初始化用户[%s]社区积分失败: %w", user.Username, err) - } - logger.InfoF(ctx, "用户[%s]首次同步社区积分: %s", user.Username, newCommunityBalance.String()) - continue - } - - // 积分未变化,跳过 - if newCommunityBalance.Equal(user.CommunityBalance) { - continue - } - - diff := newCommunityBalance.Sub(user.CommunityBalance) oldCommunityBalance := user.CommunityBalance + diff := newCommunityBalance.Sub(oldCommunityBalance) createOrder := func(amount decimal.Decimal, remark string) error { order := model.Order{ @@ -198,6 +181,25 @@ func batchUpdateUserScores(ctx context.Context, userScores []model.LeaderboardUs return nil } + if user.CommunityBalance.IsZero() && user.TotalCommunity.IsZero() { + if err = tx.Model(&user).UpdateColumns(map[string]interface{}{ + "community_balance": newCommunityBalance, + }).Error; err != nil { + return fmt.Errorf("初始化用户[%s]社区积分失败: %w", user.Username, err) + } + logger.InfoF(ctx, "用户[%s]首次同步社区积分: %s", user.Username, newCommunityBalance.String()) + continue + } + + // 积分未变化 + if diff.IsZero() { + remark := fmt.Sprintf("社区积分从 %s 更新到 %s,变化 %s", oldCommunityBalance.String(), newCommunityBalance.String(), diff.String()) + if err = createOrder(decimal.Zero, remark); err != nil { + return err + } + continue + } + // 新用户保护期检查 if diff.IsNegative() && protectionDays > 0 { registeredDays := int(time.Since(user.CreatedAt).Hours() / 24) From 3b72f2a1956edcd6c188de952e85bbeff5029491 Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Fri, 26 Dec 2025 14:36:47 +0800 Subject: [PATCH 067/366] feat: displays the notes --- frontend/components/common/balance/balance-table.tsx | 2 +- frontend/components/common/general/table-data.tsx | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/components/common/balance/balance-table.tsx b/frontend/components/common/balance/balance-table.tsx index 9a7fe482..2a137d3e 100644 --- a/frontend/components/common/balance/balance-table.tsx +++ b/frontend/components/common/balance/balance-table.tsx @@ -70,7 +70,7 @@ export function BalanceTable() { }, []) if (!timeRange) { - return null // or a loading skeleton + return null } return ( diff --git a/frontend/components/common/general/table-data.tsx b/frontend/components/common/general/table-data.tsx index 0a0d4c70..4afa6a73 100644 --- a/frontend/components/common/general/table-data.tsx +++ b/frontend/components/common/general/table-data.tsx @@ -71,13 +71,14 @@ export const TransactionDataTable = React.memo(function TransactionDataTable({ 创建时间 交易时间 订单过期时间 + 备注 操作 {paddingTop > 0 && (
                                                                                                        - )} {virtualItems.map((virtualRow) => { @@ -93,7 +94,7 @@ export const TransactionDataTable = React.memo(function TransactionDataTable({ })} {paddingBottom > 0 && ( - )} @@ -214,6 +215,9 @@ const TransactionTableRow = React.memo(React.forwardRef {formatDateTime(order.expires_at)} + + {order.remark || '-'} + Date: Fri, 26 Dec 2025 15:24:22 +0800 Subject: [PATCH 068/366] feat: Add conditions for order inquiries and expose users' initial community points --- docs/docs.go | 12 ++++++++++++ docs/swagger.json | 12 ++++++++++++ docs/swagger.yaml | 8 ++++++++ internal/apps/oauth/routers.go | 2 ++ internal/apps/order/routers.go | 30 +++++++++++++++++++++++------- internal/model/orders.go | 2 +- 6 files changed, 58 insertions(+), 8 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index 40e756fa..3f3baf31 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1417,6 +1417,12 @@ const docTemplate = `{ "endTime": { "type": "string" }, + "id": { + "type": "integer" + }, + "order_name": { + "type": "string" + }, "page": { "type": "integer", "minimum": 1 @@ -1426,6 +1432,12 @@ const docTemplate = `{ "maximum": 100, "minimum": 1 }, + "payee_username": { + "type": "string" + }, + "payer_username": { + "type": "string" + }, "startTime": { "type": "string" }, diff --git a/docs/swagger.json b/docs/swagger.json index 1f4391f1..12ad919a 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1408,6 +1408,12 @@ "endTime": { "type": "string" }, + "id": { + "type": "integer" + }, + "order_name": { + "type": "string" + }, "page": { "type": "integer", "minimum": 1 @@ -1417,6 +1423,12 @@ "maximum": 100, "minimum": 1 }, + "payee_username": { + "type": "string" + }, + "payer_username": { + "type": "string" + }, "startTime": { "type": "string" }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 27af16a1..19745c47 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -145,6 +145,10 @@ definitions: type: string endTime: type: string + id: + type: integer + order_name: + type: string page: minimum: 1 type: integer @@ -152,6 +156,10 @@ definitions: maximum: 100 minimum: 1 type: integer + payee_username: + type: string + payer_username: + type: string startTime: type: string status: diff --git a/internal/apps/oauth/routers.go b/internal/apps/oauth/routers.go index 6e05098d..677242df 100644 --- a/internal/apps/oauth/routers.go +++ b/internal/apps/oauth/routers.go @@ -116,6 +116,7 @@ type BasicUserInfo struct { TotalPayment decimal.Decimal `json:"total_payment"` TotalTransfer decimal.Decimal `json:"total_transfer"` TotalCommunity decimal.Decimal `json:"total_community"` + CommunityBalance decimal.Decimal `json:"community_balance"` AvailableBalance decimal.Decimal `json:"available_balance"` PayScore int64 `json:"pay_score"` IsPayKey bool `json:"is_pay_key"` @@ -162,6 +163,7 @@ func UserInfo(c *gin.Context) { TotalPayment: user.TotalPayment, TotalTransfer: user.TotalTransfer, TotalCommunity: user.TotalCommunity, + CommunityBalance: user.CommunityBalance, AvailableBalance: user.AvailableBalance, PayScore: user.PayScore, IsPayKey: user.PayKey != "", diff --git a/internal/apps/order/routers.go b/internal/apps/order/routers.go index e4415a0d..b49694a4 100644 --- a/internal/apps/order/routers.go +++ b/internal/apps/order/routers.go @@ -28,13 +28,17 @@ import ( ) type TransactionListRequest struct { - Page int `json:"page" form:"page" binding:"min=1"` - PageSize int `json:"page_size" form:"page_size" binding:"min=1,max=100"` - Type string `json:"type" form:"type" binding:"omitempty,oneof=receive payment transfer community online"` - Status string `json:"status" form:"status" binding:"omitempty,oneof=success pending failed expired disputing refund refused"` - ClientID string `json:"client_id" form:"client_id" binding:"omitempty"` - StartTime *time.Time `json:"startTime" form:"startTime" binding:"omitempty"` - EndTime *time.Time `json:"endTime" form:"endTime" binding:"omitempty,gtfield=StartTime"` + Page int `json:"page" form:"page" binding:"min=1"` + PageSize int `json:"page_size" form:"page_size" binding:"min=1,max=100"` + Type string `json:"type" form:"type" binding:"omitempty,oneof=receive payment transfer community online"` + Status string `json:"status" form:"status" binding:"omitempty,oneof=success pending failed expired disputing refund refused"` + ClientID string `json:"client_id" form:"client_id" binding:"omitempty"` + StartTime *time.Time `json:"startTime" form:"startTime" binding:"omitempty"` + EndTime *time.Time `json:"endTime" form:"endTime" binding:"omitempty,gtfield=StartTime"` + ID *uint64 `json:"id" form:"id" binding:"omitempty"` + OrderName string `json:"order_name" form:"order_name" binding:"omitempty"` + PayerUsername string `json:"payer_username" form:"payer_username" binding:"omitempty"` + PayeeUsername string `json:"payee_username" form:"payee_username" binding:"omitempty"` } type TransactionListResponse struct { @@ -121,6 +125,18 @@ func ListTransactions(c *gin.Context) { if req.ClientID != "" && !clientIDHandled { baseQuery = baseQuery.Where("orders.client_id = ?", req.ClientID) } + if req.ID != nil { + baseQuery = baseQuery.Where("orders.id = ?", req.ID) + } + if req.OrderName != "" { + baseQuery = baseQuery.Where("orders.order_name LIKE ?", req.OrderName+"%") + } + if req.PayerUsername != "" { + baseQuery = baseQuery.Where("payer_user.username LIKE ?", req.PayerUsername+"%") + } + if req.PayeeUsername != "" { + baseQuery = baseQuery.Where("payee_user.username LIKE ?", req.PayeeUsername+"%") + } if req.StartTime != nil { baseQuery = baseQuery.Where("orders.created_at >= ?", req.StartTime) } diff --git a/internal/model/orders.go b/internal/model/orders.go index 395ba6d7..22d11f3d 100644 --- a/internal/model/orders.go +++ b/internal/model/orders.go @@ -54,7 +54,7 @@ const ( type Order struct { ID uint64 `json:"id" gorm:"primaryKey"` OrderNo string `json:"order_no" gorm:"-"` - OrderName string `json:"order_name" gorm:"size:64;not null"` + OrderName string `json:"order_name" gorm:"size:64;not null;index"` MerchantOrderNo string `json:"merchant_order_no" gorm:"size:64;index"` ClientID string `json:"client_id" gorm:"size:64;index:idx_orders_client_status_created,priority:1;index:idx_orders_client_payee,priority:1;index:idx_orders_client_payer,priority:1"` PayerUserID uint64 `json:"payer_user_id" gorm:"index:idx_orders_payer_status_type_created,priority:1;index:idx_orders_payer_status_type_trade,priority:1;index:idx_orders_client_payer,priority:2"` From 5a237cd8617c6e930d2c7e813a531b6bc5f83111 Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Fri, 26 Dec 2025 16:36:34 +0800 Subject: [PATCH 069/366] feat: show more points details & support search activities --- .../common/balance/balance-summary.tsx | 6 +- .../common/general/table-filter.tsx | 150 +++++++++++++++++- .../common/merchant/merchant-data.tsx | 15 +- .../components/common/settings/profile.tsx | 21 +++ .../components/common/trade/trade-table.tsx | 15 +- frontend/lib/services/transaction/types.ts | 8 + frontend/lib/utils.ts | 11 +- 7 files changed, 213 insertions(+), 13 deletions(-) diff --git a/frontend/components/common/balance/balance-summary.tsx b/frontend/components/common/balance/balance-summary.tsx index 50163b87..612a1939 100644 --- a/frontend/components/common/balance/balance-summary.tsx +++ b/frontend/components/common/balance/balance-summary.tsx @@ -40,9 +40,9 @@ export function BalanceSummary() { const { user, loading } = useUser() const available = parseFloat(user?.available_balance || '0') - const community = parseFloat(user?.community_balance || '0') - const total = available + community - const pending = total - available + // const community = parseFloat(user?.community_balance || '0') + const total = available + const pending = 0 const percentages = React.useMemo( () => calculatePercentages(available, total), diff --git a/frontend/components/common/general/table-filter.tsx b/frontend/components/common/general/table-filter.tsx index 60574bdf..bd7368c4 100644 --- a/frontend/components/common/general/table-filter.tsx +++ b/frontend/components/common/general/table-filter.tsx @@ -1,10 +1,12 @@ import * as React from "react" -import { Filter, CalendarIcon, X } from "lucide-react" +import { Filter, CalendarIcon, X, Search } from "lucide-react" import { zhCN } from "date-fns/locale" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Calendar } from "@/components/ui/calendar" import { Separator } from "@/components/ui/separator" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" import { DropdownMenu, DropdownMenuContent, @@ -111,6 +113,7 @@ export interface TableFilterProps { type?: boolean status?: boolean timeRange?: boolean + search?: boolean } /* 当前选中的值 */ @@ -118,12 +121,15 @@ export interface TableFilterProps { selectedStatuses?: OrderStatus[] selectedTimeRange?: { from: Date; to: Date } | null selectedQuickSelection?: string | null + /* 搜索值 */ + searchValues?: SearchValues /* 回调函数 */ onTypeChange?: (types: OrderType[]) => void onStatusChange?: (statuses: OrderStatus[]) => void onTimeRangeChange?: (range: { from: Date; to: Date } | null) => void onQuickSelectionChange?: (selection: string | null) => void + onSearch?: (values: SearchValues) => void // 其他选项 showClearButton?: boolean @@ -135,15 +141,17 @@ export interface TableFilterProps { * 支持类型、状态、时间范围筛选 */ export function TableFilter({ - enabledFilters = { type: true, status: true, timeRange: true }, + enabledFilters = { type: true, status: true, timeRange: true, search: true }, selectedTypes = [], selectedStatuses = [], selectedTimeRange = null, selectedQuickSelection = null, + searchValues = {}, onTypeChange, onStatusChange, onTimeRangeChange, onQuickSelectionChange, + onSearch, showClearButton = true, onClearAll, }: TableFilterProps) { @@ -172,11 +180,18 @@ export function TableFilter({ } /* 是否有激活的筛选 */ - const hasActiveFilters = selectedTypes.length > 0 || selectedStatuses.length > 0 || selectedTimeRange !== null + const hasActiveFilters = selectedTypes.length > 0 || selectedStatuses.length > 0 || selectedTimeRange !== null || Object.values(searchValues).some(v => v) return (
                                                                                                        + {enabledFilters.search && ( + + )} + {enabledFilters.type && ( label="类型" @@ -383,3 +398,132 @@ function TimeRangeFilter({ ) } + +export interface SearchValues { + id?: string + order_name?: string + payer_username?: string + payee_username?: string +} + +function SearchFilter({ + values, + onSearch +}: { + values?: SearchValues + onSearch?: (values: SearchValues) => void +}) { + const [localValues, setLocalValues] = React.useState(values || {}) + const [isOpen, setIsOpen] = React.useState(false) + + // 当外部 values 更新时,同步到本地 + React.useEffect(() => { + setLocalValues(values || {}) + }, [values, isOpen]) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + onSearch?.(localValues) + setIsOpen(false) + } + } + + const handleSearch = () => { + onSearch?.(localValues) + setIsOpen(false) + } + + const handleClear = () => { + const empty = { id: '', order_name: '', payer_username: '', payee_username: '' } + setLocalValues(empty) + onSearch?.(empty) + setIsOpen(false) + } + + const hasSearchValues = Object.values(values || {}).some(v => v) + + return ( + + + + + +
                                                                                                        +
                                                                                                        + + setLocalValues(prev => ({ ...prev, id: e.target.value }))} + onKeyDown={handleKeyDown} + placeholder="精确匹配" + className="h-8 text-xs" + /> +
                                                                                                        +
                                                                                                        + + setLocalValues(prev => ({ ...prev, order_name: e.target.value }))} + onKeyDown={handleKeyDown} + placeholder="模糊匹配" + className="h-8 text-xs" + /> +
                                                                                                        +
                                                                                                        +
                                                                                                        + + setLocalValues(prev => ({ ...prev, payer_username: e.target.value }))} + onKeyDown={handleKeyDown} + placeholder="模糊匹配" + className="h-8 text-xs" + /> +
                                                                                                        +
                                                                                                        + + setLocalValues(prev => ({ ...prev, payee_username: e.target.value }))} + onKeyDown={handleKeyDown} + placeholder="模糊匹配" + className="h-8 text-xs" + /> +
                                                                                                        +
                                                                                                        + +
                                                                                                        + + +
                                                                                                        +
                                                                                                        +
                                                                                                        +
                                                                                                        + ) +} diff --git a/frontend/components/common/merchant/merchant-data.tsx b/frontend/components/common/merchant/merchant-data.tsx index 49e12852..9fe9fcf1 100644 --- a/frontend/components/common/merchant/merchant-data.tsx +++ b/frontend/components/common/merchant/merchant-data.tsx @@ -5,7 +5,7 @@ import { useEffect } from "react" import { useRouter } from "next/navigation" import { Undo2, FileText, Link2 } from "lucide-react" import { toast } from "sonner" -import { TableFilter } from "@/components/common/general/table-filter" +import { TableFilter, type SearchValues } from "@/components/common/general/table-filter" import { TransactionTableList } from "@/components/common/general/table-data" import type { MerchantAPIKey, OrderType, OrderStatus } from "@/lib/services" import { TransactionProvider, useTransaction } from "@/contexts/transaction-context" @@ -78,12 +78,14 @@ function MerchantDataContent({ apiKey }: MerchantDataProps) { const [selectedStatuses, setSelectedStatuses] = React.useState([]) const [selectedQuickSelection, setSelectedQuickSelection] = React.useState("最近 1 个月") const [dateRange, setDateRange] = React.useState<{ from: Date; to: Date } | null>(getDefaultDateRange) + const [selectedSearch, setSelectedSearch] = React.useState({}) const clearAllFilters = () => { setSelectedTypes([]) setSelectedStatuses([]) setDateRange(getDefaultDateRange()) setSelectedQuickSelection("最近 1 个月") + setSelectedSearch({}) } useEffect(() => { @@ -99,9 +101,13 @@ function MerchantDataContent({ apiKey }: MerchantDataProps) { return formatLocalDate(endDate) })() : undefined, client_id: apiKey.client_id, + id: selectedSearch.id ? parseInt(selectedSearch.id) : undefined, + order_name: selectedSearch.order_name || undefined, + payer_username: selectedSearch.payer_username || undefined, + payee_username: selectedSearch.payee_username || undefined, } fetchTransactions(params) - }, [fetchTransactions, dateRange, selectedTypes, selectedStatuses, apiKey.client_id]) + }, [fetchTransactions, dateRange, selectedTypes, selectedStatuses, apiKey.client_id, selectedSearch]) const actionHandlers = { refund: () => { @@ -147,7 +153,8 @@ function MerchantDataContent({ apiKey }: MerchantDataProps) { enabledFilters={{ type: true, status: true, - timeRange: true + timeRange: true, + search: true }} selectedTypes={selectedTypes} selectedStatuses={selectedStatuses} @@ -157,6 +164,8 @@ function MerchantDataContent({ apiKey }: MerchantDataProps) { onStatusChange={setSelectedStatuses} onTimeRangeChange={setDateRange} onQuickSelectionChange={setSelectedQuickSelection} + onSearch={setSelectedSearch} + searchValues={selectedSearch} onClearAll={clearAllFilters} /> diff --git a/frontend/components/common/settings/profile.tsx b/frontend/components/common/settings/profile.tsx index 487bb2ae..b111e8e0 100644 --- a/frontend/components/common/settings/profile.tsx +++ b/frontend/components/common/settings/profile.tsx @@ -5,6 +5,8 @@ import Link from "next/link" import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar" import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious, type CarouselApi } from "@/components/ui/carousel" import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" +import { Info } from "lucide-react" import { cn } from "@/lib/utils" import { PayLevel, User } from "@/lib/services/auth" @@ -402,6 +404,25 @@ export function ProfileMain() { : `${ (parseFloat(currentLevel.scoreRate as string) * 100).toFixed(2) }%`}
                                                                                                        + +
                                                                                                        +
                                                                                                        + 社区积分 + + + + + + +

                                                                                                        上一次从社区同步的积分,积分会在每日划转时通过系统定时任务自动同步更新

                                                                                                        +
                                                                                                        +
                                                                                                        +
                                                                                                        +
                                                                                                        +
                                                                                                        + {parseFloat(user.community_balance).toLocaleString()} +
                                                                                                        +
                                                                                                        diff --git a/frontend/components/common/trade/trade-table.tsx b/frontend/components/common/trade/trade-table.tsx index 98b1472d..5264123d 100644 --- a/frontend/components/common/trade/trade-table.tsx +++ b/frontend/components/common/trade/trade-table.tsx @@ -1,6 +1,6 @@ import * as React from "react" import { TransactionTableList } from "@/components/common/general/table-data" -import { TableFilter } from "@/components/common/general/table-filter" +import { TableFilter, type SearchValues } from "@/components/common/general/table-filter" import { TransactionProvider, useTransaction } from "@/contexts/transaction-context" import type { OrderStatus, OrderType, TransactionQueryParams } from "@/lib/services" import { formatLocalDate } from "@/lib/utils" @@ -57,6 +57,7 @@ function TransactionList({ initialType }: { initialType?: OrderType }) { const [selectedStatuses, setSelectedStatuses] = React.useState([]) const [selectedQuickSelection, setSelectedQuickSelection] = React.useState("最近 1 个月") const [dateRange, setDateRange] = React.useState<{ from: Date; to: Date } | null>(getDefaultDateRange) + const [selectedSearch, setSelectedSearch] = React.useState({}) /* 清空所有筛选 */ const clearAllFilters = () => { @@ -65,6 +66,7 @@ function TransactionList({ initialType }: { initialType?: OrderType }) { const { from, to: tomorrow } = getDefaultDateRange() setDateRange({ from, to: tomorrow }) setSelectedQuickSelection("最近 1 个月") + setSelectedSearch({}) /* 重新获取数据 */ fetchTransactions({ @@ -89,10 +91,14 @@ function TransactionList({ initialType }: { initialType?: OrderType }) { endDate.setDate(endDate.getDate() + 1) return formatLocalDate(endDate) })() : undefined, + id: selectedSearch.id ? parseInt(selectedSearch.id) : undefined, + order_name: selectedSearch.order_name || undefined, + payer_username: selectedSearch.payer_username || undefined, + payee_username: selectedSearch.payee_username || undefined, } fetchTransactions(params) - }, [fetchTransactions, dateRange, selectedTypes, selectedStatuses]) + }, [fetchTransactions, dateRange, selectedTypes, selectedStatuses, selectedSearch]) /* 当initialType改变时,更新筛选状态 */ React.useEffect(() => { @@ -109,7 +115,8 @@ function TransactionList({ initialType }: { initialType?: OrderType }) { enabledFilters={{ type: initialType === undefined, status: true, - timeRange: true + timeRange: true, + search: true }} selectedTypes={selectedTypes} selectedStatuses={selectedStatuses} @@ -119,6 +126,8 @@ function TransactionList({ initialType }: { initialType?: OrderType }) { onStatusChange={setSelectedStatuses} onTimeRangeChange={setDateRange} onQuickSelectionChange={setSelectedQuickSelection} + onSearch={setSelectedSearch} + searchValues={selectedSearch} onClearAll={clearAllFilters} /> diff --git a/frontend/lib/services/transaction/types.ts b/frontend/lib/services/transaction/types.ts index 96ddce52..4857973b 100644 --- a/frontend/lib/services/transaction/types.ts +++ b/frontend/lib/services/transaction/types.ts @@ -78,6 +78,14 @@ export interface TransactionQueryParams { startTime?: string; /** 结束时间(可选) */ endTime?: string; + /** 订单 ID(可选) */ + id?: number; + /** 订单名称,支持前缀模糊查询(可选) */ + order_name?: string; + /** 付款方账户,支持前缀模糊查询(可选) */ + payer_username?: string; + /** 收款方账户,支持前缀模糊查询(可选) */ + payee_username?: string; } /** diff --git a/frontend/lib/utils.ts b/frontend/lib/utils.ts index a1ddc37d..b272a78c 100644 --- a/frontend/lib/utils.ts +++ b/frontend/lib/utils.ts @@ -50,11 +50,20 @@ export function generateTransactionCacheKey(params: { page_size?: number startTime?: string endTime?: string + id?: number + order_name?: string + payer_username?: string + payee_username?: string }): string { const typeKey = params.type || 'all' const statusKey = params.status || 'all' const clientIdKey = params.client_id || 'all' const startTimeKey = params.startTime || 'no-start' const endTimeKey = params.endTime || 'no-end' - return `${ typeKey }_${ statusKey }_${ clientIdKey }_${ params.page }_${ params.page_size }_${ startTimeKey }_${ endTimeKey }` + const idKey = params.id || 'no-id' + const orderNameKey = params.order_name || 'no-name' + const payerKey = params.payer_username || 'no-payer' + const payeeKey = params.payee_username || 'no-payee' + + return `${ typeKey }_${ statusKey }_${ clientIdKey }_${ params.page }_${ params.page_size }_${ startTimeKey }_${ endTimeKey }_${ idKey }_${ orderNameKey }_${ payerKey }_${ payeeKey }` } From 489d1d25ac4575ab2a32e201b86bbd833a57188f Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Fri, 26 Dec 2025 16:39:02 +0800 Subject: [PATCH 070/366] fix: text position --- frontend/components/common/settings/profile.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/common/settings/profile.tsx b/frontend/components/common/settings/profile.tsx index b111e8e0..21f49e26 100644 --- a/frontend/components/common/settings/profile.tsx +++ b/frontend/components/common/settings/profile.tsx @@ -413,7 +413,7 @@ export function ProfileMain() { - +

                                                                                                        上一次从社区同步的积分,积分会在每日划转时通过系统定时任务自动同步更新

                                                                                                        From a9e47cf0ebb5681ddd8c62ddecf7adfa98ba18e6 Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Fri, 26 Dec 2025 16:46:18 +0800 Subject: [PATCH 071/366] fix: total balance display error --- frontend/components/common/balance/balance-main.tsx | 4 ++-- frontend/components/common/balance/balance-summary.tsx | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/components/common/balance/balance-main.tsx b/frontend/components/common/balance/balance-main.tsx index d0887c91..5a579769 100644 --- a/frontend/components/common/balance/balance-main.tsx +++ b/frontend/components/common/balance/balance-main.tsx @@ -17,8 +17,8 @@ export function BalanceMain() { /** 计算总余额 */ const totalBalance = React.useMemo(() => { - return parseFloat(user?.available_balance || '0') + parseFloat(user?.community_balance || '0') - }, [user?.available_balance, user?.community_balance]) + return parseFloat(user?.available_balance || '0') + }, [user?.available_balance]) return (
                                                                                                        diff --git a/frontend/components/common/balance/balance-summary.tsx b/frontend/components/common/balance/balance-summary.tsx index 612a1939..a574074c 100644 --- a/frontend/components/common/balance/balance-summary.tsx +++ b/frontend/components/common/balance/balance-summary.tsx @@ -40,7 +40,6 @@ export function BalanceSummary() { const { user, loading } = useUser() const available = parseFloat(user?.available_balance || '0') - // const community = parseFloat(user?.community_balance || '0') const total = available const pending = 0 From 50aa2e7f920e99ebbcfac80d9ed4eaaa9a85f0b5 Mon Sep 17 00:00:00 2001 From: zfb132 Date: Fri, 26 Dec 2025 17:43:28 +0800 Subject: [PATCH 072/366] fix: wrong remain quota (-1) --- frontend/components/common/home/data-panel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/common/home/data-panel.tsx b/frontend/components/common/home/data-panel.tsx index 500849dc..de15ddd7 100644 --- a/frontend/components/common/home/data-panel.tsx +++ b/frontend/components/common/home/data-panel.tsx @@ -202,7 +202,7 @@ export function DataPanel() {
                                                                                                        - {userLoading ? '-' : } + {userLoading ? '-' : remainQuota < 0 ? "无限制" : }
                                                                                                        From b53cbfbeb0c9b42ea2641185d713e457524c4486 Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Fri, 26 Dec 2025 17:53:07 +0800 Subject: [PATCH 073/366] feat: Local tasks are not issued --- internal/apps/merchant/link/routers.go | 7 ++++++- internal/config/model.go | 5 +++++ internal/db/postgres.go | 4 ++-- internal/router/router.go | 6 +++--- internal/util/http_clients.go | 11 +++++++++++ 5 files changed, 27 insertions(+), 6 deletions(-) diff --git a/internal/apps/merchant/link/routers.go b/internal/apps/merchant/link/routers.go index f2db1c9f..1f08e2a7 100644 --- a/internal/apps/merchant/link/routers.go +++ b/internal/apps/merchant/link/routers.go @@ -28,11 +28,12 @@ import ( "github.com/linux-do/credit/internal/apps/merchant" "github.com/linux-do/credit/internal/apps/oauth" "github.com/linux-do/credit/internal/common" + "github.com/linux-do/credit/internal/config" "github.com/linux-do/credit/internal/db" "github.com/linux-do/credit/internal/model" "github.com/linux-do/credit/internal/service" "github.com/linux-do/credit/internal/task" - "github.com/linux-do/credit/internal/task/scheduler" + "github.com/linux-do/credit/internal/task/scheduler" "github.com/linux-do/credit/internal/util" "github.com/shopspring/decimal" "gorm.io/gorm" @@ -296,6 +297,10 @@ func PayByLink(c *gin.Context) { return err } + if config.Config.App.IsProduction() && util.IsLocalhost(merchantAPIKey.NotifyURL) { + return nil + } + notifyPayload, _ := json.Marshal(map[string]interface{}{ "order_id": order.ID, "client_id": merchantAPIKey.ClientID, diff --git a/internal/config/model.go b/internal/config/model.go index c1e8b621..66ae34f7 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -46,6 +46,11 @@ type appConfig struct { SessionSecure bool `mapstructure:"session_secure"` } +// IsProduction 检查当前环境是否为生产环境 +func (a *appConfig) IsProduction() bool { + return a.Env == "production" +} + // OAuth2Config OAuth2/OIDC认证配置 type OAuth2Config struct { ClientID string `mapstructure:"client_id"` diff --git a/internal/db/postgres.go b/internal/db/postgres.go index 4ad0c5ab..8069be46 100644 --- a/internal/db/postgres.go +++ b/internal/db/postgres.go @@ -59,8 +59,8 @@ func init() { logger.Config{ SlowThreshold: 200 * time.Millisecond, LogLevel: logger.Warn, - IgnoreRecordNotFoundError: config.Config.App.Env == "production", - Colorful: config.Config.App.Env == "development", + IgnoreRecordNotFoundError: config.Config.App.IsProduction(), + Colorful: !config.Config.App.IsProduction(), }, ) diff --git a/internal/router/router.go b/internal/router/router.go index 57b252e9..d4ca11a2 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -30,6 +30,7 @@ import ( "github.com/linux-do/credit/internal/apps/admin" publicconfig "github.com/linux-do/credit/internal/apps/config" "github.com/linux-do/credit/internal/apps/dispute" + "github.com/linux-do/credit/internal/apps/health" "github.com/linux-do/credit/internal/apps/merchant/api_key" "github.com/linux-do/credit/internal/apps/merchant/link" "github.com/linux-do/credit/internal/listener" @@ -44,7 +45,6 @@ import ( "github.com/linux-do/credit/internal/apps/admin/system_config" "github.com/linux-do/credit/internal/apps/admin/user_pay_config" "github.com/linux-do/credit/internal/apps/dashboard" - "github.com/linux-do/credit/internal/apps/health" "github.com/linux-do/credit/internal/apps/oauth" "github.com/linux-do/credit/internal/apps/order" "github.com/linux-do/credit/internal/apps/user" @@ -57,7 +57,7 @@ import ( func Serve() { // 运行模式 - if config.Config.App.Env == "production" { + if config.Config.App.IsProduction() { gin.SetMode(gin.ReleaseMode) } @@ -108,7 +108,7 @@ func Serve() { apiGroup := r.Group(config.Config.App.APIPrefix) { - if config.Config.App.Env == "development" { + if !config.Config.App.IsProduction() { // Swagger apiGroup.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) } diff --git a/internal/util/http_clients.go b/internal/util/http_clients.go index 4ada39a2..67928603 100644 --- a/internal/util/http_clients.go +++ b/internal/util/http_clients.go @@ -21,9 +21,20 @@ import ( "fmt" "io" "net/http" + "net/url" "time" ) +// IsLocalhost 检查 URL 是否为 localhost +func IsLocalhost(urlStr string) bool { + u, err := url.Parse(urlStr) + if err != nil { + return false + } + hostname := u.Hostname() + return hostname == "localhost" || hostname == "127.0.0.1" || hostname == "::1" +} + // 配置HTTP客户端 var httpClient = &http.Client{ Timeout: 10 * time.Second, From 72c540ee3fcfe834541e8acb9bd545595b6f057b Mon Sep 17 00:00:00 2001 From: Fengfeng2010 Date: Fri, 26 Dec 2025 12:07:32 +0000 Subject: [PATCH 074/366] feat: show order remark in dispute dialog --- frontend/components/common/general/dispute-dialog.tsx | 4 ++++ frontend/package.json | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/components/common/general/dispute-dialog.tsx b/frontend/components/common/general/dispute-dialog.tsx index 106c333a..0c1e8fce 100644 --- a/frontend/components/common/general/dispute-dialog.tsx +++ b/frontend/components/common/general/dispute-dialog.tsx @@ -89,6 +89,10 @@ export function OrderDetailDialog({ order }: { order: Order }) { 服务方 {order.payee_username} +
                                                                                                        + 备注 + {order.remark} +
                                                                                                        {(order.status === 'success' || order.status === 'refund') && (
                                                                                                        时间 diff --git a/frontend/package.json b/frontend/package.json index 530d7a31..4ddaa1df 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -68,7 +68,7 @@ "@tailwindcss/postcss": "^4", "@tailwindcss/typography": "^0.5.19", "@types/node": "^20", - "@types/react": "^19", + "@types/react": "^19.2.7", "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "15.5.6", @@ -76,4 +76,4 @@ "tw-animate-css": "^1.4.0", "typescript": "^5" } -} \ No newline at end of file +} From 6abef2fb57d4fdecf01da4a7b0d42995200b531f Mon Sep 17 00:00:00 2001 From: Fengfeng2010 Date: Fri, 26 Dec 2025 07:36:44 -0500 Subject: [PATCH 075/366] Update package.json --- frontend/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/package.json b/frontend/package.json index 4ddaa1df..2967de52 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -68,7 +68,7 @@ "@tailwindcss/postcss": "^4", "@tailwindcss/typography": "^0.5.19", "@types/node": "^20", - "@types/react": "^19.2.7", + "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "15.5.6", From 5e289871a6bfa76d49d511790289a228c04c3d5e Mon Sep 17 00:00:00 2001 From: lurk Date: Sat, 27 Dec 2025 00:35:01 +0800 Subject: [PATCH 076/366] docs: Remove useless --- README_zh.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/README_zh.md b/README_zh.md index 56c40075..58a0ed9e 100644 --- a/README_zh.md +++ b/README_zh.md @@ -207,13 +207,6 @@ API 文档通过 Swagger 自动生成,启动后端服务后可访问: http://localhost:8000/swagger/index.html ``` -### 主要 API 端点 - -- `GET /api/health` - 健康检查 -- `GET /api/oauth2/login` - OAuth2 登录 -- `GET /api/projects` - 获取项目列表 -- `POST /api/projects` - 创建新项目 - ## 🧪 测试 ```bash From b8fa3334dbe2cd707171b0b9a6cacbc9e2cbad1a Mon Sep 17 00:00:00 2001 From: lurk Date: Sat, 27 Dec 2025 00:37:34 +0800 Subject: [PATCH 077/366] docs: Remove useless --- README.md | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/README.md b/README.md index 966cf244..ec19b726 100644 --- a/README.md +++ b/README.md @@ -207,13 +207,6 @@ API documentation is automatically generated by Swagger and can be accessed afte http://localhost:8000/swagger/index.html ``` -### Main API Endpoints - -- `GET /api/health` - Health Check -- `GET /api/oauth2/login` - OAuth2 Login -- `GET /api/projects` - Get Project List -- `POST /api/projects` - Create New Project - ## 🧪 Testing ```bash @@ -288,4 +281,4 @@ Thanks to all developers who contributed to this project and the support of the ## 📈 Star History -[![Star History Chart](https://api.star-history.com/svg?repos=linux-do/credit&type=Date)](https://star-history.com/#linux-do/credit&Date) \ No newline at end of file +[![Star History Chart](https://api.star-history.com/svg?repos=linux-do/credit&type=Date)](https://star-history.com/#linux-do/credit&Date) From ec3d4dcb6745ee9e830dd5912f37b35d2b2b3f07 Mon Sep 17 00:00:00 2001 From: cattie <2237829695@qq.com> Date: Sat, 27 Dec 2025 13:48:01 +0800 Subject: [PATCH 078/366] feat: change some tooltip to popover to fit mobile view --- .../components/common/home/data-panel.tsx | 20 ++++++------ .../components/common/settings/profile.tsx | 20 ++++++------ frontend/components/ui/popover.tsx | 32 +++++++++++++++++++ frontend/package.json | 1 + frontend/pnpm-lock.yaml | 3 ++ 5 files changed, 54 insertions(+), 22 deletions(-) create mode 100644 frontend/components/ui/popover.tsx diff --git a/frontend/components/common/home/data-panel.tsx b/frontend/components/common/home/data-panel.tsx index de15ddd7..fc4f23c6 100644 --- a/frontend/components/common/home/data-panel.tsx +++ b/frontend/components/common/home/data-panel.tsx @@ -3,7 +3,7 @@ import { Area, AreaChart, XAxis } from "recharts" import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart" import { CountingNumber } from '@/components/animate-ui/primitives/texts/counting-number' import { Skeleton } from "@/components/ui/skeleton" -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { Info } from "lucide-react" import { useUser } from "@/contexts/user-context" @@ -190,16 +190,14 @@ export function DataPanel() {
                                                                                                        今日剩余额度 - - - - - - -

                                                                                                        每日积分消耗额度限制,每日 0 点自动重置

                                                                                                        -
                                                                                                        -
                                                                                                        -
                                                                                                        + + + + + +

                                                                                                        每日积分消耗额度限制,每日 0 点自动重置

                                                                                                        +
                                                                                                        +
                                                                                                        {userLoading ? '-' : remainQuota < 0 ? "无限制" : } diff --git a/frontend/components/common/settings/profile.tsx b/frontend/components/common/settings/profile.tsx index 21f49e26..1272401b 100644 --- a/frontend/components/common/settings/profile.tsx +++ b/frontend/components/common/settings/profile.tsx @@ -5,7 +5,7 @@ import Link from "next/link" import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar" import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious, type CarouselApi } from "@/components/ui/carousel" import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb" -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { Info } from "lucide-react" import { cn } from "@/lib/utils" @@ -408,16 +408,14 @@ export function ProfileMain() {
                                                                                                        社区积分 - - - - - - -

                                                                                                        上一次从社区同步的积分,积分会在每日划转时通过系统定时任务自动同步更新

                                                                                                        -
                                                                                                        -
                                                                                                        -
                                                                                                        + + + + + +

                                                                                                        上一次从社区同步的积分,积分会在每日划转时通过系统定时任务自动同步更新

                                                                                                        +
                                                                                                        +
                                                                                                        {parseFloat(user.community_balance).toLocaleString()} diff --git a/frontend/components/ui/popover.tsx b/frontend/components/ui/popover.tsx new file mode 100644 index 00000000..78677484 --- /dev/null +++ b/frontend/components/ui/popover.tsx @@ -0,0 +1,32 @@ +"use client" + +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "@/lib/utils" + +const Popover = PopoverPrimitive.Root +const PopoverTrigger = PopoverPrimitive.Trigger + +function PopoverContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { Popover, PopoverTrigger, PopoverContent } \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 2967de52..9d080744 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 916bf92d..d7acaa10 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: '@radix-ui/react-label': specifier: ^2.1.8 version: 2.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-popover': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-progress': specifier: ^1.1.8 version: 1.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) From ca8f2e64bd11fc3601fe8ccb0d9d8638bb166fdf Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Sat, 27 Dec 2025 18:18:01 +0800 Subject: [PATCH 079/366] feat: Issue task endpoint --- docs/docs.go | 70 ++++++++++++++++ docs/swagger.json | 70 ++++++++++++++++ docs/swagger.yaml | 44 ++++++++++ internal/apps/admin/task/errs.go | 24 ++++++ internal/apps/admin/task/routers.go | 120 ++++++++++++++++++++++++++++ internal/apps/order/tasks.go | 22 +++-- internal/apps/user/tasks.go | 5 ++ internal/router/router.go | 5 ++ internal/task/constants.go | 59 ++++++++++++++ 9 files changed, 414 insertions(+), 5 deletions(-) create mode 100644 internal/apps/admin/task/errs.go create mode 100644 internal/apps/admin/task/routers.go diff --git a/docs/docs.go b/docs/docs.go index 3f3baf31..a353d7a5 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -233,6 +233,56 @@ const docTemplate = `{ } } }, + "/api/v1/admin/tasks/dispatch": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "parameters": [ + { + "description": "request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/task.DispatchTaskRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/admin/tasks/types": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/admin/user-pay-configs": { "get": { "produces": [ @@ -1660,6 +1710,26 @@ const docTemplate = `{ } } }, + "task.DispatchTaskRequest": { + "type": "object", + "required": [ + "task_type" + ], + "properties": { + "end_time": { + "type": "string" + }, + "start_time": { + "type": "string" + }, + "task_type": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, "user.UpdatePayKeyRequest": { "type": "object", "required": [ diff --git a/docs/swagger.json b/docs/swagger.json index 12ad919a..ffd6779f 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -224,6 +224,56 @@ } } }, + "/api/v1/admin/tasks/dispatch": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "parameters": [ + { + "description": "request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/task.DispatchTaskRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/admin/tasks/types": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/admin/user-pay-configs": { "get": { "produces": [ @@ -1651,6 +1701,26 @@ } } }, + "task.DispatchTaskRequest": { + "type": "object", + "required": [ + "task_type" + ], + "properties": { + "end_time": { + "type": "string" + }, + "start_time": { + "type": "string" + }, + "task_type": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, "user.UpdatePayKeyRequest": { "type": "object", "required": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 19745c47..001fb00e 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -319,6 +319,19 @@ definitions: required: - value type: object + task.DispatchTaskRequest: + properties: + end_time: + type: string + start_time: + type: string + task_type: + type: string + user_id: + type: integer + required: + - task_type + type: object user.UpdatePayKeyRequest: properties: pay_key: @@ -513,6 +526,37 @@ paths: $ref: '#/definitions/util.ResponseAny' tags: - admin + /api/v1/admin/tasks/dispatch: + post: + consumes: + - application/json + parameters: + - description: request body + in: body + name: request + required: true + schema: + $ref: '#/definitions/task.DispatchTaskRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - admin + /api/v1/admin/tasks/types: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - admin /api/v1/admin/user-pay-configs: get: produces: diff --git a/internal/apps/admin/task/errs.go b/internal/apps/admin/task/errs.go new file mode 100644 index 00000000..127983e9 --- /dev/null +++ b/internal/apps/admin/task/errs.go @@ -0,0 +1,24 @@ +/* +Copyright 2025 linux.do + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package task + +const ( + InvalidTaskType = "无效的任务类型" + InvalidTimeRange = "无效的时间范围" + TaskDispatchFailed = "任务下发失败" + UserIDRequired = "用户ID必填" +) diff --git a/internal/apps/admin/task/routers.go b/internal/apps/admin/task/routers.go new file mode 100644 index 00000000..6cbe9e25 --- /dev/null +++ b/internal/apps/admin/task/routers.go @@ -0,0 +1,120 @@ +/* +Copyright 2025 linux.do + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package task + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/hibiken/asynq" + "github.com/linux-do/credit/internal/task" + "github.com/linux-do/credit/internal/task/scheduler" + "github.com/linux-do/credit/internal/util" +) + +// ListTaskTypes 获取支持的任务类型列表 +// @Tags admin +// @Produce json +// @Success 200 {object} util.ResponseAny +// @Router /api/v1/admin/tasks/types [get] +func ListTaskTypes(c *gin.Context) { + c.JSON(http.StatusOK, util.OK(task.DispatchableTasks)) +} + +// DispatchTaskRequest 下发任务请求 +type DispatchTaskRequest struct { + TaskType string `json:"task_type" binding:"required"` + StartTime *time.Time `json:"start_time"` + EndTime *time.Time `json:"end_time"` + UserID *uint64 `json:"user_id"` +} + +// DispatchTask 下发任务 +// @Tags admin +// @Accept json +// @Produce json +// @Param request body DispatchTaskRequest true "request body" +// @Success 200 {object} util.ResponseAny +// @Router /api/v1/admin/tasks/dispatch [post] +func DispatchTask(c *gin.Context) { + var req DispatchTaskRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, util.Err(err.Error())) + return + } + + meta := task.GetTaskMeta(req.TaskType) + if meta == nil { + c.JSON(http.StatusBadRequest, util.Err(InvalidTaskType)) + return + } + + var taskInfo *asynq.Task + var taskID string + + switch req.TaskType { + case task.TaskTypeOrderSync: + if req.StartTime != nil && req.EndTime != nil { + if req.EndTime.Before(*req.StartTime) { + c.JSON(http.StatusBadRequest, util.Err(InvalidTimeRange)) + return + } + payload, _ := json.Marshal(map[string]interface{}{ + "start_time": req.StartTime, + "end_time": req.EndTime, + }) + taskInfo = asynq.NewTask(meta.AsynqTask, payload) + taskID = fmt.Sprintf("manual_%s_%s_%s", req.TaskType, + req.StartTime.Format("20060102150405"), + req.EndTime.Format("20060102150405")) + } else { + taskInfo = asynq.NewTask(meta.AsynqTask, nil) + taskID = fmt.Sprintf("manual_%s", req.TaskType) + } + + case task.TaskTypeUserGamification: + if req.UserID == nil || *req.UserID == 0 { + c.JSON(http.StatusBadRequest, util.Err(UserIDRequired)) + return + } + payload, _ := json.Marshal(map[string]interface{}{ + "user_id": *req.UserID, + }) + taskInfo = asynq.NewTask(meta.AsynqTask, payload) + taskID = fmt.Sprintf("manual_%s_user_%d", req.TaskType, *req.UserID) + + default: + taskInfo = asynq.NewTask(meta.AsynqTask, nil) + taskID = fmt.Sprintf("manual_%s", req.TaskType) + } + + info, err := scheduler.AsynqClient.Enqueue( + taskInfo, + asynq.TaskID(taskID), + asynq.MaxRetry(meta.MaxRetry), + asynq.Queue(meta.Queue), + ) + if err != nil { + c.JSON(http.StatusInternalServerError, util.Err(fmt.Sprintf("%s: %v", TaskDispatchFailed, err))) + return + } + + c.JSON(http.StatusOK, util.OK(info.Queue)) +} diff --git a/internal/apps/order/tasks.go b/internal/apps/order/tasks.go index 225c5a7c..5fe98be3 100644 --- a/internal/apps/order/tasks.go +++ b/internal/apps/order/tasks.go @@ -18,6 +18,7 @@ package order import ( "context" + "encoding/json" "time" "github.com/hibiken/asynq" @@ -34,11 +35,22 @@ func HandleSyncOrdersToClickHouse(ctx context.Context, t *asynq.Task) error { return nil } - // 计算昨天的时间范围 - now := time.Now() - yesterday := now.AddDate(0, 0, -1) - startOfDay := time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 0, 0, 0, 0, yesterday.Location()) - endOfDay := startOfDay.AddDate(0, 0, 1) + var payload struct { + StartTime *time.Time `json:"start_time"` + EndTime *time.Time `json:"end_time"` + } + _ = json.Unmarshal(t.Payload(), &payload) + + var startOfDay, endOfDay time.Time + if payload.StartTime != nil && payload.EndTime != nil { + startOfDay = *payload.StartTime + endOfDay = *payload.EndTime + } else { + now := time.Now() + yesterday := now.AddDate(0, 0, -1) + startOfDay = time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 0, 0, 0, 0, yesterday.Location()) + endOfDay = startOfDay.AddDate(0, 0, 1) + } logger.InfoF(ctx, "开始同步订单到 ClickHouse: %s ~ %s", startOfDay.Format("2006-01-02 15:04:05"), endOfDay.Format("2006-01-02 15:04:05")) diff --git a/internal/apps/user/tasks.go b/internal/apps/user/tasks.go index c4b79b64..66f4e246 100644 --- a/internal/apps/user/tasks.go +++ b/internal/apps/user/tasks.go @@ -19,6 +19,7 @@ package user import ( "context" "encoding/json" + "errors" "fmt" "time" @@ -256,6 +257,10 @@ func HandleUpdateSingleUserGamificationScore(ctx context.Context, t *asynq.Task) if payload.UserID > 0 { var user model.User if err := user.GetByID(db.DB(ctx), payload.UserID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + logger.InfoF(ctx, "用户[%d]不存在,跳过积分更新", payload.UserID) + return nil + } return fmt.Errorf("查询用户[%d]失败: %w", payload.UserID, err) } diff --git a/internal/router/router.go b/internal/router/router.go index d4ca11a2..c1f26711 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -28,6 +28,7 @@ import ( "time" "github.com/linux-do/credit/internal/apps/admin" + admin_task "github.com/linux-do/credit/internal/apps/admin/task" publicconfig "github.com/linux-do/credit/internal/apps/config" "github.com/linux-do/credit/internal/apps/dispute" "github.com/linux-do/credit/internal/apps/health" @@ -203,6 +204,10 @@ func Serve() { adminRouter := apiV1Router.Group("/admin") adminRouter.Use(oauth.LoginRequired(), admin.LoginAdminRequired()) { + // Task dispatch + adminRouter.GET("/tasks/types", admin_task.ListTaskTypes) + adminRouter.POST("/tasks/dispatch", admin_task.DispatchTask) + // System Config adminRouter.POST("/system-configs", system_config.CreateSystemConfig) adminRouter.GET("/system-configs", system_config.ListSystemConfigs) diff --git a/internal/task/constants.go b/internal/task/constants.go index 753923f7..4e67a4de 100644 --- a/internal/task/constants.go +++ b/internal/task/constants.go @@ -30,3 +30,62 @@ const ( QueueWebhook = "webhook" QueueDefault = "default" ) + +// 管理员可下发的任务类型标识 +const ( + TaskTypeOrderSync = "order_sync" + TaskTypeUserGamification = "user_gamification" + TaskTypeDisputeRefund = "dispute_auto_refund" +) + +// TaskMeta 任务元数据 +type TaskMeta struct { + Type string + AsynqTask string + Name string + Description string + SupportsTime bool + MaxRetry int + Queue string +} + +// DispatchableTasks 可下发的任务列表 +var DispatchableTasks = []TaskMeta{ + { + Type: TaskTypeOrderSync, + AsynqTask: SyncOrdersToClickHouseTask, + Name: "订单同步", + Description: "同步订单数据到 ClickHouse", + SupportsTime: true, + MaxRetry: 5, + Queue: QueueDefault, + }, + { + Type: TaskTypeUserGamification, + AsynqTask: UpdateSingleUserGamificationScoreTask, + Name: "用户积分更新", + Description: "更新指定用户的游戏化积分", + SupportsTime: false, + MaxRetry: 5, + Queue: QueueWhitelistOnly, + }, + { + Type: TaskTypeDisputeRefund, + AsynqTask: AutoRefundExpiredDisputesTask, + Name: "争议自动退款", + Description: "处理过期争议的自动退款", + SupportsTime: false, + MaxRetry: 5, + Queue: QueueDefault, + }, +} + +// GetTaskMeta 根据任务类型获取元数据 +func GetTaskMeta(taskType string) *TaskMeta { + for _, t := range DispatchableTasks { + if t.Type == taskType { + return &t + } + } + return nil +} From c4a2d62c7961139b2e19e5fd92693b5edb87d529 Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Sat, 27 Dec 2025 19:17:16 +0800 Subject: [PATCH 080/366] style: remove useless --- internal/apps/admin/task/routers.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/apps/admin/task/routers.go b/internal/apps/admin/task/routers.go index 6cbe9e25..8fe55b86 100644 --- a/internal/apps/admin/task/routers.go +++ b/internal/apps/admin/task/routers.go @@ -105,7 +105,7 @@ func DispatchTask(c *gin.Context) { taskID = fmt.Sprintf("manual_%s", req.TaskType) } - info, err := scheduler.AsynqClient.Enqueue( + _, err := scheduler.AsynqClient.Enqueue( taskInfo, asynq.TaskID(taskID), asynq.MaxRetry(meta.MaxRetry), @@ -116,5 +116,5 @@ func DispatchTask(c *gin.Context) { return } - c.JSON(http.StatusOK, util.OK(info.Queue)) + c.JSON(http.StatusOK, util.OKNil()) } From c40018532ae0fb84da2d1f003ed559c5880764d9 Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Sat, 27 Dec 2025 19:26:50 +0800 Subject: [PATCH 081/366] feat: Add a user management API --- docs/docs.go | 82 ++++++++++++++ docs/swagger.json | 82 ++++++++++++++ docs/swagger.yaml | 52 +++++++++ internal/apps/admin/user/errs.go | 23 ++++ internal/apps/admin/user/routers.go | 163 ++++++++++++++++++++++++++++ internal/router/router.go | 5 + 6 files changed, 407 insertions(+) create mode 100644 internal/apps/admin/user/errs.go create mode 100644 internal/apps/admin/user/routers.go diff --git a/docs/docs.go b/docs/docs.go index a353d7a5..a0c06687 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -420,6 +420,80 @@ const docTemplate = `{ } } }, + "/api/v1/admin/users": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "parameters": [ + { + "minimum": 1, + "type": "integer", + "name": "page", + "in": "query" + }, + { + "maximum": 100, + "minimum": 1, + "type": "integer", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "name": "username", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/admin/users/{id}/status": { + "put": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "parameters": [ + { + "type": "integer", + "description": "用户ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "状态", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.updateUserStatusRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/config/public": { "get": { "consumes": [ @@ -1742,6 +1816,14 @@ const docTemplate = `{ } } }, + "user.updateUserStatusRequest": { + "type": "object", + "properties": { + "is_active": { + "type": "boolean" + } + } + }, "user_pay_config.CreateUserPayConfigRequest": { "type": "object", "required": [ diff --git a/docs/swagger.json b/docs/swagger.json index ffd6779f..785a8e75 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -411,6 +411,80 @@ } } }, + "/api/v1/admin/users": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "parameters": [ + { + "minimum": 1, + "type": "integer", + "name": "page", + "in": "query" + }, + { + "maximum": 100, + "minimum": 1, + "type": "integer", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "name": "username", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/admin/users/{id}/status": { + "put": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "parameters": [ + { + "type": "integer", + "description": "用户ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "状态", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.updateUserStatusRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/config/public": { "get": { "consumes": [ @@ -1733,6 +1807,14 @@ } } }, + "user.updateUserStatusRequest": { + "type": "object", + "properties": { + "is_active": { + "type": "boolean" + } + } + }, "user_pay_config.CreateUserPayConfigRequest": { "type": "object", "required": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 001fb00e..afe78267 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -340,6 +340,11 @@ definitions: required: - pay_key type: object + user.updateUserStatusRequest: + properties: + is_active: + type: boolean + type: object user_pay_config.CreateUserPayConfigRequest: properties: daily_limit: @@ -644,6 +649,53 @@ paths: $ref: '#/definitions/util.ResponseAny' tags: - admin + /api/v1/admin/users: + get: + parameters: + - in: query + minimum: 1 + name: page + type: integer + - in: query + maximum: 100 + minimum: 1 + name: page_size + type: integer + - in: query + name: username + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - admin + /api/v1/admin/users/{id}/status: + put: + parameters: + - description: 用户ID + in: path + name: id + required: true + type: integer + - description: 状态 + in: body + name: request + required: true + schema: + $ref: '#/definitions/user.updateUserStatusRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - admin /api/v1/config/public: get: consumes: diff --git a/internal/apps/admin/user/errs.go b/internal/apps/admin/user/errs.go new file mode 100644 index 00000000..acfcaec2 --- /dev/null +++ b/internal/apps/admin/user/errs.go @@ -0,0 +1,23 @@ +/* +Copyright 2025 linux.do + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package user + +const ( + userNotFound = "用户不存在" + cannotDisable = "不能禁用管理员用户" + updateUserFailed = "更新用户状态失败" +) diff --git a/internal/apps/admin/user/routers.go b/internal/apps/admin/user/routers.go new file mode 100644 index 00000000..fd8930e1 --- /dev/null +++ b/internal/apps/admin/user/routers.go @@ -0,0 +1,163 @@ +/* +Copyright 2025 linux.do + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package user + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/linux-do/credit/internal/db" + "github.com/linux-do/credit/internal/model" + "github.com/linux-do/credit/internal/util" + "github.com/shopspring/decimal" + "gorm.io/gorm" +) + +// listUsersRequest 用户列表查询请求 +type listUsersRequest struct { + Page int `form:"page" binding:"min=1"` + PageSize int `form:"page_size" binding:"min=1,max=100"` + Username string `form:"username"` +} + +type user struct { + ID uint64 `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + AvatarUrl string `json:"avatar_url"` + TrustLevel model.TrustLevel `json:"trust_level"` + PayScore int64 `json:"pay_score"` + TotalReceive decimal.Decimal `json:"total_receive"` + TotalPayment decimal.Decimal `json:"total_payment"` + TotalTransfer decimal.Decimal `json:"total_transfer"` + TotalCommunity decimal.Decimal `json:"total_community"` + CommunityBalance decimal.Decimal `json:"community_balance"` + AvailableBalance decimal.Decimal `json:"available_balance"` + IsActive bool `json:"is_active"` + IsAdmin bool `json:"is_admin"` + LastLoginAt time.Time `json:"last_login_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// listUsersResponse 用户列表响应 +type listUsersResponse struct { + Users []user `json:"users"` + Total int64 `json:"total"` +} + +// ListUsers 获取用户列表 +// @Tags admin +// @Produce json +// @Param request query listUsersRequest true "查询参数" +// @Success 200 {object} util.ResponseAny +// @Router /api/v1/admin/users [get] +func ListUsers(c *gin.Context) { + var req listUsersRequest + if err := c.ShouldBindQuery(&req); err != nil { + c.JSON(http.StatusBadRequest, util.Err(err.Error())) + return + } + + var users []user + var total int64 + + query := db.DB(c.Request.Context()).Table("users") + + if req.Username != "" { + query = query.Where("username LIKE ?", req.Username+"%") + } + + if err := query.Count(&total).Error; err != nil { + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + + offset := (req.Page - 1) * req.PageSize + if err := query. + Select("id, username, nickname, avatar_url, trust_level, pay_score, " + + "total_receive, total_payment, total_transfer, total_community, " + + "community_balance, available_balance, is_active, is_admin, " + + "last_login_at, created_at, updated_at"). + Order("id DESC"). + Offset(offset). + Limit(req.PageSize). + Find(&users).Error; err != nil { + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + + c.JSON(http.StatusOK, util.OK(listUsersResponse{ + Users: users, + Total: total, + })) +} + +// updateUserStatusRequest 更新用户状态请求 +type updateUserStatusRequest struct { + IsActive bool `json:"is_active"` +} + +// UpdateUserStatus 更新用户状态(启用/禁用) +// @Tags admin +// @Produce json +// @Param id path int true "用户ID" +// @Param request body updateUserStatusRequest true "状态" +// @Success 200 {object} util.ResponseAny +// @Router /api/v1/admin/users/{id}/status [put] +func UpdateUserStatus(c *gin.Context) { + var req updateUserStatusRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, util.Err(err.Error())) + return + } + + id := c.Param("id") + + var targetUser struct { + ID uint64 `gorm:"column:id"` + IsAdmin bool `gorm:"column:is_admin"` + } + if err := db.DB(c.Request.Context()). + Table("users"). + Select("id, is_admin"). + Where("id = ?", id). + First(&targetUser).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, util.Err(userNotFound)) + return + } + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + + if !req.IsActive && targetUser.IsAdmin { + c.JSON(http.StatusForbidden, util.Err(cannotDisable)) + return + } + + if err := db.DB(c.Request.Context()). + Table("users"). + Where("id = ?", id). + Update("is_active", req.IsActive).Error; err != nil { + c.JSON(http.StatusInternalServerError, util.Err(updateUserFailed)) + return + } + + c.JSON(http.StatusOK, util.OKNil()) +} diff --git a/internal/router/router.go b/internal/router/router.go index c1f26711..749093fa 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -29,6 +29,7 @@ import ( "github.com/linux-do/credit/internal/apps/admin" admin_task "github.com/linux-do/credit/internal/apps/admin/task" + admin_user "github.com/linux-do/credit/internal/apps/admin/user" publicconfig "github.com/linux-do/credit/internal/apps/config" "github.com/linux-do/credit/internal/apps/dispute" "github.com/linux-do/credit/internal/apps/health" @@ -208,6 +209,10 @@ func Serve() { adminRouter.GET("/tasks/types", admin_task.ListTaskTypes) adminRouter.POST("/tasks/dispatch", admin_task.DispatchTask) + // Users + adminRouter.GET("/users", admin_user.ListUsers) + adminRouter.PUT("/users/:id/status", admin_user.UpdateUserStatus) + // System Config adminRouter.POST("/system-configs", system_config.CreateSystemConfig) adminRouter.GET("/system-configs", system_config.ListSystemConfigs) From 2b914e0f8ec40fb99c02c6d6d0d1ad1ff81da56c Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Sun, 28 Dec 2025 00:51:22 +0800 Subject: [PATCH 082/366] feat: supports user management and publishing scheduled tasks. --- frontend/app/(main)/admin/layout.tsx | 15 + frontend/app/(main)/admin/system/page.tsx | 2 +- frontend/app/(main)/admin/tasks/page.tsx | 28 + frontend/app/(main)/admin/user_pay/page.tsx | 48 -- frontend/app/(main)/admin/users/page.tsx | 28 + .../{user-pay-configs.tsx => credit.tsx} | 0 .../admin/{system-configs.tsx => system.tsx} | 0 frontend/components/common/admin/tasks.tsx | 370 +++++++++++++ frontend/components/common/admin/users.tsx | 486 ++++++++++++++++++ .../components/common/general/table-data.tsx | 6 +- .../components/common/settings/profile.tsx | 4 +- frontend/components/layout/sidebar.tsx | 5 +- frontend/components/ui/pagination.tsx | 127 +++++ frontend/components/ui/switch.tsx | 31 ++ frontend/contexts/admin-users-context.tsx | 214 ++++++++ frontend/lib/services/admin/admin.service.ts | 145 +++++- frontend/lib/services/admin/index.ts | 6 + frontend/lib/services/admin/types.ts | 128 +++++ frontend/lib/services/index.ts | 6 + frontend/package.json | 1 + frontend/pnpm-lock.yaml | 3 + 21 files changed, 1592 insertions(+), 61 deletions(-) create mode 100644 frontend/app/(main)/admin/layout.tsx create mode 100644 frontend/app/(main)/admin/tasks/page.tsx delete mode 100644 frontend/app/(main)/admin/user_pay/page.tsx create mode 100644 frontend/app/(main)/admin/users/page.tsx rename frontend/components/common/admin/{user-pay-configs.tsx => credit.tsx} (100%) rename frontend/components/common/admin/{system-configs.tsx => system.tsx} (100%) create mode 100644 frontend/components/common/admin/tasks.tsx create mode 100644 frontend/components/common/admin/users.tsx create mode 100644 frontend/components/ui/pagination.tsx create mode 100644 frontend/components/ui/switch.tsx create mode 100644 frontend/contexts/admin-users-context.tsx diff --git a/frontend/app/(main)/admin/layout.tsx b/frontend/app/(main)/admin/layout.tsx new file mode 100644 index 00000000..bde38c83 --- /dev/null +++ b/frontend/app/(main)/admin/layout.tsx @@ -0,0 +1,15 @@ +"use client" + +import { AdminUsersProvider } from "@/contexts/admin-users-context" + +export default function AdminLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/frontend/app/(main)/admin/system/page.tsx b/frontend/app/(main)/admin/system/page.tsx index 9b65ebfc..5ea0ac5e 100644 --- a/frontend/app/(main)/admin/system/page.tsx +++ b/frontend/app/(main)/admin/system/page.tsx @@ -1,7 +1,7 @@ "use client" import { useEffect } from "react" -import { SystemConfigs } from "@/components/common/admin/system-configs" +import { SystemConfigs } from "@/components/common/admin/system" import { ErrorPage } from "@/components/layout/error" import { LoadingPage } from "@/components/layout/loading" diff --git a/frontend/app/(main)/admin/tasks/page.tsx b/frontend/app/(main)/admin/tasks/page.tsx new file mode 100644 index 00000000..1f63c887 --- /dev/null +++ b/frontend/app/(main)/admin/tasks/page.tsx @@ -0,0 +1,28 @@ +"use client" + +import { TaskManager } from "@/components/common/admin/tasks" +import { ErrorPage } from "@/components/layout/error" +import { LoadingPage } from "@/components/layout/loading" +import { useUser } from "@/contexts/user-context" + +/* 任务管理页面 */ +export default function TasksPage() { + const { user, loading } = useUser() + + /* 等待用户信息加载完成 */ + if (loading) { + return + } + + /* 权限检查:只有管理员才能访问 */ + if (!user?.is_admin) { + return ( + + ) + } + + return +} diff --git a/frontend/app/(main)/admin/user_pay/page.tsx b/frontend/app/(main)/admin/user_pay/page.tsx deleted file mode 100644 index 8d0895b3..00000000 --- a/frontend/app/(main)/admin/user_pay/page.tsx +++ /dev/null @@ -1,48 +0,0 @@ -"use client" - -import { useEffect } from "react" -import { UserPayConfigs } from "@/components/common/admin/user-pay-configs" -import { ErrorPage } from "@/components/layout/error" -import { LoadingPage } from "@/components/layout/loading" - -import { useUser } from "@/contexts/user-context" -import { AdminProvider, useAdmin } from "@/contexts/admin-context" - - -/* 用户积分配置页面 */ -export default function UserPayConfigPage() { - return ( - - - - ) -} - -/* 用户积分配置页面内容 */ -function UserPayConfigPageContent() { - const { user, loading } = useUser() - const { refetchUserPayConfigs } = useAdmin() - - useEffect(() => { - if (user?.is_admin) { - refetchUserPayConfigs() - } - }, [user?.is_admin, refetchUserPayConfigs]) - - /* 等待用户信息加载完成 */ - if (loading) { - return - } - - /* 权限检查:只有管理员才能访问 */ - if (!user?.is_admin) { - return ( - - ) - } - - return -} diff --git a/frontend/app/(main)/admin/users/page.tsx b/frontend/app/(main)/admin/users/page.tsx new file mode 100644 index 00000000..6f3f3df6 --- /dev/null +++ b/frontend/app/(main)/admin/users/page.tsx @@ -0,0 +1,28 @@ +"use client" + +import { UsersManager } from "@/components/common/admin/users" +import { ErrorPage } from "@/components/layout/error" +import { LoadingPage } from "@/components/layout/loading" +import { useUser } from "@/contexts/user-context" + +/* 用户管理页面 */ +export default function UsersPage() { + const { user, loading } = useUser() + + /* 等待用户信息加载完成 */ + if (loading) { + return + } + + /* 权限检查:只有管理员才能访问 */ + if (!user?.is_admin) { + return ( + + ) + } + + return +} diff --git a/frontend/components/common/admin/user-pay-configs.tsx b/frontend/components/common/admin/credit.tsx similarity index 100% rename from frontend/components/common/admin/user-pay-configs.tsx rename to frontend/components/common/admin/credit.tsx diff --git a/frontend/components/common/admin/system-configs.tsx b/frontend/components/common/admin/system.tsx similarity index 100% rename from frontend/components/common/admin/system-configs.tsx rename to frontend/components/common/admin/system.tsx diff --git a/frontend/components/common/admin/tasks.tsx b/frontend/components/common/admin/tasks.tsx new file mode 100644 index 00000000..38ddc9a8 --- /dev/null +++ b/frontend/components/common/admin/tasks.tsx @@ -0,0 +1,370 @@ +"use client" + +import { useState, useEffect, useCallback } from "react" +import { toast } from "sonner" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Spinner } from "@/components/ui/spinner" +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Layers, Play, Clock, Info } from "lucide-react" + +import { TaskMeta, AdminService, DispatchTaskRequest } from "@/lib/services" +import { ErrorInline } from "@/components/layout/error" +import { LoadingStateWithBorder } from "@/components/layout/loading" +import { EmptyStateWithBorder } from "@/components/layout/empty" +import { Badge } from "@/components/ui/badge" +import { cn } from "@/lib/utils" + +import { format } from "date-fns" +import { zhCN } from "date-fns/locale" +import { Calendar as CalendarIcon } from "lucide-react" +import { Calendar } from "@/components/ui/calendar" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" + +const TASK_CONFIGS: Record, color: string, gradient: string }> = { + 'order_sync': { + icon: Layers, + color: "text-blue-600 dark:text-blue-400", + gradient: "from-blue-500/10 via-blue-500/5 to-transparent border-blue-200/50 dark:border-blue-800/50 hover:border-blue-400 dark:hover:border-blue-500", + }, + 'user_gamification': { + icon: Play, + color: "text-amber-600 dark:text-amber-400", + gradient: "from-amber-500/10 via-amber-500/5 to-transparent border-amber-200/50 dark:border-amber-800/50 hover:border-amber-400 dark:hover:border-amber-500", + }, + 'dispute_auto_refund': { + icon: Clock, + color: "text-rose-600 dark:text-rose-400", + gradient: "from-rose-500/10 via-rose-500/5 to-transparent border-rose-200/50 dark:border-rose-800/50 hover:border-rose-400 dark:hover:border-rose-500", + } +} + +const DEFAULT_TASK_CONFIG = { + icon: Layers, + color: "text-zinc-600 dark:text-zinc-400", + gradient: "from-zinc-500/10 via-zinc-500/5 to-transparent border-zinc-200/50 dark:border-zinc-800/50 hover:border-zinc-400 dark:hover:border-zinc-500", +} + +function DatePickerWithTime({ date, setDate }: { date: Date | undefined, setDate: (date: Date | undefined) => void }) { + const timeString = date ? format(date, "HH:mm:ss") : "" + + const handleDateSelect = (newDate: Date | undefined) => { + if (!newDate) { + setDate(undefined) + return + } + + const updated = new Date(newDate) + + if (date) { + updated.setHours(date.getHours()) + updated.setMinutes(date.getMinutes()) + updated.setSeconds(date.getSeconds()) + } else { + updated.setHours(0, 0, 0, 0) + } + + setDate(updated) + } + + const handleTimeChange = (e: React.ChangeEvent) => { + const newTime = e.target.value + if (!newTime) return + + if (date) { + const [hours, minutes, seconds] = newTime.split(':').map(Number) + const updated = new Date(date) + updated.setHours(hours || 0) + updated.setMinutes(minutes || 0) + updated.setSeconds(seconds || 0) + setDate(updated) + } else { + const today = new Date() + const [hours, minutes, seconds] = newTime.split(':').map(Number) + today.setHours(hours || 0) + today.setMinutes(minutes || 0) + today.setSeconds(seconds || 0) + setDate(today) + } + } + + return ( +
                                                                                                        +
                                                                                                        + + + + + + + + +
                                                                                                        +
                                                                                                        + +
                                                                                                        +
                                                                                                        + ) +} + +export function TaskManager() { + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [taskTypes, setTaskTypes] = useState([]) + + const [dispatching, setDispatching] = useState(false) + const [selectedTaskType, setSelectedTaskType] = useState(null) + const [dialogOpen, setDialogOpen] = useState(false) + + const [startTime, setStartTime] = useState(undefined) + const [endTime, setEndTime] = useState(undefined) + const [userId, setUserId] = useState("") + + const fetchTaskTypes = useCallback(async () => { + try { + setLoading(true) + setError(null) + const data = await AdminService.getTaskTypes() + setTaskTypes(data) + } catch (err) { + setError(err instanceof Error ? err : new Error('加载任务类型失败')) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchTaskTypes() + }, [fetchTaskTypes]) + + const handleDispatch = async () => { + if (!selectedTaskType) return + + try { + setDispatching(true) + + const targetTask = taskTypes.find(t => t.type === selectedTaskType) + + const params: DispatchTaskRequest = { + task_type: selectedTaskType + } + + if (targetTask?.type === 'order_sync') { + if (startTime) params.start_time = startTime.toISOString() + if (endTime) params.end_time = endTime.toISOString() + } + + if (targetTask?.type === 'user_gamification') { + if (userId) params.user_id = parseInt(userId) + } + + await AdminService.dispatchTask(params) + + toast.success('任务下发成功', { + description: `已成功将任务 ${ targetTask?.name || selectedTaskType } 加入队列` + }) + setDialogOpen(false) + + setStartTime(undefined) + setEndTime(undefined) + setUserId("") + } catch (err) { + toast.error('任务下发失败', { + description: err instanceof Error ? err.message : '未知错误' + }) + } finally { + setDispatching(false) + } + } + + const openDispatchDialog = (type: string) => { + setSelectedTaskType(type) + setDialogOpen(true) + } + + const getSelectedTaskMeta = () => { + return taskTypes.find(t => t.type === selectedTaskType) + } + + return ( +
                                                                                                        +
                                                                                                        +
                                                                                                        +
                                                                                                        任务管理
                                                                                                        +
                                                                                                        +
                                                                                                        + +
                                                                                                        + {error ? ( +
                                                                                                        + +
                                                                                                        + ) : loading && taskTypes.length === 0 ? ( + + ) : taskTypes.length === 0 ? ( + + ) : ( +
                                                                                                        + {taskTypes.map((task, index) => { + const config = TASK_CONFIGS[task.type] || DEFAULT_TASK_CONFIG + + return ( +
                                                                                                        +
                                                                                                        +
                                                                                                        +
                                                                                                        +
                                                                                                        +

                                                                                                        {task.name}

                                                                                                        +

                                                                                                        + {task.description} +

                                                                                                        +
                                                                                                        + + {task.queue} + +
                                                                                                        + +
                                                                                                        + + 类型:{task.type} + + + 重试:{task.max_retry} + +
                                                                                                        +
                                                                                                        + +
                                                                                                        + +
                                                                                                        +
                                                                                                        +
                                                                                                        + ) + })} +
                                                                                                        + )} +
                                                                                                        + + + + + 下发任务 + + 配置任务参数并启动,任务将在后台异步执行。 + + + +
                                                                                                        +
                                                                                                        + +
                                                                                                        + {(() => { + const meta = getSelectedTaskMeta() + if (!meta) return null + const config = TASK_CONFIGS[meta.type] || DEFAULT_TASK_CONFIG + const Icon = config.icon + return ( + <> +
                                                                                                        + +
                                                                                                        +
                                                                                                        + {meta.name} + {meta.type} +
                                                                                                        + + ) + })()} +
                                                                                                        +
                                                                                                        + + {selectedTaskType === 'order_sync' && ( + <> +
                                                                                                        + + +

                                                                                                        选择同步的起始时间点

                                                                                                        +
                                                                                                        +
                                                                                                        + + +

                                                                                                        选择同步的结束时间点

                                                                                                        +
                                                                                                        + + )} + + {selectedTaskType === 'user_gamification' && ( +
                                                                                                        + + setUserId(e.target.value)} + /> +

                                                                                                        指定需要更新积分数据的用户 ID

                                                                                                        +
                                                                                                        + )} + + {!['order_sync', 'user_gamification'].includes(selectedTaskType || '') && ( +
                                                                                                        + + 此任务无需额外配置参数,确认后将直接下发。 +
                                                                                                        + )} +
                                                                                                        + + + + + +
                                                                                                        +
                                                                                                        +
                                                                                                        + ) +} diff --git a/frontend/components/common/admin/users.tsx b/frontend/components/common/admin/users.tsx new file mode 100644 index 00000000..8602208b --- /dev/null +++ b/frontend/components/common/admin/users.tsx @@ -0,0 +1,486 @@ +"use client" + +import { useState, useEffect } from "react" +import { Button } from "@/components/ui/button" +import { Switch } from "@/components/ui/switch" +import { Separator } from "@/components/ui/separator" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Sheet, SheetTitle, SheetContent } from "@/components/ui/sheet" +import { Badge } from "@/components/ui/badge" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { Layers, Search, UserX, UserCheck, Eye, Wallet, CreditCard, ShieldCheck, Filter, X, ChevronDown, ChevronLeft, ChevronRight, Users } from "lucide-react" + +import { AdminUser } from "@/lib/services" +import { formatDateTime } from "@/lib/utils" +import { EmptyStateWithBorder } from "@/components/layout/empty" +import { LoadingStateWithBorder } from "@/components/layout/loading" +import { ErrorInline } from "@/components/layout/error" +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" +import { cn } from "@/lib/utils" +import { useAdminUsers } from "@/contexts/admin-users-context" + +export function UsersManager() { + const { + users, + total, + loading, + error, + page, + pageSize, + searchUsername, + statusFilter, + setPage, + setPageSize, + setSearchUsername, + setStatusFilter, + fetchUsers, + updateUserStatus + } = useAdminUsers() + + const [selectedUser, setSelectedUser] = useState(null) + const [detailOpen, setDetailOpen] = useState(false) + + useEffect(() => { + fetchUsers() + }, [fetchUsers]) + + const handleStatusToggle = async (user: AdminUser) => { + await updateUserStatus(user) + + if (selectedUser?.id === user.id) { + setSelectedUser(prev => prev ? { ...prev, is_active: !prev.is_active } : null) + } + } + + const handleShowDetail = (user: AdminUser) => { + setSelectedUser(user) + setDetailOpen(true) + } + + const totalPages = Math.ceil(total / pageSize) + + const renderFilterBar = () => ( +
                                                                                                        +
                                                                                                        +
                                                                                                        + +
                                                                                                        + + + + + + + setStatusFilter('all')} + > + 全部状态 + + setStatusFilter('active')} + > +
                                                                                                        + 正常 + + setStatusFilter('inactive')} + > +
                                                                                                        + 禁用 + + + + + {(searchUsername || statusFilter !== 'all') && ( + + )} +
                                                                                                        + +
                                                                                                        +
                                                                                                        + 共 {total} 条 + + + {page} / {totalPages} + + +
                                                                                                        + + + + + + + {[20, 50, 100].map(size => ( + setPageSize(size)} + className={cn("font-mono text-xs mb-1", pageSize === size && "bg-accent")} + > + {size} 条/页 + + ))} + + +
                                                                                                        +
                                                                                                        + ) + + return ( +
                                                                                                        +
                                                                                                        +
                                                                                                        +
                                                                                                        用户管理
                                                                                                        +
                                                                                                        +
                                                                                                        + + {renderFilterBar()} + + {error ? ( +
                                                                                                        + fetchUsers(true)} className="justify-center" /> +
                                                                                                        + ) : loading && users.length === 0 ? ( + + ) : users.length === 0 ? ( + + ) : ( +
                                                                                                        +
                                                                                                        +
                                                                                                        +
                                                                                                        + + + ID + 用户 + 余额 + 基准分 + 支付分 + 总收益 + 总消耗 + 总转移 + 总划转 + 上次登陆 + 注册时间 + 上次更新 + 操作 + + + + {users.map((user) => ( + handleShowDetail(user)} + > + {user.id} + +
                                                                                                        + + + + {user.username.substring(0, 2).toUpperCase()} + + +
                                                                                                        +
                                                                                                        + {user.nickname} + {user.is_admin && ( + + ADM + + )} +
                                                                                                        +
                                                                                                        + @{user.username} +
                                                                                                        +
                                                                                                        +
                                                                                                        +
                                                                                                        + + {Number(user.available_balance).toFixed(2)} + + + {Number(user.community_balance).toFixed(2)} + + + {Number(user.pay_score).toFixed(2)} + + + {Number(user.total_receive).toFixed(2)} + + + {Number(user.total_payment).toFixed(2)} + + + {Number(user.total_transfer).toFixed(2)} + + + {Number(user.total_community).toFixed(2)} + + + {formatDateTime(user.last_login_at)} + + + {formatDateTime(user.created_at)} + + + {formatDateTime(user.updated_at)} + + e.stopPropagation()}> +
                                                                                                        + handleStatusToggle(user)} + disabled={user.is_admin} + className="scale-75 data-[state=checked]:bg-green-600 h-4 w-7" + /> + +
                                                                                                        +
                                                                                                        +
                                                                                                        + ))} +
                                                                                                        +
                                                                                                        +
                                                                                                        + )} + + + + 用户档案 + + {selectedUser && ( + <> +
                                                                                                        +
                                                                                                        +
                                                                                                        +
                                                                                                        + + + + {selectedUser.username.substring(0, 2).toUpperCase()} + + + +
                                                                                                        +

                                                                                                        {selectedUser.nickname}

                                                                                                        +
                                                                                                        + @{selectedUser.username} + + UID: {selectedUser.id} + + + Lv.{selectedUser.trust_level} + + {selectedUser.is_admin && ( + + Admin + + )} +
                                                                                                        +
                                                                                                        + +
                                                                                                        +
                                                                                                        + 注册时间 + {formatDateTime(selectedUser.created_at).split(' ')[0]} +
                                                                                                        +
                                                                                                        +
                                                                                                        +
                                                                                                        + +
                                                                                                        +

                                                                                                        核心资产

                                                                                                        +
                                                                                                        +
                                                                                                        +
                                                                                                        + + 可用余额 +
                                                                                                        +
                                                                                                        + {Number(selectedUser.available_balance).toFixed(2)} +
                                                                                                        +
                                                                                                        +
                                                                                                        +
                                                                                                        + + 支付积分 +
                                                                                                        +
                                                                                                        + {Number(selectedUser.pay_score).toFixed(2)} +
                                                                                                        +
                                                                                                        +
                                                                                                        +
                                                                                                        + + 社区余额 +
                                                                                                        +
                                                                                                        + {Number(selectedUser.community_balance).toFixed(2)} +
                                                                                                        +
                                                                                                        +
                                                                                                        +
                                                                                                        + + + +
                                                                                                        +
                                                                                                        +

                                                                                                        财务概况

                                                                                                        +
                                                                                                        +
                                                                                                        + 累计收益 + +{Number(selectedUser.total_community).toFixed(2)} +
                                                                                                        +
                                                                                                        + 累计收入 + +{Number(selectedUser.total_receive).toFixed(2)} +
                                                                                                        +
                                                                                                        + 累计支出 + {Number(selectedUser.total_payment) === 0 ? '0.00' : `-${ Number(selectedUser.total_payment).toFixed(2) }`} +
                                                                                                        +
                                                                                                        + 累计转账 + {Number(selectedUser.total_transfer).toFixed(2)} +
                                                                                                        +
                                                                                                        +
                                                                                                        + +
                                                                                                        +

                                                                                                        系统记录

                                                                                                        +
                                                                                                        +
                                                                                                        + 最后登录 + {formatDateTime(selectedUser.last_login_at)} +
                                                                                                        +
                                                                                                        + 注册时间 + {formatDateTime(selectedUser.created_at)} +
                                                                                                        +
                                                                                                        + 最后更新 + {formatDateTime(selectedUser.updated_at)} +
                                                                                                        +
                                                                                                        +
                                                                                                        +
                                                                                                        + +
                                                                                                        +
                                                                                                        + + {!selectedUser.is_admin && ( +
                                                                                                        + +
                                                                                                        + )} + + )} +
                                                                                                        +
                                                                                                        +
                                                                                                        + ) +} diff --git a/frontend/components/common/general/table-data.tsx b/frontend/components/common/general/table-data.tsx index 4afa6a73..bb6d7409 100644 --- a/frontend/components/common/general/table-data.tsx +++ b/frontend/components/common/general/table-data.tsx @@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { typeConfig, statusConfig } from "@/components/common/general/table-filter" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" -import { TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table" +import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table" import { Spinner } from "@/components/ui/spinner" import { ErrorInline } from "@/components/layout/error" import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area" @@ -57,7 +57,7 @@ export const TransactionDataTable = React.memo(function TransactionDataTable({ ref={scrollAreaRef} className="w-full h-[600px] whitespace-nowrap" > - +
                                                                                                        名称 @@ -98,7 +98,7 @@ export const TransactionDataTable = React.memo(function TransactionDataTable({ )} -
                                                                                                        +
      diff --git a/frontend/components/common/settings/profile.tsx b/frontend/components/common/settings/profile.tsx index 1272401b..bd06cb27 100644 --- a/frontend/components/common/settings/profile.tsx +++ b/frontend/components/common/settings/profile.tsx @@ -407,13 +407,13 @@ export function ProfileMain() {
      - 社区积分 + 积分基准值 -

      上一次从社区同步的积分,积分会在每日划转时通过系统定时任务自动同步更新

      +

      上一次从社区同步的积分基准值

      diff --git a/frontend/components/layout/sidebar.tsx b/frontend/components/layout/sidebar.tsx index 26958bc2..6813ccd9 100644 --- a/frontend/components/layout/sidebar.tsx +++ b/frontend/components/layout/sidebar.tsx @@ -56,6 +56,7 @@ import { FileQuestionMark, ShieldCheck, Globe, + Layers, } from "lucide-react" import { useUser } from "@/contexts/user-context" @@ -71,7 +72,9 @@ const data = { ], admin: [ { title: "系统配置", url: "/admin/system", icon: ShieldCheck }, - { title: "积分配置", url: "/admin/user_pay", icon: Settings }, + { title: "积分配置", url: "/admin/credit", icon: Settings }, + { title: "用户管理", url: "/admin/users", icon: UserRound }, + { title: "任务管理", url: "/admin/tasks", icon: Layers }, ], document: [ { title: "接口文档", url: "/docs/api", icon: CreditCard }, diff --git a/frontend/components/ui/pagination.tsx b/frontend/components/ui/pagination.tsx new file mode 100644 index 00000000..1dcfb0cc --- /dev/null +++ b/frontend/components/ui/pagination.tsx @@ -0,0 +1,127 @@ +import * as React from "react" +import { + ChevronLeftIcon, + ChevronRightIcon, + MoreHorizontalIcon, +} from "lucide-react" + +import { cn } from "@/lib/utils" +import { buttonVariants, type Button } from "@/components/ui/button" + +function Pagination({ className, ...props }: React.ComponentProps<"nav">) { + return ( +

@@ -215,11 +215,11 @@ const TransactionTableRow = React.memo(React.forwardRef {formatDateTime(order.expires_at)} - + {order.remark || '-'} = { */ export function TradeMain() { const [activeTab, setActiveTab] = React.useState('receive') + const [mounted, setMounted] = React.useState(false) + + React.useEffect(() => { + setMounted(true) + }, []) /* 获取活动类型 */ const getOrderType = (tab: TabValue): OrderType | undefined => { @@ -67,6 +72,18 @@ export function TradeMain() { return Component ? : null } + if (!mounted) { + return ( +
+

积分活动

+
+
+
+
+
+ ) + } + return (
diff --git a/frontend/package.json b/frontend/package.json index 228d9330..d9e9f87c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,6 +33,7 @@ "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", + "@radix-ui/react-visually-hidden": "^1.2.4", "@tabler/icons-react": "^3.35.0", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.13", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 74dd8aa4..559cf5f3 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.2.8 version: 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-visually-hidden': + specifier: ^1.2.4 + version: 1.2.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@tabler/icons-react': specifier: ^3.35.0 version: 3.35.0(react@19.1.0) @@ -1332,6 +1335,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-visually-hidden@1.2.4': + resolution: {integrity: sha512-kaeiyGCe844dkb9AVF+rb4yTyb1LiLN/e3es3nLiRyN4dC8AduBYPMnnNlDjX2VDOcvDEiPnRNMJeWCfsX0txg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} @@ -4727,6 +4743,15 @@ snapshots: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-visually-hidden@1.2.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/rect@1.1.1': {} '@rtsao/scc@1.1.0': {} From b49e2a63775385a87a095b4178e14948416b7932 Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Sun, 28 Dec 2025 01:33:38 +0800 Subject: [PATCH 084/366] feat: optimize the administrator page loading logic --- .gitignore | 2 +- frontend/app/(main)/admin/credit/page.tsx | 29 +++++++++++++++++++++++ frontend/app/(main)/admin/layout.tsx | 20 ++++++++++++++++ frontend/app/(main)/admin/system/page.tsx | 21 +--------------- frontend/app/(main)/admin/tasks/page.tsx | 20 ---------------- frontend/app/(main)/admin/users/page.tsx | 20 ---------------- 6 files changed, 51 insertions(+), 61 deletions(-) create mode 100644 frontend/app/(main)/admin/credit/page.tsx diff --git a/.gitignore b/.gitignore index f02cfa3e..9a4df574 100644 --- a/.gitignore +++ b/.gitignore @@ -35,5 +35,5 @@ Thumbs.db # go workspace go.work go.work.sum -credit +/credit main diff --git a/frontend/app/(main)/admin/credit/page.tsx b/frontend/app/(main)/admin/credit/page.tsx new file mode 100644 index 00000000..20b4075d --- /dev/null +++ b/frontend/app/(main)/admin/credit/page.tsx @@ -0,0 +1,29 @@ +"use client" + +import { useEffect } from "react" +import { UserPayConfigs } from "@/components/common/admin/credit" +import { useUser } from "@/contexts/user-context" +import { AdminProvider, useAdmin } from "@/contexts/admin-context" + +/* 用户积分配置页面 */ +export default function UserPayConfigPage() { + return ( + + + + ) +} + +/* 用户积分配置页面内容 */ +function UserPayConfigPageContent() { + const { user } = useUser() + const { refetchUserPayConfigs } = useAdmin() + + useEffect(() => { + if (user?.is_admin) { + refetchUserPayConfigs() + } + }, [user?.is_admin, refetchUserPayConfigs]) + + return +} diff --git a/frontend/app/(main)/admin/layout.tsx b/frontend/app/(main)/admin/layout.tsx index bde38c83..2cc9a5f4 100644 --- a/frontend/app/(main)/admin/layout.tsx +++ b/frontend/app/(main)/admin/layout.tsx @@ -1,12 +1,32 @@ "use client" import { AdminUsersProvider } from "@/contexts/admin-users-context" +import { useUser } from "@/contexts/user-context" +import { LoadingPage } from "@/components/layout/loading" +import { ErrorPage } from "@/components/layout/error" export default function AdminLayout({ children, }: { children: React.ReactNode }) { + const { user, loading } = useUser() + + /* 等待用户信息加载完成 */ + if (loading) { + return + } + + /* 权限检查:只有管理员才能访问 */ + if (!user?.is_admin) { + return ( + + ) + } + return ( {children} diff --git a/frontend/app/(main)/admin/system/page.tsx b/frontend/app/(main)/admin/system/page.tsx index 5ea0ac5e..93c1690e 100644 --- a/frontend/app/(main)/admin/system/page.tsx +++ b/frontend/app/(main)/admin/system/page.tsx @@ -2,13 +2,9 @@ import { useEffect } from "react" import { SystemConfigs } from "@/components/common/admin/system" -import { ErrorPage } from "@/components/layout/error" -import { LoadingPage } from "@/components/layout/loading" - import { useUser } from "@/contexts/user-context" import { AdminProvider, useAdmin } from "@/contexts/admin-context" - /* 系统配置页面 */ export default function SystemConfigPage() { return ( @@ -20,7 +16,7 @@ export default function SystemConfigPage() { /* 系统配置页面内容 */ function SystemConfigPageContent() { - const { user, loading } = useUser() + const { user } = useUser() const { refetchSystemConfigs } = useAdmin() useEffect(() => { @@ -29,21 +25,6 @@ function SystemConfigPageContent() { } }, [user?.is_admin, refetchSystemConfigs]) - /* 等待用户信息加载完成 */ - if (loading) { - return - } - - /* 权限检查:只有管理员才能访问 */ - if (!user?.is_admin) { - return ( - - ) - } - return } diff --git a/frontend/app/(main)/admin/tasks/page.tsx b/frontend/app/(main)/admin/tasks/page.tsx index 1f63c887..39e329a5 100644 --- a/frontend/app/(main)/admin/tasks/page.tsx +++ b/frontend/app/(main)/admin/tasks/page.tsx @@ -1,28 +1,8 @@ "use client" import { TaskManager } from "@/components/common/admin/tasks" -import { ErrorPage } from "@/components/layout/error" -import { LoadingPage } from "@/components/layout/loading" -import { useUser } from "@/contexts/user-context" /* 任务管理页面 */ export default function TasksPage() { - const { user, loading } = useUser() - - /* 等待用户信息加载完成 */ - if (loading) { - return - } - - /* 权限检查:只有管理员才能访问 */ - if (!user?.is_admin) { - return ( - - ) - } - return } diff --git a/frontend/app/(main)/admin/users/page.tsx b/frontend/app/(main)/admin/users/page.tsx index 6f3f3df6..6e20d868 100644 --- a/frontend/app/(main)/admin/users/page.tsx +++ b/frontend/app/(main)/admin/users/page.tsx @@ -1,28 +1,8 @@ "use client" import { UsersManager } from "@/components/common/admin/users" -import { ErrorPage } from "@/components/layout/error" -import { LoadingPage } from "@/components/layout/loading" -import { useUser } from "@/contexts/user-context" /* 用户管理页面 */ export default function UsersPage() { - const { user, loading } = useUser() - - /* 等待用户信息加载完成 */ - if (loading) { - return - } - - /* 权限检查:只有管理员才能访问 */ - if (!user?.is_admin) { - return ( - - ) - } - return } From 037918e7747afaaeebd9d65637cccbad270cb5d1 Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Sun, 28 Dec 2025 12:54:17 +0800 Subject: [PATCH 085/366] feat: multiple fun feature updates --- frontend/app/(main)/test/page.tsx | 4 + .../components/common/docs/how-to-use.tsx | 29 +++ .../common/docs/legal-page-layout.tsx | 96 +++++----- .../layout/avater-style/Christmas.tsx | 97 ++++++++++ .../layout/avater-style/NewYear.tsx | 121 +++++++++++++ .../layout/avater-style/registry.ts | 48 +++++ .../components/layout/avater-style/types.ts | 17 ++ frontend/components/layout/header.tsx | 169 +++++++++++------ frontend/components/layout/sidebar.tsx | 88 +++------ frontend/components/ui/docs-table.tsx | 10 +- frontend/components/ui/fireworks-effect.tsx | 170 ++++++++++++++++++ frontend/components/ui/sheet.tsx | 8 +- frontend/components/ui/snow-effect.tsx | 5 +- 13 files changed, 689 insertions(+), 173 deletions(-) create mode 100644 frontend/app/(main)/test/page.tsx create mode 100644 frontend/components/layout/avater-style/Christmas.tsx create mode 100644 frontend/components/layout/avater-style/NewYear.tsx create mode 100644 frontend/components/layout/avater-style/registry.ts create mode 100644 frontend/components/layout/avater-style/types.ts create mode 100644 frontend/components/ui/fireworks-effect.tsx diff --git a/frontend/app/(main)/test/page.tsx b/frontend/app/(main)/test/page.tsx new file mode 100644 index 00000000..8c5237ea --- /dev/null +++ b/frontend/app/(main)/test/page.tsx @@ -0,0 +1,4 @@ + +export default function TradePage() { + return
test
+} diff --git a/frontend/components/common/docs/how-to-use.tsx b/frontend/components/common/docs/how-to-use.tsx index 0d742ba7..645260ba 100644 --- a/frontend/components/common/docs/how-to-use.tsx +++ b/frontend/components/common/docs/how-to-use.tsx @@ -326,5 +326,34 @@ export const howToUseSections: PolicySection[] = [
) + }, + { + value: "scripts", + title: "10. 辅助脚本", + content: ( +
+

为了方便用户随时查看当前的实时积分收入,我们提供了开源的 Userscript 脚本。

+ +
+

+ 注:脚本完全开源且安全,仅通过官方 API 获取公开数据,不涉及任何敏感权限。 +

+
+
+ ) } ] diff --git a/frontend/components/common/docs/legal-page-layout.tsx b/frontend/components/common/docs/legal-page-layout.tsx index 42e49dd1..29e54f9f 100644 --- a/frontend/components/common/docs/legal-page-layout.tsx +++ b/frontend/components/common/docs/legal-page-layout.tsx @@ -74,7 +74,7 @@ export function LegalPageLayout({ } return ( -
+
@@ -228,7 +230,7 @@ export function LegalPageLayout({
-
-
+
+
) } diff --git a/frontend/components/layout/avater-style/Christmas.tsx b/frontend/components/layout/avater-style/Christmas.tsx new file mode 100644 index 00000000..e54681b4 --- /dev/null +++ b/frontend/components/layout/avater-style/Christmas.tsx @@ -0,0 +1,97 @@ +/** + * 圣诞节主题装饰组件归档 + * 包含圣诞树、圣诞帽图标以及下雪效果的集成组件 + * + * 使用示例: + * ```tsx + * import { ChristmasDecorations } from "@/components/layout/avater-style/Christmas" + * + * function MyComponent() { + * const [showChristmas, setShowChristmas] = useState(false) + * + * return ( + * <> + * {showChristmas && } + *
setShowChristmas(!showChristmas)}> + * + * + *
+ * + * ) + * } + * ``` + */ + +import { cn } from "@/lib/utils" +import { SnowEffect } from "@/components/ui/snow-effect" + +/** + * 圣诞树图标组件 + */ +export const ChristmasTree = ({ + className, + show = true +}: { + className?: string + show?: boolean +}) => ( + + + + + + + + + + + + +) + +/** + * 圣诞帽图标组件 + */ +export const ChristmasHat = ({ className }: { className?: string }) => ( + + + + + +) + +/** + * 圣诞节装饰集合 + * 包含所有圣诞节主题组件的命名空间 + */ +export const ChristmasDecorations = { + Tree: ChristmasTree, + Hat: ChristmasHat, + SnowEffect: SnowEffect, +} + +export default ChristmasDecorations diff --git a/frontend/components/layout/avater-style/NewYear.tsx b/frontend/components/layout/avater-style/NewYear.tsx new file mode 100644 index 00000000..2ec051ae --- /dev/null +++ b/frontend/components/layout/avater-style/NewYear.tsx @@ -0,0 +1,121 @@ +/** + * 元旦/新年主题装饰组件归档 + * 包含灯笼、福字以及烟花效果的集成组件 + * + * 使用示例: + * ```tsx + * import { NewYearDecorations } from "@/components/layout/avater-style/NewYear" + * + * function MyComponent() { + * const [showNewYear, setShowNewYear] = useState(false) + * + * return ( + * <> + * {showNewYear && } + *
setShowNewYear(!showNewYear)}> + * + * + *
+ * + * ) + * } + * ``` + */ + +import { cn } from "@/lib/utils" +import { FireworksEffect } from "@/components/ui/fireworks-effect" + +/** + * 红灯笼组件 - 默认头像装饰 + */ +export const Lantern = ({ className }: { className?: string }) => ( + + + + + + + + + + + + + + + + + + + + + + + +) + +/** + * "福"字挂件 - 点击出现的装饰 + * 一个菱形的福字牌,使用文字渲染确保准确 + */ +export const FuCharacter = ({ + className, + show = true +}: { + className?: string + show?: boolean +}) => ( + + + + + + + + 福 + + + + + + + +) + +/** + * 元旦/新年装饰集合 + */ +export const NewYearDecorations = { + Lantern, + Fu: FuCharacter, + FireworksEffect, +} + +export default NewYearDecorations diff --git a/frontend/components/layout/avater-style/registry.ts b/frontend/components/layout/avater-style/registry.ts new file mode 100644 index 00000000..489ef7ee --- /dev/null +++ b/frontend/components/layout/avater-style/registry.ts @@ -0,0 +1,48 @@ +import { AvatarDecorationTheme } from "./types" +import { ChristmasDecorations } from "./Christmas" +import { NewYearDecorations } from "./NewYear" + +export const DecorationThemes: Record = { + christmas: { + key: "christmas", + MainDecoration: ChristmasDecorations.Hat, + InteractionDecoration: ChristmasDecorations.Tree, + BackgroundEffect: ChristmasDecorations.SnowEffect, + smallMainDecorationConfig: { + className: "absolute -top-1 -left-1 w-3 h-3 z-20 rotate-[-20deg] drop-shadow-sm" + }, + largeMainDecorationConfig: { + className: "absolute -top-2 -left-1 w-5 h-5 z-20 rotate-[-20deg] drop-shadow-sm" + }, + interactionDecorationConfig: { + className: "absolute -top-2 right-2 w-12 h-12 z-0 rotate-[10deg] transition-all duration-500 ease-out" + } + }, + newyear: { + key: "newyear", + MainDecoration: NewYearDecorations.Lantern, + InteractionDecoration: NewYearDecorations.Fu, + BackgroundEffect: NewYearDecorations.FireworksEffect, + smallMainDecorationConfig: { + className: "absolute -top-2 -right-1 w-8 h-8 z-20 rotate-[10deg] drop-shadow-md pointer-events-none" + }, + largeMainDecorationConfig: { + className: "absolute -top-3 -right-3 w-8 h-8 z-20 rotate-[10deg] drop-shadow-md pointer-events-none" + }, + interactionDecorationConfig: { + className: "absolute top-6 left-4 w-10 h-10 z-20 -rotate-[5deg] transition-all duration-500 ease-out" + } + }, + none: { + key: "none", + MainDecoration: () => null, + InteractionDecoration: () => null, + } +} + +/* 可选值: 'christmas', 'newyear', 'none' */ +export const CURRENT_THEME_KEY: string = "none" + +export const getCurrentTheme = (): AvatarDecorationTheme => { + return DecorationThemes[CURRENT_THEME_KEY] || DecorationThemes.none +} diff --git a/frontend/components/layout/avater-style/types.ts b/frontend/components/layout/avater-style/types.ts new file mode 100644 index 00000000..07c2b6c3 --- /dev/null +++ b/frontend/components/layout/avater-style/types.ts @@ -0,0 +1,17 @@ +import { ComponentType } from "react" + +export interface AvatarDecorationTheme { + key: string + MainDecoration: ComponentType<{ className?: string }> + InteractionDecoration: ComponentType<{ className?: string; show?: boolean }> + BackgroundEffect?: ComponentType + smallMainDecorationConfig?: { + className?: string + } + largeMainDecorationConfig?: { + className?: string + } + interactionDecorationConfig?: { + className?: string + } +} diff --git a/frontend/components/layout/header.tsx b/frontend/components/layout/header.tsx index 6fd8d988..ff6016ad 100644 --- a/frontend/components/layout/header.tsx +++ b/frontend/components/layout/header.tsx @@ -1,6 +1,7 @@ "use client" import { useState, useEffect } from "react" +import { AnimatePresence, motion } from "motion/react" import { Button } from "@/components/ui/button" import { Bell, Plus, Settings, Search, Moon, Sun } from "lucide-react" import { useUser } from "@/contexts/user-context" @@ -31,63 +32,129 @@ export function SiteHeader() { }, []) return ( -
-
-
- - - {user?.nickname || user?.username || "Guest"} - -
-
- - - +
+ +
+
+
+ + + {user?.nickname || user?.username || "Guest"} + +
+
+ + + +
-
-
-
setSearchOpen(true)}> - -
- 搜索 - - K - +
+
setSearchOpen(true)}> + +
+ 搜索 + + K + +
-
-
- - - +
+ + + - + +
-
- {mounted && } -
+ {mounted && } + +
+ ) +} + +function AppBanner() { + const [isVisible, setIsVisible] = useState(false) + const bannerKey = 'ldc-banner-dismissed-script' + + useEffect(() => { + const dismissed = localStorage.getItem(bannerKey) + if (!dismissed) { + setIsVisible(true) + } + }, []) + + const handleDismiss = () => { + localStorage.setItem(bannerKey, 'true') + setIsVisible(false) + } + + return ( + + {isVisible && ( + +
+

+ 「LINUX DO Credit」实时积分收入脚本已发布 + + 查看详情 + +

+ +
+
+ )} +
) } diff --git a/frontend/components/layout/sidebar.tsx b/frontend/components/layout/sidebar.tsx index 6813ccd9..6daf2800 100644 --- a/frontend/components/layout/sidebar.tsx +++ b/frontend/components/layout/sidebar.tsx @@ -5,7 +5,6 @@ import Link from "next/link" import { usePathname, useRouter } from "next/navigation" import { toast } from "sonner" import { Button } from "@/components/ui/button" -import { cn } from "@/lib/utils" import { Spinner } from "@/components/ui/spinner" import { AnimateIcon } from "@/components/animate-ui/icons/icon" import { ChevronLeft } from "@/components/animate-ui/icons/chevron-left" @@ -19,7 +18,7 @@ import { DropdownMenuLabel, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu" -import { SnowEffect } from "@/components/ui/snow-effect" +import { getCurrentTheme } from "@/components/layout/avater-style/registry" import { Sidebar, SidebarContent, @@ -85,54 +84,6 @@ const data = { ], } -const ChristmasTree = ({ className }: { className?: string }) => ( - - {/* 树干 */} - - {/* 树叶层 - 从上到下 */} - - - - {/* 装饰球 */} - - - - - - {/* 顶部星星 */} - - -) - -const ChristmasHat = ({ className }: { className?: string }) => ( - - {/* 稍微倾斜的红色帽身 */} - - {/* 顶部白色绒球 */} - - {/* 底部白色绒边 - 使用圆角矩形模拟 */} - - {/* 添加一些简单的纹理/阴影细节 (可选,保持简洁) */} - -) - /** * 应用侧边栏组件 * 显示应用侧边栏 @@ -151,7 +102,17 @@ export function AppSidebar({ ...props }: React.ComponentProps) { const pathname = usePathname() const router = useRouter() - const [showChristmasTree, setShowChristmasTree] = React.useState(false) + const currentTheme = getCurrentTheme() + const { + MainDecoration, + InteractionDecoration, + BackgroundEffect, + smallMainDecorationConfig, + largeMainDecorationConfig, + interactionDecorationConfig + } = currentTheme + + const [showEffect, setShowEffect] = React.useState(false) const handleCloseSidebar = React.useCallback(() => { if (isMobile) { @@ -174,7 +135,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { return ( <> - {showChristmasTree && } + {showEffect && BackgroundEffect && } - + - +
setShowChristmasTree(!showChristmasTree)} + onClick={() => setShowEffect(!showEffect)} > -
- + >(({ className, ...props }, ref) => ( - + )) DocsTableHeader.displayName = "DocsTableHeader" @@ -29,7 +29,7 @@ const DocsTableBody = React.forwardRef< >(({ className, ...props }, ref) => ( )) @@ -41,7 +41,7 @@ const DocsTableRow = React.forwardRef< >(({ className, ...props }, ref) => ( )) @@ -54,7 +54,7 @@ const DocsTableHead = React.forwardRef< (({ className, ...props }, ref) => ( )) diff --git a/frontend/components/ui/fireworks-effect.tsx b/frontend/components/ui/fireworks-effect.tsx new file mode 100644 index 00000000..d0ddafe2 --- /dev/null +++ b/frontend/components/ui/fireworks-effect.tsx @@ -0,0 +1,170 @@ +"use client" + +import { useEffect, useRef } from "react" + +interface Particle { + x: number + y: number + vx: number + vy: number + alpha: number + color: string + decay: number +} + +interface Firework { + x: number + y: number + targetY: number + vx: number + vy: number + color: string + particles: Particle[] + exploded: boolean + dead: boolean +} + +export function FireworksEffect() { + const canvasRef = useRef(null) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + + const ctx = canvas.getContext("2d") + if (!ctx) return + + let animationFrameId: number + let fireworks: Firework[] = [] + + const colors = [ + "#ff0000", "#ff7f00", "#ffff00", "#00ff00", "#0000ff", "#4b0082", "#8f00ff", + "#ff1493", "#00bfff", "#ffd700", "#ff4500", "#32cd32" + ] + + const resizeCanvas = () => { + canvas.width = window.innerWidth + canvas.height = window.innerHeight + } + + const createFirework = () => { + const x = Math.random() * canvas.width + const y = canvas.height + + const targetY = canvas.height * 0.2 + Math.random() * (canvas.height * 0.5) + const speed = 12 + Math.random() * 8 + + fireworks.push({ + x, + y, + targetY, + vx: (Math.random() - 0.5) * 3, + vy: -speed, + color: colors[Math.floor(Math.random() * colors.length)], + particles: [], + exploded: false, + dead: false + }) + } + + const createParticles = (x: number, y: number, color: string) => { + const particleCount = 50 + Math.random() * 50 + const particles: Particle[] = [] + + for (let i = 0; i < particleCount; i++) { + const angle = Math.random() * Math.PI * 2 + const speed = Math.random() * 5 + 1 + particles.push({ + x, + y, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed, + alpha: 1, + color: color, + decay: Math.random() * 0.015 + 0.01 + }) + } + return particles + } + + const updateAndDraw = () => { + if (!ctx || !canvas) return + + ctx.globalCompositeOperation = "destination-out" + ctx.fillStyle = "rgba(0, 0, 0, 0.1)" + ctx.fillRect(0, 0, canvas.width, canvas.height) + + ctx.globalCompositeOperation = "lighter" + + if (Math.random() < 0.05) { + createFirework() + } + + fireworks.forEach((fw) => { + if (fw.dead) return + + if (!fw.exploded) { + ctx.beginPath() + ctx.arc(fw.x, fw.y, 2, 0, Math.PI * 2) + ctx.fillStyle = fw.color + ctx.fill() + + fw.x += fw.vx + fw.y += fw.vy + fw.vy += 0.15 + + if (fw.vy >= 0 || fw.y <= fw.targetY) { + fw.exploded = true + fw.particles = createParticles(fw.x, fw.y, fw.color) + } + } else { + for (let i = fw.particles.length - 1; i >= 0; i--) { + const p = fw.particles[i] + + ctx.beginPath() + ctx.arc(p.x, p.y, 1.5, 0, Math.PI * 2) + ctx.fillStyle = p.color + ctx.globalAlpha = p.alpha + ctx.fill() + ctx.globalAlpha = 1 + + p.x += p.vx + p.y += p.vy + p.vy += 0.1 + p.alpha -= p.decay + + if (p.alpha <= 0) { + fw.particles.splice(i, 1) + } + } + + if (fw.particles.length === 0) { + fw.dead = true + } + } + }) + + fireworks = fireworks.filter(fw => !fw.dead) + + animationFrameId = requestAnimationFrame(updateAndDraw) + } + + resizeCanvas() + updateAndDraw() + + window.addEventListener("resize", resizeCanvas) + + return () => { + cancelAnimationFrame(animationFrameId) + window.removeEventListener("resize", resizeCanvas) + } + }, []) + + return ( + + ) +} diff --git a/frontend/components/ui/sheet.tsx b/frontend/components/ui/sheet.tsx index 84649ad0..cb81d73b 100644 --- a/frontend/components/ui/sheet.tsx +++ b/frontend/components/ui/sheet.tsx @@ -60,13 +60,13 @@ function SheetContent({ className={cn( "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500", side === "right" && - "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm", + "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-svh w-3/4 border-l sm:max-w-sm", side === "left" && - "data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm", + "data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-svh w-3/4 border-r sm:max-w-sm", side === "top" && - "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b", + "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b", side === "bottom" && - "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t", + "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t", className )} {...props} diff --git a/frontend/components/ui/snow-effect.tsx b/frontend/components/ui/snow-effect.tsx index 16ce5298..364c154d 100644 --- a/frontend/components/ui/snow-effect.tsx +++ b/frontend/components/ui/snow-effect.tsx @@ -30,7 +30,7 @@ export function SnowEffect() { } const createSnowflakes = () => { - const count = Math.floor(window.innerWidth / 4) // Adjust density + const count = Math.floor(window.innerWidth / 4) snowflakes = [] for (let i = 0; i < count; i++) { snowflakes.push({ @@ -54,11 +54,9 @@ export function SnowEffect() { ctx.fillStyle = `rgba(255, 255, 255, ${ flake.opacity })` ctx.fill() - // Update position flake.y += flake.speed flake.x += flake.drift - // Reset if out of bounds if (flake.y > canvas.height) { flake.y = -flake.radius flake.x = Math.random() * canvas.width @@ -73,7 +71,6 @@ export function SnowEffect() { animationFrameId = requestAnimationFrame(drawSnowflakes) } - // Initialize resizeCanvas() createSnowflakes() drawSnowflakes() From 72feaec0186f7f27bce08d2ac01717c5b0eabaa5 Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Sun, 28 Dec 2025 13:08:15 +0800 Subject: [PATCH 086/366] feat: distribute the task of updating all user points --- internal/apps/admin/task/routers.go | 16 ++++++++-------- internal/task/constants.go | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/apps/admin/task/routers.go b/internal/apps/admin/task/routers.go index 8fe55b86..f3e36975 100644 --- a/internal/apps/admin/task/routers.go +++ b/internal/apps/admin/task/routers.go @@ -91,15 +91,15 @@ func DispatchTask(c *gin.Context) { case task.TaskTypeUserGamification: if req.UserID == nil || *req.UserID == 0 { - c.JSON(http.StatusBadRequest, util.Err(UserIDRequired)) - return + taskInfo = asynq.NewTask(task.UpdateUserGamificationScoresTask, nil) + taskID = fmt.Sprintf("manual_%s", req.TaskType) + } else { + payload, _ := json.Marshal(map[string]interface{}{ + "user_id": *req.UserID, + }) + taskInfo = asynq.NewTask(meta.AsynqTask, payload) + taskID = fmt.Sprintf("manual_%s_user_%d", req.TaskType, *req.UserID) } - payload, _ := json.Marshal(map[string]interface{}{ - "user_id": *req.UserID, - }) - taskInfo = asynq.NewTask(meta.AsynqTask, payload) - taskID = fmt.Sprintf("manual_%s_user_%d", req.TaskType, *req.UserID) - default: taskInfo = asynq.NewTask(meta.AsynqTask, nil) taskID = fmt.Sprintf("manual_%s", req.TaskType) diff --git a/internal/task/constants.go b/internal/task/constants.go index 4e67a4de..99b4ec3b 100644 --- a/internal/task/constants.go +++ b/internal/task/constants.go @@ -64,7 +64,7 @@ var DispatchableTasks = []TaskMeta{ Type: TaskTypeUserGamification, AsynqTask: UpdateSingleUserGamificationScoreTask, Name: "用户积分更新", - Description: "更新指定用户的游戏化积分", + Description: "更新用户的点数积分", SupportsTime: false, MaxRetry: 5, Queue: QueueWhitelistOnly, From 46cb1113833e0340dd4acbaf6d60ef4dc84e36d5 Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Sun, 28 Dec 2025 14:45:13 +0800 Subject: [PATCH 087/366] feat: Integrate the test payment environment --- docs/docs.go | 9 ++- docs/swagger.json | 9 ++- docs/swagger.yaml | 5 ++ internal/apps/merchant/api_key/routers.go | 4 ++ internal/apps/merchant/link/routers.go | 81 +++++++++++----------- internal/apps/oauth/errs.go | 1 - internal/apps/oauth/middlewares.go | 3 +- internal/apps/order/routers.go | 4 +- internal/apps/payment/errs.go | 18 +++-- internal/apps/payment/routers.go | 83 ++++++++++------------- internal/apps/payment/utils.go | 52 ++++++++------ internal/common/errs.go | 3 + internal/model/merchant_api_keys.go | 1 + internal/model/orders.go | 1 + internal/service/payment.go | 35 ++++++++++ 15 files changed, 183 insertions(+), 126 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index a0c06687..1040152e 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1362,6 +1362,9 @@ const docTemplate = `{ "redirect_uri": { "type": "string", "maxLength": 100 + }, + "test_mode": { + "type": "boolean" } } }, @@ -1387,6 +1390,9 @@ const docTemplate = `{ "redirect_uri": { "type": "string", "maxLength": 100 + }, + "test_mode": { + "type": "boolean" } } }, @@ -1584,7 +1590,8 @@ const docTemplate = `{ "payment", "transfer", "community", - "online" + "online", + "test" ] } } diff --git a/docs/swagger.json b/docs/swagger.json index 785a8e75..124adaa5 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1353,6 +1353,9 @@ "redirect_uri": { "type": "string", "maxLength": 100 + }, + "test_mode": { + "type": "boolean" } } }, @@ -1378,6 +1381,9 @@ "redirect_uri": { "type": "string", "maxLength": 100 + }, + "test_mode": { + "type": "boolean" } } }, @@ -1575,7 +1581,8 @@ "payment", "transfer", "community", - "online" + "online", + "test" ] } } diff --git a/docs/swagger.yaml b/docs/swagger.yaml index afe78267..3d4602f7 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -16,6 +16,8 @@ definitions: redirect_uri: maxLength: 100 type: string + test_mode: + type: boolean required: - app_homepage_url - app_name @@ -38,6 +40,8 @@ definitions: redirect_uri: maxLength: 100 type: string + test_mode: + type: boolean type: object dispute.CloseDisputeRequest: properties: @@ -179,6 +183,7 @@ definitions: - transfer - community - online + - test type: string type: object payment.CreateOrderRequest: diff --git a/internal/apps/merchant/api_key/routers.go b/internal/apps/merchant/api_key/routers.go index 410f8aef..314e0909 100644 --- a/internal/apps/merchant/api_key/routers.go +++ b/internal/apps/merchant/api_key/routers.go @@ -33,6 +33,7 @@ type CreateAPIKeyRequest struct { AppDescription string `json:"app_description" binding:"max=100"` RedirectURI string `json:"redirect_uri" binding:"omitempty,max=100,url"` NotifyURL string `json:"notify_url" binding:"required,max=100,url"` + TestMode bool `json:"test_mode"` } type UpdateAPIKeyRequest struct { @@ -41,6 +42,7 @@ type UpdateAPIKeyRequest struct { AppDescription string `json:"app_description" binding:"omitempty,max=100"` RedirectURI string `json:"redirect_uri" binding:"omitempty,max=100,url"` NotifyURL string `json:"notify_url" binding:"omitempty,max=100,url"` + TestMode bool `json:"test_mode"` } type APIKeyListResponse struct { @@ -73,6 +75,7 @@ func CreateAPIKey(c *gin.Context) { AppDescription: req.AppDescription, RedirectURI: req.RedirectURI, NotifyURL: req.NotifyURL, + TestMode: req.TestMode, } if err := db.DB(c.Request.Context()).Create(&apiKey).Error; err != nil { @@ -137,6 +140,7 @@ func UpdateAPIKey(c *gin.Context) { "app_description": req.AppDescription, "redirect_uri": req.RedirectURI, "notify_url": req.NotifyURL, + "test_mode": req.TestMode, } if err := db.DB(c.Request.Context()). diff --git a/internal/apps/merchant/link/routers.go b/internal/apps/merchant/link/routers.go index 1f08e2a7..9fd698b6 100644 --- a/internal/apps/merchant/link/routers.go +++ b/internal/apps/merchant/link/routers.go @@ -17,14 +17,12 @@ limitations under the License. package link import ( - "encoding/json" "errors" "fmt" "net/http" "time" "github.com/gin-gonic/gin" - "github.com/hibiken/asynq" "github.com/linux-do/credit/internal/apps/merchant" "github.com/linux-do/credit/internal/apps/oauth" "github.com/linux-do/credit/internal/common" @@ -32,8 +30,6 @@ import ( "github.com/linux-do/credit/internal/db" "github.com/linux-do/credit/internal/model" "github.com/linux-do/credit/internal/service" - "github.com/linux-do/credit/internal/task" - "github.com/linux-do/credit/internal/task/scheduler" "github.com/linux-do/credit/internal/util" "github.com/shopspring/decimal" "gorm.io/gorm" @@ -105,6 +101,7 @@ type PaymentLinkDetail struct { Remark string `json:"remark"` CreatedAt time.Time `json:"created_at"` AppName string `json:"app_name"` + RedirectURI string `json:"redirect_uri"` } // ListPaymentLinks 获取支付链接列表 @@ -119,7 +116,7 @@ func ListPaymentLinks(c *gin.Context) { var paymentLinks []PaymentLinkDetail if err := db.DB(c.Request.Context()). Table("merchant_payment_links"). - Select("merchant_payment_links.id, merchant_payment_links.token, merchant_payment_links.amount, merchant_payment_links.product_name, merchant_payment_links.remark, merchant_payment_links.created_at, merchant_api_keys.app_name"). + Select("merchant_payment_links.id, merchant_payment_links.token, merchant_payment_links.amount, merchant_payment_links.product_name, merchant_payment_links.remark, merchant_payment_links.created_at, merchant_api_keys.app_name, merchant_api_keys.redirect_uri"). Joins("JOIN merchant_api_keys ON merchant_api_keys.id = merchant_payment_links.merchant_api_key_id"). Where("merchant_payment_links.merchant_api_key_id = ? AND merchant_payment_links.deleted_at IS NULL", apiKey.ID). Order("merchant_payment_links.created_at DESC"). @@ -231,9 +228,9 @@ func PayByLink(c *gin.Context) { return } - // 不能给自己付款 - if currentUser.ID == merchantUser.ID { - c.JSON(http.StatusBadRequest, util.Err(common.CannotPaySelf)) + // 验证测试模式下的支付权限 + if err := service.ValidateTestModePayment(currentUser.ID, merchantUser.ID, merchantAPIKey.TestMode); err != nil { + c.JSON(http.StatusBadRequest, util.Err(err.Error())) return } @@ -251,22 +248,34 @@ func PayByLink(c *gin.Context) { return } + isTestMode := merchantAPIKey.TestMode + if err := db.DB(c.Request.Context()).Transaction( func(tx *gorm.DB) error { - // 检查每日限额 - if err := service.CheckDailyLimit(tx, currentUser.ID, paymentLink.Amount, payerPayConfig.DailyLimit); err != nil { - return err + // 非测试模式:检查每日限额 + if !isTestMode { + if err := service.CheckDailyLimit(tx, currentUser.ID, paymentLink.Amount, payerPayConfig.DailyLimit); err != nil { + return err + } } // 计算手续费 _, merchantAmount, feePercent := service.CalculateFee(paymentLink.Amount, merchantPayConfig.FeeRate) - feeRemark := fmt.Sprintf("[系统]: 收取商家%d%%手续费", feePercent) - remark := req.Remark - if remark != "" { - remark = remark + " " + feeRemark + var remark string + var orderType model.OrderType + + if isTestMode { + remark = common.TestModeOrderRemark + orderType = model.OrderTypeTest } else { - remark = feeRemark + feeRemark := fmt.Sprintf("[系统]: 收取商家%d%%手续费", feePercent) + if req.Remark != "" { + remark = req.Remark + " " + feeRemark + } else { + remark = feeRemark + } + orderType = model.OrderTypeOnline } // 创建订单 @@ -277,7 +286,7 @@ func PayByLink(c *gin.Context) { ClientID: merchantAPIKey.ClientID, Amount: paymentLink.Amount, Status: model.OrderStatusSuccess, - Type: model.OrderTypeOnline, + Type: orderType, Remark: remark, TradeTime: time.Now(), ExpiresAt: time.Now(), @@ -286,43 +295,29 @@ func PayByLink(c *gin.Context) { return err } - // 扣减用户余额 - if err := service.DeductUserBalance(tx, currentUser.ID, paymentLink.Amount); err != nil { - return err - } + // 非测试模式:扣减用户余额和增加商户余额 + if !isTestMode { + if err := service.DeductUserBalance(tx, currentUser.ID, paymentLink.Amount); err != nil { + return err + } - // 增加商户余额和积分 - merchantScoreIncrease := paymentLink.Amount.Mul(merchantPayConfig.ScoreRate).Round(0).IntPart() - if err := service.AddMerchantBalance(tx, merchantUser.ID, merchantAmount, merchantScoreIncrease); err != nil { - return err + merchantScoreIncrease := paymentLink.Amount.Mul(merchantPayConfig.ScoreRate).Round(0).IntPart() + if err := service.AddMerchantBalance(tx, merchantUser.ID, merchantAmount, merchantScoreIncrease); err != nil { + return err + } } if config.Config.App.IsProduction() && util.IsLocalhost(merchantAPIKey.NotifyURL) { return nil } - notifyPayload, _ := json.Marshal(map[string]interface{}{ - "order_id": order.ID, - "client_id": merchantAPIKey.ClientID, - }) - if _, errTask := scheduler.AsynqClient.Enqueue( - asynq.NewTask(task.MerchantPaymentNotifyTask, notifyPayload), - asynq.Queue(task.QueueWebhook), - asynq.MaxRetry(10), - asynq.Timeout(30*time.Second), - ); errTask != nil { - return fmt.Errorf("下发商户回调任务失败: %w", errTask) - } - - return nil + return service.EnqueueMerchantNotify(order.ID, merchantAPIKey.ClientID) }, ); err != nil { errMsg := err.Error() switch errMsg { - case common.InsufficientBalance: - c.JSON(http.StatusBadRequest, util.Err(common.InsufficientBalance)) - case common.DailyLimitExceeded: - c.JSON(http.StatusBadRequest, util.Err(common.DailyLimitExceeded)) + case common.InsufficientBalance, common.DailyLimitExceeded: + c.JSON(http.StatusBadRequest, util.Err(errMsg)) default: c.JSON(http.StatusInternalServerError, util.Err(errMsg)) } diff --git a/internal/apps/oauth/errs.go b/internal/apps/oauth/errs.go index 4299c0ff..0414ef7b 100644 --- a/internal/apps/oauth/errs.go +++ b/internal/apps/oauth/errs.go @@ -17,7 +17,6 @@ limitations under the License. package oauth const ( - UnAuthorized = "未登录" InvalidState = "非法登录请求" IDTokenVerifyFailed = "ID Token 验证失败" NonceMismatch = "nonce 不匹配,可能存在重放攻击" diff --git a/internal/apps/oauth/middlewares.go b/internal/apps/oauth/middlewares.go index f8bbfff9..c8cc40d4 100644 --- a/internal/apps/oauth/middlewares.go +++ b/internal/apps/oauth/middlewares.go @@ -20,6 +20,7 @@ import ( "net/http" "github.com/gin-gonic/gin" + "github.com/linux-do/credit/internal/common" "github.com/linux-do/credit/internal/db" "github.com/linux-do/credit/internal/logger" "github.com/linux-do/credit/internal/model" @@ -36,7 +37,7 @@ func LoginRequired() gin.HandlerFunc { // load user userId := GetUserIDFromContext(c) if userId <= 0 { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error_msg": UnAuthorized, "data": nil}) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error_msg": common.UnAuthorized, "data": nil}) return } diff --git a/internal/apps/order/routers.go b/internal/apps/order/routers.go index b49694a4..36d012ef 100644 --- a/internal/apps/order/routers.go +++ b/internal/apps/order/routers.go @@ -30,7 +30,7 @@ import ( type TransactionListRequest struct { Page int `json:"page" form:"page" binding:"min=1"` PageSize int `json:"page_size" form:"page_size" binding:"min=1,max=100"` - Type string `json:"type" form:"type" binding:"omitempty,oneof=receive payment transfer community online"` + Type string `json:"type" form:"type" binding:"omitempty,oneof=receive payment transfer community online test"` Status string `json:"status" form:"status" binding:"omitempty,oneof=success pending failed expired disputing refund refused"` ClientID string `json:"client_id" form:"client_id" binding:"omitempty"` StartTime *time.Time `json:"startTime" form:"startTime" binding:"omitempty"` @@ -110,7 +110,7 @@ func ListTransactions(c *gin.Context) { } else { baseQuery = baseQuery.Where("orders.type = ? AND (orders.payer_user_id = ? OR orders.payee_user_id = ?)", orderType, user.ID, user.ID) } - case model.OrderTypePayment, model.OrderTypeTransfer: + case model.OrderTypePayment, model.OrderTypeTransfer, model.OrderTypeTest: // payment、transfer 类型:查询当前用户作为付款方的订单 baseQuery = baseQuery.Where("orders.type = ? AND orders.payer_user_id = ?", orderType, user.ID) } diff --git a/internal/apps/payment/errs.go b/internal/apps/payment/errs.go index 76b8c77d..d55e0859 100644 --- a/internal/apps/payment/errs.go +++ b/internal/apps/payment/errs.go @@ -17,14 +17,12 @@ limitations under the License. package payment const ( - OrderNotFound = "订单不存在或已完成" - OrderStatusInvalid = "订单状态不允许支付" - OrderExpired = "订单已过期" - MerchantInfoNotFound = "商户信息不存在" - RecipientNotFound = "收款人不存在" - OrderNoFormatError = "订单号格式错误" - CannotPayOwnOrder = "不能支付自己的订单" - CannotTransferToSelf = "不能转账给自己" - PayConfigNotFound = "支付配置不存在" - SystemConfigValueInvalid = "系统配置 %s 的值无法转换为整数: %v" + OrderNotFound = "订单不存在或已完成" + OrderStatusInvalid = "订单状态不允许支付" + OrderExpired = "订单已过期" + MerchantInfoNotFound = "商户信息不存在" + RecipientNotFound = "收款人不存在" + OrderNoFormatError = "订单号格式错误" + CannotTransferToSelf = "不能转账给自己" + PayConfigNotFound = "支付配置不存在" ) diff --git a/internal/apps/payment/routers.go b/internal/apps/payment/routers.go index 36c8bece..f95aff00 100644 --- a/internal/apps/payment/routers.go +++ b/internal/apps/payment/routers.go @@ -17,7 +17,6 @@ limitations under the License. package payment import ( - "encoding/json" "errors" "fmt" "log" @@ -26,13 +25,10 @@ import ( "strconv" "time" - "github.com/hibiken/asynq" "github.com/linux-do/credit/internal/apps/oauth" "github.com/linux-do/credit/internal/common" "github.com/linux-do/credit/internal/config" "github.com/linux-do/credit/internal/service" - "github.com/linux-do/credit/internal/task" - "github.com/linux-do/credit/internal/task/scheduler" "github.com/gin-gonic/gin" "github.com/linux-do/credit/internal/db" @@ -424,37 +420,49 @@ func PayMerchantOrder(c *gin.Context) { return errors.New(OrderExpired) } - // 检查每日限额 - if err := service.CheckDailyLimit(tx, orderCtx.CurrentUser.ID, order.Amount, orderCtx.PayerPayConfig.DailyLimit); err != nil { - return err + isTestMode := orderCtx.MerchantAPIKey.TestMode + + // 非测试模式:检查每日限额 + if !isTestMode { + if err := service.CheckDailyLimit(tx, orderCtx.CurrentUser.ID, order.Amount, orderCtx.PayerPayConfig.DailyLimit); err != nil { + return err + } } // 计算手续费 _, merchantAmount, feePercent := service.CalculateFee(order.Amount, orderCtx.MerchantPayConfig.FeeRate) - feeRemark := fmt.Sprintf("[系统]: 收取商家%d%%手续费", feePercent) - // 更新订单状态和备注 - if order.Remark != "" { - order.Remark = order.Remark + " " + feeRemark - } else { - order.Remark = feeRemark - } + // 更新订单状态 order.Status = model.OrderStatusSuccess order.PayerUserID = orderCtx.CurrentUser.ID order.TradeTime = time.Now() - if err := tx.Save(&order).Error; err != nil { - return err + + if isTestMode { + order.Type = model.OrderTypeTest + order.Remark = common.TestModeOrderRemark + } else { + feeRemark := fmt.Sprintf("[系统]: 收取商家%d%%手续费", feePercent) + if order.Remark != "" { + order.Remark = order.Remark + " " + feeRemark + } else { + order.Remark = feeRemark + } } - // 扣减用户余额 - if err := service.DeductUserBalance(tx, orderCtx.CurrentUser.ID, order.Amount); err != nil { + if err := tx.Save(&order).Error; err != nil { return err } - // 增加商户余额和积分 - merchantScoreIncrease := order.Amount.Mul(orderCtx.MerchantPayConfig.ScoreRate).Round(0).IntPart() - if err := service.AddMerchantBalance(tx, orderCtx.MerchantUser.ID, merchantAmount, merchantScoreIncrease); err != nil { - return err + // 非测试模式:扣减用户余额和增加商户余额 + if !isTestMode { + if err := service.DeductUserBalance(tx, orderCtx.CurrentUser.ID, order.Amount); err != nil { + return err + } + + merchantScoreIncrease := order.Amount.Mul(orderCtx.MerchantPayConfig.ScoreRate).Round(0).IntPart() + if err := service.AddMerchantBalance(tx, orderCtx.MerchantUser.ID, merchantAmount, merchantScoreIncrease); err != nil { + return err + } } expireKey := db.PrefixedKey(fmt.Sprintf(OrderExpireKeyFormat, order.ID)) @@ -462,33 +470,16 @@ func PayMerchantOrder(c *gin.Context) { log.Printf("[Payment] 删除订单过期key失败: order_id=%d, error=%v", order.ID, err) } - // 下发商户回调任务 - notifyPayload, _ := json.Marshal(map[string]interface{}{ - "order_id": order.ID, - "client_id": order.ClientID, - }) - if _, errTask := scheduler.AsynqClient.Enqueue( - asynq.NewTask(task.MerchantPaymentNotifyTask, notifyPayload), - asynq.Queue(task.QueueWebhook), - asynq.MaxRetry(10), - asynq.Timeout(30*time.Second), - ); errTask != nil { - return fmt.Errorf("下发商户回调任务失败: %w", errTask) - } - - return nil + return service.EnqueueMerchantNotify(order.ID, order.ClientID) }, ); err != nil { errMsg := err.Error() - if errMsg == common.InsufficientBalance { - c.JSON(http.StatusBadRequest, util.Err(common.InsufficientBalance)) - } else if errMsg == OrderNotFound { - c.JSON(http.StatusNotFound, util.Err(OrderNotFound)) - } else if errMsg == OrderExpired { - c.JSON(http.StatusBadRequest, util.Err(OrderExpired)) - } else if errMsg == common.DailyLimitExceeded { - c.JSON(http.StatusBadRequest, util.Err(common.DailyLimitExceeded)) - } else { + switch errMsg { + case common.InsufficientBalance, OrderExpired, common.DailyLimitExceeded: + c.JSON(http.StatusBadRequest, util.Err(errMsg)) + case OrderNotFound: + c.JSON(http.StatusNotFound, util.Err(errMsg)) + default: c.JSON(http.StatusInternalServerError, util.Err(errMsg)) } return diff --git a/internal/apps/payment/utils.go b/internal/apps/payment/utils.go index 7db98936..14fc9415 100644 --- a/internal/apps/payment/utils.go +++ b/internal/apps/payment/utils.go @@ -32,6 +32,7 @@ import ( "github.com/linux-do/credit/internal/common" "github.com/linux-do/credit/internal/db" "github.com/linux-do/credit/internal/model" + "github.com/linux-do/credit/internal/service" "github.com/linux-do/credit/internal/util" "github.com/redis/go-redis/v9" "github.com/shopspring/decimal" @@ -45,19 +46,16 @@ func HandleParseOrderNoError(c *gin.Context, err error) bool { } errMsg := err.Error() - if errMsg == OrderNotFound { - c.JSON(http.StatusNotFound, util.Err(OrderNotFound)) - } else if errMsg == MerchantInfoNotFound { - c.JSON(http.StatusInternalServerError, util.Err(MerchantInfoNotFound)) - } else if errMsg == CannotPayOwnOrder { - c.JSON(http.StatusBadRequest, util.Err(CannotPayOwnOrder)) - } else if errMsg == OrderNoFormatError { - c.JSON(http.StatusBadRequest, util.Err(OrderNoFormatError)) - } else if errMsg == PayConfigNotFound { - c.JSON(http.StatusInternalServerError, util.Err(PayConfigNotFound)) - } else if errMsg == "未登录" { - c.JSON(http.StatusUnauthorized, util.Err("未登录")) - } else { + switch errMsg { + case OrderNotFound: + c.JSON(http.StatusNotFound, util.Err(errMsg)) + case MerchantInfoNotFound, PayConfigNotFound: + c.JSON(http.StatusInternalServerError, util.Err(errMsg)) + case common.CannotPaySelf, common.TestModeCannotProcessOrder, OrderNoFormatError: + c.JSON(http.StatusBadRequest, util.Err(errMsg)) + case common.UnAuthorized: + c.JSON(http.StatusUnauthorized, util.Err(errMsg)) + default: c.JSON(http.StatusInternalServerError, util.Err(errMsg)) } return true @@ -70,6 +68,7 @@ type OrderContext struct { CurrentUser *model.User PayerPayConfig *model.UserPayConfig MerchantPayConfig *model.UserPayConfig + MerchantAPIKey *model.MerchantAPIKey } // ParseOrderNo 解析订单号,获取订单上下文信息 @@ -98,11 +97,6 @@ func ParseOrderNo(c *gin.Context, orderNo string) (*OrderContext, error) { currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey) - // 验证不是商户自己支付自己的订单 - if currentUser.ID == merchantUser.ID { - return nil, errors.New(CannotPayOwnOrder) - } - orderNoStr, errDecrypt := util.Decrypt(merchantUser.SignKey, orderNo) if errDecrypt != nil { return nil, errors.New(OrderNoFormatError) @@ -113,10 +107,26 @@ func ParseOrderNo(c *gin.Context, orderNo string) (*OrderContext, error) { return nil, errors.New(OrderNoFormatError) } + var apiKey model.MerchantAPIKey + if err := db.DB(c.Request.Context()). + Where("client_id = (SELECT client_id FROM orders WHERE id = ?)", orderID). + First(&apiKey).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New(OrderNotFound) + } + return nil, err + } + + // 验证测试模式下的支付权限 + if err := service.ValidateTestModePayment(currentUser.ID, merchantUser.ID, apiKey.TestMode); err != nil { + return nil, err + } + ctx := &OrderContext{ - OrderID: orderID, - MerchantUser: &merchantUser, - CurrentUser: currentUser, + OrderID: orderID, + MerchantUser: &merchantUser, + CurrentUser: currentUser, + MerchantAPIKey: &apiKey, } // 获取付款用户的支付配置(用于限额检查) diff --git a/internal/common/errs.go b/internal/common/errs.go index f53c2928..10b2f72b 100644 --- a/internal/common/errs.go +++ b/internal/common/errs.go @@ -24,6 +24,9 @@ const ( DailyLimitExceeded = "已超过每日限额" PayKeyIncorrect = "支付密钥错误" CannotPaySelf = "不能给自己付款" + TestModeCannotProcessOrder = "测试模式下无法处理订单" + TestModeOrderRemark = "[测试模式] 此订单为测试订单,未实际扣款" + UnAuthorized = "未登录" ) const ( diff --git a/internal/model/merchant_api_keys.go b/internal/model/merchant_api_keys.go index 0fdb5ee9..3016d8f8 100644 --- a/internal/model/merchant_api_keys.go +++ b/internal/model/merchant_api_keys.go @@ -33,6 +33,7 @@ type MerchantAPIKey struct { AppDescription string `json:"app_description" gorm:"size:100"` RedirectURI string `json:"redirect_uri" gorm:"size:100"` NotifyURL string `json:"notify_url" gorm:"size:100;not null"` + TestMode bool `json:"test_mode" gorm:"default:false"` CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index:idx_merchant_api_keys_user_created,priority:2"` UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` diff --git a/internal/model/orders.go b/internal/model/orders.go index 22d11f3d..32ddd56e 100644 --- a/internal/model/orders.go +++ b/internal/model/orders.go @@ -37,6 +37,7 @@ const ( OrderTypeTransfer OrderType = "transfer" OrderTypeCommunity OrderType = "community" OrderTypeOnline OrderType = "online" + OrderTypeTest OrderType = "test" ) type OrderStatus string diff --git a/internal/service/payment.go b/internal/service/payment.go index e7376256..ad189f16 100644 --- a/internal/service/payment.go +++ b/internal/service/payment.go @@ -17,11 +17,16 @@ limitations under the License. package service import ( + "encoding/json" "errors" + "fmt" "time" + "github.com/hibiken/asynq" "github.com/linux-do/credit/internal/common" "github.com/linux-do/credit/internal/model" + "github.com/linux-do/credit/internal/task" + "github.com/linux-do/credit/internal/task/scheduler" "github.com/shopspring/decimal" "gorm.io/gorm" ) @@ -127,3 +132,33 @@ func GetTodayUsedAmount(db *gorm.DB, userID uint64) (decimal.Decimal, error) { return todayTotalAmount, nil } + +// ValidateTestModePayment 验证测试模式下的支付权限 +// 返回 error:nil 表示允许支付,非 nil 表示拒绝支付 +func ValidateTestModePayment(currentUserID, merchantUserID uint64, isTestMode bool) error { + if currentUserID == merchantUserID { + if !isTestMode { + return errors.New(common.CannotPaySelf) + } + } else if isTestMode { + return errors.New(common.TestModeCannotProcessOrder) + } + return nil +} + +// EnqueueMerchantNotify 下发商户回调任务 +func EnqueueMerchantNotify(orderID uint64, clientID string) error { + notifyPayload, _ := json.Marshal(map[string]interface{}{ + "order_id": orderID, + "client_id": clientID, + }) + if _, err := scheduler.AsynqClient.Enqueue( + asynq.NewTask(task.MerchantPaymentNotifyTask, notifyPayload), + asynq.Queue(task.QueueWebhook), + asynq.MaxRetry(10), + asynq.Timeout(30*time.Second), + ); err != nil { + return fmt.Errorf("下发商户回调任务失败: %w", err) + } + return nil +} From c16b2f7e4d6fbe5cb19c8a92c278703c86e78316 Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Sun, 28 Dec 2025 15:02:18 +0800 Subject: [PATCH 088/366] feat: Return the redirect_uri field --- internal/apps/merchant/link/routers.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/apps/merchant/link/routers.go b/internal/apps/merchant/link/routers.go index 9fd698b6..efde4e45 100644 --- a/internal/apps/merchant/link/routers.go +++ b/internal/apps/merchant/link/routers.go @@ -138,7 +138,7 @@ func GetPaymentLinkByToken(c *gin.Context) { var paymentLink PaymentLinkDetail if err := db.DB(c.Request.Context()). Table("merchant_payment_links"). - Select("merchant_payment_links.id, merchant_payment_links.token, merchant_payment_links.amount, merchant_payment_links.product_name, merchant_payment_links.remark, merchant_payment_links.created_at, merchant_api_keys.app_name"). + Select("merchant_payment_links.id, merchant_payment_links.token, merchant_payment_links.amount, merchant_payment_links.product_name, merchant_payment_links.remark, merchant_payment_links.created_at, merchant_api_keys.app_name, merchant_api_keys.redirect_uri"). Joins("JOIN merchant_api_keys ON merchant_api_keys.id = merchant_payment_links.merchant_api_key_id"). Where("merchant_payment_links.token = ? AND merchant_payment_links.deleted_at IS NULL", c.Param("token")). First(&paymentLink).Error; err != nil { From b7b973339987605f436ff9632d6a26bb306acae2 Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Sun, 28 Dec 2025 16:12:21 +0800 Subject: [PATCH 089/366] feat: support merchant activity testing --- .../common/general/table-filter.tsx | 3 +- .../common/merchant/merchant-dialog.tsx | 3 + .../common/merchant/merchant-info.tsx | 16 +- .../common/merchant/merchant-online.tsx | 10 +- .../common/merchant/merchant-test.tsx | 176 ++++++++++++++++++ .../common/pay/paying/paying-online.tsx | 2 +- .../components/common/trade/trade-main.tsx | 1 + frontend/lib/services/merchant/types.ts | 8 + frontend/lib/services/transaction/types.ts | 2 +- 9 files changed, 212 insertions(+), 9 deletions(-) create mode 100644 frontend/components/common/merchant/merchant-test.tsx diff --git a/frontend/components/common/general/table-filter.tsx b/frontend/components/common/general/table-filter.tsx index bd7368c4..e45f38df 100644 --- a/frontend/components/common/general/table-filter.tsx +++ b/frontend/components/common/general/table-filter.tsx @@ -23,7 +23,8 @@ export const typeConfig: Record = { payment: { label: '积分消耗', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300' }, transfer: { label: '积分转移', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300' }, community: { label: '社区划转', color: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300' }, - online: { label: '在线活动', color: 'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-300' } + online: { label: '在线活动', color: 'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-300' }, + test: { label: '测试', color: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300 font-bold' } } /* 状态标签配置 */ diff --git a/frontend/components/common/merchant/merchant-dialog.tsx b/frontend/components/common/merchant/merchant-dialog.tsx index 531643f5..6db774b8 100644 --- a/frontend/components/common/merchant/merchant-dialog.tsx +++ b/frontend/components/common/merchant/merchant-dialog.tsx @@ -49,6 +49,7 @@ export function MerchantDialog({ app_description: '', redirect_uri: '', notify_url: '', + test_mode: false, }) useEffect(() => { @@ -66,6 +67,7 @@ export function MerchantDialog({ app_description: apiKey.app_description, redirect_uri: apiKey.redirect_uri, notify_url: apiKey.notify_url, + test_mode: apiKey.test_mode, } } return { @@ -74,6 +76,7 @@ export function MerchantDialog({ app_description: '', redirect_uri: '', notify_url: '', + test_mode: false, } }, [mode, apiKey]) diff --git a/frontend/components/common/merchant/merchant-info.tsx b/frontend/components/common/merchant/merchant-info.tsx index a736e8c3..4361e7d0 100644 --- a/frontend/components/common/merchant/merchant-info.tsx +++ b/frontend/components/common/merchant/merchant-info.tsx @@ -16,6 +16,7 @@ import { AlertDialogTrigger, } from "@/components/ui/alert-dialog" import { MerchantDialog } from "@/components/common/merchant/merchant-dialog" +import { TestModeToggle } from "@/components/common/merchant/merchant-test" import { formatDateTime } from "@/lib/utils" import { type UpdateAPIKeyRequest, type MerchantAPIKey } from "@/lib/services" @@ -77,7 +78,14 @@ export function MerchantInfo({ apiKey, onUpdate, onDelete, updateAPIKey }: Merch
-

{apiKey.app_name}

+
+

{apiKey.app_name}

+ {apiKey.test_mode && ( + + 测试 + + )} +
{apiKey.app_description && ( @@ -239,6 +247,12 @@ export function MerchantInfo({ apiKey, onUpdate, onDelete, updateAPIKey }: Merch + +
diff --git a/frontend/components/common/merchant/merchant-online.tsx b/frontend/components/common/merchant/merchant-online.tsx index 8fd06f09..693daf27 100644 --- a/frontend/components/common/merchant/merchant-online.tsx +++ b/frontend/components/common/merchant/merchant-online.tsx @@ -525,12 +525,12 @@ function MerchantOnlineContent({ apiKeys }: MerchantOnlineContentProps) {
{ if (!open) { setIsCreating(false); setSelectedLink(null); } }}> - + {isCreating ? (
- + 创建新服务 - 创建一个在线积分流转服务。 + 创建一个在线积分流转服务。
@@ -574,9 +574,9 @@ function MerchantOnlineContent({ apiKeys }: MerchantOnlineContentProps) {
) : selectedLink ? (
- + {selectedLink.product_name} - 服务管理 + 在线积分流转服务内容详情,为了保障在线活动的安全性,不支持编辑
diff --git a/frontend/components/common/merchant/merchant-test.tsx b/frontend/components/common/merchant/merchant-test.tsx new file mode 100644 index 00000000..875a10a3 --- /dev/null +++ b/frontend/components/common/merchant/merchant-test.tsx @@ -0,0 +1,176 @@ +"use client" + +import * as React from "react" +import { useState } from "react" +import { toast } from "sonner" +import { Power, PowerOff } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Spinner } from "@/components/ui/spinner" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" +import type { MerchantAPIKey, UpdateAPIKeyRequest } from "@/lib/services" + +interface TestModeToggleProps { + /** API Key */ + apiKey: MerchantAPIKey + /** 更新回调 */ + onUpdate?: (updatedKey: MerchantAPIKey) => void + /** 更新 API Key */ + updateAPIKey?: (id: number, data: UpdateAPIKeyRequest) => Promise +} + +/** + * 测试模式切换组件 + * 提供测试模式开关按钮,带有详细的说明提示 + */ +export function TestModeToggle({ apiKey, onUpdate, updateAPIKey }: TestModeToggleProps) { + const [open, setOpen] = useState(false) + const [processing, setProcessing] = useState(false) + const [pendingValue, setPendingValue] = useState(false) + + const handleToggle = async () => { + try { + setProcessing(true) + + const updateData = { + app_name: apiKey.app_name, + app_homepage_url: apiKey.app_homepage_url, + app_description: apiKey.app_description, + redirect_uri: apiKey.redirect_uri, + notify_url: apiKey.notify_url, + test_mode: pendingValue + } + + if (updateAPIKey) { + await updateAPIKey(apiKey.id, updateData) + } else { + const services = await import("@/lib/services") + await services.default.merchant.updateAPIKey(apiKey.id, updateData) + } + + toast.success(pendingValue ? '测试模式已开启' : '测试模式已关闭', { + description: pendingValue + ? '现在订单不会真实扣费,仅用于测试调试' + : '现在订单将正常扣费,可以接收真实付款' + }) + + const updatedKey = { ...apiKey, test_mode: pendingValue } + onUpdate?.(updatedKey) + setOpen(false) + } catch (error) { + const errorMessage = (error as Error).message || '更新测试模式失败' + toast.error('更新失败', { description: errorMessage }) + } finally { + setProcessing(false) + } + } + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault() + setPendingValue(!apiKey.test_mode) + setOpen(true) + } + + return ( + + + + + + + + {pendingValue ? ( + <> + + 开启测试模式 + + ) : ( + <> + + 关闭测试模式 + + )} + + +
+ {pendingValue ? ( + <> +

测试模式开启后:

+
    +
  • 订单不会真实扣费:用户余额不会扣减
  • +
  • 商户不会收款:你的余额不会增加
  • +
  • 仅限自己测试:只有你可以支付测试订单
  • +
  • 订单标记明显:测试订单会显示警告标识
  • +
+
+

+ 使用场景:在正式上线前测试支付流程、回调通知等功能 +

+
+ + ) : ( + <> +

测试模式关闭后:

+
    +
  • 订单真实扣费:用户余额会正常扣减
  • +
  • 商户正常收款:你的余额会正常增加
  • +
  • 其他用户可支付:任何人都可以支付订单
  • +
  • 正式生产环境:可以接收真实的业务订单
  • +
+
+

+ 注意:关闭后应用将进入生产模式,请确保已完成测试 +

+
+ + )} +
+
+
+ + 取消 + { + e.preventDefault() + handleToggle() + }} + disabled={processing} + className={pendingValue ? "bg-orange-600 hover:bg-orange-700" : "bg-green-600 hover:bg-green-700"} + > + {processing ? ( + <> + + {pendingValue ? '开启中' : '关闭中'} + + ) : ( + pendingValue ? '确认开启' : '确认关闭' + )} + + +
+
+ ) +} diff --git a/frontend/components/common/pay/paying/paying-online.tsx b/frontend/components/common/pay/paying/paying-online.tsx index e787dd15..d61d7c88 100644 --- a/frontend/components/common/pay/paying/paying-online.tsx +++ b/frontend/components/common/pay/paying/paying-online.tsx @@ -118,7 +118,7 @@ export function PayingOnline() { const mockOrderInfo: GetMerchantOrderResponse = { merchant: { app_name: data.app_name || "商户", - redirect_uri: "", // data.redirect_uri? + redirect_uri: data.redirect_uri || "", }, order: { id: 0, diff --git a/frontend/components/common/trade/trade-main.tsx b/frontend/components/common/trade/trade-main.tsx index a73c6cdf..e7c37fd9 100644 --- a/frontend/components/common/trade/trade-main.tsx +++ b/frontend/components/common/trade/trade-main.tsx @@ -44,6 +44,7 @@ const PAGE_COMPONENTS: Record = { transfer: Transfer, community: Community, online: Online, + test: () => null, all: AllActivity, } diff --git a/frontend/lib/services/merchant/types.ts b/frontend/lib/services/merchant/types.ts index d332e183..cfb8864d 100644 --- a/frontend/lib/services/merchant/types.ts +++ b/frontend/lib/services/merchant/types.ts @@ -20,6 +20,8 @@ export interface MerchantAPIKey { redirect_uri?: string; /** 通知 URL */ notify_url: string; + /** 测试模式 */ + test_mode: boolean; /** 创建时间 */ created_at: string; /** 更新时间 */ @@ -42,6 +44,8 @@ export interface CreateAPIKeyRequest { redirect_uri?: string; /** 通知 URL(最大100字符,必须是有效的 URL) */ notify_url: string; + /** 测试模式(可选,默认为 false) */ + test_mode?: boolean; } /** @@ -58,6 +62,8 @@ export interface UpdateAPIKeyRequest { redirect_uri?: string; /** 通知 URL(最大100字符,必须是有效的 URL,可选) */ notify_url?: string; + /** 测试模式(可选) */ + test_mode?: boolean; } /** @@ -167,6 +173,8 @@ export interface PaymentLink { updated_at: string; /** 应用名称 */ app_name: string; + /** 重定向 URI */ + redirect_uri: string; } /** diff --git a/frontend/lib/services/transaction/types.ts b/frontend/lib/services/transaction/types.ts index 4857973b..2d5d4949 100644 --- a/frontend/lib/services/transaction/types.ts +++ b/frontend/lib/services/transaction/types.ts @@ -1,7 +1,7 @@ /** * 订单类型 */ -export type OrderType = 'receive' | 'payment' | 'transfer' | 'community' | 'online'; +export type OrderType = 'receive' | 'payment' | 'transfer' | 'community' | 'online' | 'test'; /** * 订单状态 From cdc6f3f35c432ff802a504c3775227a8ca6270a2 Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Sun, 28 Dec 2025 18:04:20 +0800 Subject: [PATCH 090/366] feat: Added Payment Link Update Endpoint and New Fields --- docs/docs.go | 86 ++++++++++++--- docs/swagger.json | 86 ++++++++++++--- docs/swagger.yaml | 67 ++++++++--- internal/apps/merchant/link/errs.go | 4 +- internal/apps/merchant/link/routers.go | 135 +++++++++++++++++++---- internal/apps/payment/routers.go | 18 +-- internal/apps/payment/utils.go | 12 +- internal/common/errs.go | 2 + internal/model/merchant_payment_links.go | 3 + internal/model/orders.go | 3 +- internal/router/router.go | 1 + internal/util/validate.go | 16 ++- 12 files changed, 336 insertions(+), 97 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index 1040152e..16963dff 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -794,7 +794,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/link.CreatePaymentLinkRequest" + "$ref": "#/definitions/link.PaymentLinkRequest" } } ], @@ -809,6 +809,52 @@ const docTemplate = `{ } }, "/api/v1/merchant/api-keys/{id}/payment-links/{linkId}": { + "put": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "merchant" + ], + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "API Key ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "Payment Link ID", + "name": "linkId", + "in": "path", + "required": true + }, + { + "description": "更新支付链接请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/link.PaymentLinkRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + }, "delete": { "produces": [ "application/json" @@ -1471,43 +1517,51 @@ const docTemplate = `{ } } }, - "link.CreatePaymentLinkRequest": { + "link.PayByLinkRequest": { "type": "object", "required": [ - "amount", - "product_name" + "pay_key", + "token" ], "properties": { - "amount": { - "type": "number" - }, - "product_name": { + "pay_key": { "type": "string", - "maxLength": 30 + "maxLength": 6 }, "remark": { "type": "string", "maxLength": 100 + }, + "token": { + "type": "string" } } }, - "link.PayByLinkRequest": { + "link.PaymentLinkRequest": { "type": "object", "required": [ - "pay_key", - "token" + "amount", + "product_name" ], "properties": { - "pay_key": { + "amount": { + "type": "number" + }, + "product_name": { "type": "string", - "maxLength": 6 + "maxLength": 30 }, "remark": { "type": "string", "maxLength": 100 }, - "token": { - "type": "string" + "total_limit": { + "type": "integer", + "minimum": 1 + }, + "user_limit": { + "type": "integer", + "minimum": 1 } } }, diff --git a/docs/swagger.json b/docs/swagger.json index 124adaa5..3e007cce 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -785,7 +785,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/link.CreatePaymentLinkRequest" + "$ref": "#/definitions/link.PaymentLinkRequest" } } ], @@ -800,6 +800,52 @@ } }, "/api/v1/merchant/api-keys/{id}/payment-links/{linkId}": { + "put": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "merchant" + ], + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "API Key ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "Payment Link ID", + "name": "linkId", + "in": "path", + "required": true + }, + { + "description": "更新支付链接请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/link.PaymentLinkRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + }, "delete": { "produces": [ "application/json" @@ -1462,43 +1508,51 @@ } } }, - "link.CreatePaymentLinkRequest": { + "link.PayByLinkRequest": { "type": "object", "required": [ - "amount", - "product_name" + "pay_key", + "token" ], "properties": { - "amount": { - "type": "number" - }, - "product_name": { + "pay_key": { "type": "string", - "maxLength": 30 + "maxLength": 6 }, "remark": { "type": "string", "maxLength": 100 + }, + "token": { + "type": "string" } } }, - "link.PayByLinkRequest": { + "link.PaymentLinkRequest": { "type": "object", "required": [ - "pay_key", - "token" + "amount", + "product_name" ], "properties": { - "pay_key": { + "amount": { + "type": "number" + }, + "product_name": { "type": "string", - "maxLength": 6 + "maxLength": 30 }, "remark": { "type": "string", "maxLength": 100 }, - "token": { - "type": "string" + "total_limit": { + "type": "integer", + "minimum": 1 + }, + "user_limit": { + "type": "integer", + "minimum": 1 } } }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 3d4602f7..54949434 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -95,20 +95,6 @@ definitions: - dispute_id - status type: object - link.CreatePaymentLinkRequest: - properties: - amount: - type: number - product_name: - maxLength: 30 - type: string - remark: - maxLength: 100 - type: string - required: - - amount - - product_name - type: object link.PayByLinkRequest: properties: pay_key: @@ -123,6 +109,26 @@ definitions: - pay_key - token type: object + link.PaymentLinkRequest: + properties: + amount: + type: number + product_name: + maxLength: 30 + type: string + remark: + maxLength: 100 + type: string + total_limit: + minimum: 1 + type: integer + user_limit: + minimum: 1 + type: integer + required: + - amount + - product_name + type: object model.PayLevel: enum: - 0 @@ -893,7 +899,7 @@ paths: name: request required: true schema: - $ref: '#/definitions/link.CreatePaymentLinkRequest' + $ref: '#/definitions/link.PaymentLinkRequest' produces: - application/json responses: @@ -927,6 +933,37 @@ paths: $ref: '#/definitions/util.ResponseAny' tags: - merchant + put: + consumes: + - application/json + parameters: + - description: API Key ID + format: int64 + in: path + name: id + required: true + type: integer + - description: Payment Link ID + format: int64 + in: path + name: linkId + required: true + type: integer + - description: 更新支付链接请求 + in: body + name: request + required: true + schema: + $ref: '#/definitions/link.PaymentLinkRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - merchant /api/v1/merchant/payment: post: consumes: diff --git a/internal/apps/merchant/link/errs.go b/internal/apps/merchant/link/errs.go index 2dfb0bde..80943dac 100644 --- a/internal/apps/merchant/link/errs.go +++ b/internal/apps/merchant/link/errs.go @@ -17,5 +17,7 @@ limitations under the License. package link const ( - PaymentLinkNotFound = "支付链接不存在" + PaymentLinkNotFound = "支付链接不存在" + PaymentLinkTotalLimitExceeded = "该支付链接已达到付款次数上限" + PaymentLinkUserLimitExceeded = "您已达到该链接的付款次数限制" ) diff --git a/internal/apps/merchant/link/routers.go b/internal/apps/merchant/link/routers.go index efde4e45..97a823aa 100644 --- a/internal/apps/merchant/link/routers.go +++ b/internal/apps/merchant/link/routers.go @@ -42,11 +42,13 @@ type PayByLinkRequest struct { Remark string `json:"remark" binding:"max=100"` } -// CreatePaymentLinkRequest 创建支付链接请求 -type CreatePaymentLinkRequest struct { +// PaymentLinkRequest 创建支付链接请求 +type PaymentLinkRequest struct { Amount decimal.Decimal `json:"amount" binding:"required"` ProductName string `json:"product_name" binding:"required,max=30"` Remark string `json:"remark" binding:"max=100"` + TotalLimit *uint `json:"total_limit" binding:"omitempty,min=1"` + UserLimit *uint `json:"user_limit" binding:"omitempty,min=1"` } // CreatePaymentLink 创建支付链接 @@ -54,23 +56,19 @@ type CreatePaymentLinkRequest struct { // @Accept json // @Produce json // @Param id path uint64 true "API Key ID" -// @Param request body CreatePaymentLinkRequest true "创建支付链接请求" +// @Param request body PaymentLinkRequest true "创建支付链接请求" // @Success 200 {object} util.ResponseAny // @Router /api/v1/merchant/api-keys/{id}/payment-links [post] func CreatePaymentLink(c *gin.Context) { - var req CreatePaymentLinkRequest + var req PaymentLinkRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, util.Err(err.Error())) return } // 验证金额 - if req.Amount.LessThanOrEqual(decimal.Zero) { - c.JSON(http.StatusBadRequest, util.Err(common.AmountMustBeGreaterThanZero)) - return - } - if req.Amount.Exponent() < -2 { - c.JSON(http.StatusBadRequest, util.Err(common.AmountDecimalPlacesExceeded)) + if err := util.ValidateAmount(req.Amount); err != nil { + c.JSON(http.StatusBadRequest, util.Err(err.Error())) return } @@ -82,6 +80,8 @@ func CreatePaymentLink(c *gin.Context) { Amount: req.Amount, ProductName: req.ProductName, Remark: req.Remark, + TotalLimit: req.TotalLimit, + UserLimit: req.UserLimit, } if err := db.DB(c.Request.Context()).Create(&paymentLink).Error; err != nil { @@ -99,6 +99,8 @@ type PaymentLinkDetail struct { Amount decimal.Decimal `json:"amount"` ProductName string `json:"product_name"` Remark string `json:"remark"` + TotalLimit *uint `json:"total_limit"` + UserLimit *uint `json:"user_limit"` CreatedAt time.Time `json:"created_at"` AppName string `json:"app_name"` RedirectURI string `json:"redirect_uri"` @@ -116,7 +118,7 @@ func ListPaymentLinks(c *gin.Context) { var paymentLinks []PaymentLinkDetail if err := db.DB(c.Request.Context()). Table("merchant_payment_links"). - Select("merchant_payment_links.id, merchant_payment_links.token, merchant_payment_links.amount, merchant_payment_links.product_name, merchant_payment_links.remark, merchant_payment_links.created_at, merchant_api_keys.app_name, merchant_api_keys.redirect_uri"). + Select("merchant_payment_links.id, merchant_payment_links.token, merchant_payment_links.amount, merchant_payment_links.product_name, merchant_payment_links.remark, merchant_payment_links.total_limit, merchant_payment_links.user_limit, merchant_payment_links.created_at, merchant_api_keys.app_name, merchant_api_keys.redirect_uri"). Joins("JOIN merchant_api_keys ON merchant_api_keys.id = merchant_payment_links.merchant_api_key_id"). Where("merchant_payment_links.merchant_api_key_id = ? AND merchant_payment_links.deleted_at IS NULL", apiKey.ID). Order("merchant_payment_links.created_at DESC"). @@ -181,6 +183,55 @@ func DeletePaymentLink(c *gin.Context) { c.JSON(http.StatusOK, util.OKNil()) } +// UpdatePaymentLink 更新支付链接 +// @Tags merchant +// @Accept json +// @Produce json +// @Param id path uint64 true "API Key ID" +// @Param linkId path uint64 true "Payment Link ID" +// @Param request body PaymentLinkRequest true "更新支付链接请求" +// @Success 200 {object} util.ResponseAny +// @Router /api/v1/merchant/api-keys/{id}/payment-links/{linkId} [put] +func UpdatePaymentLink(c *gin.Context) { + var req PaymentLinkRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, util.Err(err.Error())) + return + } + + // 验证金额 + if err := util.ValidateAmount(req.Amount); err != nil { + c.JSON(http.StatusBadRequest, util.Err(err.Error())) + return + } + + apiKey, _ := util.GetFromContext[*model.MerchantAPIKey](c, merchant.APIKeyObjKey) + linkID := c.Param("linkId") + + result := db.DB(c.Request.Context()). + Model(&model.MerchantPaymentLink{}). + Where("id = ? AND merchant_api_key_id = ?", linkID, apiKey.ID). + Updates(map[string]interface{}{ + "amount": req.Amount, + "product_name": req.ProductName, + "remark": req.Remark, + "total_limit": req.TotalLimit, + "user_limit": req.UserLimit, + }) + + if result.Error != nil { + c.JSON(http.StatusInternalServerError, util.Err(result.Error.Error())) + return + } + + if result.RowsAffected == 0 { + c.JSON(http.StatusNotFound, util.Err(PaymentLinkNotFound)) + return + } + + c.JSON(http.StatusOK, util.OKNil()) +} + // PayByLink 通过支付链接支付 // @Tags merchant // @Accept json @@ -252,8 +303,42 @@ func PayByLink(c *gin.Context) { if err := db.DB(c.Request.Context()).Transaction( func(tx *gorm.DB) error { - // 非测试模式:检查每日限额 + // 非测试模式 if !isTestMode { + if paymentLink.TotalLimit != nil || paymentLink.UserLimit != nil { + if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", paymentLink.ID).Error; err != nil { + return err + } + } + + // 检查总付款次数限制 + if paymentLink.TotalLimit != nil { + var totalCount int64 + if err := tx.Table("orders"). + Where("payment_link_id = ? AND status = ?", paymentLink.ID, model.OrderStatusSuccess). + Count(&totalCount).Error; err != nil { + return err + } + if totalCount >= int64(*paymentLink.TotalLimit) { + return errors.New(PaymentLinkTotalLimitExceeded) + } + } + + // 检查单用户付款次数限制 + if paymentLink.UserLimit != nil { + var userCount int64 + if err := tx.Table("orders"). + Where("payment_link_id = ? AND status = ? AND payer_user_id = ?", + paymentLink.ID, model.OrderStatusSuccess, currentUser.ID). + Count(&userCount).Error; err != nil { + return err + } + if userCount >= int64(*paymentLink.UserLimit) { + return errors.New(PaymentLinkUserLimitExceeded) + } + } + + // 检查每日限额 if err := service.CheckDailyLimit(tx, currentUser.ID, paymentLink.Amount, payerPayConfig.DailyLimit); err != nil { return err } @@ -264,6 +349,7 @@ func PayByLink(c *gin.Context) { var remark string var orderType model.OrderType + var paymentLinkID *uint64 if isTestMode { remark = common.TestModeOrderRemark @@ -276,20 +362,22 @@ func PayByLink(c *gin.Context) { remark = feeRemark } orderType = model.OrderTypeOnline + paymentLinkID = &paymentLink.ID } // 创建订单 order := model.Order{ - OrderName: paymentLink.ProductName, - PayerUserID: currentUser.ID, - PayeeUserID: merchantUser.ID, - ClientID: merchantAPIKey.ClientID, - Amount: paymentLink.Amount, - Status: model.OrderStatusSuccess, - Type: orderType, - Remark: remark, - TradeTime: time.Now(), - ExpiresAt: time.Now(), + OrderName: paymentLink.ProductName, + PayerUserID: currentUser.ID, + PayeeUserID: merchantUser.ID, + ClientID: merchantAPIKey.ClientID, + Amount: paymentLink.Amount, + Status: model.OrderStatusSuccess, + Type: orderType, + Remark: remark, + PaymentLinkID: paymentLinkID, + TradeTime: time.Now(), + ExpiresAt: time.Now(), } if err := tx.Create(&order).Error; err != nil { return err @@ -316,7 +404,8 @@ func PayByLink(c *gin.Context) { ); err != nil { errMsg := err.Error() switch errMsg { - case common.InsufficientBalance, common.DailyLimitExceeded: + case common.InsufficientBalance, common.DailyLimitExceeded, + PaymentLinkTotalLimitExceeded, PaymentLinkUserLimitExceeded: c.JSON(http.StatusBadRequest, util.Err(errMsg)) default: c.JSON(http.StatusInternalServerError, util.Err(errMsg)) diff --git a/internal/apps/payment/routers.go b/internal/apps/payment/routers.go index f95aff00..05bc7f13 100644 --- a/internal/apps/payment/routers.go +++ b/internal/apps/payment/routers.go @@ -247,13 +247,8 @@ func RefundMerchantOrder(c *gin.Context) { return } - if req.Amount.LessThanOrEqual(decimal.Zero) { - c.JSON(http.StatusBadRequest, gin.H{"code": -1, "msg": common.AmountMustBeGreaterThanZero}) - return - } - - if req.Amount.Exponent() < -2 { - c.JSON(http.StatusBadRequest, gin.H{"code": -1, "msg": common.AmountDecimalPlacesExceeded}) + if err := util.ValidateAmount(req.Amount); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": -1, "msg": err.Error()}) return } @@ -502,13 +497,8 @@ func Transfer(c *gin.Context) { return } - if req.Amount.LessThanOrEqual(decimal.Zero) { - c.JSON(http.StatusBadRequest, util.Err(common.AmountMustBeGreaterThanZero)) - return - } - - if req.Amount.Exponent() < -2 { - c.JSON(http.StatusBadRequest, util.Err(common.AmountDecimalPlacesExceeded)) + if err := util.ValidateAmount(req.Amount); err != nil { + c.JSON(http.StatusBadRequest, util.Err(err.Error())) return } diff --git a/internal/apps/payment/utils.go b/internal/apps/payment/utils.go index 14fc9415..5d8cd52b 100644 --- a/internal/apps/payment/utils.go +++ b/internal/apps/payment/utils.go @@ -35,7 +35,6 @@ import ( "github.com/linux-do/credit/internal/service" "github.com/linux-do/credit/internal/util" "github.com/redis/go-redis/v9" - "github.com/shopspring/decimal" "gorm.io/gorm" ) @@ -194,14 +193,9 @@ func VerifySignature(c *gin.Context, apiKey *model.MerchantAPIKey) (*CreateOrder return nil, err } - // 验证金额必须大于0 - if req.Amount.LessThanOrEqual(decimal.Zero) { - return nil, errors.New(common.AmountMustBeGreaterThanZero) - } - - // 验证小数位数不超过2位 - if req.Amount.Exponent() < -2 { - return nil, errors.New(common.AmountDecimalPlacesExceeded) + // 验证金额 + if err := util.ValidateAmount(req.Amount); err != nil { + return nil, err } if err := apiKey.GetByClientID(db.DB(c.Request.Context()), req.ClientID); err != nil { diff --git a/internal/common/errs.go b/internal/common/errs.go index 10b2f72b..ca5c7485 100644 --- a/internal/common/errs.go +++ b/internal/common/errs.go @@ -20,6 +20,8 @@ const ( BannedAccount = "账号已被封禁" AmountMustBeGreaterThanZero = "金额必须大于0" AmountDecimalPlacesExceeded = "金额小数位数不能超过2位" + RateMustBeBetweenZeroAndOne = "比率必须在 0 到 1 之间" + RateDecimalPlacesExceeded = "比率小数位数不能超过2位" InsufficientBalance = "余额不足" DailyLimitExceeded = "已超过每日限额" PayKeyIncorrect = "支付密钥错误" diff --git a/internal/model/merchant_payment_links.go b/internal/model/merchant_payment_links.go index 5d82d635..9d16c483 100644 --- a/internal/model/merchant_payment_links.go +++ b/internal/model/merchant_payment_links.go @@ -31,7 +31,10 @@ type MerchantPaymentLink struct { Amount decimal.Decimal `json:"amount" gorm:"type:numeric(20,2);not null"` ProductName string `json:"product_name" gorm:"size:30;not null"` Remark string `json:"remark" gorm:"size:100"` + TotalLimit *uint `json:"total_limit" gorm:"default:null"` + UserLimit *uint `json:"user_limit" gorm:"default:null"` CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` } diff --git a/internal/model/orders.go b/internal/model/orders.go index 32ddd56e..5712f6dd 100644 --- a/internal/model/orders.go +++ b/internal/model/orders.go @@ -63,10 +63,11 @@ type Order struct { PayerUsername string `json:"payer_username" gorm:"->"` PayeeUsername string `json:"payee_username" gorm:"->"` Amount decimal.Decimal `json:"amount" gorm:"type:numeric(20,2);not null;index"` - Status OrderStatus `json:"status" gorm:"type:varchar(20);not null;index:idx_orders_payee_status_type_created,priority:2;index:idx_orders_payer_status_type_created,priority:2;index:idx_orders_client_status_created,priority:2;index:idx_orders_payer_status_type_trade,priority:2"` + Status OrderStatus `json:"status" gorm:"type:varchar(20);not null;index:idx_orders_payee_status_type_created,priority:2;index:idx_orders_payer_status_type_created,priority:2;index:idx_orders_client_status_created,priority:2;index:idx_orders_payer_status_type_trade,priority:2;index:idx_orders_payment_link_status,priority:2"` Type OrderType `json:"type" gorm:"type:varchar(20);not null;index:idx_orders_payee_status_type_created,priority:3;index:idx_orders_payer_status_type_created,priority:3;index:idx_orders_payer_status_type_trade,priority:3"` Remark string `json:"remark" gorm:"size:255"` PaymentType string `json:"payment_type" gorm:"size:20"` + PaymentLinkID *uint64 `json:"payment_link_id" gorm:"index:idx_orders_payment_link_status,priority:1"` TradeTime time.Time `json:"trade_time" gorm:"index:idx_orders_payer_status_type_trade,priority:4"` ExpiresAt time.Time `json:"expires_at" gorm:"not null"` CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index:idx_orders_payee_status_type_created,priority:4;index:idx_orders_payer_status_type_created,priority:4;index:idx_orders_client_status_created,priority:3"` diff --git a/internal/router/router.go b/internal/router/router.go index 749093fa..9b6c012c 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -186,6 +186,7 @@ func Serve() { { linkRouter.GET("", link.ListPaymentLinks) linkRouter.POST("", link.CreatePaymentLink) + linkRouter.PUT("/:linkId", link.UpdatePaymentLink) linkRouter.DELETE("/:linkId", link.DeletePaymentLink) } } diff --git a/internal/util/validate.go b/internal/util/validate.go index 82f082d2..610e1cd6 100644 --- a/internal/util/validate.go +++ b/internal/util/validate.go @@ -19,6 +19,7 @@ package util import ( "errors" + "github.com/linux-do/credit/internal/common" "github.com/shopspring/decimal" ) @@ -27,14 +28,25 @@ func ValidateRates(rates ...decimal.Decimal) error { for _, rate := range rates { // 验证范围:必须在 [0, 1] 之间 if rate.LessThan(decimal.Zero) || rate.GreaterThan(decimal.NewFromInt(1)) { - return errors.New("必须在 0 到 1 之间") + return errors.New(common.RateMustBeBetweenZeroAndOne) } // 验证小数位数:不超过2位 if rate.Exponent() < -2 { - return errors.New("小数位数不能超过2位") + return errors.New(common.RateDecimalPlacesExceeded) } } return nil } + +// ValidateAmount 验证金额:必须大于0,且小数位数不超过2位 +func ValidateAmount(amount decimal.Decimal) error { + if amount.LessThanOrEqual(decimal.Zero) { + return errors.New(common.AmountMustBeGreaterThanZero) + } + if amount.Exponent() < -2 { + return errors.New(common.AmountDecimalPlacesExceeded) + } + return nil +} From 480f902ee8cc5ad7caccfe66b2110bee2c591738 Mon Sep 17 00:00:00 2001 From: Fengfeng2010 <248922903+Fengfeng2010@users.noreply.github.com> Date: Sun, 28 Dec 2025 10:10:25 +0000 Subject: [PATCH 091/366] Change some expressions to make it easier for users to understand. --- frontend/components/common/trade/transfer.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/frontend/components/common/trade/transfer.tsx b/frontend/components/common/trade/transfer.tsx index e258e4d5..2013da90 100644 --- a/frontend/components/common/trade/transfer.tsx +++ b/frontend/components/common/trade/transfer.tsx @@ -44,19 +44,19 @@ export function Transfer() { /* 处理表单提交(第一步)*/ const handleFormSubmit = () => { if (!recipientUsername.trim()) { - toast.error("请输入接收方账户") + toast.error("请输入接收方 LINUX DO 用户名") return } if (!recipientId.trim()) { - toast.error("请输入接收方 ID") + toast.error("请输入接收方 LINUX DO ID") return } /* 验证ID是否为有效数字*/ const idNum = parseInt(recipientId) if (isNaN(idNum) || idNum <= 0) { - toast.error("接收方 ID 格式不正确") + toast.error("接收方 LINUX DO ID 格式不正确") return } @@ -142,30 +142,30 @@ export function Transfer() {
- + setRecipientUsername(e.target.value)} disabled={loading} /> -

请输入接收方的账户

+

请输入接收方 LINUX DO 用户名

- + setRecipientId(e.target.value)} - placeholder="输入接收方 ID" + placeholder="输入接收方 LINUX DO ID" className="font-mono" disabled={loading} /> -

请输入接收方的用户 ID

+

请输入接收方的用户 LINUX DO ID

From 12fbbef5cb12511b35a99d1e9a0fd68471aa3b8a Mon Sep 17 00:00:00 2001 From: Fengfeng2010 <248922903+Fengfeng2010@users.noreply.github.com> Date: Sun, 28 Dec 2025 10:12:56 +0000 Subject: [PATCH 092/366] Update the document. --- frontend/components/common/docs/api.tsx | 2 +- frontend/components/common/docs/how-to-use.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/components/common/docs/api.tsx b/frontend/components/common/docs/api.tsx index 7b2a8cfa..54b49779 100644 --- a/frontend/components/common/docs/api.tsx +++ b/frontend/components/common/docs/api.tsx @@ -9,7 +9,7 @@ import { DocsTableCell, } from "@/components/ui/docs-table" -export const DOCS_LAST_UPDATED = "2025-12-22" +export const DOCS_LAST_UPDATED = "2025-12-28" /** * ------------------------------------------------------------------ diff --git a/frontend/components/common/docs/how-to-use.tsx b/frontend/components/common/docs/how-to-use.tsx index 645260ba..09d38e7f 100644 --- a/frontend/components/common/docs/how-to-use.tsx +++ b/frontend/components/common/docs/how-to-use.tsx @@ -270,8 +270,8 @@ export const howToUseSections: PolicySection[] = [
  • 进入 活动 页面,选择 积分转移
  • 填写接收方信息:
      -
    • 接收方账户:对方的用户名
    • -
    • 接收方 ID:对方的用户ID,确保积分去向准确
    • +
    • 接收方账户:对方的 LINUX DO 用户名
    • +
    • 接收方 ID:对方的用户 LINUX DO 用户 ID,确保积分去向准确
    • 积分数量:转移的积分数量
  • From 54558ca30ddee4fbc7e61274b218cf8eab72e53a Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Sun, 28 Dec 2025 18:44:03 +0800 Subject: [PATCH 093/366] feat: update Next.js 16.1 --- frontend/package.json | 6 +- frontend/pnpm-lock.yaml | 1457 ++++++++++++++++++++------------------- 2 files changed, 735 insertions(+), 728 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index d9e9f87c..73ba8d49 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -48,12 +48,12 @@ "jspdf": "^3.0.4", "lucide-react": "^0.552.0", "motion": "^12.23.24", - "next": "16.0.10", + "next": "16.1.1", "next-themes": "^0.4.6", "radix-ui": "^1.4.3", - "react": "19.1.0", + "react": "19.2.3", "react-day-picker": "^9.11.1", - "react-dom": "19.1.0", + "react-dom": "19.2.3", "react-hook-form": "^7.68.0", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 559cf5f3..5b02b974 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -10,88 +10,88 @@ importers: dependencies: '@dnd-kit/core': specifier: ^6.3.1 - version: 6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@dnd-kit/modifiers': specifier: ^9.0.0 - version: 9.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) + version: 9.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) '@dnd-kit/sortable': specifier: ^10.0.0 - version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) '@dnd-kit/utilities': specifier: ^3.2.2 - version: 3.2.2(react@19.1.0) + version: 3.2.2(react@19.2.3) '@fontsource/noto-sans-sc': specifier: ^5.2.8 version: 5.2.8 '@hookform/resolvers': specifier: ^5.2.2 - version: 5.2.2(react-hook-form@7.68.0(react@19.1.0)) + version: 5.2.2(react-hook-form@7.68.0(react@19.2.3)) '@radix-ui/react-accordion': specifier: ^1.2.12 - version: 1.2.12(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.2.12(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-alert-dialog': specifier: ^1.1.15 - version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-avatar': specifier: ^1.1.11 - version: 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-checkbox': specifier: ^1.3.3 - version: 1.3.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.3.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-dialog': specifier: ^1.1.15 - version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-dropdown-menu': specifier: ^2.1.16 - version: 2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-label': specifier: ^2.1.8 - version: 2.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 2.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-popover': specifier: ^1.1.15 - version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-progress': specifier: ^1.1.8 - version: 1.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-scroll-area': specifier: ^1.2.10 - version: 1.2.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.2.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-select': specifier: ^2.2.6 - version: 2.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 2.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-separator': specifier: ^1.1.8 - version: 1.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-slot': specifier: ^1.2.4 - version: 1.2.4(@types/react@19.2.2)(react@19.1.0) + version: 1.2.4(@types/react@19.2.2)(react@19.2.3) '@radix-ui/react-switch': specifier: ^1.2.6 - version: 1.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-tabs': specifier: ^1.1.13 - version: 1.1.13(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.1.13(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-toggle': specifier: ^1.1.10 - version: 1.1.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.1.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-toggle-group': specifier: ^1.1.11 - version: 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-tooltip': specifier: ^1.2.8 - version: 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-visually-hidden': specifier: ^1.2.4 - version: 1.2.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.2.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tabler/icons-react': specifier: ^3.35.0 - version: 3.35.0(react@19.1.0) + version: 3.35.0(react@19.2.3) '@tanstack/react-table': specifier: ^8.21.3 - version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 8.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-virtual': specifier: ^3.13.13 - version: 3.13.13(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 3.13.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3) axios: specifier: ^1.13.2 version: 1.13.2 @@ -106,55 +106,55 @@ importers: version: 2.1.1 cmdk: specifier: ^1.1.1 - version: 1.1.1(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.1.1(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) date-fns: specifier: ^4.1.0 version: 4.1.0 embla-carousel-react: specifier: ^8.6.0 - version: 8.6.0(react@19.1.0) + version: 8.6.0(react@19.2.3) input-otp: specifier: ^1.4.2 - version: 1.4.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.4.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) jspdf: specifier: ^3.0.4 version: 3.0.4 lucide-react: specifier: ^0.552.0 - version: 0.552.0(react@19.1.0) + version: 0.552.0(react@19.2.3) motion: specifier: ^12.23.24 - version: 12.23.24(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 12.23.24(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next: - specifier: 16.0.10 - version: 16.0.10(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + specifier: 16.1.1 + version: 16.1.1(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next-themes: specifier: ^0.4.6 - version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) radix-ui: specifier: ^1.4.3 - version: 1.4.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.4.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: - specifier: 19.1.0 - version: 19.1.0 + specifier: 19.2.3 + version: 19.2.3 react-day-picker: specifier: ^9.11.1 - version: 9.11.1(react@19.1.0) + version: 9.11.1(react@19.2.3) react-dom: - specifier: 19.1.0 - version: 19.1.0(react@19.1.0) + specifier: 19.2.3 + version: 19.2.3(react@19.2.3) react-hook-form: specifier: ^7.68.0 - version: 7.68.0(react@19.1.0) + version: 7.68.0(react@19.2.3) react-markdown: specifier: ^10.1.0 - version: 10.1.0(@types/react@19.2.2)(react@19.1.0) + version: 10.1.0(@types/react@19.2.2)(react@19.2.3) react-syntax-highlighter: specifier: ^16.1.0 - version: 16.1.0(react@19.1.0) + version: 16.1.0(react@19.2.3) recharts: specifier: 2.15.4 - version: 2.15.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 2.15.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) rehype-highlight: specifier: ^7.0.2 version: 7.0.2 @@ -166,13 +166,13 @@ importers: version: 4.0.1 sonner: specifier: ^2.0.7 - version: 2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) tailwind-merge: specifier: ^3.3.1 version: 3.3.1 vaul: specifier: ^1.1.2 - version: 1.1.2(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.1.2(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) zod: specifier: ^4.1.12 version: 4.1.12 @@ -495,56 +495,56 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} - '@next/env@16.0.10': - resolution: {integrity: sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==} + '@next/env@16.1.1': + resolution: {integrity: sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA==} '@next/eslint-plugin-next@15.5.6': resolution: {integrity: sha512-YxDvsT2fwy1j5gMqk3ppXlsgDopHnkM4BoxSVASbvvgh5zgsK8lvWerDzPip8k3WVzsTZ1O7A7si1KNfN4OZfQ==} - '@next/swc-darwin-arm64@16.0.10': - resolution: {integrity: sha512-4XgdKtdVsaflErz+B5XeG0T5PeXKDdruDf3CRpnhN+8UebNa5N2H58+3GDgpn/9GBurrQ1uWW768FfscwYkJRg==} + '@next/swc-darwin-arm64@16.1.1': + resolution: {integrity: sha512-JS3m42ifsVSJjSTzh27nW+Igfha3NdBOFScr9C80hHGrWx55pTrVL23RJbqir7k7/15SKlrLHhh/MQzqBBYrQA==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.0.10': - resolution: {integrity: sha512-spbEObMvRKkQ3CkYVOME+ocPDFo5UqHb8EMTS78/0mQ+O1nqE8toHJVioZo4TvebATxgA8XMTHHrScPrn68OGw==} + '@next/swc-darwin-x64@16.1.1': + resolution: {integrity: sha512-hbyKtrDGUkgkyQi1m1IyD3q4I/3m9ngr+V93z4oKHrPcmxwNL5iMWORvLSGAf2YujL+6HxgVvZuCYZfLfb4bGw==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.0.10': - resolution: {integrity: sha512-uQtWE3X0iGB8apTIskOMi2w/MKONrPOUCi5yLO+v3O8Mb5c7K4Q5KD1jvTpTF5gJKa3VH/ijKjKUq9O9UhwOYw==} + '@next/swc-linux-arm64-gnu@16.1.1': + resolution: {integrity: sha512-/fvHet+EYckFvRLQ0jPHJCUI5/B56+2DpI1xDSvi80r/3Ez+Eaa2Yq4tJcRTaB1kqj/HrYKn8Yplm9bNoMJpwQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@16.0.10': - resolution: {integrity: sha512-llA+hiDTrYvyWI21Z0L1GiXwjQaanPVQQwru5peOgtooeJ8qx3tlqRV2P7uH2pKQaUfHxI/WVarvI5oYgGxaTw==} + '@next/swc-linux-arm64-musl@16.1.1': + resolution: {integrity: sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@16.0.10': - resolution: {integrity: sha512-AK2q5H0+a9nsXbeZ3FZdMtbtu9jxW4R/NgzZ6+lrTm3d6Zb7jYrWcgjcpM1k8uuqlSy4xIyPR2YiuUr+wXsavA==} + '@next/swc-linux-x64-gnu@16.1.1': + resolution: {integrity: sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@16.0.10': - resolution: {integrity: sha512-1TDG9PDKivNw5550S111gsO4RGennLVl9cipPhtkXIFVwo31YZ73nEbLjNC8qG3SgTz/QZyYyaFYMeY4BKZR/g==} + '@next/swc-linux-x64-musl@16.1.1': + resolution: {integrity: sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@16.0.10': - resolution: {integrity: sha512-aEZIS4Hh32xdJQbHz121pyuVZniSNoqDVx1yIr2hy+ZwJGipeqnMZBJHyMxv2tiuAXGx6/xpTcQJ6btIiBjgmg==} + '@next/swc-win32-arm64-msvc@16.1.1': + resolution: {integrity: sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.0.10': - resolution: {integrity: sha512-E+njfCoFLb01RAFEnGZn6ERoOqhK1Gl3Lfz1Kjnj0Ulfu7oJbuMyvBKNj/bw8XZnenHDASlygTjZICQW+rYW1Q==} + '@next/swc-win32-x64-msvc@16.1.1': + resolution: {integrity: sha512-Ncwbw2WJ57Al5OX0k4chM68DKhEPlrXBaSXDCi2kPi5f4d8b3ejr3RRJGfKBLrn2YJL5ezNS7w2TZLHSti8CMw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -1822,6 +1822,10 @@ packages: resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} engines: {node: '>= 0.6.0'} + baseline-browser-mapping@2.9.11: + resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==} + hasBin: true + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -2982,8 +2986,8 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@16.0.10: - resolution: {integrity: sha512-RtWh5PUgI+vxlV3HdR+IfWA1UUHu0+Ram/JBO4vWB54cVPentCD0e+lxyAYEsDTqGGMg7qpjhKh6dc6aW7W/sA==} + next@16.1.1: + resolution: {integrity: sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -3148,10 +3152,10 @@ packages: peerDependencies: react: '>=16.8.0' - react-dom@19.1.0: - resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==} + react-dom@19.2.3: + resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} peerDependencies: - react: ^19.1.0 + react: ^19.2.3 react-hook-form@7.68.0: resolution: {integrity: sha512-oNN3fjrZ/Xo40SWlHf1yCjlMK417JxoSJVUXQjGdvdRCU07NTFei1i1f8ApUAts+IVh14e4EdakeLEA+BEAs/Q==} @@ -3219,8 +3223,8 @@ packages: react: '>=16.6.0' react-dom: '>=16.6.0' - react@19.1.0: - resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} + react@19.2.3: + resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} engines: {node: '>=0.10.0'} recharts-scale@0.4.5: @@ -3304,8 +3308,8 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} - scheduler@0.26.0: - resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} @@ -3647,36 +3651,36 @@ snapshots: '@date-fns/tz@1.4.1': {} - '@dnd-kit/accessibility@3.1.1(react@19.1.0)': + '@dnd-kit/accessibility@3.1.1(react@19.2.3)': dependencies: - react: 19.1.0 + react: 19.2.3 tslib: 2.8.1 - '@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@dnd-kit/core@6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@dnd-kit/accessibility': 3.1.1(react@19.1.0) - '@dnd-kit/utilities': 3.2.2(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@dnd-kit/accessibility': 3.1.1(react@19.2.3) + '@dnd-kit/utilities': 3.2.2(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) tslib: 2.8.1 - '@dnd-kit/modifiers@9.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)': + '@dnd-kit/modifiers@9.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)': dependencies: - '@dnd-kit/core': 6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@dnd-kit/utilities': 3.2.2(react@19.1.0) - react: 19.1.0 + '@dnd-kit/core': 6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@dnd-kit/utilities': 3.2.2(react@19.2.3) + react: 19.2.3 tslib: 2.8.1 - '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)': + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)': dependencies: - '@dnd-kit/core': 6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@dnd-kit/utilities': 3.2.2(react@19.1.0) - react: 19.1.0 + '@dnd-kit/core': 6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@dnd-kit/utilities': 3.2.2(react@19.2.3) + react: 19.2.3 tslib: 2.8.1 - '@dnd-kit/utilities@3.2.2(react@19.1.0)': + '@dnd-kit/utilities@3.2.2(react@19.2.3)': dependencies: - react: 19.1.0 + react: 19.2.3 tslib: 2.8.1 '@emnapi/core@1.7.0': @@ -3750,20 +3754,20 @@ snapshots: '@floating-ui/core': 1.7.3 '@floating-ui/utils': 0.2.10 - '@floating-ui/react-dom@2.1.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@floating-ui/react-dom@2.1.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@floating-ui/dom': 1.7.4 - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) '@floating-ui/utils@0.2.10': {} '@fontsource/noto-sans-sc@5.2.8': {} - '@hookform/resolvers@5.2.2(react-hook-form@7.68.0(react@19.1.0))': + '@hookform/resolvers@5.2.2(react-hook-form@7.68.0(react@19.2.3))': dependencies: '@standard-schema/utils': 0.3.0 - react-hook-form: 7.68.0(react@19.1.0) + react-hook-form: 7.68.0(react@19.2.3) '@humanfs/core@0.19.1': {} @@ -3891,34 +3895,34 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@next/env@16.0.10': {} + '@next/env@16.1.1': {} '@next/eslint-plugin-next@15.5.6': dependencies: fast-glob: 3.3.1 - '@next/swc-darwin-arm64@16.0.10': + '@next/swc-darwin-arm64@16.1.1': optional: true - '@next/swc-darwin-x64@16.0.10': + '@next/swc-darwin-x64@16.1.1': optional: true - '@next/swc-linux-arm64-gnu@16.0.10': + '@next/swc-linux-arm64-gnu@16.1.1': optional: true - '@next/swc-linux-arm64-musl@16.0.10': + '@next/swc-linux-arm64-musl@16.1.1': optional: true - '@next/swc-linux-x64-gnu@16.0.10': + '@next/swc-linux-x64-gnu@16.1.1': optional: true - '@next/swc-linux-x64-musl@16.0.10': + '@next/swc-linux-x64-musl@16.1.1': optional: true - '@next/swc-win32-arm64-msvc@16.0.10': + '@next/swc-win32-arm64-msvc@16.1.1': optional: true - '@next/swc-win32-x64-msvc@16.0.10': + '@next/swc-win32-x64-msvc@16.1.1': optional: true '@nodelib/fs.scandir@2.1.5': @@ -3939,815 +3943,815 @@ snapshots: '@radix-ui/primitive@1.1.3': {} - '@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-avatar@1.1.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-avatar@1.1.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-avatar@1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-avatar@1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@radix-ui/react-context': 1.1.3(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-context': 1.1.3(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.2)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.2)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.2)(react@19.1.0)': + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.2)(react@19.2.3)': dependencies: - react: 19.1.0 + react: 19.2.3 optionalDependencies: '@types/react': 19.2.2 - '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-context@1.1.2(@types/react@19.2.2)(react@19.1.0)': + '@radix-ui/react-context@1.1.2(@types/react@19.2.2)(react@19.2.3)': dependencies: - react: 19.1.0 + react: 19.2.3 optionalDependencies: '@types/react': 19.2.2 - '@radix-ui/react-context@1.1.3(@types/react@19.2.2)(react@19.1.0)': + '@radix-ui/react-context@1.1.3(@types/react@19.2.2)(react@19.2.3)': dependencies: - react: 19.1.0 + react: 19.2.3 optionalDependencies: '@types/react': 19.2.2 - '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) aria-hidden: 1.2.6 - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - react-remove-scroll: 2.7.1(@types/react@19.2.2)(react@19.1.0) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-remove-scroll: 2.7.1(@types/react@19.2.2)(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-direction@1.1.1(@types/react@19.2.2)(react@19.1.0)': + '@radix-ui/react-direction@1.1.1(@types/react@19.2.2)(react@19.2.3)': dependencies: - react: 19.1.0 + react: 19.2.3 optionalDependencies: '@types/react': 19.2.2 - '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.2)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.2)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.2)(react@19.1.0)': + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.2)(react@19.2.3)': dependencies: - react: 19.1.0 + react: 19.2.3 optionalDependencies: '@types/react': 19.2.2 - '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-form@0.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-form@0.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-id@1.1.1(@types/react@19.2.2)(react@19.1.0)': + '@radix-ui/react-id@1.1.1(@types/react@19.2.2)(react@19.2.3)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.1.0) - react: 19.1.0 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3) + react: 19.2.3 optionalDependencies: '@types/react': 19.2.2 - '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-label@2.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-label@2.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.1.0) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.3) aria-hidden: 1.2.6 - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - react-remove-scroll: 2.7.1(@types/react@19.2.2)(react@19.1.0) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-remove-scroll: 2.7.1(@types/react@19.2.2)(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-one-time-password-field@0.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-one-time-password-field@0.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/number': 1.1.1 '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-password-toggle-field@0.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-password-toggle-field@0.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.2)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.2)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) aria-hidden: 1.2.6 - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - react-remove-scroll: 2.7.1(@types/react@19.2.2)(react@19.1.0) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-remove-scroll: 2.7.1(@types/react@19.2.2)(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - '@floating-ui/react-dom': 2.1.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.2)(react@19.1.0) + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@floating-ui/react-dom': 2.1.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.2)(react@19.2.3) '@radix-ui/rect': 1.1.1 - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@radix-ui/react-slot': 1.2.4(@types/react@19.2.2)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.2)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-progress@1.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-progress@1.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@radix-ui/react-context': 1.1.3(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-context': 1.1.3(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.2)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.2)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/number': 1.1.1 '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/number': 1.1.1 '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) aria-hidden: 1.2.6 - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - react-remove-scroll: 2.7.1(@types/react@19.2.2)(react@19.1.0) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-remove-scroll: 2.7.1(@types/react@19.2.2)(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-separator@1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-separator@1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-separator@1.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-separator@1.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/number': 1.1.1 '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.2)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.2)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-slot@1.2.3(@types/react@19.2.2)(react@19.1.0)': + '@radix-ui/react-slot@1.2.3(@types/react@19.2.2)(react@19.2.3)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.0) - react: 19.1.0 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) + react: 19.2.3 optionalDependencies: '@types/react': 19.2.2 - '@radix-ui/react-slot@1.2.4(@types/react@19.2.2)(react@19.1.0)': + '@radix-ui/react-slot@1.2.4(@types/react@19.2.2)(react@19.2.3)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.0) - react: 19.1.0 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) + react: 19.2.3 optionalDependencies: '@types/react': 19.2.2 - '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.2)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.2)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-toast@1.2.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-toast@1.2.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.2)(react@19.1.0)': + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.2)(react@19.2.3)': dependencies: - react: 19.1.0 + react: 19.2.3 optionalDependencies: '@types/react': 19.2.2 - '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.2)(react@19.1.0)': + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.2)(react@19.2.3)': dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.1.0) - react: 19.1.0 + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3) + react: 19.2.3 optionalDependencies: '@types/react': 19.2.2 - '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.2)(react@19.1.0)': + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.2)(react@19.2.3)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.1.0) - react: 19.1.0 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3) + react: 19.2.3 optionalDependencies: '@types/react': 19.2.2 - '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.2)(react@19.1.0)': + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.2)(react@19.2.3)': dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.1.0) - react: 19.1.0 + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.3) + react: 19.2.3 optionalDependencies: '@types/react': 19.2.2 - '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.2)(react@19.1.0)': + '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.2)(react@19.2.3)': dependencies: - react: 19.1.0 - use-sync-external-store: 1.6.0(react@19.1.0) + react: 19.2.3 + use-sync-external-store: 1.6.0(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 - '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.2)(react@19.1.0)': + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.2)(react@19.2.3)': dependencies: - react: 19.1.0 + react: 19.2.3 optionalDependencies: '@types/react': 19.2.2 - '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.2)(react@19.1.0)': + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.2)(react@19.2.3)': dependencies: - react: 19.1.0 + react: 19.2.3 optionalDependencies: '@types/react': 19.2.2 - '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.2)(react@19.1.0)': + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.2)(react@19.2.3)': dependencies: '@radix-ui/rect': 1.1.1 - react: 19.1.0 + react: 19.2.3 optionalDependencies: '@types/react': 19.2.2 - '@radix-ui/react-use-size@1.1.1(@types/react@19.2.2)(react@19.1.0)': + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.2)(react@19.2.3)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.1.0) - react: 19.1.0 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3) + react: 19.2.3 optionalDependencies: '@types/react': 19.2.2 - '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - '@radix-ui/react-visually-hidden@1.2.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-visually-hidden@1.2.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) @@ -4764,10 +4768,10 @@ snapshots: dependencies: tslib: 2.8.1 - '@tabler/icons-react@3.35.0(react@19.1.0)': + '@tabler/icons-react@3.35.0(react@19.2.3)': dependencies: '@tabler/icons': 3.35.0 - react: 19.1.0 + react: 19.2.3 '@tabler/icons@3.35.0': {} @@ -4845,17 +4849,17 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.1.16 - '@tanstack/react-table@8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@tanstack/react-table@8.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@tanstack/table-core': 8.21.3 - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) - '@tanstack/react-virtual@3.13.13(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@tanstack/react-virtual@3.13.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@tanstack/virtual-core': 3.13.13 - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) '@tanstack/table-core@8.21.3': {} @@ -5219,6 +5223,8 @@ snapshots: base64-arraybuffer@1.0.2: optional: true + baseline-browser-mapping@2.9.11: {} + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -5288,14 +5294,14 @@ snapshots: clsx@2.1.1: {} - cmdk@1.1.1(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + cmdk@1.1.1(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -5454,11 +5460,11 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - embla-carousel-react@8.6.0(react@19.1.0): + embla-carousel-react@8.6.0(react@19.2.3): dependencies: embla-carousel: 8.6.0 embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0) - react: 19.1.0 + react: 19.2.3 embla-carousel-reactive-utils@8.6.0(embla-carousel@8.6.0): dependencies: @@ -5860,14 +5866,14 @@ snapshots: format@0.2.2: {} - framer-motion@12.23.24(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + framer-motion@12.23.24(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: motion-dom: 12.23.23 motion-utils: 12.23.6 tslib: 2.8.1 optionalDependencies: - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) function-bind@1.1.2: {} @@ -6041,10 +6047,10 @@ snapshots: inline-style-parser@0.2.7: {} - input-otp@1.4.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + input-otp@1.4.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) internal-slot@1.1.0: dependencies: @@ -6319,9 +6325,9 @@ snapshots: devlop: 1.1.0 highlight.js: 11.11.1 - lucide-react@0.552.0(react@19.1.0): + lucide-react@0.552.0(react@19.2.3): dependencies: - react: 19.1.0 + react: 19.2.3 magic-string@0.30.21: dependencies: @@ -6704,13 +6710,13 @@ snapshots: motion-utils@12.23.6: {} - motion@12.23.24(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + motion@12.23.24(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: - framer-motion: 12.23.24(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + framer-motion: 12.23.24(react-dom@19.2.3(react@19.2.3))(react@19.2.3) tslib: 2.8.1 optionalDependencies: - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) ms@2.1.3: {} @@ -6720,29 +6726,30 @@ snapshots: natural-compare@1.4.0: {} - next-themes@0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + next-themes@0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) - next@16.0.10(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + next@16.1.1(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: - '@next/env': 16.0.10 + '@next/env': 16.1.1 '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.9.11 caniuse-lite: 1.0.30001753 postcss: 8.4.31 - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - styled-jsx: 5.1.6(react@19.1.0) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + styled-jsx: 5.1.6(react@19.2.3) optionalDependencies: - '@next/swc-darwin-arm64': 16.0.10 - '@next/swc-darwin-x64': 16.0.10 - '@next/swc-linux-arm64-gnu': 16.0.10 - '@next/swc-linux-arm64-musl': 16.0.10 - '@next/swc-linux-x64-gnu': 16.0.10 - '@next/swc-linux-x64-musl': 16.0.10 - '@next/swc-win32-arm64-msvc': 16.0.10 - '@next/swc-win32-x64-msvc': 16.0.10 + '@next/swc-darwin-arm64': 16.1.1 + '@next/swc-darwin-x64': 16.1.1 + '@next/swc-linux-arm64-gnu': 16.1.1 + '@next/swc-linux-arm64-musl': 16.1.1 + '@next/swc-linux-x64-gnu': 16.1.1 + '@next/swc-linux-x64-musl': 16.1.1 + '@next/swc-win32-arm64-msvc': 16.1.1 + '@next/swc-win32-x64-msvc': 16.1.1 babel-plugin-react-compiler: 1.0.0 sharp: 0.34.4 transitivePeerDependencies: @@ -6882,65 +6889,65 @@ snapshots: queue-microtask@1.2.3: {} - radix-ui@1.4.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + radix-ui@1.4.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-accessible-icon': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-alert-dialog': 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-aspect-ratio': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-avatar': 1.1.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-checkbox': 1.3.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-context-menu': 2.2.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-form': 0.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-hover-card': 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-menubar': 1.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-one-time-password-field': 0.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-password-toggle-field': 0.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-progress': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-radio-group': 1.3.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-slider': 1.3.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-switch': 1.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-toast': 1.2.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-toolbar': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.2)(react@19.1.0) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-accessible-icon': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-alert-dialog': 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-aspect-ratio': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-avatar': 1.1.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-checkbox': 1.3.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-context-menu': 2.2.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-form': 0.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-hover-card': 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-menubar': 1.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-one-time-password-field': 0.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-password-toggle-field': 0.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-progress': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-radio-group': 1.3.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slider': 1.3.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-switch': 1.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-toast': 1.2.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-toolbar': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) @@ -6950,27 +6957,27 @@ snapshots: performance-now: 2.1.0 optional: true - react-day-picker@9.11.1(react@19.1.0): + react-day-picker@9.11.1(react@19.2.3): dependencies: '@date-fns/tz': 1.4.1 date-fns: 4.1.0 date-fns-jalali: 4.1.0-0 - react: 19.1.0 + react: 19.2.3 - react-dom@19.1.0(react@19.1.0): + react-dom@19.2.3(react@19.2.3): dependencies: - react: 19.1.0 - scheduler: 0.26.0 + react: 19.2.3 + scheduler: 0.27.0 - react-hook-form@7.68.0(react@19.1.0): + react-hook-form@7.68.0(react@19.2.3): dependencies: - react: 19.1.0 + react: 19.2.3 react-is@16.13.1: {} react-is@18.3.1: {} - react-markdown@10.1.0(@types/react@19.2.2)(react@19.1.0): + react-markdown@10.1.0(@types/react@19.2.2)(react@19.2.3): dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 @@ -6979,7 +6986,7 @@ snapshots: hast-util-to-jsx-runtime: 2.3.6 html-url-attributes: 3.0.1 mdast-util-to-hast: 13.2.1 - react: 19.1.0 + react: 19.2.3 remark-parse: 11.0.0 remark-rehype: 11.1.2 unified: 11.0.5 @@ -6988,75 +6995,75 @@ snapshots: transitivePeerDependencies: - supports-color - react-remove-scroll-bar@2.3.8(@types/react@19.2.2)(react@19.1.0): + react-remove-scroll-bar@2.3.8(@types/react@19.2.2)(react@19.2.3): dependencies: - react: 19.1.0 - react-style-singleton: 2.2.3(@types/react@19.2.2)(react@19.1.0) + react: 19.2.3 + react-style-singleton: 2.2.3(@types/react@19.2.2)(react@19.2.3) tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.2 - react-remove-scroll@2.7.1(@types/react@19.2.2)(react@19.1.0): + react-remove-scroll@2.7.1(@types/react@19.2.2)(react@19.2.3): dependencies: - react: 19.1.0 - react-remove-scroll-bar: 2.3.8(@types/react@19.2.2)(react@19.1.0) - react-style-singleton: 2.2.3(@types/react@19.2.2)(react@19.1.0) + react: 19.2.3 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.2)(react@19.2.3) + react-style-singleton: 2.2.3(@types/react@19.2.2)(react@19.2.3) tslib: 2.8.1 - use-callback-ref: 1.3.3(@types/react@19.2.2)(react@19.1.0) - use-sidecar: 1.1.3(@types/react@19.2.2)(react@19.1.0) + use-callback-ref: 1.3.3(@types/react@19.2.2)(react@19.2.3) + use-sidecar: 1.1.3(@types/react@19.2.2)(react@19.2.3) optionalDependencies: '@types/react': 19.2.2 - react-smooth@4.0.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + react-smooth@4.0.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: fast-equals: 5.3.2 prop-types: 15.8.1 - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - react-transition-group: 4.4.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-transition-group: 4.4.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - react-style-singleton@2.2.3(@types/react@19.2.2)(react@19.1.0): + react-style-singleton@2.2.3(@types/react@19.2.2)(react@19.2.3): dependencies: get-nonce: 1.0.1 - react: 19.1.0 + react: 19.2.3 tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.2 - react-syntax-highlighter@16.1.0(react@19.1.0): + react-syntax-highlighter@16.1.0(react@19.2.3): dependencies: '@babel/runtime': 7.28.4 highlight.js: 10.7.3 highlightjs-vue: 1.0.0 lowlight: 1.20.0 prismjs: 1.30.0 - react: 19.1.0 + react: 19.2.3 refractor: 5.0.0 - react-transition-group@4.4.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + react-transition-group@4.4.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@babel/runtime': 7.28.4 dom-helpers: 5.2.1 loose-envify: 1.4.0 prop-types: 15.8.1 - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) - react@19.1.0: {} + react@19.2.3: {} recharts-scale@0.4.5: dependencies: decimal.js-light: 2.5.1 - recharts@2.15.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + recharts@2.15.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: clsx: 2.1.1 eventemitter3: 4.0.7 lodash: 4.17.21 - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) react-is: 18.3.1 - react-smooth: 4.0.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react-smooth: 4.0.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) recharts-scale: 0.4.5 tiny-invariant: 1.3.3 victory-vendor: 36.9.2 @@ -7185,7 +7192,7 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 - scheduler@0.26.0: {} + scheduler@0.27.0: {} semver@6.3.1: {} @@ -7277,10 +7284,10 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 - sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + sonner@2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) source-map-js@1.2.1: {} @@ -7363,10 +7370,10 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-jsx@5.1.6(react@19.1.0): + styled-jsx@5.1.6(react@19.2.3): dependencies: client-only: 0.0.1 - react: 19.1.0 + react: 19.2.3 supports-color@7.2.0: dependencies: @@ -7532,24 +7539,24 @@ snapshots: dependencies: punycode: 2.3.1 - use-callback-ref@1.3.3(@types/react@19.2.2)(react@19.1.0): + use-callback-ref@1.3.3(@types/react@19.2.2)(react@19.2.3): dependencies: - react: 19.1.0 + react: 19.2.3 tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.2 - use-sidecar@1.1.3(@types/react@19.2.2)(react@19.1.0): + use-sidecar@1.1.3(@types/react@19.2.2)(react@19.2.3): dependencies: detect-node-es: 1.1.0 - react: 19.1.0 + react: 19.2.3 tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.2 - use-sync-external-store@1.6.0(react@19.1.0): + use-sync-external-store@1.6.0(react@19.2.3): dependencies: - react: 19.1.0 + react: 19.2.3 util-deprecate@1.0.2: {} @@ -7558,11 +7565,11 @@ snapshots: base64-arraybuffer: 1.0.2 optional: true - vaul@1.1.2(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + vaul@1.1.2(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) transitivePeerDependencies: - '@types/react' - '@types/react-dom' From c6167e68435b236c5566db6f1ef4654db014f380 Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Sun, 28 Dec 2025 19:33:01 +0800 Subject: [PATCH 094/366] feat: supports setting the number of online activity cycles. --- .../common/merchant/merchant-online.tsx | 317 ++++++++++++++---- .../lib/services/merchant/merchant.service.ts | 32 ++ frontend/lib/services/merchant/types.ts | 25 ++ 3 files changed, 311 insertions(+), 63 deletions(-) diff --git a/frontend/components/common/merchant/merchant-online.tsx b/frontend/components/common/merchant/merchant-online.tsx index 693daf27..fef21ee1 100644 --- a/frontend/components/common/merchant/merchant-online.tsx +++ b/frontend/components/common/merchant/merchant-online.tsx @@ -141,12 +141,15 @@ function MerchantOnlineContent({ apiKeys }: MerchantOnlineContentProps) { const [selectedLink, setSelectedLink] = useState(null) const [previewLink, setPreviewLink] = useState(null) const [isCreating, setIsCreating] = useState(false) + const [isEditing, setIsEditing] = useState(false) const [loading, setLoading] = useState(false) - /* 创建表单状态 */ + /* 创建/编辑表单状态 */ const [productName, setProductName] = useState("") const [amount, setAmount] = useState("") const [remark, setRemark] = useState("") + const [totalLimit, setTotalLimit] = useState("") + const [userLimit, setUserLimit] = useState("") /* 设备预览状态 */ const [previewDevice, setPreviewDevice] = useState<'mobile' | 'tablet' | 'desktop'>('mobile') @@ -208,18 +211,32 @@ function MerchantOnlineContent({ apiKeys }: MerchantOnlineContentProps) { return } + // 验证限制字段 + if (totalLimit && (isNaN(parseInt(totalLimit)) || parseInt(totalLimit) < 1)) { + toast.error("总数量限制必须是大于0的整数") + return + } + if (userLimit && (isNaN(parseInt(userLimit)) || parseInt(userLimit) < 1)) { + toast.error("单用户限制必须是大于0的整数") + return + } + try { setLoading(true) const newLink = await MerchantService.createPaymentLink(selectedKey.id, { product_name: productName, amount: parseFloat(amount), - remark + remark, + ...(totalLimit && { total_limit: parseInt(totalLimit) }), + ...(userLimit && { user_limit: parseInt(userLimit) }) }) toast.success("在线积分流转服务创建成功") /* 重置表单 */ setProductName("") setAmount("") setRemark("") + setTotalLimit("") + setUserLimit("") /* 更新UI */ fetchLinks() @@ -254,6 +271,61 @@ function MerchantOnlineContent({ apiKeys }: MerchantOnlineContentProps) { } } + /* 处理编辑 */ + const handleEdit = (link: PaymentLink) => { + setProductName(link.product_name) + setAmount(link.amount) + setRemark(link.remark || "") + setTotalLimit(link.total_limit?.toString() || "") + setUserLimit(link.user_limit?.toString() || "") + setIsEditing(true) + } + + /* 处理更新 */ + const handleUpdate = async () => { + if (!selectedKey || !selectedLink) return + if (!productName || !amount) { + toast.error("请填写在线积分流转服务的名称和数量") + return + } + if (isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) { + toast.error("积分数量必须设置为大于0的数值") + return + } + + // 验证限制字段 + if (totalLimit && (isNaN(parseInt(totalLimit)) || parseInt(totalLimit) < 1)) { + toast.error("总数量限制必须是大于0的整数") + return + } + if (userLimit && (isNaN(parseInt(userLimit)) || parseInt(userLimit) < 1)) { + toast.error("单用户限制必须是大于0的整数") + return + } + + try { + setLoading(true) + await MerchantService.updatePaymentLink(selectedKey.id, selectedLink.id, { + product_name: productName, + amount: parseFloat(amount), + remark, + ...(totalLimit && { total_limit: parseInt(totalLimit) }), + ...(userLimit && { user_limit: parseInt(userLimit) }) + }) + toast.success("在线积分流转服务已更新") + + /* 更新UI */ + fetchLinks() + setIsEditing(false) + setSelectedLink(null) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "在线积分流转服务更新失败" + toast.error(message) + } finally { + setLoading(false) + } + } + /* 处理复制链接 */ const handleCopyLink = (token: string) => { const url = `${ window.location.origin }/paying/online?token=${ token }` @@ -359,6 +431,8 @@ function MerchantOnlineContent({ apiKeys }: MerchantOnlineContentProps) { setProductName("") setAmount("") setRemark("") + setTotalLimit("") + setUserLimit("") }} className="rounded-lg p-4 border border-dashed hover:border-primary/50 shadow-none transition-all text-left group bg-background min-h-[100px] w-[180px] shrink-0 flex flex-col items-center justify-center gap-2" > @@ -553,6 +627,28 @@ function MerchantOnlineContent({ apiKeys }: MerchantOnlineContentProps) { onChange={e => setAmount(e.target.value)} />
    +
    + + setTotalLimit(e.target.value)} + /> +

    设置该链接总共可以支付的次数

    +
    +
    + + setUserLimit(e.target.value)} + /> +

    设置每个用户最多可以支付的次数

    +