压缩历史至 95081aff

This commit is contained in:
ryan
2026-06-08 20:34:27 +08:00
commit 8a782525de
435 changed files with 71146 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
root = true
[*]
indent_style = space
indent_size = 4
charset = utf-8
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
[*.{json,yml,yaml}]
indent_size = 2
[*.md]
insert_final_newline = false
trim_trailing_whitespace = false
[*.{js,ts,css,html,jsx,tsx,vue}]
indent_size = 2
+90
View File
@@ -0,0 +1,90 @@
name: 使用时的错误报告
description: 某些事情不按照预期工作。
title: "bug: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
感谢您花时间填写此 Bug 报告!
- **提交错误报告前**:请检查 [已有 Issues](https://github.com/linux-do/credit/issues)列表,了解是否有类似问题被报告。如果不确定,请进行搜索,这有助于我们高效地专注于改进项目。
- type: checkboxes
id: issue-check
attributes:
label: 检查现有问题
description: 确认您在提交新报告之前已经检查了现有报告。
options:
- label: 我已经搜索了现有问题和讨论。
required: true
- label: 我正在使用最新版本或 credit.linux.do 上的此应用。
required: true
- type: textarea
id: what-happened
attributes:
label: 发生了什么?
description: 请详细描述您正在进行的操作,您期待看到什么,实际发生了什么。
placeholder: 请告诉我们您看到了什么!
validations:
required: true
- type: textarea
id: steps-to-reproduce
attributes:
label: 如何重现此 Bug?
description: 请提供详细的步骤来重现此 Bug。
placeholder: |
1. 在此环境中...
2. 使用此配置...
3. 运行 '...'
4. 看到错误...
validations:
required: true
- type: dropdown
id: browsers
attributes:
label: 在哪些浏览器中出现问题?
multiple: true
options:
- Firefox
- Chrome
- Safari
- Microsoft Edge
- Other (请在“其他信息”中说明)
validations:
required: false
- type: textarea
id: other-info
attributes:
label: 任何其他信息
description: 您有任何其他关于此报告的信息吗?
validations:
required: false
- type: checkboxes
id: confirmation
attributes:
label: 确认
description: 确保已满足以下先决条件。
options:
- label: 我已阅读并遵循了 `README.md` 中的所有说明。
required: true
- label: 我正在使用 linux-do/credit 的最新版本。
required: true
- label: 我已提供我能够提供的尽可能多的相关日志,屏幕截图等。
required: true
- label: |
我已详细记录了精确、按顺序且无歧义的逐步重现说明。我的步骤:
- 从正在执行的操作开始,
- 指定进入了什么页面,
- 列出访问的 URL、用户输入(包括所需的示例值/电子邮件/密码),
- 描述所有已启用或更改的选项和开关,
- 包含任何可能的浏览器控制台日志,
- 识别每个阶段的预期和实际结果,
- 确保任何有合理技能的用户都可以遵循并遇到相同的问题。
required: true
- type: markdown
attributes:
value: |
## 注意
如果 Bug 报告不完整或不遵循说明,则可能不会得到处理。请确保您已遵循所有 **README.md** 指南,并提供所有必要信息以便我们重现该问题。
感谢您为 linux-do/credit 做出贡献!
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
@@ -0,0 +1,79 @@
name: 新功能建议
description: 请求新的功能或对现有功能进行改进。
title: "feature: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
感谢您花时间填写此功能请求!
- **提交功能请求前**:请检查 [已有 Issues](https://github.com/linux-do/credit/issues) 列表和讨论区,了解是否有类似功能已被讨论或请求。这有助于我们避免重复工作,并高效地专注于改进项目。
- type: checkboxes
id: check-existing
attributes:
label: 检查现有问题和讨论
description: 确认您在提交新请求之前已经检查了现有报告和讨论。
options:
- label: 我已经搜索了现有问题和讨论。
required: true
- label: 我正在使用最新版本或 credit.linux.do 上的此应用。
required: true
- type: textarea
id: feature-description
attributes:
label: 你希望添加什么功能或改进什么?
description: 请详细描述您希望添加的功能或进行的改进。
placeholder: 我希望可以...
validations:
required: true
- type: textarea
id: why-needed
attributes:
label: 为什么需要此功能?
description: 请说明此功能解决了什么问题,或提供了什么价值。请提供具体的用例和场景,帮助我们理解其重要性。
placeholder: |
目前我遇到...
如果有了此功能,我可以...
这将为用户带来...
validations:
required: true
- type: textarea
id: proposed-solution
attributes:
label: 建议的解决方案(可选)
description: 如果您对如何实现此功能有任何想法,请在此处描述。这可以包括用户界面草图、API 设想、技术方案等。
placeholder: |
我设想此功能可以通过以下方式实现:
1. ...
2. ...
validations:
required: false
- type: textarea
id: other-info
attributes:
label: 任何其他信息
description: 您有任何其他关于此报告的信息吗?例如,您目前如何解决这个问题,或者其他类似项目的实现方式等。
validations:
required: false
- type: checkboxes
id: confirmation
attributes:
label: 确认
description: 确保已满足以下先决条件。
options:
- label: 我已阅读并遵循了 `README.md` 中的所有说明。
required: true
- label: 我正在使用 linux-do/credit 的最新版本。
required: true
- label: 我已提供我能够提供的尽可能多的相关信息,包括用例和场景。
required: true
- label: 我理解功能请求的实现取决于项目优先级和资源。
required: true
- type: markdown
attributes:
value: |
## 注意
如果功能请求不完整或不遵循说明,则可能不会得到处理。请确保您已提供所有必要信息以便我们理解您的建议。
感谢您为 linux-do/credit 做出贡献!
+30
View File
@@ -0,0 +1,30 @@
## 基础规范
- 在任何情况都使用简体中文
- 你是一个专业的代码助手,专门为 LINUX DO Credit 项目提供代码编写和优化服务
- 严格遵循项目的代码规范和最佳实践,确保代码质量和一致性
- 保持代码简洁、可读、高效
- 优先考虑项目的可维护性、性能和安全性,避免引入不必要的复杂性
- 注释和上一行代码之间保留一行空格
- 编写代码前仔细分析需求,确保改动有实际价值和意义
- 避免仅修改格式、注释或无影响力的拼写错误
- 重构代码时必须带来可维护性或功能上的实质提升
- 新增功能时考虑向后兼容性和 API 稳定性
- 遵循项目的 MIT 许可证要求
- 遵循语义化版本控制规范
## 后端规范
- 后端开发使用 Go 语言,所有接口需要符合 Restful 风格
- 数据库使用 PostgreSQL 作为主存储,Redis 作为缓存和会话存储
- Go 代码遵循 gofmt 标准格式,使用 snake_case 命名数据库字段
- 所有 API 接口必须编写完整的 Swagger 文档
- API 响应格式统一为 {"error_msg": "", "data": {}} 结构
- 分页数据返回 {"error_msg": "", "data": {"total": 0, "results": []}} 格式
- 数据库设计禁止使用外键,但必须保留对应字段的索引
## 前端规范
- TypeScript 代码严禁使用 any 类型,优先使用 unknown 进行类型安全处理
- 组件按功能分类:公共组件放在 components/common,UI 组件放在 components/ui
- 自定义图标统一放置在 components/icons 目录,常规图标使用 Lucide 库
+3
View File
@@ -0,0 +1,3 @@
- 如果有其他代码文件,忽略 Swagger 变更和版本号变更,只需要关注其他代码文件的变更
- 需要符合 Github 的提交规范,使用 <type>(<scope>): <subject> 格式
- Commit Message 必须有 Scope 信息
+25
View File
@@ -0,0 +1,25 @@
**例行检查**
<!-- 请在下面的 [ ] 中删除空格并打 x ,表示已完成相关检查 -->
- [ ] 我已阅读并理解 [贡献者公约](https://github.com/linux-do/credit/blob/master/CODE_OF_CONDUCT.md)
- [ ] 我已阅读并同意 [贡献者许可协议 (CLA)](https://github.com/linux-do/credit/blob/master/CLA.md),确认我的贡献将根据项目的 Apache2.0 许可证进行许可
- [ ] 我知晓如果此 PR 并不做出实质性更改,或可被认为是*为了PR被合并而提交PR*的,则可能不会被合并
**关联信息**
<!--
如此 PR 解决了一个 Issue, 请在下方填写
resolves #<issue_number>,例如:
resolves #1234
-->
<!-- 若以上均没有,请删除此节 -->
**变更内容**
<!-- 请在下方简要描述此 PR 的变更内容 -->
**变更原因**
<!-- 请在下方简要描述此 PR 的变更原因 -->
+40
View File
@@ -0,0 +1,40 @@
name: Go Build
on:
pull_request:
branches: [ "*" ]
push:
branches:
- "dev"
- "main"
jobs:
go_build:
runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.25"
check-latest: true
- name: Install dependencies
run: |
go mod download
go install github.com/swaggo/swag/cmd/swag@v1.16.6
- name: Make Swagger
run: make swagger
- name: Diff Files
run: |
if ! git diff --exit-code; then
echo 'Detected unstaged changes after command. Please make swagger locally.'
exit 1
fi
- name: Build
run: go build main.go
+36
View File
@@ -0,0 +1,36 @@
name: NextJS Build
on:
pull_request:
branches: [ "*" ]
push:
branches:
- "dev"
- "main"
jobs:
nextjs_build:
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
+200
View File
@@ -0,0 +1,200 @@
name: Build Docker Image
on:
push:
branches: [ "master" ]
tags: [ "*" ]
pull_request:
branches: [ "master" ]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
FRONTEND_IMAGE_NAME: ${{ github.repository }}-web
# add permission for package publishing
permissions:
contents: read
packages: write
jobs:
build_image:
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
# build do
- name: Build Go Binary (multi-arch)
run: |
# build amd64 binary
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 credit-server-arm64 main.go
# show binaries
ls -alh credit-server-*
# set up docker buildx
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# login to registry
- name: Log into registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# extract metadata for docker
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=ref,event=tag
type=sha,format=short,prefix=
# build and push docker image
- name: Build Docker image
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.ref_type == 'tag' || (github.ref == 'refs/heads/master' && github.event_name == 'push') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
build_frontend_image:
runs-on: ubuntu-24.04
steps:
# checkout code
- name: Checkout repository
uses: actions/checkout@v4
# set up docker buildx
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# login to registry
- name: Log into registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# extract metadata for docker (frontend)
- name: Extract Docker metadata (frontend)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}
tags: |
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=ref,event=tag
type=sha,format=short,prefix=
# generate frontend build date in utc
- name: Generate frontend build date
id: frontend_build_meta
run: echo "build_date=$(date -u +'%Y/%m/%d UTC')" >> "$GITHUB_OUTPUT"
# build and push frontend docker image with version argument
- name: Build Frontend Docker image
uses: docker/build-push-action@v5
with:
context: ./frontend
push: ${{ github.ref_type == 'tag' || (github.ref == 'refs/heads/master' && github.event_name == 'push') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
build-args: |
VERSION=${{ github.ref_type == 'tag' && github.ref_name || '' }}
BUILD_DATE=${{ steps.frontend_build_meta.outputs.build_date }}
update_helm_values:
runs-on: ubuntu-24.04
needs: [build_image, build_frontend_image]
if: github.ref_type == 'tag'
steps:
# configure SSH agent with deploy key
- name: Setup SSH agent
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.DEPLOY_KEY }}
# add github.com to known_hosts to avoid host verification prompt
- name: Add GitHub to known_hosts
run: ssh-keyscan github.com >> ~/.ssh/known_hosts
# clone the helm charts repository
- name: Clone Helm Charts repository
run: git clone ${{ vars.HELM_CHARTS_REPO }} helm-charts
# install yq for yaml editing
- name: Install yq
run: |
sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64
sudo chmod +x /usr/local/bin/yq
# determine helm values file based on tag type (production or test)
- name: Determine Helm values file
id: helm_config
run: |
TAG="${{ github.ref_name }}"
if [[ $TAG =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "values_file=values/credit/credit.yaml" >> "$GITHUB_OUTPUT"
echo "branch_prefix=update/credit" >> "$GITHUB_OUTPUT"
else
echo "values_file=values/credit_test/credit.yaml" >> "$GITHUB_OUTPUT"
echo "branch_prefix=update/credit_test" >> "$GITHUB_OUTPUT"
fi
# update image tags in helm values file
- name: Update image tags in helm values file
run: |
cd helm-charts
yq -i '.image.tag = "${{ github.ref_name }}"' ${{ steps.helm_config.outputs.values_file }}
yq -i '.image.webTag = "${{ github.ref_name }}"' ${{ steps.helm_config.outputs.values_file }}
# commit and push to a new branch, then open a PR for review
- name: Commit, push and create PR
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
run: |
cd helm-charts
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
BRANCH_NAME="${{ steps.helm_config.outputs.branch_prefix }}-${{ github.ref_name }}"
git checkout -b "$BRANCH_NAME"
git add ${{ steps.helm_config.outputs.values_file }}
git commit -m "chore: update credit image tag to ${{ github.ref_name }}"
git push origin "$BRANCH_NAME"
HELM_REPO=$(echo "${{ vars.HELM_CHARTS_REPO }}" | sed 's/git@github.com://;s/\.git$//')
gh pr create \
--repo "${HELM_REPO}" \
--title "chore: update credit image tag to ${{ github.ref_name }}" \
--body "Automated PR: update \`${{ steps.helm_config.outputs.values_file }}\` image tags to \`${{ github.ref_name }}\` after Docker image build." \
--base main \
--head "$BRANCH_NAME"
+18
View File
@@ -0,0 +1,18 @@
name: check license
on:
push:
branches: [ "master" ]
tags: [ "*" ]
pull_request:
branches: [ "master" ]
jobs:
check_license:
runs-on: ubuntu-24.04
steps:
- name: checkout code
uses: actions/checkout@v4
- name: run make add_license
run: make check_license
+24
View File
@@ -0,0 +1,24 @@
name: Close Ticket
on:
schedule:
- cron: "0 0 * * *"
jobs:
close_ticket:
runs-on: ubuntu-24.04
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v9
with:
days-before-issue-stale: 14
days-before-issue-close: 14
stale-issue-message: "此 issue 长期无活动,将在 14 天后自动关闭。如需继续讨论请回复"
close-issue-message: "此 issue 因长期无活动已自动关闭,如有需要请重新开启"
days-before-pr-stale: 14
days-before-pr-close: 14
stale-pr-message: "此 PR 长期无活动,将在 14 天后自动关闭。如需继续讨论请回复"
close-pr-message: "此 PR 因长期无活动已自动关闭,如有需要请重新开启"
+40
View File
@@ -0,0 +1,40 @@
name: "CodeQL"
on:
pull_request:
branches: [ "*" ]
push:
branches:
- "dev"
- "main"
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-24.04
permissions:
security-events: write
packages: read
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: go
build-mode: autobuild
- language: javascript-typescript
build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"
+48
View File
@@ -0,0 +1,48 @@
name: "Copilot Setup Steps"
on:
workflow_dispatch:
push:
paths:
- .github/workflows/copilot-setup-steps.yml
pull_request:
paths:
- .github/workflows/copilot-setup-steps.yml
jobs:
copilot-setup-steps:
runs-on: ubuntu-24.04
permissions:
contents: read
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 JavaScript dependencies
working-directory: frontend
run: pnpm install
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.25"
check-latest: true
- name: Install dependencies
run: |
go mod download
go install github.com/swaggo/swag/cmd/swag@v1.16.6
+35
View File
@@ -0,0 +1,35 @@
name: ESLint
on:
pull_request:
branches: [ "*" ]
push:
branches:
- "dev"
- "main"
jobs:
lint:
runs-on: ubuntu-24.04
steps:
- 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
- name: Run ESLint
working-directory: frontend
run: npx eslint . --max-warnings 0
+32
View File
@@ -0,0 +1,32 @@
name: Check PR Template Checklist
on:
pull_request:
types: [opened, edited, synchronize]
jobs:
check-pr-template:
runs-on: ubuntu-24.04
steps:
- name: check all checklist items are checked
uses: actions/github-script@v7
with:
script: |
// get the pull request body
const prBody = context.payload.pull_request.body || '';
// regex to match all checklist items in the template
// matches lines like: - [ ] ... or - [x] ...
const checklistRegex = /^- \[( |x|X)\] .+$/gm;
const matches = prBody.match(checklistRegex) || [];
// check if any checklist item is not checked
const unchecked = matches.filter(line => line.startsWith('- [ ]'));
// if any unchecked, fail the workflow
if (unchecked.length > 0) {
core.setFailed(`PR checklist 未全部勾选,请确保所有 checklist 项都已勾选。未勾选项如下:\n${unchecked.join('\n')}`);
} else {
console.log('all checklist items are checked.');
}
+45
View File
@@ -0,0 +1,45 @@
# IDE
.idea/*
.idea
!.idea/icon.png
.vscode
.pnpm-store/
# logs
logs
*.log
# config
config.yaml
# frontend
frontend/build
frontend/disk
frontend/node_modules/*
frontend/.next/*
frontend/next-env.d.ts
frontend/package-lock.json
frontend/.env
.env.*
!.env.example
*.tsbuildinfo
# os
.DS_Store
Thumbs.db
# build
/build/
/bin/
/dist/
# go workspace
go.work
go.work.sum
/credit
main
# upload
uploads/*
s3_cache
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+55
View File
@@ -0,0 +1,55 @@
# 贡献者许可协议 (Contributor License Agreement)
## 中文版
### 什么是贡献者许可协议 (CLA)?
贡献者许可协议 (CLA) 是一份法律文件,明确了您对开源项目贡献代码时的权利和义务。通过签署 CLA,您确认:
1. **您拥有贡献内容的版权**:您确认您提交的代码、文档或其他内容是您原创的,或者您有权将其贡献给本项目。
2. **您同意许可条款**:您同意将您的贡献按照本项目的 MIT 许可证进行许可。
3. **您授予项目维护者权利**:您授予项目维护者和 linux.do 社区永久、全球、免版税、不可撤销的权利来使用、修改、分发您的贡献。
### 您的贡献将如何使用?
- 您的贡献将根据 [MIT 许可证](LICENSE) 进行许可
- 您的贡献可能被修改、整合到项目中或重新分发
- 您将在项目的贡献者名单中被适当地标识
### 如何表示同意?
在创建 Pull Request 时,请在 PR 描述中勾选 CLA 同意选项,表示您已阅读并同意本贡献者许可协议的条款。
---
## English Version
### What is a Contributor License Agreement (CLA)?
A Contributor License Agreement (CLA) is a legal document that clarifies your rights and obligations when contributing code to an open source project. By agreeing to the CLA, you confirm that:
1. **You own the copyright to your contributions**: You confirm that the code, documentation, or other content you submit is your original work or you have the right to contribute it to this project.
2. **You agree to the license terms**: You agree to license your contributions under this project's MIT License.
3. **You grant rights to project maintainers**: You grant the project maintainers and linux.do community a perpetual, worldwide, royalty-free, irrevocable right to use, modify, and distribute your contributions.
### How will your contributions be used?
- Your contributions will be licensed under the [MIT License](LICENSE)
- Your contributions may be modified, integrated into the project, or redistributed
- You will be appropriately credited in the project's contributor list
### How to indicate agreement?
When creating a Pull Request, please check the CLA agreement option in the PR description to indicate that you have read and agree to the terms of this Contributor License Agreement.
---
## 许可证信息 (License Information)
本项目使用 MIT 许可证。详情请查看 [LICENSE](LICENSE) 文件。
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
+98
View File
@@ -0,0 +1,98 @@
# 贡献者公约
## 我们的承诺
身为社区成员、贡献者和领袖,我们承诺使社区参与者不受骚扰,无论其年龄、体型、可见或不可见的缺陷、族裔、性征、性别认同和表达、经验水平、教育程度、社会与经济地位、国籍、相貌、种族、种姓、肤色、宗教信仰、性倾向或性取向如何。
我们承诺以有助于建立开放、友善、多样化、包容、健康社区的方式行事和互动。
## 我们的准则
有助于为我们的社区创造积极环境的行为例子包括但不限于:
* 表现出对他人的同情和善意
* 尊重不同的主张、观点和感受
* 提出和大方接受建设性意见
* 承担责任并向受我们错误影响的人道歉
* 注重社区共同诉求,而非个人得失
不当行为例子包括:
* 使用情色化的语言或图像,及性引诱或挑逗
* 嘲弄、侮辱或诋毁性评论,以及人身或政治攻击
* 公开或私下的骚扰行为
* 未经他人明确许可,公布他人的私人信息,如物理或电子邮件地址
* 其他有理由认定为违反职业操守的不当行为
## 代码质量与性能准则
为确保项目的持续改进和稳定性,我们制定以下代码质量与性能准则:
* **性能标准**:所有代码变更不得导致项目整体性能下降
* **性能测试**:提交的 Pull Request 应确保不会引入性能回归
* **拒绝合并**:如果 Pull Request 导致性能下降,将被拒绝合并,直到性能问题得到解决
* **优化鼓励**:我们鼓励并欢迎提升系统性能的优化改进
## 责任和权力
社区领袖有责任解释和落实我们所认可的行为准则,并妥善公正地对他们认为不当、威胁、冒犯或有害的任何行为采取纠正措施。
社区领导有权力和责任删除、编辑或拒绝或拒绝与本行为准则不相符的评论(comment)、提交(commits)、代码、维基(wiki)编辑、议题(issues)或其他贡献,并在适当时机知采取措施的理由。
## 适用范围
本行为准则适用于所有社区场合,也适用于在公共场所代表社区时的个人。
代表社区的情形包括使用官方电子邮件地址、通过官方社交媒体帐户发帖或在线上或线下活动中担任指定代表。
## 监督
辱骂、骚扰或其他不可接受的行为可通过 [插入联系方式] 向负责监督的社区领袖报告。
所有投诉都将得到及时和公平的审查和调查。
所有社区领袖都有义务尊重任何事件报告者的隐私和安全。
## 处理方针
社区领袖将遵循下列社区处理方针来明确他们所认定违反本行为准则的行为的处理方式:
### 1. 纠正
**社区影响**:使用不恰当的语言或其他在社区中被认定为不符合职业道德或不受欢迎的行为。
**处理意见**:由社区领袖发出非公开的书面警告,明确说明违规行为的性质,并解释举止如何不妥。或将要求公开道歉。
### 2. 警告
**社区影响**:单个或一系列违规行为。
**处理意见**:警告并对连续性行为进行处理。在指定时间内,不得与相关人员互动,包括主动与行为准则执行者互动。这包括避免在社区场所和外部渠道中的互动。违反这些条款可能会导致临时或永久封禁。
### 3. 临时封禁
**社区影响**: 严重违反社区准则,包括持续的不当行为。
**处理意见**: 在指定时间内,暂时禁止与社区进行任何形式的互动或公开交流。在此期间,不得与相关人员进行公开或私下互动,包括主动与行为准则执行者互动。违反这些条款可能会导致永久封禁。
### 4. 永久封禁
**社区影响**:行为模式表现出违反社区准则,包括持续的不当行为、骚扰个人或攻击或贬低某个类别的个体。
**处理意见**:永久禁止在社区内进行任何形式的公开互动。
## 参见
本行为准则改编自 [Contributor Covenant][homepage] 2.1 版, 参见 [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]。
社区处理方针灵感来源于 [Mozilla's code of conduct enforcement ladder][Mozilla CoC]。
有关本行为准则的常见问题的答案,参见 [https://www.contributor-covenant.org/faq][FAQ]。
其他语言翻译参见 [https://www.contributor-covenant.org/translations][translations]。
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations
+151
View File
@@ -0,0 +1,151 @@
# 贡献指南
感谢您有兴趣为本项目做出贡献!我们欢迎各种形式的贡献,但请先阅读如下文档,以节省您和我们的时间。
当您在使用 Claude Code, Gemini CLI 等 Vibe-coding 工具时,推荐将此文档内容附加到上下文内。
## 我们不接受的更改
出于包括但不限于项目可持续性与可维护性考虑,我们不接受如下类型的更改。
如果您提交的 PR 包含以下类型的更改,我们可能会包括但不限于忽略、关闭或要求您更改 PR 内容。
- 导致项目整体性能下降的更改;
- 仅修改注释、空格、格式的小 PR;
- 仅修正无影响力的拼写错误(typo)或代码注释,不提升可读性或准确性;
- 重构已稳定工作的逻辑而不带来可维护性或功能上的实质提升;
- 未经讨论的接口或 API 命名改动;
- 为了获取社区徽章而提交的大量无实际意义的更改。
**请注意:判断标准不是改动大小,而是改动是否有实际作用。**
为提高协作效率,我们建议您在提交 PR 前,先通过 Issue 简要说明动机与背景。
## 在编写代码前
1. 请仔细阅读[贡献者许可协议](/CLA.md),您提交 PR 默认您已经阅读并同意该协议。
2. 请预先构想您的更改,并确保它不在**我们不接受的更改**列表中。
## 贡献步骤
1. **Fork 本仓库** 并创建您的分支(建议使用有意义的分支名)。
2. **编写代码**,确保遵循项目的代码风格和最佳实践。
3. **添加/更新测试**,确保您的更改不会破坏现有功能。
4. **本地测试**,确认所有测试通过。
5. **提交 Pull Request**,请详细描述您的更改内容和动机。
## 代码规范
### 通用
所有 PR ,需至少有一位具有写权限的协作者 Approve 后再合并。
### 后端
**基础检查**
需要通过 CodeQL 扫描,较长的代码建议增加 Copilot 检查。
**API 文档**
所有接口需要写 Swagger 文档,提交前通过 make swagger 更新文档后再提交。
**响应格式**
```json
# 响应数据最外层有两个字段,error_msg 和 data
{
"error_msg": "",
"data": null
}
# 如果是非列表数据
{
"error_msg": "",
"data": {}
}
# 如果是分页数据
{
"error_msg": "",
"data": {
"total": 0,
"results": []
}
}
```
**数据库**
- 禁止使用外键,但需要保留对应字段的索引;
- 字段如有默认值,需要与 struct 默认值相同,如 nil,0,false,空字符串等,避免初始化时未填写或漏填写导致的数据异常。
### 前端
**基础检查**
代码需要通过 ESLint 检查和 CodeQL 扫描。
**类型安全**
- 禁止使用 `any` 类型,`any` 类型绕过了 TypeScript 的类型检查系统,会导致潜在的运行时错误;
- `unknown` 是类型安全的 `any`,但必须立即进行类型断言或类型收窄;
- `never` 类型表示永远不会发生的值类型,必须谨慎使用,并提供清晰的注释说明。
**组件规范**
- 组件应按功能分类
- 公共组件放在 `components/common` 目录
- ShadcnUI 组件放在 `components/ui` 目录
- 自定义图标应放置在 `/components/icons/` 目录下以命名导出形式管理,对于常规的图标,我们使用 Lucide 库
**服务层**
服务层架构是前端与API交互的统一入口,基于以下原则:
1. 关注点分离 - 每个服务负责一个业务领域
2. 统一入口 - 通过services对象导出所有服务
3. 类型安全 - 所有请求和响应有明确类型定义
**如何新建接口服务**
1. **创建目录结构**:
```
/services/新服务名/
- types.ts // 类型定义
- 服务名.service.ts // 服务实现
- index.ts // 导出服务
```
2. **实现服务类**:
```typescript
// 新服务名/服务名.service.ts
import {BaseService} from '../core/base.service';
export class 新服务类 extends BaseService {
protected static readonly basePath = '/api/v1/路径';
static async 方法名(参数): Promise<返回类型> {
return this.get<返回类型>('/endpoint');
}
}
```
3. **在services/index.ts注册**:
```typescript
import {新服务类} from './新服务名';
const services = {
auth: AuthService,
新服务名: 新服务类
};
```
**使用方法**
```typescript
import services from '@/lib/services';
// 调用服务方法
const 结果 = await services.新服务名.方法名(参数);
```
+23
View File
@@ -0,0 +1,23 @@
FROM alpine:3.23
# 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/${TZ} /etc/localtime && \
echo "${TZ}" > /etc/timezone
WORKDIR /app
# set build arg for platform
ARG TARGETPLATFORM
# copy the appropriate binary based on platform
COPY credit-server-${TARGETPLATFORM#linux/} ./credit-server
# copy docs
COPY docs ./docs
EXPOSE 8000
# set entrypoint
ENTRYPOINT ["./credit-server"]
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
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.
+10
View File
@@ -0,0 +1,10 @@
swagger:
scripts/swagger.sh
tidy:
scripts/tidy.sh
check_license:
scripts/license.sh
pre_commit: tidy swagger check_license
+284
View File
@@ -0,0 +1,284 @@
# LINUX DO Credit
🚀 Linux Do Community Credit Service Platform
[中文](./README_zh.md)
[![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/)
[![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)
## 📖 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.
### ✨ Key Features
- 🔐 **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
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 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 │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
## 🛠️ Tech Stack
### Backend
- **[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
- **[PostgreSQL](https://www.postgresql.org)** - Primary Database
- **[OpenTelemetry](https://opentelemetry.io)** - Observability
- **[Swagger](https://github.com/swaggo/swag)** - API Documentation
### 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.5
- **Node.js** >= 18.0
- **PostgreSQL** >= 18
- **Redis** >= 6.0
- **pnpm** >= 8.0 (Recommended)
## 🚀 Quick Start
### 1. Clone the Project
```bash
git clone https://github.com/linux-do/credit.git
cd credit
```
### 2. Configure Environment
Copy the configuration file and edit it:
```bash
cp config.example.yaml config.yaml
```
Edit `config.yaml` to configure database connections, Redis, OAuth2, etc.
### 3. Initialize Database
```bash
# Create database
createdb -h <host> -p 5432 -U postgres linux_do_credit
# If you need to specify encoding, use:
# psql -h <host> -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. Start Backend
```bash
# Install Go dependencies
go mod tidy
# Generate API documentation
make swagger
# Start backend service
go run main.go api
```
### 5. Start Frontend
```bash
cd frontend
# Install dependencies
pnpm install
# Start development server
pnpm dev
```
### 6. Access Application
- **Frontend Interface**: http://localhost:3000
- **API Documentation**: http://localhost:8000/swagger/index.html
- **Health Check**: http://localhost:8000/api/health
## ⚙️ Configuration
### Main Configuration Options
| 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` |
For detailed configuration instructions, please refer to the `config.example.yaml` file.
## 🔧 Development Guide
### Backend Development
```bash
# Run API server
go run main.go api
# Run task scheduler
go run main.go scheduler
# Run worker queue
go run main.go worker
# Generate Swagger documentation
make swagger
# Format and check code
make tidy
```
### Frontend Development
```bash
cd frontend
# 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 Documentation
API documentation is automatically generated by Swagger and can be accessed after starting the backend service:
```
http://localhost:8000/swagger/index.html
```
## 🧪 Testing
```bash
# Backend testing
go test ./...
# Frontend testing
cd frontend
pnpm test
```
## 🚀 Deployment
### 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. Build frontend resources:
```bash
cd frontend && pnpm build
```
2. Compile backend program:
```bash
go build -o credit main.go
```
3. Configure `config.yaml` for production
4. Start service:
```bash
./credit api
```
## 🤝 Contribution Guidelines
We welcome community contributions! Please read the following before submitting code:
- [Contribution Guidelines](CONTRIBUTING.md)
- [Code of Conduct](CODE_OF_CONDUCT.md)
- [Contributor License Agreement](CLA.md)
### Submission Process
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
This project is open source under the [Apache2.0 License](LICENSE).
## 🔗 Related Links
- [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
Thanks to all developers who contributed to this project and the support of the Linux Do Community!
## 📈 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)
+284
View File
@@ -0,0 +1,284 @@
# LINUX DO Credit
🚀 Linux Do 社区 Credit 积分服务平台
[English](./README.md)
[![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/)
[![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.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)** - 缓存和会话存储
- **[PostgreSQL](https://www.postgresql.org)** - 主数据库
- **[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.5
- **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
```
## 🧪 测试
```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 Chart](https://api.star-history.com/svg?repos=linux-do/credit&type=Date)](https://star-history.com/#linux-do/credit&Date)
+164
View File
@@ -0,0 +1,164 @@
# LINUX DO Credit Config
# Copy to config.yaml for runtime
# App
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: "<uniq string>" # you can't change this after first time start
session_domain: ".linux.do"
session_age: 86400
session_secure: false
session_http_only: false
api_prefix: "/api"
frontend_url: "http://localhost:3000"
frontend_pay_url: "http://localhost:3000/paying"
# OAuth2/OIDC(优先)
oauth2:
client_id: "<OAUTH2_CLIENT_ID>"
client_secret: "<OAUTH2_CLIENT_SECRET>"
redirect_uri: "<OAUTH2_REDIRECT_URI>"
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(读写分离)
database:
enabled: true
host: "127.0.0.1"
port: 5432
username: "postgres"
password: ""
database: "linux_do_credit"
max_idle_conn: 16
max_open_conn: 128
conn_max_lifetime: 1800
conn_max_idle_time: 600
log_level: "info" # error, warn, info, debug, silent
ssl_mode: "disable"
time_zone: "Asia/Shanghai"
application_name: "pay-server"
# pgb改为true,默认false
prefer_simple_protocol: false
search_path: "public"
statement_cache_capacity: 256
default_query_exec_mode: "cache_statement"
slow_threshold: 200ms
# replicas:
# - host: "replica1.db.local"
# port: 5432
# - 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:
enabled: true
addrs:
- "127.0.0.1:6379"
username: ""
password: ""
db: 0 # Cluster 模式忽略此项
cluster_mode: false # true 启用 Cluster 模式
master_name: "" # 非空启用 Sentinel 模式
key_prefix: "credit:"
pool_size: 100
min_idle_conn: 10
dial_timeout: 5
read_timeout: 3
write_timeout: 3
max_retries: 3
pool_timeout: 4
conn_max_idle_time: 300
# Log
log:
level: "info" # debug, info, warn, error, fatal, panic
format: "json" # text, json
output: "stdout" # stdout, file
file_path: "./logs/app.log"
max_size: 100
max_age: 30
max_backups: 10
compress: true
# Scheduler
scheduler:
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 * * *"
refund_expired_red_envelopes_task_cron: "0 1 * * *"
cleanup_unused_uploads_task_cron: "0 */2 * * *"
settle_pending_payments_task_cron: "0 * * * *"
# Worker
worker:
concurrency: 20
strict_priority: false
queues:
- name: webhook
priority: 10
- name: whitelist_only
priority: 5
- name: default
priority: 3
# 积分更新速率限制:rate 次/period 秒
gamification_score_rate_limit:
rate: 1 # 允许的请求次数
period: 3 # 时间周期(秒)
# linuxDo
linuxDo:
api_key: "<LINUX_DO_API_KEY>"
# OpenAPI Risk
openapi_risk:
enabled: false
base_url: "https://audit.example.com"
username: "<OPENAPI_USERNAME>"
password: "<OPENAPI_PASSWORD>"
cache_ttl_seconds: 3600
prompt_risk_levels: []
block_risk_levels: []
# OpenTelemetry
otel:
sampling_rate: 0.1 # 采样率 0.0-1.0
# S3 Compatible Storage
# 支持 AWS S3、MinIO、Cloudflare R2、腾讯 COS 等 S3 兼容存储
s3:
enabled: true
endpoint: "https://<Account ID>.r2.cloudflarestorage.com"
region: "auto"
bucket: "<Bucket Name>"
access_key_id: "<S3_ACCESS_KEY_ID>"
secret_access_key: "<S3_SECRET_ACCESS_KEY>"
path_style: false # MinIO 等自托管服务设为 true
key_prefix: "" # 对象 key 前缀,如 "uploads/",可用于分目录存储
cdn_url: "" # CDN 域名(如 https://cdn.example.com),为空则直接读 S3
local_cache:
enabled: false
cache_dir: "./s3_cache"
+2566
View File
File diff suppressed because it is too large Load Diff
+2539
View File
File diff suppressed because it is too large Load Diff
+1658
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
# 前端 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
+52
View File
@@ -0,0 +1,52 @@
# ---- base stage ----
FROM node:22-alpine AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable && corepack prepare pnpm@10.10.0 --activate
# ---- build stage ----
FROM base AS builder
ENV NODE_ENV=production
ENV NEXT_PUBLIC_LINUX_DO_CREDIT_BACKEND_URL=https://build-placeholder.invalid
ENV LINUX_DO_CREDIT_SESSION_COOKIE_NAME=__LINUX_DO_CREDIT_SESSION_COOKIE_NAME__
ENV LINUX_DO_CREDIT_RATE_LIMIT_ENABLED=__LINUX_DO_CREDIT_RATE_LIMIT_ENABLED__
ENV LINUX_DO_CREDIT_BACKEND_URL=https://build-placeholder-2.invalid
ARG VERSION=""
ARG BUILD_DATE=""
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN if [ -n "$VERSION" ] || [ -n "$BUILD_DATE" ]; then \
VERSION="$VERSION" BUILD_DATE="$BUILD_DATE" node -e "const fs = require('fs'); const pkg = require('./package.json'); const version = process.env.VERSION; const buildDate = process.env.BUILD_DATE; if (version) pkg.version = version; if (buildDate) pkg.buildDate = buildDate; fs.writeFileSync('./package.json', JSON.stringify(pkg, null, 2) + '\\n')"; \
fi
RUN pnpm build
RUN grep -rl \
-e "https://build-placeholder.invalid" \
-e "https://build-placeholder-2.invalid" \
-e "__LINUX_DO_CREDIT_SESSION_COOKIE_NAME__" \
-e "__LINUX_DO_CREDIT_RATE_LIMIT_ENABLED__" \
/app/.next > /app/.replace.files
# ---- runner stage ----
FROM base AS runner
WORKDIR /app
COPY --from=builder /app .
EXPOSE 3000
ENTRYPOINT ["./entrypoint.sh"]
CMD ["pnpm", "start"]
+50
View File
@@ -0,0 +1,50 @@
# Linux Do Credit Frontend
Modern frontend application for the Linux Do Credit system.
[中文](./README_zh.md) | English
## Tech Stack
- **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/)
## Getting Started
### Prerequisites
- Node.js (Latest LTS recommended)
- pnpm
### Installation
1. Install dependencies:
```bash
pnpm install
```
2. Run the development server:
```bash
pnpm dev
```
Open [http://localhost:3000](http://localhost:3000) (or the port shown in your terminal) with your browser to see the result.
## Project Structure
- `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
## Scripts
- `pnpm dev`: Start development server with Turbopack
- `pnpm build`: Build the application for production
- `pnpm start`: Start production server
- `pnpm lint`: Run ESLint
+50
View File
@@ -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
+10
View File
@@ -0,0 +1,10 @@
import { Suspense } from "react"
import { LoginPage } from "@/components/auth/login-page"
export default function Page() {
return (
<Suspense>
<LoginPage />
</Suspense>
)
}
+18
View File
@@ -0,0 +1,18 @@
import { LegalPageLayout } from "@/components/common/docs/legal-page-layout";
import { apiSections, DOCS_LAST_UPDATED } from "@/components/common/docs/api";
export default function ApiDocPage() {
return (
<LegalPageLayout
title="API 接口文档"
lastUpdated={DOCS_LAST_UPDATED}
sections={apiSections}
description={
<p className="text-muted-foreground text-sm leading-relaxed">
LINUX DO Credit 提供简单、强大的 API 接口,支持多种编程语言和开发环境。通过标准化的 HTTP 接口,您可以轻松将积分服务集成到您的应用中。
</p>
}
/>
);
}
@@ -0,0 +1,19 @@
import { LegalPageLayout } from "@/components/common/docs/legal-page-layout";
import { howToUseSections } from "@/components/common/docs/how-to-use";
import { DOCS_LAST_UPDATED } from "@/components/common/docs/api";
export default function HowToUsePage() {
return (
<LegalPageLayout
title="平台使用指南"
lastUpdated={DOCS_LAST_UPDATED}
sections={howToUseSections}
description={
<p className="text-muted-foreground text-sm leading-relaxed">
欢迎使用 LINUX DO Credit。本文档将引导您快速了解平台的核心功能、角色定义及使用流程,帮助您更好地使用我们的服务。
</p>
}
/>
);
}
@@ -0,0 +1,20 @@
import { LegalPageLayout } from "@/components/common/docs/legal-page-layout"
import { privacySections } from "@/components/common/docs/privacy"
import { TERMS_LAST_UPDATED } from "@/components/common/docs/terms"
export default function PrivacyPolicyPage() {
return (
<LegalPageLayout
title="隐私政策 (Privacy)"
lastUpdated={TERMS_LAST_UPDATED}
sections={privacySections}
description={
<span>
我们非常重视您的隐私保护。本政策将向您透明地展示我们如何收集、使用、存储您的个人信息,
<br className="hidden md:block" />
以及您享有的相关权利。
</span>
}
/>
)
}
@@ -0,0 +1,19 @@
import { LegalPageLayout } from "@/components/common/docs/legal-page-layout"
import { termsSections, TERMS_LAST_UPDATED } from "@/components/common/docs/terms"
export default function TermsOfServicePage() {
return (
<LegalPageLayout
title="服务协议 (Terms)"
lastUpdated={TERMS_LAST_UPDATED}
sections={termsSections}
description={
<span>
欢迎使用 LINUX DO Credit。本协议详述了您在使用本平台服务时的权利与义务。
<br className="hidden md:block" />
为了保障您的合法权益,请您仔细阅读以下条款。
</span>
}
/>
)
}
+29
View File
@@ -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 (
<AdminProvider>
<UserPayConfigPageContent />
</AdminProvider>
)
}
/* 用户积分配置页面内容 */
function UserPayConfigPageContent() {
const { user } = useUser()
const { refetchUserPayConfigs } = useAdmin()
useEffect(() => {
if (user?.is_admin) {
refetchUserPayConfigs()
}
}, [user?.is_admin, refetchUserPayConfigs])
return <UserPayConfigs />
}
+35
View File
@@ -0,0 +1,35 @@
"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 <LoadingPage text="管理后台" badgeText="管理" />
}
/* 权限检查:只有管理员才能访问 */
if (!user?.is_admin) {
return (
<ErrorPage
title="访问被拒绝"
message="您没有权限访问此页面"
/>
)
}
return (
<AdminUsersProvider>
{children}
</AdminUsersProvider>
)
}
+30
View File
@@ -0,0 +1,30 @@
"use client"
import { useEffect } from "react"
import { SystemConfigs } from "@/components/common/admin/system"
import { useUser } from "@/contexts/user-context"
import { AdminProvider, useAdmin } from "@/contexts/admin-context"
/* 系统配置页面 */
export default function SystemConfigPage() {
return (
<AdminProvider>
<SystemConfigPageContent />
</AdminProvider>
)
}
/* 系统配置页面内容 */
function SystemConfigPageContent() {
const { user } = useUser()
const { refetchSystemConfigs } = useAdmin()
useEffect(() => {
if (user?.is_admin) {
refetchSystemConfigs()
}
}, [user?.is_admin, refetchSystemConfigs])
return <SystemConfigs />
}
+8
View File
@@ -0,0 +1,8 @@
"use client"
import { TaskManager } from "@/components/common/admin/tasks"
/* 任务管理页面 */
export default function TasksPage() {
return <TaskManager />
}
+8
View File
@@ -0,0 +1,8 @@
"use client"
import { UsersManager } from "@/components/common/admin/users"
/* 用户管理页面 */
export default function UsersPage() {
return <UsersManager />
}
+5
View File
@@ -0,0 +1,5 @@
import { BalanceMain } from "@/components/common/balance/balance-main"
export default function BalancePage() {
return <BalanceMain />
}
+5
View File
@@ -0,0 +1,5 @@
import { HomeMain } from "@/components/common/home/home-main"
export default function HomePage() {
return <HomeMain />
}
+57
View File
@@ -0,0 +1,57 @@
"use client"
import { motion } from "motion/react"
import { usePathname } from "next/navigation"
import { useState } from "react"
import { AppSidebar } from "@/components/layout/sidebar"
import { SiteHeader } from "@/components/layout/header"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
import { UserProvider } from "@/contexts/user-context"
import { MerchantProvider } from "@/contexts/merchant-context"
export default function MainLayout({
children,
}: {
children: React.ReactNode
}) {
const pathname = usePathname()
const [isFullWidth, setIsFullWidth] = useState(false)
return (
<UserProvider>
<MerchantProvider>
<SidebarProvider
className="h-screen"
style={
{
"--header-height": "60px",
} as React.CSSProperties
}
>
<AppSidebar />
<SidebarInset className="flex flex-col min-w-0 h-screen">
<SiteHeader isFullWidth={isFullWidth} onToggleFullWidth={setIsFullWidth} />
<div className="flex flex-1 flex-col bg-background overflow-y-auto overflow-x-hidden min-w-0 hide-scrollbar">
<div className={`w-full mx-auto px-4 sm:px-6 md:px-8 lg:px-12 min-w-0 transition-all duration-300 ease-in-out ${!isFullWidth ? "max-w-[1320px]" : "max-w-full"}`}>
<motion.div
key={pathname}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: 0.5,
ease: "easeOut",
}}
className="w-full"
>
{children}
</motion.div>
</div>
</div>
</SidebarInset>
</SidebarProvider>
</MerchantProvider>
</UserProvider>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { LeaderboardMain } from "@/components/common/leaderboard/leaderboard-main"
export default function LeaderboardPage() {
return <LeaderboardMain />
}
@@ -0,0 +1,10 @@
import { Suspense } from "react"
import { MerchantOnline } from "@/components/common/merchant/merchant-online"
export default function Page() {
return (
<Suspense>
<MerchantOnline />
</Suspense>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { MerchantMain } from "@/components/common/merchant/merchant-main"
export default function MerchantPage() {
return <MerchantMain />
}
@@ -0,0 +1,5 @@
import { AppearanceMain } from "@/components/common/settings/appearance"
export default function AppearancePage() {
return <AppearanceMain />
}
@@ -0,0 +1,5 @@
import { NotificationsMain } from "@/components/common/settings/notifications"
export default function NotificationsPage() {
return <NotificationsMain />
}
+77
View File
@@ -0,0 +1,77 @@
import Link from "next/link"
import { Card, CardTitle, CardDescription, CardContent } from "@/components/ui/card"
import { UserRound, Bell, Shield, Palette } from "lucide-react"
/* 设置项 */
const settingsItems = [
{
title: "个人资料",
description: "查看您的账户信息",
icon: UserRound,
href: "/settings/profile",
category: "个人设置",
},
{
title: "通知设置",
description: "设置您的通知偏好",
icon: Bell,
href: "/settings/notifications",
category: "账户设置",
},
{
title: "安全设置",
description: "管理您的账户安全",
icon: Shield,
href: "/settings/security",
category: "账户设置",
},
{
title: "外观设置",
description: "自定义界面主题和显示",
icon: Palette,
href: "/settings/appearance",
category: "个人设置",
},
]
export default function SettingsPage() {
const groupedSettings = settingsItems.reduce((acc, item) => {
if (!acc[item.category]) {
acc[item.category] = []
}
acc[item.category].push(item)
return acc
}, {} as Record<string, typeof settingsItems>)
return (
<div className="space-y-6 py-6">
<div className="font-semibold">设置</div>
{Object.entries(groupedSettings).map(([category, items]) => (
<div key={category} className="space-y-4">
<div className="font-medium text-sm text-muted-foreground">{category}</div>
<div className="grid grid-cols-2 gap-4">
{items.map((item) => (
<Link key={item.href} href={item.href}>
<Card className="py-2 border border-dashed shadow-none hover:bg-muted/50 transition-colors cursor-pointer h-full">
<CardContent>
<div className="flex items-center gap-4">
<item.icon className="size-5 text-primary" />
<div>
<CardTitle className="mb-1 text-sm">{item.title}</CardTitle>
<CardDescription className="text-xs">
{item.description}
</CardDescription>
</div>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
))}
</div>
)
}
@@ -0,0 +1,5 @@
import { ProfileMain } from "@/components/common/settings/profile"
export default function ProfilePage() {
return <ProfileMain />
}
@@ -0,0 +1,5 @@
import { SecurityMain } from "@/components/common/settings/security"
export default function SecurityPage() {
return <SecurityMain />
}
+5
View File
@@ -0,0 +1,5 @@
import { TradeMain } from "@/components/common/trade/trade-main"
export default function TradePage() {
return <TradeMain />
}
+22
View File
@@ -0,0 +1,22 @@
"use client"
import { MerchantProvider } from "@/contexts/merchant-context"
import { UserProvider } from "@/contexts/user-context"
/**
* 积分服务页面布局
* 为积分服务相关的页面提供社区用户和开发者上下文
*/
export default function PayLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<UserProvider>
<MerchantProvider>
{children}
</MerchantProvider>
</UserProvider>
)
}
+10
View File
@@ -0,0 +1,10 @@
import { Suspense } from "react"
import { PayingOnline } from "@/components/common/pay/paying/paying-online"
export default function Page() {
return (
<Suspense>
<PayingOnline />
</Suspense >
)
}
+10
View File
@@ -0,0 +1,10 @@
import { Suspense } from "react"
import { PayingMain } from "@/components/common/pay/paying/paying-main"
export default function Page() {
return (
<Suspense>
<PayingMain />
</Suspense>
)
}
+16
View File
@@ -0,0 +1,16 @@
/**
* 红包页面布局
* 提供简洁的布局,不包含侧边栏和用户上下文
* 允许未登录用户访问红包详情
*/
export default function RedEnvelopeLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="min-h-screen w-full">
{children}
</div>
)
}
@@ -0,0 +1,32 @@
import type { Metadata } from "next"
import { headers } from "next/headers"
import { RedEnvelopeClaimPage } from "@/components/common/redenvelope/red-envelope-claim"
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params
const requestHeaders = await headers()
const protocol = requestHeaders.get("x-forwarded-proto") ?? "https"
const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host") ?? ""
const baseUrl = host ? `${protocol}://${host}` : ""
const title = "你收到一个LDC红包"
const description = "点击打开领取"
const url = baseUrl ? `${baseUrl}/redenvelope/${id}` : undefined
const image = baseUrl ? `${baseUrl}/red-envelope.svg` : "/red-envelope.svg"
return {
title,
description,
openGraph: {
title,
description,
url,
type: "website",
images: [{ url: image, width: 384, height: 512, alt: "红包" }],
}
}
}
export default async function RedEnvelopePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
return <RedEnvelopeClaimPage id={id} />
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+196
View File
@@ -0,0 +1,196 @@
@import "tailwindcss";
@import "tw-animate-css";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans), system-ui, -apple-system, BlinkMacSystemFont, var(--font-noto-sans-sc), sans-serif;
--font-mono: var(--font-geist-mono), 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', var(--font-noto-sans-sc), monospace;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(58.5% 51% 277.117);
--primary-foreground: oklch(98.5% 0% 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: hsl(0 0% 98%);
--sidebar-foreground: hsl(240 5.3% 26.1%);
--sidebar-primary: hsl(240 5.9% 10%);
--sidebar-primary-foreground: hsl(0 0% 98%);
--sidebar-accent: hsl(240 4.8% 95.9%);
--sidebar-accent-foreground: hsl(240 5.9% 10%);
--sidebar-border: hsl(220 13% 91%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: hsl(240 5.9% 10%);
--sidebar-foreground: hsl(240 4.8% 95.9%);
--sidebar-primary: hsl(224.3 76.3% 48%);
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(240 3.7% 15.9%);
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--sidebar-border: hsl(240 3.7% 15.9%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
@theme inline {
/*
* Aurora 动画配置
*/
--animate-aurora: aurora 120s linear infinite;
@keyframes aurora {
from {
background-position:
50% 50%,
50% 50%;
}
to {
background-position:
350% 50%,
350% 50%;
}
}
/*
* 红包动画配置
*/
--animate-redenvelope-shake: redenvelope-shake 0.5s ease-in-out;
--animate-redenvelope-glow: redenvelope-glow 2s ease-in-out infinite;
--animate-gold-particle: gold-particle 1s ease-out forwards;
@keyframes redenvelope-shake {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(-5deg); }
75% { transform: rotate(5deg); }
}
@keyframes redenvelope-glow {
0%, 100% { box-shadow: 0 0 20px rgba(255, 215, 0, 0.3); }
50% { box-shadow: 0 0 40px rgba(255, 215, 0, 0.6); }
}
@keyframes gold-particle {
0% { transform: translateY(0) scale(1); opacity: 1; }
100% { transform: translateY(-100px) scale(0); opacity: 0; }
}
/*
* 铃铛响铃动画配置
* 使用微妙的摆动效果,优雅且不过度
*/
--animate-bell-ring: bell-ring 0.6s cubic-bezier(0.36, 0.07, 0.19, 0.97);
@keyframes bell-ring {
0% { transform: rotate(0deg); }
10% { transform: rotate(14deg); }
20% { transform: rotate(-12deg); }
30% { transform: rotate(10deg); }
40% { transform: rotate(-8deg); }
50% { transform: rotate(6deg); }
60% { transform: rotate(-4deg); }
70% { transform: rotate(2deg); }
80% { transform: rotate(-1deg); }
100% { transform: rotate(0deg); }
}
}
+67
View File
@@ -0,0 +1,67 @@
import type { Metadata } from "next";
import { Inter, Noto_Sans_SC, Geist_Mono } from 'next/font/google';
import { Toaster } from "@/components/ui/sonner";
import { ThemeProvider } from "@/components/layout/theme-provider";
import { CustomThemeProvider } from "@/lib/theme";
import { BellRingProvider } from "@/contexts/bell-ring-context";
import { NotificationSettingsProvider } from "@/contexts/notification-settings-context";
import { RiskBlockDialog } from "@/components/common/risk/risk-block-dialog";
import "./globals.css";
const inter = Inter({
variable: '--font-inter',
subsets: ['latin'],
display: 'swap',
});
const notoSansSC = Noto_Sans_SC({
variable: '--font-noto-sans-sc',
subsets: ['latin'],
display: 'swap',
weight: ['300', '400', '500', '600', '700']
});
const geistMono = Geist_Mono({
variable: '--font-geist-mono',
subsets: ['latin'],
});
export const metadata: Metadata = {
title: "LINUX DO Credit",
description: "Linux Do 社区积分服务平台",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="zh-CN"
className={`${ inter.variable } ${ notoSansSC.variable } ${ geistMono.variable } hide-scrollbar font-sans`}
suppressHydrationWarning
>
<body
className={`${ inter.variable } ${ notoSansSC.variable } ${ geistMono.variable } hide-scrollbar font-sans antialiased`}
>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<CustomThemeProvider>
<NotificationSettingsProvider>
<BellRingProvider>
{children}
<RiskBlockDialog />
<Toaster position="top-center" />
</BellRingProvider>
</NotificationSettingsProvider>
</CustomThemeProvider>
</ThemeProvider>
</body>
</html>
);
}
+79
View File
@@ -0,0 +1,79 @@
"use client"
import { useRouter } from "next/navigation";
import Link from "next/link";
import { motion } from "motion/react";
import { Home, ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
/**
* 404 Not Found Page
* 当用户访问不存在的页面时显示
*/
export default function NotFound() {
const router = useRouter();
return (
<div className="relative min-h-screen w-full flex flex-col items-center justify-center bg-background overflow-hidden selection:bg-primary/20">
<div className="absolute inset-0 -z-10 overflow-hidden">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[500px] bg-primary/20 rounded-full blur-[120px] opacity-20 animate-pulse" />
</div>
<div className="container px-6 flex flex-col items-center text-center z-10">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.8, ease: "easeOut" }}
className="relative mb-6"
>
<h1 className="text-[12rem] md:text-[16rem] font-bold leading-none tracking-tighter text-transparent bg-clip-text bg-gradient-to-b from-foreground/10 to-foreground/5 select-none">
404
</h1>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-2xl font-medium tracking-[0.2em] text-foreground/80 uppercase">Page Not Found</span>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.8 }}
className="space-y-8"
>
<p className="text-muted-foreground max-w-[400px] mx-auto text-sm leading-relaxed">
抱歉,您访问的页面似已迷失在数字星云中。
<br />
请检查路径或返回安全地带。
</p>
<div className="flex justify-center gap-4">
<Button
variant="secondary"
size="sm"
onClick={() => router.back()}
className="rounded-full w-24 text-xs border-foreground/10 hover:bg-foreground/10 transition-all duration-300"
>
<ArrowLeft className="size-3 opacity-70" />
上一页
</Button>
<Link href="/">
<Button
variant="default"
size="sm"
className="rounded-full w-24 text-xs hover:bg-primary/80 transition-all duration-300"
>
<Home className="size-3" />
首页
</Button>
</Link>
</div>
</motion.div>
</div>
<div className="absolute bottom-8 text-xs text-muted-foreground/30 font-mono">
ERR_HTTP_NOT_FOUND
</div>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
"use client";
import { HeroSection } from "@/components/home/hero-section";
import { DeveloperSection } from "@/components/home/developer-section";
import { FooterSection } from "@/components/home/footer-section";
export default function HomePage() {
return (
<div className="relative w-full min-h-screen bg-transparent text-foreground font-sans overflow-x-hidden selection:bg-primary selection:text-primary-foreground z-10">
<HeroSection />
<DeveloperSection />
<FooterSection />
</div>
)
}
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {
"@animate-ui": "https://animate-ui.com/r/{name}.json",
"@aceternity": "https://ui.aceternity.com/registry/{name}.json"
}
}
@@ -0,0 +1,57 @@
'use client';
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import {
Button as ButtonPrimitive,
type ButtonProps as ButtonPrimitiveProps,
} from '@/components/animate-ui/primitives/buttons/button';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[box-shadow,_color,_background-color,_border-color,_outline-color,_text-decoration-color,_fill,_stroke] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
accent: 'bg-accent text-accent-foreground shadow-xs hover:bg-accent/90',
destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8 rounded-md',
'icon-lg': 'size-10 rounded-md',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
type ButtonProps = ButtonPrimitiveProps & VariantProps<typeof buttonVariants>;
function Button({ className, variant, size, ...props }: ButtonProps) {
return (
<ButtonPrimitive
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants, type ButtonProps };
@@ -0,0 +1,56 @@
'use client';
import * as React from 'react';
import { type VariantProps } from 'class-variance-authority';
import { buttonVariants } from '@/components/animate-ui/components/buttons/button';
import {
RippleButton as RippleButtonPrimitive,
RippleButtonRipples as RippleButtonRipplesPrimitive,
type RippleButtonProps as RippleButtonPrimitiveProps,
type RippleButtonRipplesProps as RippleButtonRipplesPrimitiveProps,
} from '@/components/animate-ui/primitives/buttons/ripple';
import { cn } from '@/lib/utils';
const rippleButtonVariants = {
default: '[--ripple-button-ripple-color:var(--primary-foreground)]',
accent: '[--ripple-button-ripple-color:var(--accent-foreground)]',
destructive: '[--ripple-button-ripple-color:var(--destructive-foreground)]',
outline: '[--ripple-button-ripple-color:var(--foreground)]',
secondary: '[--ripple-button-ripple-color:var(--secondary-foreground)]',
ghost: '[--ripple-button-ripple-color:var(--foreground)]',
link: '[--ripple-button-ripple-color:var(--primary-foreground)]',
};
type RippleButtonProps = RippleButtonPrimitiveProps &
VariantProps<typeof buttonVariants>;
function RippleButton({
className,
variant,
size,
...props
}: RippleButtonProps) {
return (
<RippleButtonPrimitive
className={cn(
buttonVariants({ variant, size, className }),
rippleButtonVariants[variant as keyof typeof rippleButtonVariants],
)}
{...props}
/>
);
}
type RippleButtonRipplesProps = RippleButtonRipplesPrimitiveProps;
function RippleButtonRipples(props: RippleButtonRipplesProps) {
return <RippleButtonRipplesPrimitive {...props} />;
}
export {
RippleButton,
RippleButtonRipples,
type RippleButtonProps,
type RippleButtonRipplesProps,
};
@@ -0,0 +1,75 @@
'use client';
import * as React from 'react';
import { motion, type Variants } from 'motion/react';
import {
getVariants,
useAnimateIconContext,
IconWrapper,
type IconProps,
} from '@/components/animate-ui/icons/icon';
type CheckProps = IconProps<keyof typeof animations>;
const animations = {
default: {
path: {
initial: {
pathLength: 1,
opacity: 1,
scale: 1,
},
animate: {
pathLength: [0, 1],
opacity: [0, 1],
scale: [1, 1.1, 1],
transition: {
duration: 0.6,
ease: 'easeInOut',
},
},
},
} satisfies Record<string, Variants>,
} as const;
function IconComponent({ size, ...props }: CheckProps) {
const { controls } = useAnimateIconContext();
const variants = getVariants(animations);
return (
<motion.svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
initial="initial"
animate={controls}
{...props}
>
<motion.path
d="m4 12 5 5L20 6"
variants={variants.path}
initial="initial"
animate={controls}
/>
</motion.svg>
);
}
function Check(props: CheckProps) {
return <IconWrapper icon={IconComponent} {...props} />;
}
export {
animations,
Check,
Check as CheckIcon,
type CheckProps,
type CheckProps as CheckIconProps,
};
@@ -0,0 +1,78 @@
'use client';
import * as React from 'react';
import { motion, type Variants } from 'motion/react';
import {
getVariants,
useAnimateIconContext,
IconWrapper,
type IconProps,
} from '@/components/animate-ui/icons/icon';
type ChevronDownProps = IconProps<keyof typeof animations>;
const animations = {
default: {
path: {
initial: {
y: 0,
transition: { duration: 0.3, ease: 'easeInOut' },
},
animate: {
y: 4,
transition: { duration: 0.3, ease: 'easeInOut' },
},
},
} satisfies Record<string, Variants>,
'default-loop': {
path: {
initial: {
y: 0,
},
animate: {
y: [0, 4, 0],
transition: { duration: 0.6, ease: 'easeInOut' },
},
},
} satisfies Record<string, Variants>,
} as const;
function IconComponent({ size, ...props }: ChevronDownProps) {
const { controls } = useAnimateIconContext();
const variants = getVariants(animations);
return (
<motion.svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<motion.path
d="m6 9 6 6 6-6"
variants={variants.path}
initial="initial"
animate={controls}
/>
</motion.svg>
);
}
function ChevronDown(props: ChevronDownProps) {
return <IconWrapper icon={IconComponent} {...props} />;
}
export {
animations,
ChevronDown,
ChevronDown as ChevronDownIcon,
type ChevronDownProps,
type ChevronDownProps as ChevronDownIconProps,
};
@@ -0,0 +1,78 @@
'use client';
import * as React from 'react';
import { motion, type Variants } from 'motion/react';
import {
getVariants,
useAnimateIconContext,
IconWrapper,
type IconProps,
} from '@/components/animate-ui/icons/icon';
type ChevronLeftProps = IconProps<keyof typeof animations>;
const animations = {
default: {
path: {
initial: {
x: 0,
transition: { duration: 0.3, ease: 'easeInOut' },
},
animate: {
x: -4,
transition: { duration: 0.3, ease: 'easeInOut' },
},
},
} satisfies Record<string, Variants>,
'default-loop': {
path: {
initial: {
x: 0,
},
animate: {
x: [0, -4, 0],
transition: { duration: 0.6, ease: 'easeInOut' },
},
},
} satisfies Record<string, Variants>,
} as const;
function IconComponent({ size, ...props }: ChevronLeftProps) {
const { controls } = useAnimateIconContext();
const variants = getVariants(animations);
return (
<motion.svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<motion.path
d="m15 18-6-6 6-6"
variants={variants.path}
initial="initial"
animate={controls}
/>
</motion.svg>
);
}
function ChevronLeft(props: ChevronLeftProps) {
return <IconWrapper icon={IconComponent} {...props} />;
}
export {
animations,
ChevronLeft,
ChevronLeft as ChevronLeftIcon,
type ChevronLeftProps,
type ChevronLeftProps as ChevronLeftIconProps,
};
@@ -0,0 +1,78 @@
'use client';
import * as React from 'react';
import { motion, type Variants } from 'motion/react';
import {
getVariants,
useAnimateIconContext,
IconWrapper,
type IconProps,
} from '@/components/animate-ui/icons/icon';
type ChevronRightProps = IconProps<keyof typeof animations>;
const animations = {
default: {
path: {
initial: {
x: 0,
transition: { duration: 0.3, ease: 'easeInOut' },
},
animate: {
x: 4,
transition: { duration: 0.3, ease: 'easeInOut' },
},
},
} satisfies Record<string, Variants>,
'default-loop': {
path: {
initial: {
x: 0,
},
animate: {
x: [0, 4, 0],
transition: { duration: 0.6, ease: 'easeInOut' },
},
},
} satisfies Record<string, Variants>,
} as const;
function IconComponent({ size, ...props }: ChevronRightProps) {
const { controls } = useAnimateIconContext();
const variants = getVariants(animations);
return (
<motion.svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<motion.path
d="m9 18 6-6-6-6"
variants={variants.path}
initial="initial"
animate={controls}
/>
</motion.svg>
);
}
function ChevronRight(props: ChevronRightProps) {
return <IconWrapper icon={IconComponent} {...props} />;
}
export {
animations,
ChevronRight,
ChevronRight as ChevronRightIcon,
type ChevronRightProps,
type ChevronRightProps as ChevronRightIconProps,
};
@@ -0,0 +1,82 @@
'use client';
import * as React from 'react';
import { motion, type Variants } from 'motion/react';
import {
getVariants,
useAnimateIconContext,
IconWrapper,
type IconProps,
} from '@/components/animate-ui/icons/icon';
type CircleCheckProps = IconProps<keyof typeof animations>;
const animations = {
default: {
circle: {},
path: {
initial: {
pathLength: 1,
opacity: 1,
scale: 1,
},
animate: {
pathLength: [0, 1],
opacity: [0, 1],
scale: [1, 1.1, 1],
transition: {
duration: 0.6,
ease: 'easeInOut',
},
},
},
} satisfies Record<string, Variants>,
} as const;
function IconComponent({ size, ...props }: CircleCheckProps) {
const { controls } = useAnimateIconContext();
const variants = getVariants(animations);
return (
<motion.svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<motion.circle
cx="12"
cy="12"
r="10"
variants={variants.circle}
initial="initial"
animate={controls}
/>
<motion.path
d="m9 12 2 2 4-4"
variants={variants.path}
initial="initial"
animate={controls}
/>
</motion.svg>
);
}
function CircleCheck(props: CircleCheckProps) {
return <IconWrapper icon={IconComponent} {...props} />;
}
export {
animations,
CircleCheck,
CircleCheck as CircleCheckIcon,
type CircleCheckProps,
type CircleCheckProps as CircleCheckIconProps,
};
@@ -0,0 +1,655 @@
'use client';
import * as React from 'react';
import {
motion,
useAnimation,
type SVGMotionProps,
type UseInViewOptions,
type LegacyAnimationControls,
type Variants,
type HTMLMotionProps,
} from 'motion/react';
import { cn } from '@/lib/utils';
import { useIsInView } from '@/hooks/use-is-in-view';
import { Slot, type WithAsChild } from '@/components/animate-ui/primitives/animate/slot';
const staticAnimations = {
path: {
initial: { pathLength: 1 },
animate: {
pathLength: [0.05, 1],
transition: {
duration: 0.8,
ease: 'easeInOut',
},
},
} as Variants,
'path-loop': {
initial: { pathLength: 1 },
animate: {
pathLength: [1, 0.05, 1],
transition: {
duration: 1.6,
ease: 'easeInOut',
},
},
} as Variants,
} as const;
type StaticAnimations = keyof typeof staticAnimations;
type TriggerProp<T = string> = boolean | StaticAnimations | T;
type Trigger = TriggerProp<string>;
type AnimateIconContextValue = {
controls: LegacyAnimationControls | undefined;
animation: StaticAnimations | string;
loop: boolean;
loopDelay: number;
active: boolean;
animate?: Trigger;
initialOnAnimateEnd?: boolean;
completeOnStop?: boolean;
persistOnAnimateEnd?: boolean;
delay?: number;
};
type DefaultIconProps<T = string> = {
animate?: TriggerProp<T>;
animateOnHover?: TriggerProp<T>;
animateOnTap?: TriggerProp<T>;
animateOnView?: TriggerProp<T>;
animateOnViewMargin?: UseInViewOptions['margin'];
animateOnViewOnce?: boolean;
animation?: T | StaticAnimations;
loop?: boolean;
loopDelay?: number;
initialOnAnimateEnd?: boolean;
completeOnStop?: boolean;
persistOnAnimateEnd?: boolean;
delay?: number;
};
type AnimateIconProps<T = string> = WithAsChild<
HTMLMotionProps<'span'> &
DefaultIconProps<T> & {
children: React.ReactNode;
asChild?: boolean;
}
>;
type IconProps<T> = DefaultIconProps<T> &
Omit<SVGMotionProps<SVGSVGElement>, 'animate'> & {
size?: number;
};
type IconWrapperProps<T> = IconProps<T> & {
icon: React.ComponentType<IconProps<T>>;
};
const AnimateIconContext = React.createContext<AnimateIconContextValue | null>(
null,
);
function useAnimateIconContext() {
const context = React.useContext(AnimateIconContext);
if (!context)
return {
controls: undefined,
animation: 'default',
loop: undefined,
loopDelay: undefined,
active: undefined,
animate: undefined,
initialOnAnimateEnd: undefined,
completeOnStop: undefined,
persistOnAnimateEnd: undefined,
delay: undefined,
};
return context;
}
function composeEventHandlers<E extends React.SyntheticEvent<unknown>>(
theirs?: (event: E) => void,
ours?: (event: E) => void,
) {
return (event: E) => {
theirs?.(event);
ours?.(event);
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyProps = Record<string, any>;
function AnimateIcon({
asChild = false,
animate = false,
animateOnHover = false,
animateOnTap = false,
animateOnView = false,
animateOnViewMargin = '0px',
animateOnViewOnce = true,
animation = 'default',
loop = false,
loopDelay = 0,
initialOnAnimateEnd = false,
completeOnStop = false,
persistOnAnimateEnd = false,
delay = 0,
children,
...props
}: AnimateIconProps) {
const controls = useAnimation();
const [localAnimate, setLocalAnimate] = React.useState<boolean>(() => {
if (animate === undefined || animate === false) return false;
return delay <= 0;
});
const [currentAnimation, setCurrentAnimation] = React.useState<
string | StaticAnimations
>(typeof animate === 'string' ? animate : animation);
const [status, setStatus] = React.useState<'initial' | 'animate'>('initial');
const delayRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const loopDelayRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const isAnimateInProgressRef = React.useRef<boolean>(false);
const animateEndPromiseRef = React.useRef<Promise<void> | null>(null);
const resolveAnimateEndRef = React.useRef<(() => void) | null>(null);
const activeRef = React.useRef<boolean>(localAnimate);
const runGenRef = React.useRef(0);
const cancelledRef = React.useRef(false);
const bumpGeneration = React.useCallback(() => {
runGenRef.current++;
}, []);
const startAnimation = React.useCallback(
(trigger: TriggerProp) => {
const next = typeof trigger === 'string' ? trigger : animation;
bumpGeneration();
if (delayRef.current) {
clearTimeout(delayRef.current);
delayRef.current = null;
}
setCurrentAnimation(next);
if (delay > 0) {
setLocalAnimate(false);
delayRef.current = setTimeout(() => {
setLocalAnimate(true);
}, delay);
} else {
setLocalAnimate(true);
}
},
[animation, delay, bumpGeneration],
);
const stopAnimation = React.useCallback(() => {
bumpGeneration();
if (delayRef.current) {
clearTimeout(delayRef.current);
delayRef.current = null;
}
if (loopDelayRef.current) {
clearTimeout(loopDelayRef.current);
loopDelayRef.current = null;
}
setLocalAnimate(false);
}, [bumpGeneration]);
React.useEffect(() => {
activeRef.current = localAnimate;
}, [localAnimate]);
React.useEffect(() => {
if (animate === undefined) return;
setCurrentAnimation(typeof animate === 'string' ? animate : animation);
if (animate) startAnimation(animate as TriggerProp);
else stopAnimation();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [animate]);
React.useEffect(() => {
return () => {
if (delayRef.current) clearTimeout(delayRef.current);
if (loopDelayRef.current) clearTimeout(loopDelayRef.current);
};
}, []);
const viewOuterRef = React.useRef<HTMLElement>(null);
const { ref: inViewRef, isInView } = useIsInView(viewOuterRef, {
inView: !!animateOnView,
inViewOnce: animateOnViewOnce,
inViewMargin: animateOnViewMargin,
});
const startAnim = React.useCallback(
async (anim: 'initial' | 'animate', method: 'start' | 'set' = 'start') => {
try {
await controls[method](anim);
setStatus(anim);
} catch {
return;
}
},
[controls],
);
React.useEffect(() => {
if (!animateOnView) return;
if (isInView) startAnimation(animateOnView);
else stopAnimation();
}, [isInView, animateOnView, startAnimation, stopAnimation]);
React.useEffect(() => {
const gen = ++runGenRef.current;
cancelledRef.current = false;
async function run() {
if (cancelledRef.current || gen !== runGenRef.current) {
await startAnim('initial');
return;
}
if (!localAnimate) {
if (
completeOnStop &&
isAnimateInProgressRef.current &&
animateEndPromiseRef.current
) {
try {
await animateEndPromiseRef.current;
} catch {
// noop
}
}
if (!persistOnAnimateEnd) {
if (cancelledRef.current || gen !== runGenRef.current) {
await startAnim('initial');
return;
}
await startAnim('initial');
}
return;
}
if (loop) {
if (cancelledRef.current || gen !== runGenRef.current) {
await startAnim('initial');
return;
}
await startAnim('initial', 'set');
}
isAnimateInProgressRef.current = true;
animateEndPromiseRef.current = new Promise<void>((resolve) => {
resolveAnimateEndRef.current = resolve;
});
if (cancelledRef.current || gen !== runGenRef.current) {
isAnimateInProgressRef.current = false;
resolveAnimateEndRef.current?.();
resolveAnimateEndRef.current = null;
animateEndPromiseRef.current = null;
await startAnim('initial');
return;
}
await startAnim('animate');
if (cancelledRef.current || gen !== runGenRef.current) {
isAnimateInProgressRef.current = false;
resolveAnimateEndRef.current?.();
resolveAnimateEndRef.current = null;
animateEndPromiseRef.current = null;
await startAnim('initial');
return;
}
isAnimateInProgressRef.current = false;
resolveAnimateEndRef.current?.();
resolveAnimateEndRef.current = null;
animateEndPromiseRef.current = null;
if (initialOnAnimateEnd) {
if (cancelledRef.current || gen !== runGenRef.current) {
await startAnim('initial');
return;
}
await startAnim('initial', 'set');
}
if (loop) {
if (loopDelay > 0) {
await new Promise<void>((resolve) => {
loopDelayRef.current = setTimeout(() => {
loopDelayRef.current = null;
resolve();
}, loopDelay);
});
if (cancelledRef.current || gen !== runGenRef.current) {
await startAnim('initial');
return;
}
if (!activeRef.current) {
if (status !== 'initial' && !persistOnAnimateEnd)
await startAnim('initial');
return;
}
} else {
if (!activeRef.current) {
if (status !== 'initial' && !persistOnAnimateEnd)
await startAnim('initial');
return;
}
}
if (cancelledRef.current || gen !== runGenRef.current) {
await startAnim('initial');
return;
}
await run();
}
}
void run();
return () => {
cancelledRef.current = true;
if (delayRef.current) {
clearTimeout(delayRef.current);
delayRef.current = null;
}
if (loopDelayRef.current) {
clearTimeout(loopDelayRef.current);
loopDelayRef.current = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localAnimate, controls]);
const childProps = (
React.isValidElement(children) ? (children as React.ReactElement).props : {}
) as AnyProps;
const handleMouseEnter = composeEventHandlers<React.MouseEvent<HTMLElement>>(
childProps.onMouseEnter,
() => {
if (animateOnHover) startAnimation(animateOnHover);
},
);
const handleMouseLeave = composeEventHandlers<React.MouseEvent<HTMLElement>>(
childProps.onMouseLeave,
() => {
if (animateOnHover || animateOnTap) stopAnimation();
},
);
const handlePointerDown = composeEventHandlers<
React.PointerEvent<HTMLElement>
>(childProps.onPointerDown, () => {
if (animateOnTap) startAnimation(animateOnTap);
});
const handlePointerUp = composeEventHandlers<React.PointerEvent<HTMLElement>>(
childProps.onPointerUp,
() => {
if (animateOnTap) stopAnimation();
},
);
const content = asChild ? (
<Slot
ref={inViewRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
{...props}
>
{children}
</Slot>
) : (
<motion.span
ref={inViewRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
{...props}
>
{children}
</motion.span>
);
return (
<AnimateIconContext.Provider
value={{
controls,
animation: currentAnimation,
loop,
loopDelay,
active: localAnimate,
animate,
initialOnAnimateEnd,
completeOnStop,
delay,
}}
>
{content}
</AnimateIconContext.Provider>
);
}
const pathClassName =
"[&_[stroke-dasharray='1px_1px']]:![stroke-dasharray:1px_0px]";
function IconWrapper<T extends string>({
size = 28,
animation: animationProp,
animate,
animateOnHover,
animateOnTap,
animateOnView,
animateOnViewMargin,
animateOnViewOnce,
icon: IconComponent,
loop,
loopDelay,
persistOnAnimateEnd,
initialOnAnimateEnd,
delay,
completeOnStop,
className,
...props
}: IconWrapperProps<T>) {
const context = React.useContext(AnimateIconContext);
if (context) {
const {
controls,
animation: parentAnimation,
loop: parentLoop,
loopDelay: parentLoopDelay,
active: parentActive,
animate: parentAnimate,
persistOnAnimateEnd: parentPersistOnAnimateEnd,
initialOnAnimateEnd: parentInitialOnAnimateEnd,
delay: parentDelay,
completeOnStop: parentCompleteOnStop,
} = context;
const hasOverrides =
animate !== undefined ||
animateOnHover !== undefined ||
animateOnTap !== undefined ||
animateOnView !== undefined ||
loop !== undefined ||
loopDelay !== undefined ||
initialOnAnimateEnd !== undefined ||
persistOnAnimateEnd !== undefined ||
delay !== undefined ||
completeOnStop !== undefined;
if (hasOverrides) {
const inheritedAnimate: Trigger = parentActive
? (animationProp ?? parentAnimation ?? 'default')
: false;
const finalAnimate: Trigger = (animate ??
parentAnimate ??
inheritedAnimate) as Trigger;
return (
<AnimateIcon
animate={finalAnimate}
animateOnHover={animateOnHover}
animateOnTap={animateOnTap}
animateOnView={animateOnView}
animateOnViewMargin={animateOnViewMargin}
animateOnViewOnce={animateOnViewOnce}
animation={animationProp ?? parentAnimation}
loop={loop ?? parentLoop}
loopDelay={loopDelay ?? parentLoopDelay}
persistOnAnimateEnd={persistOnAnimateEnd ?? parentPersistOnAnimateEnd}
initialOnAnimateEnd={initialOnAnimateEnd ?? parentInitialOnAnimateEnd}
delay={delay ?? parentDelay}
completeOnStop={completeOnStop ?? parentCompleteOnStop}
asChild
>
<IconComponent
size={size}
className={cn(
className,
((animationProp ?? parentAnimation) === 'path' ||
(animationProp ?? parentAnimation) === 'path-loop') &&
pathClassName,
)}
{...props}
/>
</AnimateIcon>
);
}
const animationToUse = animationProp ?? parentAnimation;
const loopToUse = parentLoop;
const loopDelayToUse = parentLoopDelay;
return (
<AnimateIconContext.Provider
value={{
controls,
animation: animationToUse,
loop: loopToUse,
loopDelay: loopDelayToUse,
active: parentActive,
animate: parentAnimate,
initialOnAnimateEnd: parentInitialOnAnimateEnd,
delay: parentDelay,
completeOnStop: parentCompleteOnStop,
}}
>
<IconComponent
size={size}
className={cn(
className,
(animationToUse === 'path' || animationToUse === 'path-loop') &&
pathClassName,
)}
{...props}
/>
</AnimateIconContext.Provider>
);
}
if (
animate !== undefined ||
animateOnHover !== undefined ||
animateOnTap !== undefined ||
animateOnView !== undefined ||
animationProp !== undefined
) {
return (
<AnimateIcon
animate={animate}
animateOnHover={animateOnHover}
animateOnTap={animateOnTap}
animateOnView={animateOnView}
animateOnViewMargin={animateOnViewMargin}
animateOnViewOnce={animateOnViewOnce}
animation={animationProp}
loop={loop}
loopDelay={loopDelay}
delay={delay}
completeOnStop={completeOnStop}
asChild
>
<IconComponent
size={size}
className={cn(
className,
(animationProp === 'path' || animationProp === 'path-loop') &&
pathClassName,
)}
{...props}
/>
</AnimateIcon>
);
}
return (
<IconComponent
size={size}
className={cn(
className,
(animationProp === 'path' || animationProp === 'path-loop') &&
pathClassName,
)}
{...props}
/>
);
}
function getVariants<
V extends { default: T; [key: string]: T },
T extends Record<string, Variants>,
>(animations: V): T {
// eslint-disable-next-line react-hooks/rules-of-hooks
const { animation: animationType } = useAnimateIconContext();
let result: T;
if (animationType in staticAnimations) {
const variant = staticAnimations[animationType as StaticAnimations];
result = {} as T;
for (const key in animations.default) {
if (
(animationType === 'path' || animationType === 'path-loop') &&
key.includes('group')
)
continue;
result[key] = variant as T[Extract<keyof T, string>];
}
} else {
result = (animations[animationType as keyof V] as T) ?? animations.default;
}
return result;
}
export {
pathClassName,
staticAnimations,
AnimateIcon,
IconWrapper,
useAnimateIconContext,
getVariants,
type IconProps,
type IconWrapperProps,
type AnimateIconProps,
type AnimateIconContextValue,
};
@@ -0,0 +1,92 @@
'use client';
import * as React from 'react';
import { motion, type Variants } from 'motion/react';
import {
getVariants,
useAnimateIconContext,
IconWrapper,
type IconProps,
} from '@/components/animate-ui/icons/icon';
type SendProps = IconProps<keyof typeof animations>;
const animations = {
default: {
group: {
initial: {
scale: 1,
x: 0,
y: 0,
},
animate: {
scale: [1, 0.8, 1, 1, 1],
x: [0, '-10%', '100%', '-125%', 0],
y: [0, '10%', '-100%', '125%', 0],
transition: {
default: { ease: 'easeInOut', duration: 1.2 },
x: {
ease: 'easeInOut',
duration: 1.2,
times: [0, 0.25, 0.5, 0.5, 1],
},
y: {
ease: 'easeInOut',
duration: 1.2,
times: [0, 0.25, 0.5, 0.5, 1],
},
},
},
},
path1: {},
path2: {},
} satisfies Record<string, Variants>,
} as const;
function IconComponent({ size, ...props }: SendProps) {
const { controls } = useAnimateIconContext();
const variants = getVariants(animations);
return (
<motion.svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<motion.g variants={variants.group} initial="initial" animate={controls}>
<motion.path
d="M14.5,21.7c.1.3.4.4.7.3.1,0,.2-.2.3-.3L22,2.7c0-.3,0-.5-.3-.6-.1,0-.2,0-.3,0L2.3,8.5c-.3,0-.4.4-.3.6,0,.1.2.2.3.3l7.9,3.2c.5.2.9.6,1.1,1.1l3.2,7.9Z"
variants={variants.path1}
initial="initial"
animate={controls}
/>
<motion.path
d="M21.9,2.1l-10.9,10.9"
variants={variants.path2}
initial="initial"
animate={controls}
/>
</motion.g>
</motion.svg>
);
}
function Send(props: SendProps) {
return <IconWrapper icon={IconComponent} {...props} />;
}
export {
animations,
Send,
Send as SendIcon,
type SendProps,
type SendProps as SendIconProps,
};
@@ -0,0 +1,96 @@
'use client';
import * as React from 'react';
import { motion, isMotionComponent, type HTMLMotionProps } from 'motion/react';
import { cn } from '@/lib/utils';
type AnyProps = Record<string, unknown>;
type DOMMotionProps<T extends HTMLElement = HTMLElement> = Omit<
HTMLMotionProps<keyof HTMLElementTagNameMap>,
'ref'
> & { ref?: React.Ref<T> };
type WithAsChild<Base extends object> =
| (Base & { asChild: true; children: React.ReactElement })
| (Base & { asChild?: false | undefined });
type SlotProps<T extends HTMLElement = HTMLElement> = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
children?: any;
} & DOMMotionProps<T>;
function mergeRefs<T>(
...refs: (React.Ref<T> | undefined)[]
): React.RefCallback<T> {
return (node) => {
refs.forEach((ref) => {
if (!ref) return;
if (typeof ref === 'function') {
ref(node);
} else {
(ref as React.RefObject<T | null>).current = node;
}
});
};
}
function mergeProps<T extends HTMLElement>(
childProps: AnyProps,
slotProps: DOMMotionProps<T>,
): AnyProps {
const merged: AnyProps = { ...childProps, ...slotProps };
if (childProps.className || slotProps.className) {
merged.className = cn(
childProps.className as string,
slotProps.className as string,
);
}
if (childProps.style || slotProps.style) {
merged.style = {
...(childProps.style as React.CSSProperties),
...(slotProps.style as React.CSSProperties),
};
}
return merged;
}
function Slot<T extends HTMLElement = HTMLElement>({
children,
ref,
...props
}: SlotProps<T>) {
const isAlreadyMotion =
typeof children.type === 'object' &&
children.type !== null &&
isMotionComponent(children.type);
const Base = React.useMemo(
() =>
isAlreadyMotion
? (children.type as React.ElementType)
: motion.create(children.type as React.ElementType),
[isAlreadyMotion, children.type],
);
if (!React.isValidElement(children)) return null;
const { ref: childRef, ...childProps } = children.props as AnyProps;
const mergedProps = mergeProps(childProps, props);
return (
<Base {...mergedProps} ref={mergeRefs(childRef as React.Ref<T>, ref)} />
);
}
export {
Slot,
type SlotProps,
type WithAsChild,
type DOMMotionProps,
type AnyProps,
};
@@ -0,0 +1,32 @@
'use client';
import * as React from 'react';
import { motion, type HTMLMotionProps } from 'motion/react';
import { Slot, type WithAsChild } from '@/components/animate-ui/primitives/animate/slot';
type ButtonProps = WithAsChild<
HTMLMotionProps<'button'> & {
hoverScale?: number;
tapScale?: number;
}
>;
function Button({
hoverScale = 1.05,
tapScale = 0.95,
asChild = false,
...props
}: ButtonProps) {
const Component = asChild ? Slot : motion.button;
return (
<Component
whileTap={{ scale: tapScale }}
whileHover={{ scale: hoverScale }}
{...props}
/>
);
}
export { Button, type ButtonProps };
@@ -0,0 +1,144 @@
'use client';
import * as React from 'react';
import { motion, type HTMLMotionProps } from 'motion/react';
import { getStrictContext } from '@/lib/utils/get-strict-context';
import { Slot, type WithAsChild } from '@/components/animate-ui/primitives/animate/slot';
type Ripple = {
id: number;
x: number;
y: number;
};
type RippleButtonContextType = {
ripples: Ripple[];
setRipples: (ripples: Ripple[]) => void;
};
const [RippleButtonProvider, useRippleButton] =
getStrictContext<RippleButtonContextType>('RippleButtonContext');
type RippleButtonProps = WithAsChild<
HTMLMotionProps<'button'> & {
hoverScale?: number;
tapScale?: number;
}
>;
function RippleButton({
ref,
onClick,
hoverScale = 1,
tapScale = 0.95,
asChild = false,
style,
...props
}: RippleButtonProps) {
const [ripples, setRipples] = React.useState<Ripple[]>([]);
const buttonRef = React.useRef<HTMLButtonElement>(null);
React.useImperativeHandle(ref, () => buttonRef.current as HTMLButtonElement);
const createRipple = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
const button = buttonRef.current;
if (!button) return;
const rect = button.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const newRipple: Ripple = {
id: Date.now(),
x,
y,
};
setRipples((prev) => [...prev, newRipple]);
setTimeout(() => {
setRipples((prev) => prev.filter((r) => r.id !== newRipple.id));
}, 600);
},
[],
);
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
createRipple(event);
if (onClick) {
onClick(event);
}
},
[createRipple, onClick],
);
const Component = asChild ? Slot : motion.button;
return (
<RippleButtonProvider value={{ ripples, setRipples }}>
<Component
ref={buttonRef}
data-slot="ripple-button"
onClick={handleClick}
whileTap={{ scale: tapScale }}
whileHover={{ scale: hoverScale }}
style={{
position: 'relative',
overflow: 'hidden',
...style,
}}
{...props}
/>
</RippleButtonProvider>
);
}
type RippleButtonRipplesProps = WithAsChild<
HTMLMotionProps<'span'> & {
color?: string;
scale?: number;
}
>;
function RippleButtonRipples({
color = 'var(--ripple-button-ripple-color)',
scale = 10,
transition = { duration: 0.6, ease: 'easeOut' },
asChild = false,
style,
...props
}: RippleButtonRipplesProps) {
const { ripples } = useRippleButton();
const Component = asChild ? Slot : motion.span;
return ripples.map((ripple) => (
<Component
key={ripple.id}
initial={{ scale: 0, opacity: 0.5 }}
animate={{ scale, opacity: 0 }}
transition={transition}
style={{
position: 'absolute',
borderRadius: '50%',
pointerEvents: 'none',
width: '20px',
height: '20px',
backgroundColor: color,
top: ripple.y - 10,
left: ripple.x - 10,
...style,
}}
{...props}
/>
));
}
export {
RippleButton,
RippleButtonRipples,
type RippleButtonProps,
type RippleButtonRipplesProps,
};
@@ -0,0 +1,110 @@
'use client';
import * as React from 'react';
import { Collapsible as CollapsiblePrimitive } from 'radix-ui';
import { AnimatePresence, motion, type HTMLMotionProps } from 'motion/react';
import { getStrictContext } from '@/lib/utils/get-strict-context';
import { useControlledState } from '@/hooks/use-controlled-state';
type CollapsibleContextType = {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
};
const [CollapsibleProvider, useCollapsible] =
getStrictContext<CollapsibleContextType>('CollapsibleContext');
type CollapsibleProps = React.ComponentProps<typeof CollapsiblePrimitive.Root>;
function Collapsible(props: CollapsibleProps) {
const [isOpen, setIsOpen] = useControlledState({
value: props?.open,
defaultValue: props?.defaultOpen,
onChange: props?.onOpenChange,
});
return (
<CollapsibleProvider value={{ isOpen, setIsOpen }}>
<CollapsiblePrimitive.Root
data-slot="collapsible"
{...props}
onOpenChange={setIsOpen}
/>
</CollapsibleProvider>
);
}
type CollapsibleTriggerProps = React.ComponentProps<
typeof CollapsiblePrimitive.Trigger
>;
function CollapsibleTrigger(props: CollapsibleTriggerProps) {
return (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
);
}
type CollapsibleContentProps = Omit<
React.ComponentProps<typeof CollapsiblePrimitive.Content>,
'asChild' | 'forceMount'
> &
HTMLMotionProps<'div'> & {
keepRendered?: boolean;
};
function CollapsibleContent({
keepRendered = false,
transition = { duration: 0.35, ease: 'easeInOut' },
...props
}: CollapsibleContentProps) {
const { isOpen } = useCollapsible();
return (
<AnimatePresence>
{keepRendered ? (
<CollapsiblePrimitive.Content asChild forceMount>
<motion.div
key="collapsible-content"
data-slot="collapsible-content"
layout
initial={{ opacity: 0, height: 0, overflow: 'hidden', y: 20 }}
animate={
isOpen
? { opacity: 1, height: 'auto', overflow: 'hidden', y: 0 }
: { opacity: 0, height: 0, overflow: 'hidden', y: 20 }
}
transition={transition}
{...props}
/>
</CollapsiblePrimitive.Content>
) : (
isOpen && (
<CollapsiblePrimitive.Content asChild forceMount>
<motion.div
key="collapsible-content"
data-slot="collapsible-content"
layout
initial={{ opacity: 0, height: 0, overflow: 'hidden', y: 20 }}
animate={{ opacity: 1, height: 'auto', overflow: 'hidden', y: 0 }}
exit={{ opacity: 0, height: 0, overflow: 'hidden', y: 20 }}
transition={transition}
{...props}
/>
</CollapsiblePrimitive.Content>
)
)}
</AnimatePresence>
);
}
export {
Collapsible,
CollapsibleTrigger,
CollapsibleContent,
useCollapsible,
type CollapsibleProps,
type CollapsibleTriggerProps,
type CollapsibleContentProps,
type CollapsibleContextType,
};
@@ -0,0 +1,136 @@
'use client';
import * as React from 'react';
import { useMotionValue, useSpring, type SpringOptions } from 'motion/react';
import {
useIsInView,
type UseIsInViewOptions,
} from '@/hooks/use-is-in-view';
type CountingNumberProps = Omit<React.ComponentProps<'span'>, 'children'> & {
number: number;
fromNumber?: number;
padStart?: boolean;
decimalSeparator?: string;
decimalPlaces?: number;
transition?: SpringOptions;
delay?: number;
initiallyStable?: boolean;
thousandSeparator?: string;
} & UseIsInViewOptions;
function CountingNumber({
ref,
number,
fromNumber = 0,
padStart = false,
inView = false,
inViewMargin = '0px',
inViewOnce = true,
decimalSeparator = '.',
transition = { stiffness: 200, damping: 30 },
decimalPlaces = 0,
delay = 0,
initiallyStable = false,
thousandSeparator = '',
...props
}: CountingNumberProps) {
const { ref: localRef, isInView } = useIsInView(
ref as React.Ref<HTMLElement>,
{
inView,
inViewOnce,
inViewMargin,
},
);
const numberStr = number.toString();
const decimals =
typeof decimalPlaces === 'number'
? decimalPlaces
: numberStr.includes('.')
? (numberStr.split('.')[1]?.length ?? 0)
: 0;
const motionVal = useMotionValue(initiallyStable ? number : fromNumber);
const springVal = useSpring(motionVal, transition);
React.useEffect(() => {
const timeoutId = setTimeout(() => {
if (isInView) motionVal.set(number);
}, delay);
return () => clearTimeout(timeoutId);
}, [isInView, number, motionVal, delay]);
React.useEffect(() => {
const unsubscribe = springVal.on('change', (latest) => {
if (localRef.current) {
let formatted =
decimals > 0
? latest.toFixed(decimals)
: Math.round(latest).toString();
if (decimals > 0) {
formatted = formatted.replace('.', decimalSeparator);
}
if (padStart) {
const finalIntLength = Math.floor(Math.abs(number)).toString().length;
const [intPart, fracPart] = formatted.split(decimalSeparator);
const paddedInt = intPart?.padStart(finalIntLength, '0') ?? '';
formatted = fracPart
? `${ paddedInt }${ decimalSeparator }${ fracPart }`
: paddedInt;
}
if (thousandSeparator) {
const [intPart, fracPart] = formatted.split(decimalSeparator);
const regex = /\B(?=(\d{3})+(?!\d))/g;
const separatedInt = intPart.replace(regex, thousandSeparator);
formatted = fracPart
? `${ separatedInt }${ decimalSeparator }${ fracPart }`
: separatedInt;
}
localRef.current.textContent = formatted;
}
});
return () => unsubscribe();
}, [springVal, decimals, padStart, number, decimalSeparator, localRef, thousandSeparator]);
const finalIntLength = Math.floor(Math.abs(number)).toString().length;
const formatValue = (val: number) => {
let out = decimals > 0 ? val.toFixed(decimals) : Math.round(val).toString();
if (decimals > 0) out = out.replace('.', decimalSeparator);
if (padStart) {
const [intPart, fracPart] = out.split(decimalSeparator);
const paddedInt = (intPart ?? '').padStart(finalIntLength, '0');
out = fracPart ? `${ paddedInt }${ decimalSeparator }${ fracPart }` : paddedInt;
}
if (thousandSeparator) {
const [intPart, fracPart] = out.split(decimalSeparator);
const regex = /\B(?=(\d{3})+(?!\d))/g;
const separatedInt = intPart.replace(regex, thousandSeparator);
out = fracPart ? `${ separatedInt }${ decimalSeparator }${ fracPart }` : separatedInt;
}
return out;
};
const zeroText = padStart
? '0'.padStart(finalIntLength, '0') +
(decimals > 0 ? decimalSeparator + '0'.repeat(decimals) : '')
: '0' + (decimals > 0 ? decimalSeparator + '0'.repeat(decimals) : '');
const initialText = initiallyStable ? formatValue(number) : zeroText;
return (
<span ref={localRef} data-slot="counting-number" {...props}>
{initialText}
</span>
);
}
export { CountingNumber, type CountingNumberProps };
+182
View File
@@ -0,0 +1,182 @@
"use client"
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,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion"
import { SquareArrowUpRight } from 'lucide-react';
import { cn } from "@/lib/utils"
import services from "@/lib/services"
import { termsSections } from "@/components/common/docs/terms"
import { privacySections } from "@/components/common/docs/privacy"
/**
* 登录表单组件
* 显示登录表单和登录按钮
*
* @example
* ```tsx
* <LoginForm />
* ```
* @param {React.ComponentProps<"div">} props - 组件属性
* @param {string} className - 组件类名
* @returns {React.ReactNode} 登录表单组件
*/
export function LoginForm({
className,
...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()
} catch (error) {
setIsLoading(false)
console.error('Login error:', error)
const message = error instanceof Error ? error.message : "登录失败,请重试"
toast.error(message, {
duration: 5000,
description: error instanceof Error && error.name === 'NetworkError'
? '请确认后端服务已启动'
: undefined
})
}
}
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<div className="grid gap-4 mx-4">
<Button
variant="default"
type="button"
className="w-full h-9 rounded-full tracking-wide bg-primary hover:bg-primary/90 text-primary-foreground text-sm font-bold shadow-lg shadow-primary/20 hover:shadow-primary/30 transition-all active:scale-95"
onClick={handleLogin}
disabled={isLoading}
>
{isLoading ? <Spinner className="mr-2" /> : <SquareArrowUpRight className="mr-2 h-4 w-4" />}
{isLoading ? "正在跳转..." : "使用 LINUX DO 登录"}
</Button>
</div>
<motion.div
animate={controls}
className="flex items-center justify-center space-x-2 px-4"
>
<Checkbox
id="terms"
checked={hasAgreed}
onCheckedChange={handleAgreementChange}
/>
<label
htmlFor="terms"
className="text-muted-foreground text-xs text-balance opacity-75 hover:opacity-100 transition-opacity cursor-pointer leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
我已阅读并同意
{" "}
<Dialog>
<DialogTrigger asChild>
<button
type="button"
className="underline underline-offset-4 hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
服务条款
</button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>服务条款</DialogTitle>
<DialogDescription>请仔细阅读以下条款,使用本服务即表示您接受。</DialogDescription>
</DialogHeader>
<Accordion type="single" collapsible className="w-full">
{termsSections.map((section) => (
<AccordionItem key={section.value} value={section.value}>
<AccordionTrigger>{section.title}</AccordionTrigger>
<AccordionContent>{section.content}</AccordionContent>
</AccordionItem>
))}
</Accordion>
</DialogContent>
</Dialog>
{" "}及{" "}
<Dialog>
<DialogTrigger asChild>
<button
type="button"
className="underline underline-offset-4 hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
隐私政策
</button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>隐私政策</DialogTitle>
<DialogDescription>我们重视您的隐私,以下说明信息如何收集与使用。</DialogDescription>
</DialogHeader>
<Accordion type="single" collapsible className="w-full">
{privacySections.map((section) => (
<AccordionItem key={section.value} value={section.value}>
<AccordionTrigger>{section.title}</AccordionTrigger>
<AccordionContent>{section.content}</AccordionContent>
</AccordionItem>
))}
</Accordion>
</DialogContent>
</Dialog>
</label>
</motion.div>
</div>
)
}
+390
View File
@@ -0,0 +1,390 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { motion, AnimatePresence } from "motion/react"
import { useRouter, useSearchParams } from "next/navigation"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import { LoginForm } from "@/components/auth/login-form"
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"
import { Check } from "lucide-react"
import { AuroraBackground } from "@/components/ui/aurora-background"
import services from "@/lib/services"
import type { ApiResponse } from "@/lib/services/core/types"
/**
* 登录页面组件
* 显示登录表单和登录按钮
*
* @example
* ```tsx
* <LoginPage />
* ```
* @returns {React.ReactNode} 登录页面组件
*/
export function LoginPage() {
const router = useRouter()
const searchParams = useSearchParams()
/* 处理OAuth回调 */
const [isProcessingCallback, setIsProcessingCallback] = useState(() => {
const state = searchParams.get('state')
const code = searchParams.get('code')
return !!(state && code)
})
const [isCheckingSession, setIsCheckingSession] = useState(() => !searchParams.get('state') || !searchParams.get('code'))
const [loginSuccess, setLoginSuccess] = useState(false)
const [needsPayKeySetup, setNeedsPayKeySetup] = useState(false)
const [payKey, setPayKey] = useState("")
const [confirmPayKey, setConfirmPayKey] = useState("")
const [isSubmittingPayKey, setIsSubmittingPayKey] = useState(false)
const [setupStep, setSetupStep] = useState<'password' | 'confirm'>('password')
const isPayKeyValid = payKey.length === 6 && /^\d{6}$/.test(payKey)
const isConfirmValid = confirmPayKey.length === 6 && /^\d{6}$/.test(confirmPayKey)
const passwordsMatch = payKey === confirmPayKey
const resolveRedirectTarget = useCallback(() => {
const callbackUrl = searchParams.get('callbackUrl')
const storedRedirect = sessionStorage.getItem('redirect_after_login')
const target = callbackUrl || storedRedirect || '/home'
if (storedRedirect) {
sessionStorage.removeItem('redirect_after_login')
}
return target
}, [searchParams])
/* 安全密码输入 */
const handlePayKeyChange = (value: string) => {
const numericValue = value.replace(/\D/g, '')
setPayKey(numericValue)
}
/* 确认安全密码 */
const handleConfirmPayKeyChange = (value: string) => {
const numericValue = value.replace(/\D/g, '')
setConfirmPayKey(numericValue)
}
/* 登录页兜底:已登录用户直接跳转 */
useEffect(() => {
const state = searchParams.get('state')
const code = searchParams.get('code')
if (state && code) {
setIsCheckingSession(false)
return
}
let cancelled = false
const checkExistingSession = async () => {
setIsCheckingSession(true)
try {
const response = await fetch('/api/v1/oauth/user-info', {
credentials: 'include',
cache: 'no-store',
})
if (cancelled) return
if (response.ok) {
await response.json() as ApiResponse
router.replace(resolveRedirectTarget())
return
}
if (response.status !== 401) {
console.error('Session probe failed:', response.status)
}
} catch (error) {
if (!cancelled) {
console.error('Session probe error:', error)
}
} finally {
if (!cancelled) {
setIsCheckingSession(false)
}
}
}
checkExistingSession()
return () => {
cancelled = true
}
}, [router, searchParams, resolveRedirectTarget])
/* 回调逻辑 */
useEffect(() => {
const handleOAuthCallback = async () => {
const state = searchParams.get('state')
const code = searchParams.get('code')
if (state && code) {
setIsProcessingCallback(true)
try {
await services.auth.handleCallback({ state, code })
const user = await services.auth.getUserInfo()
if (!user.is_pay_key) {
setNeedsPayKeySetup(true)
} else {
setLoginSuccess(true)
toast.success("登录成功")
setTimeout(() => {
router.replace(resolveRedirectTarget())
}, 1500)
}
} catch (error) {
console.error('OAuth callback error:', error)
toast.error(error instanceof Error ? error.message : "登录失败,请重试")
setIsProcessingCallback(false)
router.replace('/login')
}
}
}
handleOAuthCallback()
}, [searchParams, router, resolveRedirectTarget])
/* 安全密码设置 */
const handlePayKeySubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (setupStep === 'password') {
if (!isPayKeyValid) {
toast.error("安全密码必须为6位数字")
return
}
setSetupStep('confirm')
} else {
if (!isConfirmValid) {
toast.error("确认密码必须为6位数字")
return
}
if (!passwordsMatch) {
toast.error("两次输入的安全密码不一致")
setSetupStep('password')
setConfirmPayKey("")
return
}
setIsSubmittingPayKey(true)
try {
await services.user.updatePayKey(payKey)
toast.success("安全密码设置成功")
setNeedsPayKeySetup(false)
setLoginSuccess(true)
setPayKey("")
setConfirmPayKey("")
setSetupStep('password')
setTimeout(() => {
router.replace(resolveRedirectTarget())
}, 1500)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "设置安全密码失败"
toast.error(errorMessage)
setSetupStep('password')
setConfirmPayKey("")
} finally {
setIsSubmittingPayKey(false)
}
}
}
/* 渲染 PayKey 设置界面 */
const renderPayKeySetup = (key: string) => (
<motion.div
key={key}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="w-full space-y-8"
>
<div className="flex flex-col items-center gap-2 mb-4">
<h3 className="text-base font-bold tracking-tight text-center">
{setupStep === 'password' ? '设置安全密码' : '确认安全密码'}
</h3>
<p className="text-xs text-muted-foreground text-center max-w-[320px] mx-auto">
{setupStep === 'password'
? '请设置6位数字安全密码,用于安全操作'
: '请再次输入密码进行确认'}
</p>
</div>
<form onSubmit={handlePayKeySubmit} className="space-y-6">
<div className="flex justify-center">
{setupStep === 'password' ? (
<InputOTP
maxLength={6}
value={payKey}
onChange={handlePayKeyChange}
disabled={isSubmittingPayKey}
autoFocus
>
<InputOTPGroup className="gap-2">
{[0, 1, 2, 3, 4, 5].map((i) => (
<InputOTPSlot key={i} index={i} className="w-9 h-9 border-input" />
))}
</InputOTPGroup>
</InputOTP>
) : (
<div className="space-y-4">
<InputOTP
maxLength={6}
value={confirmPayKey}
onChange={handleConfirmPayKeyChange}
disabled={isSubmittingPayKey}
autoFocus
>
<InputOTPGroup className="gap-2">
{[0, 1, 2, 3, 4, 5].map((i) => (
<InputOTPSlot key={i} index={i} className="w-9 h-9 text-lg border-input" />
))}
</InputOTPGroup>
</InputOTP>
{!passwordsMatch && confirmPayKey.length === 6 && (
<motion.p
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
className="text-xs text-red-500 text-center font-medium"
>
两次输入的密码不一致
</motion.p>
)}
</div>
)}
</div>
<div className="flex gap-4 mx-12">
{setupStep === 'confirm' && (
<Button
type="button"
variant="secondary"
onClick={() => {
setSetupStep('password')
setConfirmPayKey('')
}}
className="flex-1 h-9 rounded-full tracking-wide text-sm font-bold transition-all active:scale-95"
>
返回
</Button>
)}
<Button
type="submit"
className="flex-1 h-9 rounded-full tracking-wide bg-primary hover:bg-primary/90 text-primary-foreground text-sm font-bold shadow-lg shadow-primary/20 hover:shadow-primary/30 transition-all active:scale-95"
disabled={
setupStep === 'password'
? !isPayKeyValid
: isSubmittingPayKey || !isConfirmValid
}
>
{isSubmittingPayKey && <Spinner className="mr-1" />}
{setupStep === 'password' ? '继续' : '完成'}
</Button>
</div>
</form>
</motion.div>
)
return (
<AuroraBackground>
<motion.div
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{
delay: 0.3,
duration: 0.8,
ease: "easeInOut",
}}
className="relative z-10 w-full max-w-sm px-4"
>
<div className="text-center mb-8 space-y-2">
<h1 className="text-3xl font-bold tracking-tight text-foreground">
LINUX DO <span className="font-serif italic text-primary">Credit</span>
</h1>
<p className="text-sm text-muted-foreground font-light">
简单、安全,专为社区设计
</p>
</div>
<AnimatePresence mode="wait">
{isProcessingCallback || isCheckingSession ? (
<motion.div
key={isProcessingCallback ? "processing" : "session-check"}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="w-full"
>
{isCheckingSession ? (
<div className="flex flex-col items-center justify-center space-y-4 py-2">
<div className="relative">
<Spinner className="w-8 h-8 text-blue-600" />
</div>
<div className="text-center space-y-2">
<h3 className="font-semibold tracking-tight text-foreground">正在检查登录状态</h3>
<p className="text-xs text-muted-foreground">请稍候,我们正在确认当前会话...</p>
</div>
</div>
) : needsPayKeySetup ? (
renderPayKeySetup("oauth-pay-key-setup")
) : loginSuccess ? (
<div className="flex flex-col items-center justify-center space-y-4 py-2">
<motion.div
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
className="w-8 h-8 rounded-full bg-green-500/10 flex items-center justify-center text-green-500 ring-1 ring-green-500/20"
>
<Check className="w-6 h-6" strokeWidth={3} />
</motion.div>
<div className="text-center space-y-2">
<h3 className="font-semibold tracking-tight text-foreground">登录成功</h3>
<p className="text-xs text-muted-foreground">正在跳转至控制台...</p>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center space-y-4 py-2">
<div className="relative">
<Spinner className="w-8 h-8 text-blue-600" />
</div>
<div className="text-center space-y-2">
<h3 className="font-semibold tracking-tight text-foreground">正在验证凭据</h3>
<p className="text-xs text-muted-foreground">请稍候,我们正在为您建立安全会话...</p>
</div>
</div>
)}
</motion.div>
) : (
<motion.div
key="login-form-wrapper"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4 }}
className="w-full"
>
<LoginForm />
</motion.div>
)}
</AnimatePresence>
<div className="mt-8 text-center text-xs text-muted-foreground">
&copy; {new Date().getFullYear()} LINUX DO Credit. 版权所有
</div>
</motion.div>
</AuroraBackground>
)
}
+378
View File
@@ -0,0 +1,378 @@
"use client"
import * as React from "react"
import { ManagePage, ManageDetailPanel } from "@/components/common/general/manage-pannel"
import { Input } from "@/components/ui/input"
import { formatDateTime } from "@/lib/utils"
import type { UserPayConfig } from "@/lib/services"
import { useAdmin } from "@/contexts/admin-context"
/**
* 积分配置详情面板组件
* 显示积分配置的详细信息和编辑面板
*
* @example
* ```tsx
* <PayConfigDetailPanel
* config={config}
* editData={editData}
* onEditDataChange={onEditDataChange}
* onSave={onSave}
* saving={saving}
* />
* ```
* @param {UserPayConfig} config - 积分配置
* @param {Partial<UserPayConfig>} editData - 编辑数据
* @param {function} onEditDataChange - 编辑数据改变回调
* @param {function} onSave - 保存回调
* @param {boolean} saving - 是否正在保存
* @returns {React.ReactNode} 积分配置详情面板组件
*/
function PayConfigDetailPanel({
config,
editData,
onEditDataChange,
onSave,
saving
}: {
config: UserPayConfig | null
editData: Partial<UserPayConfig>
onEditDataChange: (field: keyof UserPayConfig, value: UserPayConfig[keyof UserPayConfig]) => void
onSave: () => void
saving: boolean
}) {
return (
<ManageDetailPanel
isEmpty={!config}
onSave={onSave}
saving={saving}
>
<div className="grid grid-cols-2 gap-0">
<div className="border border-dashed rounded-l-lg">
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">配置等级</label>
<p className="text-xs text-muted-foreground">Level {config?.level}</p>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">配置ID</label>
<p className="text-xs text-muted-foreground">{config?.id}</p>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">创建时间</label>
<p className="text-xs text-muted-foreground">{config ? formatDateTime(config.created_at) : ''}</p>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed">
<label className="text-xs font-medium text-muted-foreground">更新时间</label>
<p className="text-xs text-muted-foreground">{config ? formatDateTime(config.updated_at) : ''}</p>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0" aria-hidden="true">
<span className="text-xs text-muted-foreground">&nbsp;</span>
<span className="text-xs text-muted-foreground">&nbsp;</span>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0" aria-hidden="true">
<span className="text-xs text-muted-foreground">&nbsp;</span>
<span className="text-xs text-muted-foreground">&nbsp;</span>
</div>
</div>
<div className="border border-dashed rounded-r-lg border-l-0">
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">最低分数</label>
<div className="flex items-center gap-1">
<Input
type="number"
step="1"
min="0"
value={editData.min_score !== undefined ? editData.min_score.toString() : (config?.min_score?.toString() || '')}
placeholder={editData.min_score === undefined && !config?.min_score ? '必需' : ''}
onChange={(e) => {
const value = e.target.value
if (value === '') {
onEditDataChange('min_score', 0)
return
}
const numValue = parseInt(value)
if (isNaN(numValue)) {
return
}
if (numValue >= 0) {
onEditDataChange('min_score', numValue)
}
}}
className="text-xs text-right h-4 w-16 px-0 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-[12px]"
/>
<p className="text-xs text-muted-foreground">LDC</p>
</div>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">最高分数</label>
<div className="flex items-center gap-1">
<Input
type="number"
step="1"
min="0"
value={editData.max_score !== undefined ? (editData.max_score?.toString() || '') : (config?.max_score?.toString() || '')}
placeholder={(editData.max_score === null || editData.max_score === undefined) && (config?.max_score === null || config?.max_score === undefined) ? '无限制' : ''}
onChange={(e) => {
const value = e.target.value
if (value === '') {
onEditDataChange('max_score', null)
return
}
const numValue = parseInt(value)
if (isNaN(numValue)) {
return
}
if (numValue >= 0) {
onEditDataChange('max_score', numValue)
}
}}
className="text-xs text-right h-4 w-16 px-0 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-[12px]"
/>
<p className="text-xs text-muted-foreground">LDC</p>
</div>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">手续费率</label>
<div className="flex items-right gap-1">
<Input
type="number"
step="1"
min="0"
max="100"
value={
editData.fee_rate !== undefined
? (parseFloat(editData.fee_rate.toString()) * 100).toString()
: (config?.fee_rate ? (parseFloat(config.fee_rate.toString()) * 100).toString() : '')
}
placeholder={editData.fee_rate === undefined && !config?.fee_rate ? '必需' : ''}
onChange={(e) => {
const value = e.target.value
if (value === '') {
onEditDataChange('fee_rate', '0')
return
}
const numValue = parseInt(value)
if (isNaN(numValue)) {
return
}
if (numValue >= 0 && numValue <= 100) {
onEditDataChange('fee_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]"
/>
<p className="text-xs text-muted-foreground">%</p>
</div>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">分数转化率</label>
<div className="flex items-right gap-1">
<Input
type="number"
step="1"
min="0"
max="100"
value={
editData.score_rate !== undefined
? (parseFloat(editData.score_rate.toString()) * 100).toString()
: (config?.score_rate ? (parseFloat(config.score_rate.toString()) * 100).toString() : '')
}
placeholder={editData.score_rate === undefined && !config?.score_rate ? '必需' : ''}
onChange={(e) => {
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]"
/>
<p className="text-xs text-muted-foreground">%</p>
</div>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">分发费率</label>
<div className="flex items-right gap-1">
<Input
type="number"
step="1"
min="0"
max="100"
value={
editData.distribute_rate !== undefined
? (parseFloat(editData.distribute_rate.toString()) * 100).toString()
: (config?.distribute_rate ? (parseFloat(config.distribute_rate.toString()) * 100).toString() : '')
}
placeholder={editData.distribute_rate === undefined && !config?.distribute_rate ? '必需' : ''}
onChange={(e) => {
const value = e.target.value
if (value === '') {
onEditDataChange('distribute_rate', '0')
return
}
const numValue = parseInt(value)
if (isNaN(numValue)) {
return
}
if (numValue >= 0 && numValue <= 100) {
onEditDataChange('distribute_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]"
/>
<p className="text-xs text-muted-foreground">%</p>
</div>
</div>
<div className="px-3 py-2 flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground">每日支付上限</label>
<div className="flex items-center gap-1">
<Input
type="number"
step="1"
min="0"
value={editData.daily_limit !== undefined ? (editData.daily_limit?.toString() || '') : (config?.daily_limit?.toString() || '')}
placeholder={(editData.daily_limit === null || editData.daily_limit === undefined) && (config?.daily_limit === null || config?.daily_limit === undefined) ? '无限制' : ''}
onChange={(e) => {
const value = e.target.value
if (value === '') {
onEditDataChange('daily_limit', null)
return
}
const numValue = parseInt(value)
if (isNaN(numValue)) {
return
}
if (numValue >= 0) {
onEditDataChange('daily_limit', numValue)
}
}}
className="text-xs text-right h-4 w-16 px-0 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-[12px]"
/>
<p className="text-xs text-muted-foreground">LDC</p>
</div>
</div>
</div>
</div>
</ManageDetailPanel>
)
}
/**
* 用户积分配置管理组件
*
* @example
* ```tsx
* <UserPayConfigs />
* ```
* @returns {React.ReactNode} 用户积分配置管理组件
*/
export function UserPayConfigs() {
const {
userPayConfigs: configs,
userPayConfigsLoading: loading,
userPayConfigsError: error,
refetchUserPayConfigs,
updateUserPayConfig,
deleteUserPayConfig
} = useAdmin()
const getInitialEditData = (config: UserPayConfig) => ({
min_score: config.min_score,
max_score: config.max_score,
daily_limit: config.daily_limit,
fee_rate: config.fee_rate.toString(),
score_rate: config.score_rate.toString(),
distribute_rate: config.distribute_rate.toString(),
})
const handleSave = async (config: UserPayConfig, editData: Partial<UserPayConfig>) => {
if (!config) return
await updateUserPayConfig(config.id, {
min_score: editData.min_score ?? config.min_score,
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(),
distribute_rate: editData.distribute_rate?.toString() ?? config.distribute_rate.toString(),
})
await refetchUserPayConfigs()
}
const handleDelete = async (config: UserPayConfig) => {
await deleteUserPayConfig(config.id)
}
return (
<ManagePage<UserPayConfig>
title="积分配置"
data={configs}
loading={loading}
error={error}
onReload={refetchUserPayConfigs}
getInitialEditData={getInitialEditData}
onSave={handleSave}
onDelete={handleDelete}
getId={(config) => config.id}
emptyDescription="未发现积分配置"
loadingDescription="配置加载中"
columns={[
{ header: "等级", cell: (item) => <span className="font-medium">Level {item.level}</span>, 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) => `${ (Number(item.score_rate) * 100).toFixed(2) }%`, width: "min-w-[200px]", align: "left" },
{ header: "分发费率", cell: (item) => `${ (Number(item.distribute_rate) * 100).toFixed(2) }%`, width: "min-w-[200px]", align: "left" },
{ header: "更新时间", cell: (item) => <span className="text-muted-foreground">{formatDateTime(item.updated_at)}</span>, width: "min-w-[200px]", align: "left" },
]}
renderDetail={({ selected, hovered, editData, onEditDataChange, onSave, saving }) => (
<PayConfigDetailPanel
config={selected || hovered}
editData={editData}
onEditDataChange={onEditDataChange}
onSave={onSave}
saving={saving}
/>
)}
/>
)
}
+190
View File
@@ -0,0 +1,190 @@
"use client"
import * as React from "react"
import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"
import { ManagePage, ManageDetailPanel } from "@/components/common/general/manage-pannel"
import { formatDateTime } from "@/lib/utils"
import type { SystemConfig } from "@/lib/services"
import { useAdmin } from "@/contexts/admin-context"
/**
* 系统配置
* 显示系统配置的详细信息和编辑面板
*
* @example
* ```tsx
* <SystemConfigDetailPanel
* config={config}
* editData={editData}
* onEditDataChange={onEditDataChange}
* onSave={onSave}
* saving={saving}
* />
* ```
* @param {SystemConfig} config - 系统配置
* @param {Partial<SystemConfig>} editData - 编辑数据
* @param {function} onEditDataChange - 编辑数据改变回调
* @param {function} onSave - 保存回调
* @param {boolean} saving - 是否正在保存
* @returns {React.ReactNode} 系统配置详情面板组件
*/
function SystemConfigDetailPanel({
config,
editData,
onEditDataChange,
onSave,
saving
}: {
config: SystemConfig | null
editData: Partial<SystemConfig>
onEditDataChange: (field: keyof SystemConfig, value: SystemConfig[keyof SystemConfig]) => void
onSave: () => void
saving: boolean
}) {
return (
<ManageDetailPanel
isEmpty={!config}
onSave={onSave}
saving={saving}
>
<div className="grid grid-cols-1 gap-0">
<div className="border border-dashed rounded-lg">
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">配置键</label>
<p className="text-xs text-muted-foreground font-mono">{config?.key}</p>
</div>
<div className="pl-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">配置值</label>
<div className="flex gap-1 w-[90%] justify-end items-center pr-3">
{config?.key.endsWith('_enabled') ? (
<Switch
checked={editData.value !== undefined ? editData.value === 'true' : (config?.value === 'true')}
onCheckedChange={(checked) => {
onEditDataChange('value', checked ? 'true' : 'false')
}}
/>
) : (
<Input
type="number"
step="1"
min="0"
value={editData.value !== undefined ? editData.value : (config?.value || '')}
placeholder={editData.value === undefined && !config?.value ? '必需' : ''}
onChange={(e) => {
const value = e.target.value
if (value === '') {
onEditDataChange('value', '')
return
}
onEditDataChange('value', value)
}}
className="!text-[12px] text-right h-4 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:!text-[12px] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-inner-spin-button]"
style={{
MozAppearance: 'textfield'
}}
/>
)}
</div>
</div>
<div className="pl-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">配置描述</label>
<div className="flex gap-1 w-[90%]">
<Input
type="text"
value={editData.description !== undefined ? editData.description : (config?.description || '')}
placeholder="可选描述"
onChange={(e) => {
const value = e.target.value
onEditDataChange('description', value)
}}
className="!text-[12px] text-right h-4 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:!text-[12px]"
/>
</div>
</div>
<div className="px-3 py-2 flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground">创建时间</label>
<p className="text-xs text-muted-foreground">{config ? formatDateTime(config.created_at) : ''}</p>
</div>
</div>
</div>
</ManageDetailPanel>
)
}
/**
* 系统配置管理组件
*
* @example
* ```tsx
* <SystemConfigs />
* ```
* @returns {React.ReactNode} 系统配置管理组件
*/
export function SystemConfigs() {
const {
systemConfigs: configs,
systemConfigsLoading: loading,
systemConfigsError: error,
refetchSystemConfigs,
updateSystemConfig,
deleteSystemConfig
} = useAdmin()
const getInitialEditData = (config: SystemConfig) => ({
value: config.value,
description: config.description
})
const handleSave = async (config: SystemConfig, editData: Partial<SystemConfig>) => {
if (!config) return
await updateSystemConfig(config.key, {
value: editData.value ?? config.value,
description: editData.description ?? config.description
})
await refetchSystemConfigs()
}
const handleDelete = async (config: SystemConfig) => {
await deleteSystemConfig(config.key)
}
return (
<ManagePage<SystemConfig>
title="系统配置"
data={configs}
loading={loading}
error={error}
onReload={refetchSystemConfigs}
getInitialEditData={getInitialEditData}
onSave={handleSave}
onDelete={handleDelete}
getId={(config) => config.key}
emptyDescription="未发现系统配置"
loadingDescription="配置加载中"
columns={[
{ header: "配置键", cell: (item) => <span className="font-mono font-medium">{item.key}</span>, width: "200px" },
{ header: "配置值", cell: (item) => <span className="truncate max-w-[120px] inline-block" title={item.value}>{item.value}</span>, width: "120px" },
{ header: "描述", cell: (item) => <span className="truncate max-w-[200px] inline-block text-muted-foreground" title={item.description}>{item.description}</span>, width: "200px" },
]}
renderDetail={({ selected, hovered, editData, onEditDataChange, onSave, saving }) => (
<SystemConfigDetailPanel
config={selected || hovered}
editData={editData}
onEditDataChange={onEditDataChange}
onSave={onSave}
saving={saving}
/>
)}
/>
)
}
+370
View File
@@ -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<string, { icon: React.ComponentType<{ className?: string }>, 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<HTMLInputElement>) => {
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 (
<div className="flex items-center gap-2">
<div className="flex-1">
<Popover>
<PopoverTrigger asChild>
<Button
variant={"outline"}
className={cn(
"w-full justify-start text-left font-normal text-xs h-8",
!date && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-1 size-3" />
{date ? format(date, "yyyy-MM-dd") : <span>选择日期</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={date}
onSelect={handleDateSelect}
locale={zhCN}
/>
</PopoverContent>
</Popover>
</div>
<div className="w-[120px]">
<Input
type="time"
step="1"
value={timeString}
onChange={handleTimeChange}
className="text-xs font-mono"
/>
</div>
</div>
)
}
export function TaskManager() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const [taskTypes, setTaskTypes] = useState<TaskMeta[]>([])
const [dispatching, setDispatching] = useState(false)
const [selectedTaskType, setSelectedTaskType] = useState<string | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const [startTime, setStartTime] = useState<Date | undefined>(undefined)
const [endTime, setEndTime] = useState<Date | undefined>(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 = 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 (
<div className="py-6 space-y-6">
<div className="flex items-center justify-between border-b border-border pb-2">
<div className="flex flex-col gap-1">
<div className="text-2xl font-semibold tracking-tight">任务管理</div>
</div>
</div>
<div className="space-y-6">
{error ? (
<div className="p-8 border border-dashed rounded-xl bg-card">
<ErrorInline error={error} onRetry={fetchTaskTypes} className="justify-center" />
</div>
) : loading && taskTypes.length === 0 ? (
<LoadingStateWithBorder icon={Layers} description="加载任务类型中..." />
) : taskTypes.length === 0 ? (
<EmptyStateWithBorder icon={Layers} description="暂无可用任务类型" />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{taskTypes.map((task, index) => {
const config = TASK_CONFIGS[task.type] || DEFAULT_TASK_CONFIG
return (
<div
key={`${ task.type }-${ index }`}
className={cn(
"relative group overflow-hidden rounded-xl border bg-gradient-to-br transition-all duration-500",
config.gradient
)}
>
<div className="relative h-full bg-card/40 backdrop-blur-sm p-4 flex flex-col justify-between hover:bg-card/0 transition-colors duration-500">
<div className="space-y-2">
<div className="flex items-start justify-between">
<div className="space-y-1">
<h3 className="font-semibold text-base tracking-tight">{task.name}</h3>
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2 min-h-[36px]">
{task.description}
</p>
</div>
<Badge variant="secondary" className="font-mono text-[10px] bg-background/50 backdrop-blur-md border px-1.5 h-5">
{task.queue}
</Badge>
</div>
<div className="flex flex-wrap gap-1.5">
<Badge variant="outline" className="text-[9px] h-4.5 bg-background/50 font-mono text-muted-foreground border-border/50 px-1">
类型:{task.type}
</Badge>
<Badge variant="outline" className="text-[9px] h-4.5 bg-background/50 font-mono text-muted-foreground border-border/50 px-1">
重试:{task.max_retry}
</Badge>
</div>
</div>
<div className="pt-4 mt-1">
<Button
className="w-full h-7 text-xs"
variant="secondary"
onClick={() => openDispatchDialog(task.type)}
>
<Play className="size-3 mr-1" />
立即执行
</Button>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>下发任务</DialogTitle>
<DialogDescription>
配置任务参数并启动,任务将在后台异步执行。
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>执行任务</Label>
<div className="flex items-center gap-2 p-2 rounded-lg bg-muted/50">
{(() => {
const meta = getSelectedTaskMeta()
if (!meta) return null
const config = TASK_CONFIGS[meta.type] || DEFAULT_TASK_CONFIG
const Icon = config.icon
return (
<>
<div className={cn("p-2", config.color)}>
<Icon className="size-4" />
</div>
<div className="flex flex-col">
<span className="text-xs font-medium">{meta.name}</span>
<span className="text-[10px] text-muted-foreground font-mono">{meta.type}</span>
</div>
</>
)
})()}
</div>
</div>
{selectedTaskType === 'order_sync' && (
<>
<div className="grid gap-2">
<Label>开始时间</Label>
<DatePickerWithTime date={startTime} setDate={setStartTime} />
<p className="text-xs text-muted-foreground">选择同步的起始时间点</p>
</div>
<div className="grid gap-2">
<Label>结束时间</Label>
<DatePickerWithTime date={endTime} setDate={setEndTime} />
<p className="text-xs text-muted-foreground">选择同步的结束时间点</p>
</div>
</>
)}
{selectedTaskType === 'user_gamification' && (
<div className="grid gap-2">
<Label htmlFor="user-id">用户 ID</Label>
<Input
id="user-id"
type="number"
placeholder="e.g. 10086"
className="font-mono text-xs"
value={userId}
onChange={(e) => setUserId(e.target.value)}
/>
<p className="text-xs text-muted-foreground">指定需要更新积分数据的用户 ID</p>
</div>
)}
{!['order_sync', 'user_gamification'].includes(selectedTaskType || '') && (
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/50 p-3 rounded-md border border-dashed">
<Info className="h-4 w-4" />
<span>此任务无需额外配置参数,确认后将直接下发。</span>
</div>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setDialogOpen(false)} disabled={dispatching} className="h-8 text-xs">
取消
</Button>
<Button onClick={handleDispatch} disabled={dispatching} className="h-8 text-xs">
{dispatching ? <Spinner className="size-3" /> : <Play className="size-3" />}
{dispatching ? '下发中' : '启动'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
+598
View File
@@ -0,0 +1,598 @@
"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, Loader2 } from "lucide-react"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { AdminUser, AdminService, DispatchTaskRequest } from "@/lib/services"
import { toast } from "sonner"
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,
searchUserId,
searchUsername,
statusFilter,
setPage,
setPageSize,
setSearchUserId,
setSearchUsername,
setStatusFilter,
fetchUsers,
updateUserStatus
} = useAdminUsers()
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const [updatingUserId, setUpdatingUserId] = useState<string | null>(null)
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 handleUpdateCredits = async (user: AdminUser) => {
try {
setUpdatingUserId(user.id)
const params: DispatchTaskRequest = {
task_type: 'user_gamification',
user_id: user.id
}
await AdminService.dispatchTask(params)
toast.success('积分更新任务已下发', {
description: `正在更新用户 ${ user.username } 的积分数据`
})
} catch (err) {
toast.error('任务下发失败', {
description: err instanceof Error ? err.message : '未知错误'
})
} finally {
setUpdatingUserId(null)
}
}
const totalPages = Math.ceil(total / pageSize)
const hasSearchFilter = Boolean(searchUserId || searchUsername)
const renderFilterBar = () => (
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
"h-5 border-dashed text-[10px] font-medium shadow-none focus-visible:ring-0",
hasSearchFilter && "bg-primary/5 border-primary/20"
)}
>
<Search className="size-3 mr-1" />
搜索
{hasSearchFilter && (
<>
<Separator orientation="vertical" className="mx-1" />
<Badge
variant="secondary"
className="text-[10px] h-3 px-1 rounded-full bg-primary text-primary-foreground"
>
!
</Badge>
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56 p-3" align="start">
<div className="space-y-2.5">
<input
className="w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background"
placeholder="输入用户 ID..."
value={searchUserId}
onChange={(e) => setSearchUserId(e.target.value)}
/>
<input
className="w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background"
placeholder="输入 username..."
value={searchUsername}
onChange={(e) => setSearchUsername(e.target.value)}
/>
{hasSearchFilter && (
<Button
variant="ghost"
size="sm"
className="w-full h-6 text-xs"
onClick={() => {
setSearchUserId("")
setSearchUsername("")
}}
>
清除
</Button>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
"h-5 border-dashed text-[10px] font-medium shadow-none focus-visible:ring-0",
statusFilter !== 'all' && "bg-primary/5 border-primary/20"
)}
>
<Filter className="size-3" />
状态
{statusFilter !== 'all' && (
<>
<Separator orientation="vertical" className="mx-1" />
<Badge
variant="secondary"
className="text-[10px] h-3 px-1 rounded-full bg-primary text-primary-foreground"
>
1
</Badge>
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[120px]" align="start">
<DropdownMenuItem
onSelect={(e) => { e.preventDefault(); setStatusFilter('all') }}
>
<div className={cn(
"mr-2 flex size-3 items-center justify-center rounded-sm border border-primary",
statusFilter === 'all'
? "bg-primary text-primary-foreground"
: "opacity-50"
)} />
<span className="text-xs">全部状态</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => { e.preventDefault(); setStatusFilter('active') }}
>
<div className={cn(
"mr-2 flex size-3 items-center justify-center rounded-sm border border-primary",
statusFilter === 'active'
? "bg-primary text-primary-foreground"
: "opacity-50"
)} />
<span className="text-xs">正常</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => { e.preventDefault(); setStatusFilter('inactive') }}
>
<div className={cn(
"mr-2 flex size-3 items-center justify-center rounded-sm border border-primary",
statusFilter === 'inactive'
? "bg-primary text-primary-foreground"
: "opacity-50"
)} />
<span className="text-xs">禁用</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{(hasSearchFilter || statusFilter !== 'all') && (
<>
<Separator orientation="vertical" className="h-6 hidden sm:block" />
<Button
variant="ghost"
size="sm"
onClick={() => {
setSearchUserId("")
setSearchUsername("")
setStatusFilter('all')
}}
className="h-5 px-2 lg:px-3 text-[11px] font-medium text-muted-foreground hover:text-foreground"
>
<X className="size-3" />
清空筛选
</Button>
</>
)}
</div>
<Separator className="lg:hidden" />
<div className="flex items-center gap-1.5 self-end lg:self-auto">
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
{total} 条记录
</span>
<div className="flex items-center border border-dashed rounded-md shadow-none">
<Button
variant="ghost"
size="icon"
className="h-5.5 w-6 rounded-none rounded-l-md disabled:opacity-30"
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page <= 1 || loading}
>
<ChevronLeft className="size-3" />
</Button>
<span className="text-[10px] font-mono text-muted-foreground px-2 border-x border-dashed">
{page}/{totalPages}
</span>
<Button
variant="ghost"
size="icon"
className="h-5.5 w-6 rounded-none rounded-r-md disabled:opacity-30"
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page >= totalPages || loading}
>
<ChevronRight className="size-3" />
</Button>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-6 border-dashed text-[10px] px-2 font-mono shadow-none" disabled={loading}>
{pageSize}条/页
<ChevronDown className="size-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{[20, 50, 100].map(size => (
<DropdownMenuItem
key={size}
onClick={() => setPageSize(size)}
className={cn("font-mono text-xs", pageSize === size && "bg-accent")}
>
{size}条/页
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
size="icon"
className="h-6 w-6 border-dashed shadow-none"
onClick={() => fetchUsers(true)}
disabled={loading}
title="刷新数据"
>
<Loader2 className={cn("size-3", loading && "animate-spin")} />
</Button>
</div>
</div>
)
return (
<div className="py-6 space-y-4">
<div className="flex items-center justify-between border-b border-border pb-2">
<div className="flex flex-col gap-1">
<div className="text-2xl font-semibold">用户管理</div>
</div>
</div>
{renderFilterBar()}
{error ? (
<div className="p-8 border border-dashed rounded-lg">
<ErrorInline error={error} onRetry={() => fetchUsers(true)} className="justify-center" />
</div>
) : loading && users.length === 0 ? (
<LoadingStateWithBorder icon={Layers} description="加载用户列表中..." />
) : users.length === 0 ? (
<EmptyStateWithBorder icon={UserX} description="暂无用户数据" />
) : (
<div className="border border-dashed shadow-none rounded-lg overflow-hidden">
<Table className="w-full caption-bottom text-sm min-w-full">
<TableHeader className="sticky top-0 z-20 bg-background">
<TableRow className="border-b border-dashed hover:bg-transparent">
<TableHead className="w-[90px] whitespace-nowrap py-2 h-8">ID</TableHead>
<TableHead className="w-[120px] whitespace-nowrap py-2 h-8">用户</TableHead>
<TableHead className="text-right whitespace-nowrap min-w-[80px] py-2 h-8 font-mono">余额</TableHead>
<TableHead className="text-right whitespace-nowrap min-w-[80px] py-2 h-8 font-mono">基准分</TableHead>
<TableHead className="text-right whitespace-nowrap min-w-[80px] py-2 h-8 font-mono">支付分</TableHead>
<TableHead className="text-right whitespace-nowrap min-w-[80px] py-2 h-8 font-mono">总收益</TableHead>
<TableHead className="text-right whitespace-nowrap min-w-[80px] py-2 h-8 font-mono">总消耗</TableHead>
<TableHead className="text-right whitespace-nowrap min-w-[80px] py-2 h-8 font-mono is-last-money">总划转</TableHead>
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8 pl-4">上次登陆</TableHead>
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8">注册时间</TableHead>
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8">上次更新</TableHead>
<TableHead className="sticky right-0 text-center bg-background z-10 w-[80px] py-2 h-8">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow
key={user.id}
className="border-dashed hover:bg-muted/30 cursor-pointer group"
onClick={() => handleShowDetail(user)}
>
<TableCell className="font-mono text-[11px] text-muted-foreground py-1">{user.id}</TableCell>
<TableCell className="py-1">
<div className="flex items-center gap-2">
<Avatar className="h-7 w-7 rounded-sm border">
<AvatarImage src={user.avatar_url} />
<AvatarFallback className="rounded-sm text-[10px]">
{user.username.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-0">
<div className="flex items-center gap-1.5">
<span className="font-medium text-[11px] leading-tight max-w-[100px] truncate" title={user.nickname}>{user.nickname}</span>
{user.is_admin && (
<Badge variant="secondary" className="text-[9px] h-3.5 px-0.5 rounded-[2px] font-normal leading-none tracking-tighter">
ADM
</Badge>
)}
</div>
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground font-mono leading-tight">@{user.username}</span>
</div>
</div>
</div>
</TableCell>
<TableCell className="text-right font-mono text-[11px] text-green-600 dark:text-green-500 font-medium py-1">
{Number(user.available_balance).toFixed(2)}
</TableCell>
<TableCell className="text-right font-mono text-[11px] py-1">
{Number(user.community_balance).toFixed(2)}
</TableCell>
<TableCell className="text-right font-mono text-[11px] py-1">
{Number(user.pay_score).toFixed(2)}
</TableCell>
<TableCell className="text-right font-mono text-[11px] text-green-600 py-1">
{Number(user.total_receive).toFixed(2)}
</TableCell>
<TableCell className="text-right font-mono text-[11px] text-red-500 py-1">
{Number(user.total_payment).toFixed(2)}
</TableCell>
<TableCell className="text-right font-mono text-[11px] text-purple-500 py-1 is-last-money">
{Number(user.total_community).toFixed(2)}
</TableCell>
<TableCell className="text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1 pl-4">
{formatDateTime(user.last_login_at)}
</TableCell>
<TableCell className="text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1">
{formatDateTime(user.created_at)}
</TableCell>
<TableCell className="text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1">
{formatDateTime(user.updated_at)}
</TableCell>
<TableCell className="sticky right-0 text-center bg-background z-10 py-1" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-center gap-0.5">
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<div>
<Switch
checked={user.is_active}
onCheckedChange={() => handleStatusToggle(user)}
disabled={user.is_admin}
className="scale-75 data-[state=checked]:bg-green-600 h-4 w-7"
/>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{user.is_admin ? '管理员账户' : user.is_active ? '禁用账户' : '启用账户'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-foreground"
onClick={() => handleUpdateCredits(user)}
disabled={updatingUserId === user.id}
>
<Loader2 className={cn("size-3", updatingUserId === user.id && "animate-spin")} />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
更新积分
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground hover:text-foreground" onClick={() => handleShowDetail(user)}>
<Eye className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
查看详情
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<Sheet open={detailOpen} onOpenChange={setDetailOpen}>
<SheetContent className="sm:max-w-[400px] w-full p-0 flex flex-col gap-0">
<SheetTitle className="px-5 py-3">用户档案</SheetTitle>
{selectedUser && (
<>
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
<div className="flex flex-col pb-6">
<div className="px-5 py-6 border-b border-border/50">
<div className="flex flex-col items-center text-center gap-3">
<Avatar className="h-20 w-20 rounded-full border-4 border-background ring-1 ring-border/20">
<AvatarImage src={selectedUser.avatar_url} />
<AvatarFallback className="rounded-full text-xl font-medium bg-secondary text-secondary-foreground">
{selectedUser.username.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="space-y-1.5">
<h3 className="text-lg font-bold tracking-tight">{selectedUser.nickname}</h3>
<div className="flex items-center justify-center gap-2">
<code className="px-1.5 py-0.5 rounded-md bg-muted text-[10px] font-mono text-muted-foreground">@{selectedUser.username}</code>
<Badge variant="secondary" className="h-4.5 px-1.5 text-[9px] uppercase font-medium">
UID: {selectedUser.id}
</Badge>
<Badge variant="secondary" className="h-4.5 px-1.5 text-[9px] uppercase font-medium">
Lv.{selectedUser.trust_level}
</Badge>
{selectedUser.is_admin && (
<Badge className="h-4.5 px-1.5 text-[9px] uppercase font-medium bg-primary text-primary-foreground">
Admin
</Badge>
)}
</div>
</div>
<div className="gap-4 w-full max-w-[240px] mt-1 pt-4 border-t border-border/50">
<div className="flex flex-col gap-0.5">
<span className="text-[9px] uppercase tracking-widest text-muted-foreground font-medium">注册时间</span>
<span className="font-mono text-xs font-semibold">{formatDateTime(selectedUser.created_at).split(' ')[0]}</span>
</div>
</div>
</div>
</div>
<div className="p-6">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-4 px-1">核心资产</h4>
<div className="grid grid-cols-3 gap-2">
<div className="p-3 rounded-xl border bg-card/50 hover:bg-card/80 transition-colors space-y-2">
<div className="flex flex-col gap-1 text-muted-foreground">
<Wallet className="w-4 h-4" />
<span className="text-[10px] font-medium">可用余额</span>
</div>
<div className="text-lg font-mono font-bold tracking-tight text-foreground truncate">
{Number(selectedUser.available_balance).toFixed(2)}
</div>
</div>
<div className="p-3 rounded-xl border bg-card/50 hover:bg-card/80 transition-colors space-y-2">
<div className="flex flex-col gap-1 text-muted-foreground">
<CreditCard className="w-4 h-4" />
<span className="text-[10px] font-medium">支付积分</span>
</div>
<div className="text-lg font-mono font-bold tracking-tight text-foreground truncate">
{Number(selectedUser.pay_score).toFixed(2)}
</div>
</div>
<div className="p-3 rounded-xl border bg-card/50 hover:bg-card/80 transition-colors space-y-2">
<div className="flex flex-col gap-1 text-muted-foreground">
<Users className="w-4 h-4" />
<span className="text-[10px] font-medium">社区余额</span>
</div>
<div className="text-lg font-mono font-bold tracking-tight text-foreground truncate">
{Number(selectedUser.community_balance).toFixed(2)}
</div>
</div>
</div>
</div>
<Separator className="mx-6 w-auto opacity-50" />
<div className="p-6 space-y-6">
<div className="space-y-4">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1">财务概况</h4>
<div className="rounded-lg border divide-y bg-background/50">
<div className="flex items-center justify-between p-3.5">
<span className="text-[10px]">累计收益</span>
<span className="font-mono text-[10px] font-medium text-purple-600 dark:text-purple-400">+{Number(selectedUser.total_community).toFixed(2)}</span>
</div>
<div className="flex items-center justify-between p-3.5">
<span className="text-[10px]">累计收入</span>
<span className="font-mono text-[10px] font-medium text-green-600">+{Number(selectedUser.total_receive).toFixed(2)}</span>
</div>
<div className="flex items-center justify-between p-3.5">
<span className="text-[10px]">累计支出</span>
<span className="font-mono text-[10px] font-medium text-red-500">{Number(selectedUser.total_payment) === 0 ? '0.00' : `-${ Number(selectedUser.total_payment).toFixed(2) }`}</span>
</div>
</div>
</div>
<div className="space-y-4">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1">系统记录</h4>
<div className="rounded-lg border divide-y bg-background/50">
<div className="flex items-center justify-between p-3.5 text-sm">
<span className="text-[10px]">最后登录</span>
<span className="font-mono text-[10px]">{formatDateTime(selectedUser.last_login_at)}</span>
</div>
<div className="flex items-center justify-between p-3.5 text-sm">
<span className="text-[10px]">注册时间</span>
<span className="font-mono text-[10px]">{formatDateTime(selectedUser.created_at)}</span>
</div>
<div className="flex items-center justify-between p-3.5 text-sm">
<span className="text-[10px]">最后更新</span>
<span className="font-mono text-[10px]">{formatDateTime(selectedUser.updated_at)}</span>
</div>
</div>
</div>
</div>
</div>
</div>
{!selectedUser.is_admin && (
<div className="p-4 border-t bg-background/80 backdrop-blur-md shrink-0">
<Button
variant={selectedUser.is_active ? "destructive" : "default"}
className={cn(
"w-full h-9 text-xs font-medium transition-all active:scale-[0.98]",
selectedUser.is_active
? "bg-red-500 hover:bg-red-600 text-white"
: "bg-primary text-primary-foreground hover:bg-primary/90"
)}
onClick={() => handleStatusToggle(selectedUser)}
>
{selectedUser.is_active ? (
<>
<ShieldCheck className="size-3 mr-1" />
封禁账户
</>
) : (
<>
<UserCheck className="size-3 mr-1" />
解除封禁
</>
)}
</Button>
</div>
)}
</>
)}
</SheetContent>
</Sheet>
</div>
)
}
@@ -0,0 +1,54 @@
"use client"
import * as React from "react"
import { CountingNumber } from "@/components/animate-ui/primitives/texts/counting-number"
import { BalanceReport } from "@/components/common/balance/balance-report"
import { BalanceSummary } from "@/components/common/balance/balance-summary"
import { BalanceTable } from "@/components/common/balance/balance-table"
import { useUser } from "@/contexts/user-context"
/**
* 积分余额主页面组件
*
* 负责组装积分余额页面的各个子组件,包括积分余额总览、积分余额摘要、近期活动和报告侧边栏
*/
export function BalanceMain() {
const { user, loading } = useUser()
/** 计算总余额 */
const totalBalance = React.useMemo(() => {
return parseFloat(user?.available_balance || '0')
}, [user?.available_balance])
return (
<div className="py-6">
<div className="flex items-center gap-2 pb-2">
<h1 className="text-3xl">
<span className="font-semibold">积分</span>
<span className="pl-2">LDC</span>
<span className="pl-2">
{loading ? "-" : <CountingNumber number={totalBalance} decimalPlaces={2} />}
</span>
</h1>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12 pt-4">
<div className="lg:col-span-2 space-y-12">
<section aria-labelledby="balance-summary-title">
<h2 id="balance-summary-title" className="font-semibold mb-4">积分摘要</h2>
<BalanceSummary />
</section>
<section aria-labelledby="recent-activity-title">
<h2 id="recent-activity-title" className="font-semibold mb-2">近期活动</h2>
<BalanceTable />
</section>
</div>
<aside className="lg:col-span-1" aria-labelledby="reports-title">
<BalanceReport />
</aside>
</div>
</div>
)
}
@@ -0,0 +1,15 @@
import * as React from "react"
/**
* 余额报告组件
*
* 显示可下载或查看的报告列表
*/
export function BalanceReport() {
return (
<section className="space-y-4" aria-labelledby="reports-title">
<h2 id="reports-title" className="font-semibold">报告</h2>
<div className="text-sm text-muted-foreground">暂无报告</div>
</section>
)
}
@@ -0,0 +1,117 @@
import * as React from "react"
import { CountingNumber } from "@/components/animate-ui/primitives/texts/counting-number"
import { useUser } from "@/contexts/user-context"
/** 颜色配置 - 统一管理主题色 */
const COLORS = {
available: "bg-primary/80",
pending: "bg-zinc-400/80",
emptyState: "bg-muted/50",
} as const
/**
* 计算积分余额百分比
*/
function calculatePercentages(available: number, total: number) {
if (total <= 0) {
return { available: 0, pending: 0 }
}
const pending = total - available
return {
available: (available / total) * 100,
pending: (pending / total) * 100,
}
}
/**
* 安全转换数字,避免 NaN
*/
function safeNumber(value: number): number {
return typeof value === 'number' && !isNaN(value) ? value : 0
}
/**
* 积分余额摘要组件
*
* 显示积分余额的可视化摘要,包括可视化进度条和详细的积分余额分类列表
*/
export function BalanceSummary() {
const { user, loading } = useUser()
const available = parseFloat(user?.available_balance || '0')
const pending = parseFloat(user?.pending_balance || '0')
const total = available + pending
const percentages = React.useMemo(
() => calculatePercentages(available, total),
[available, total]
)
return (
<div className="space-y-4">
<div
className={`w-full h-4 ${ COLORS.emptyState } rounded-sm overflow-hidden flex`}
role="progressbar"
aria-label="积分余额分布"
aria-valuenow={percentages.available}
aria-valuemin={0}
aria-valuemax={100}
>
<div
className={`${ COLORS.available } transition-all duration-300`}
style={{ width: `${ percentages.available }%` }}
title={`可用: ${ percentages.available.toFixed(1) }%`}
/>
<div
className={`${ COLORS.pending } transition-all duration-300`}
style={{ width: `${ percentages.pending }%` }}
title={`未来积分: ${ percentages.pending.toFixed(1) }%`}
/>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center text-xs font-medium pb-2 border-b border-border/80">
<span>积分类型</span>
<span className="text-muted-foreground">值</span>
</div>
<div className="flex justify-between items-center font-bold text-sm pb-2 border-b border-border/80">
<div className="flex items-center gap-2">
<div className={`size-2.5 ${ COLORS.available } rounded-xs`} aria-hidden="true" />
<span>可用</span>
</div>
<span className="font-semibold">
{loading ? "-" : (
<CountingNumber
number={safeNumber(available)}
decimalPlaces={2}
initiallyStable={true}
inView={true}
inViewOnce={true}
/>
)} LDC
</span>
</div>
<div className="flex justify-between items-center font-bold text-sm pb-2 border-b border-border/80">
<div className="flex items-center gap-2">
<div className={`size-2.5 ${ COLORS.pending } rounded-xs`} aria-hidden="true" />
<span>未来积分</span>
</div>
<span className="font-semibold">
{loading ? "-" : (
<CountingNumber
number={safeNumber(pending)}
decimalPlaces={2}
initiallyStable={true}
inView={true}
inViewOnce={true}
/>
)} LDC
</span>
</div>
</div>
</div>
)
}
@@ -0,0 +1,150 @@
"use client"
import * as React from "react"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { TransactionTableList } from "@/components/common/general/table-data"
import { TransactionProvider, useTransaction } from "@/contexts/transaction-context"
import { DEFAULT_ORDER_TYPES, type OrderType } from "@/lib/services"
import { formatLocalDate } from "@/lib/utils"
/** 标签触发器样式 */
const TAB_TRIGGER_STYLES =
"data-[state=active]:bg-transparent " +
"data-[state=active]:shadow-none " +
"data-[state=active]:border-0 " +
"data-[state=active]:border-b-2 " +
"data-[state=active]:border-indigo-500 " +
"bg-transparent " +
"rounded-none " +
"border-0 " +
"border-b-2 " +
"border-transparent " +
"px-0 " +
"text-sm " +
"font-bold " +
"text-muted-foreground " +
"data-[state=active]:text-indigo-500 " +
"-mb-[2px] " +
"relative " +
"hover:text-foreground " +
"transition-colors " +
"flex-none"
/** 标签配置 - 数据驱动渲染 */
const TABS = [
{ value: "receive" as const, label: "积分收益" },
{ value: "payment" as const, label: "积分消耗" },
{ value: "community" as const, label: "社区划转" },
{ value: "online" as const, label: "在线流转" },
{ value: "all" as const, label: "所有活动" },
] as const
/**
* 计算最近30天的时间范围
* 每次调用时重新计算,避免缓存过期时间
*/
function getTimeRange() {
const now = new Date()
now.setHours(23, 59, 59, 999)
const start = new Date(now)
start.setDate(start.getDate() - 30)
start.setHours(0, 0, 0, 0)
return {
startTime: formatLocalDate(start),
endTime: formatLocalDate(now)
}
}
/**
* 积分余额活动表格组件
*
* 显示不同类型的积分余额活动记录,支持多标签切换和分页加载
*/
export function BalanceTable() {
const [activeTab, setActiveTab] = React.useState<OrderType | "all">("all")
const [timeRange, setTimeRange] = React.useState<{ startTime: string; endTime: string } | null>(null)
React.useEffect(() => {
setTimeRange(getTimeRange())
}, [])
if (!timeRange) {
return null
}
return (
<div>
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as OrderType | "all")}
className="w-full"
>
<TabsList className="flex p-0 gap-4 rounded-none w-full bg-transparent justify-start border-b border-border overflow-x-auto overflow-y-hidden">
{TABS.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className={TAB_TRIGGER_STYLES}
>
{tab.label}
</TabsTrigger>
))}
</TabsList>
<div className="mt-2">
<TransactionProvider
defaultParams={{
page_size: 20,
startTime: timeRange.startTime,
endTime: timeRange.endTime,
}}
>
<TransactionList
type={activeTab === "all" ? undefined : activeTab}
/>
</TransactionProvider>
</div>
</Tabs>
</div>
)
}
/**
* 积分余额活动列表组件
*
* 负责获取和显示积分余额活动数据
*/
const TransactionList = React.memo(function TransactionList({ type }: { type?: OrderType }) {
const {
transactions,
loading,
error,
lastParams,
fetchTransactions,
} = useTransaction()
/** 当积分余额活动类型变化时重新加载数据 */
React.useEffect(() => {
fetchTransactions({
page: 1,
types: type ? [type] : DEFAULT_ORDER_TYPES,
startTime: lastParams.startTime,
endTime: lastParams.endTime,
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [type])
return (
<TransactionTableList
loading={loading}
error={error}
transactions={transactions}
onRetry={() => fetchTransactions({
page: 1,
types: type ? [type] : DEFAULT_ORDER_TYPES,
startTime: lastParams.startTime,
endTime: lastParams.endTime,
})}
/>
)
})
+589
View File
@@ -0,0 +1,589 @@
import { type PolicySection } from "./types"
import { CodeBlock } from "@/components/ui/code-block"
import {
DocsTable,
DocsTableHeader,
DocsTableBody,
DocsTableHead,
DocsTableRow,
DocsTableCell,
} from "@/components/ui/docs-table"
export const DOCS_LAST_UPDATED = "2026-04-20"
/**
* ------------------------------------------------------------------
* API 文档
* ------------------------------------------------------------------
*/
export const apiSections: PolicySection[] = [
{
value: "official-service",
title: "1. 官方 LDC 接口",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<div className="bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6">
<p className="text-muted-foreground m-0">官方原生接口,使用 Ed25519 签名算法,安全性更高</p>
</div>
<h3 id="1-1-overview" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">1.1 概览</h3>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>协议:</strong>官方 LDC 支付协议</li>
<li><strong>服务类型:</strong>支持 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">type=ldcpay</code></li>
<li><strong>网关基址:</strong><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">https://credit.linux.do/epay</code></li>
<li><strong>签名方式:</strong>Ed25519 非对称加密</li>
</ul>
<h3 id="1-2-flow" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">1.2 对接流程</h3>
<ol className="list-decimal pl-4 md:pl-5 space-y-2">
<li>控制台创建应用,配置 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">client_id</code> 并在应用设置中上传商户 Ed25519 公钥</li>
<li>根据“签名算法”及商户私钥生成 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">sign</code></li>
<li>调用 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">/pay/submit</code> 发起积分流转请求</li>
<li>认证完成后,通过异步回调或轮询接口同步状态</li>
</ol>
<h3 id="1-3-auth-sign" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">1.3 鉴权与签名</h3>
<h4 className="font-medium text-foreground mt-3 md:mt-4 mb-2">1.3.1 签名算法</h4>
<div className="space-y-4">
<ol className="list-decimal pl-4 md:pl-5 space-y-2">
<li>取除 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">sign</code> 以外的所有非空请求参数</li>
<li>将参数按参数名 ASCII 码从到大排序(字典序)</li>
<li>使用 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">k1=v1&k2=v2...</code> 格式拼接成字符串</li>
<li>将 <strong>应用密钥 (Client Secret)</strong> 直接追加到字符串末尾</li>
<li>使用商户私钥对最终字符串进行 <strong>Ed25519</strong> 签名</li>
<li>将签名结果转换成 Base64 编码作为 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">sign</code> 参数</li>
</ol>
<CodeBlock
code={`// 示例:client_id=1&money=10.00&order_name=Test&out_trade_no=M1&type=ldcpay{SECRET}
data = "client_id=1&money=10.00&order_name=Test&out_trade_no=M1&type=ldcpay" + client_secret
signature = ed25519.Sign(privateKey, []byte(data))
signBase64 = base64.StdEncoding.EncodeToString(signature)`}
language="javascript"
/>
</div>
<h3 id="1-4-submit" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">1.4 积分流转服务</h3>
<ul className="list-disc pl-4 md:pl-5 space-y-2 mb-6">
<li><strong>方法:</strong>POST <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">/epay/pay/submit.php</code></li>
<li><strong>编码:</strong><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">application/json</code> 或 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">application/x-www-form-urlencoded</code></li>
</ul>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className="w-[100px] whitespace-nowrap">参数</DocsTableHead>
<DocsTableHead className="w-[80px] whitespace-nowrap">必填</DocsTableHead>
<DocsTableHead>说明</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">client_id</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>应用客户端 ID</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">type</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>固定 <code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">ldcpay</code></DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">out_trade_no</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>业务单号</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">money</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>积分数量,必须保留两位小数(比如,10.00)</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">order_name</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>商品名称</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">notify_url</DocsTableCell>
<DocsTableCell>否</DocsTableCell>
<DocsTableCell>会参与签名;可选订单级异步通知地址。长度不超过 100,需为合法 URL。传入后支付成功优先回调该地址,未传则使用应用 notify_url</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">return_url</DocsTableCell>
<DocsTableCell>否</DocsTableCell>
<DocsTableCell>会参与签名;可选订单级回跳地址。长度不超过 100,需为合法 URL。传入后支付成功页面优先跳转该地址,未传则使用应用 redirect_uri</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">sign</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>按“签名算法”生成的 Base64 签名串</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<h3 id="1-5-others" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">1.5 其他接口</h3>
<p className="text-muted-foreground mb-4">其他接口定义请参考 <a href="#3-common-services" className="text-primary underline underline-offset-4">3. 其他接口</a>。</p>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>订单查询:</strong>详见 <a href="#3-1-order" className="text-primary underline underline-offset-4">3.1 订单查询</a></li>
<li><strong>订单退款:</strong>详见 <a href="#3-2-refund" className="text-primary underline underline-offset-4">3.2 订单退款</a></li>
<li><strong>异步通知:</strong>详见 <a href="#3-3-notify" className="text-primary underline underline-offset-4">3.3 异步通知</a></li>
</ul>
</div>
),
children: [
{ value: "1-1-overview", title: "1.1 概览" },
{ value: "1-2-flow", title: "1.2 对接流程" },
{ value: "1-3-auth-sign", title: "1.3 鉴权与签名" },
{ value: "1-4-submit", title: "1.4 积分流转服务" },
{ value: "1-5-others", title: "1.5 其他接口" },
]
},
{
value: "epay-compatibility",
title: "2. 易支付兼容接口",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<div className="bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6">
<p className="text-muted-foreground m-0">兼容易支付、CodePay、VPay 等支付协议</p>
</div>
<h3 id="2-1-overview" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">2.1 概览</h3>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>协议:</strong>EasyPay / CodePay / VPay 兼容协议</li>
<li><strong>服务类型:</strong>仅支持 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">type=epay</code></li>
<li><strong>网关基址:</strong><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">https://credit.linux.do/epay</code></li>
<li><strong>订单有效期:</strong>取系统配置 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">merchant_order_expire_minutes</code>(平台端设置)</li>
</ul>
<h3 id="2-2-common-errors" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">2.2 常见错误</h3>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">不支持的请求类型</code>:<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">type</code> 仅允许 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">epay</code></li>
<li><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">签名验证失败</code>:参与签名字段与请求体需一致,密钥直接拼接</li>
<li><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">金额必须大于0</code> / <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">积分小数位数不能超过2位</code></li>
<li><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">订单已过期</code>:超出系统配置有效期</li>
<li><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">订单不存在或已完成</code>:订单号错误、已退回或已完成</li>
<li><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">余额不足</code>:余额退回时用户积分不足</li>
</ul>
<h3 id="2-3-flow" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">2.3 对接流程</h3>
<ol className="list-decimal pl-4 md:pl-5 space-y-2">
<li>控制台创建 API Key,记录 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">pid</code>、<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">key</code>,配置回调地址</li>
<li>按“签名算法”生成 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">sign</code>,调用 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">/epay/pay/submit.php</code> 创建积分流转服务并跳转认证界面</li>
<li>可通过 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">/epay/api.php</code> 轮询结果,或等待异步回调</li>
<li>退回服务时,携带同一 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">trade_no</code> 和原积分数量,调用积分退回接口</li>
<li>回调验签通过后返回 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">success</code> 完成闭环</li>
</ol>
<h3 id="2-4-auth-sign" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">2.4 鉴权与签名</h3>
<h4 className="font-medium text-foreground mt-3 md:mt-4 mb-2">2.4.1 API Key</h4>
<ul className="list-disc pl-4 md:pl-5 space-y-2 mb-4">
<li><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">pid</code>:Client ID</li>
<li><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">key</code>:Client Secret(妥善保管)</li>
<li><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">notify_url</code> / <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">return_url</code>:应用级默认回调地址(兜底);创建订单时可在请求中传同名字段作为订单级覆盖,未传时回退应用配置。</li>
</ul>
<h4 className="font-medium text-foreground mt-3 md:mt-4 mb-2">2.4.2 签名算法</h4>
<div className="space-y-4">
<ol className="list-decimal pl-4 md:pl-5 space-y-2">
<li>取所有非空字段(排除 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">sign</code>、<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">sign_type</code> 字段)</li>
<li>将上述字段按 ASCII 升序,依次拼成 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">k1=v1&k2=v2</code></li>
<li>在末尾追加应用密钥:<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">k1=v1&k2=v2{"{secret}"}</code></li>
<li>整体进行 MD5,取小写十六进制作为 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">sign</code></li>
</ol>
<CodeBlock
code={`payload="money=10&name=Test&out_trade_no=M20250101&pid=001&type=epay"
sign=$(echo -n "\${payload}\${SECRET}" | md5) # 输出小写`}
language="bash"
/>
</div>
<h3 id="2-5-submit" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">2.5 积分流转服务</h3>
<ul className="list-disc pl-4 md:pl-5 space-y-2 mb-6">
<li><strong>方法:</strong>POST <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">/epay/pay/submit.php</code></li>
<li><strong>编码:</strong><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">application/json</code> 或 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">application/x-www-form-urlencoded</code></li>
<li><strong>成功:</strong>验签通过后,平台自动创建积分流转服务,并跳转到认证界面(Location=<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">https://credit.linux.do/paying?order_no=...</code>)</li>
<li><strong>失败:</strong>返回 JSON <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">{`{"error_msg":"...", "data":null}`}</code></li>
</ul>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className="w-[100px] whitespace-nowrap">参数</DocsTableHead>
<DocsTableHead className="w-[80px] whitespace-nowrap">必填</DocsTableHead>
<DocsTableHead>说明</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">pid</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>Client ID</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">type</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>固定 <code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">epay</code></DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">out_trade_no</DocsTableCell>
<DocsTableCell>否</DocsTableCell>
<DocsTableCell>业务单号,建议全局唯一</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">name</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>标题,最多 64 字符</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">money</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>积分数量,最多 2 位小数</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">notify_url</DocsTableCell>
<DocsTableCell>否</DocsTableCell>
<DocsTableCell>会参与签名;可选订单级异步通知地址。长度不超过 100,需为合法 URL。传入后支付成功优先回调该地址,未传则使用应用 notify_url</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">return_url</DocsTableCell>
<DocsTableCell>否</DocsTableCell>
<DocsTableCell>会参与签名;可选订单级回跳地址。长度不超过 100,需为合法 URL。传入后支付成功页面优先跳转该地址,未传则使用应用 redirect_uri</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">device</DocsTableCell>
<DocsTableCell>否</DocsTableCell>
<DocsTableCell>终端标识,可选</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">sign</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>按“签名算法”生成</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">sign_type</DocsTableCell>
<DocsTableCell>否</DocsTableCell>
<DocsTableCell>固定 <code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">MD5</code></DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<p className="text-muted-foreground mb-2">请求示例:</p>
<CodeBlock
code={`curl -X POST https://credit.linux.do/epay/pay/submit.php \\
-H "Content-Type: application/x-www-form-urlencoded" \\
-d "pid=001" \\
-d "type=epay" \\
-d "out_trade_no=M20250101" \\
-d "name=Test" \\
-d "money=10" \\
-d "sign=\${SIGN}" \\
-d "sign_type=MD5"`}
language="bash"
/>
<h3 id="2-6-others" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">2.6 其他接口</h3>
<p className="text-muted-foreground mb-4">其他接口定义请参考 <a href="#3-common-services" className="text-primary underline underline-offset-4">3. 其他接口</a>。</p>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>订单查询:</strong>详见 <a href="#3-1-order" className="text-primary underline underline-offset-4">3.1 订单查询</a></li>
<li><strong>订单退款:</strong>详见 <a href="#3-2-refund" className="text-primary underline underline-offset-4">3.2 订单退款</a></li>
<li><strong>异步通知:</strong>详见 <a href="#3-3-notify" className="text-primary underline underline-offset-4">3.3 异步通知</a></li>
</ul>
</div>
),
children: [
{ value: "2-1-overview", title: "2.1 概览" },
{ value: "2-2-common-errors", title: "2.2 常见错误" },
{ value: "2-3-flow", title: "2.3 对接流程" },
{ value: "2-4-auth-sign", title: "2.4 鉴权与签名" },
{ value: "2-5-submit", title: "2.5 积分流转服务" },
{ value: "2-6-others", title: "2.6 其他接口" },
]
},
{
value: "common-services",
title: "3. 其他接口",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<div className="bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6">
<p className="text-muted-foreground m-0">官方接口与易支付兼容接口公用接口。</p>
</div>
<h3 id="3-1-order" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">3.1 订单查询</h3>
<ul className="list-disc pl-4 md:pl-5 space-y-2 mb-6">
<li><strong>方法:</strong>GET <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">/epay/api.php</code></li>
<li><strong>认证:</strong><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">pid</code> + <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">key</code></li>
<li><strong>说明:</strong><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">out_trade_no</code> 必填;<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">act</code> 可传 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">order</code>,后端不强校验。</li>
</ul>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className="w-[100px] whitespace-nowrap">参数</DocsTableHead>
<DocsTableHead className="w-[80px] whitespace-nowrap">必填</DocsTableHead>
<DocsTableHead>说明</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">act</DocsTableCell>
<DocsTableCell>否</DocsTableCell>
<DocsTableCell>可选字段,建议 <code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">order</code></DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">pid</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>Client ID</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">key</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>Client Secret</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">out_trade_no</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>业务单号</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<p className="text-muted-foreground mb-2">成功响应:</p>
<CodeBlock
code={`{
"code": 1,
"msg": "查询订单号成功!",
"trade_no": "M20250101",
"out_trade_no": "M20250101",
"type": "epay",
"pid": "001",
"addtime": "2025-01-01 12:00:00",
"endtime": "2025-01-01 12:01:30",
"name": "Test",
"money": "10",
"status": 1
}`}
language="json"
/>
<p className="text-muted-foreground text-xs"><strong className="text-foreground">补充:</strong><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">status</code> 1=成功,0=失败/处理中;不存在会返回 HTTP 404 且 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">{`{"code":-1,"msg":"服务不存在或已完成"}`}</code>。</p>
<h3 id="3-2-refund" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">3.2 订单退款</h3>
<ul className="list-disc pl-4 md:pl-5 space-y-2 mb-6">
<li><strong>方法:</strong>POST <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">/epay/api.php</code></li>
<li><strong>编码:</strong><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">application/json</code> 或 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">application/x-www-form-urlencoded</code></li>
<li><strong>限制:</strong>仅支持对已成功的积分流转服务进行积分的全额退回</li>
</ul>
<div>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className="w-[100px] whitespace-nowrap">参数</DocsTableHead>
<DocsTableHead className="w-[80px] whitespace-nowrap">必填</DocsTableHead>
<DocsTableHead>说明</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">pid</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>Client ID</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">key</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>Client Secret</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">trade_no</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>编号</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">money</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>必须等于原积分流转服务的积分数量</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">out_trade_no</DocsTableCell>
<DocsTableCell>否</DocsTableCell>
<DocsTableCell>业务单号(兼容)</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
</div>
<p className="text-muted-foreground mb-2">响应:</p>
<CodeBlock code={`{ "code": 1, "msg": "退款成功" }`} language="json" />
<p className="text-muted-foreground text-xs"><strong className="text-foreground">常见失败:</strong>服务不存在/未认证、金额不合法(&lt;=0 或小数超过 2 位)。</p>
<h3 id="3-3-notify" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">3.3 异步通知</h3>
<ul className="list-disc pl-4 md:pl-5 space-y-2 mb-6">
<li><strong>触发:</strong>认证成功后;失败自动重试,最多 5 次(单次 30s 超时)</li>
<li><strong>目标:</strong>订单级 notify_url(如有)优先,否则回退到创建应用时设置的 notify_url</li>
<li><strong>方式:</strong>HTTP GET</li>
</ul>
<div>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className="w-[100px] whitespace-nowrap">参数</DocsTableHead>
<DocsTableHead>说明</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">pid</DocsTableCell>
<DocsTableCell>Client ID</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">trade_no</DocsTableCell>
<DocsTableCell>编号</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">out_trade_no</DocsTableCell>
<DocsTableCell>业务单号</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">type</DocsTableCell>
<DocsTableCell>固定 <code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">epay</code></DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">name</DocsTableCell>
<DocsTableCell>标题</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">money</DocsTableCell>
<DocsTableCell>积分数量,最多 2 位小数</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">trade_status</DocsTableCell>
<DocsTableCell>固定 <code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">TRADE_SUCCESS</code></DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">sign</DocsTableCell>
<DocsTableCell>按“签名算法”生成</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
</div>
<p className="text-muted-foreground text-xs">应用需返回 HTTP 200 且响应体为 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">success</code>(大小写不敏感),否则视为失败并继续重试。</p>
<h3 id="3-4-distribute" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">3.4 商户分发接口</h3>
<ul className="list-disc pl-4 md:pl-5 space-y-2 mb-6">
<li><strong>方法:</strong>POST <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">/lpay/distribute</code></li>
<li><strong>编码:</strong><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">application/json</code></li>
<li><strong>认证:</strong>Basic Auth (使用 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">client_id:client_secret</code> 进行 Base64 编码)</li>
</ul>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className="w-[100px] whitespace-nowrap">参数</DocsTableHead>
<DocsTableHead className="w-[80px] whitespace-nowrap">必填</DocsTableHead>
<DocsTableHead>说明</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">user_id</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>收款人用户 ID (数字)</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">username</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>收款人用户名 (用于二次校验)</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">amount</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>分发积分数量,最多 2 位小数</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">out_trade_no</DocsTableCell>
<DocsTableCell>否</DocsTableCell>
<DocsTableCell>商户自定义单号</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">remark</DocsTableCell>
<DocsTableCell>否</DocsTableCell>
<DocsTableCell>分发备注</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<p className="text-muted-foreground text-xs"><strong className="text-foreground">成功响应:</strong><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">{`{"code":1, "data":{"trade_no":"...", "out_trade_no":"..."}}`}</code></p>
<h3 id="3-5-user-balance" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">3.5 用户余额统计</h3>
<ul className="list-disc pl-4 md:pl-5 space-y-2 mb-6">
<li><strong>方法:</strong>GET <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">/api/v1/dashboard/stats/user-balance</code></li>
<li><strong>认证:</strong>无需鉴权(公开接口)</li>
<li><strong>说明:</strong>获取平台所有用户可用余额的统计数据,结果有缓存,TTL 由系统配置决定</li>
</ul>
<p className="text-muted-foreground mb-2">成功响应:</p>
<CodeBlock
code={`{
"error_msg": "",
"data": {
"total_count": 1234,
"total_amount": "98765.00",
"avg_amount": "80.03",
"median_amount": "50.00",
"min_amount": "0.00",
"max_amount": "9999.00",
"std_dev": "123.45"
}
}`}
language="json"
/>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className="w-[120px] whitespace-nowrap">字段</DocsTableHead>
<DocsTableHead>说明</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">total_count</DocsTableCell>
<DocsTableCell>统计用户总数</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">total_amount</DocsTableCell>
<DocsTableCell>所有用户可用余额之和</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">avg_amount</DocsTableCell>
<DocsTableCell>平均余额</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">median_amount</DocsTableCell>
<DocsTableCell>余额中位数</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">min_amount</DocsTableCell>
<DocsTableCell>最小余额</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">max_amount</DocsTableCell>
<DocsTableCell>最大余额</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">std_dev</DocsTableCell>
<DocsTableCell>余额标准差</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
</div>
),
children: [
{ value: "3-1-order", title: "3.1 订单查询" },
{ value: "3-2-refund", title: "3.2 订单退款" },
{ value: "3-3-notify", title: "3.3 异步通知" },
{ value: "3-4-distribute", title: "3.4 商户分发接口" },
{ value: "3-5-user-balance", title: "3.5 用户余额统计" },
]
},
]
@@ -0,0 +1,329 @@
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: (
<div className="space-y-4 text-sm leading-relaxed">
<div className="bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6">
<p className="text-muted-foreground m-0">为社区开发者与用户提供完整的平台使用说明</p>
</div>
<ul className="list-disc pl-5 space-y-2">
<li><strong>身份认证:</strong>基于 LINUX DO Connect (OAuth)</li>
<li><strong>认证方式:</strong>账户积分消耗认证</li>
<li><strong>手续费:</strong>动态费率,由服务方承担</li>
<li><strong>争议处理:</strong>支持服务方与消费方的双方争议处理</li>
</ul>
</div>
)
},
{
value: "roles",
title: "2. 角色说明",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<ul className="list-disc pl-5 space-y-2">
<li><strong>服务方:</strong>最终积分流转的转入方</li>
<li><strong>消费方:</strong>最终积分流转的转出方</li>
<li><strong>认证平台:</strong>LINUX DO Credit 系统本身</li>
</ul>
</div>
)
},
{
value: "integration",
title: "3. 接入积分服务",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<h3 id="3-1-api" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">3.1 使用 API 接口</h3>
<ol className="list-decimal pl-4 md:pl-5 space-y-2">
<li>
<strong>创建应用</strong>
<ul className="list-disc pl-4 md:pl-5 mt-2 space-y-1 text-muted-foreground">
<li>前往 <a href="/merchant" className="text-primary hover:underline">集市中心</a></li>
<li>点击顶部右侧 <strong>创建应用</strong> 按钮</li>
<li>填写必要信息:应用名称、应用主页、回调地址、通知地址</li>
</ul>
</li>
<li>
<strong>获取 API 凭证</strong>
<ul className="list-disc pl-4 md:pl-5 mt-2 space-y-1 text-muted-foreground">
<li>在集市中心顶部右侧选择器中选择您的应用</li>
<li>在 <strong>API 配置</strong> 面板中获取:
<ul className="list-[circle] pl-4 md:pl-5 mt-1">
<li><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">Client ID</code>:客户端ID,用于标识您的身份</li>
<li><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">Client Secret</code>:客户端密钥,用于签名验证(<strong>请妥善保管,切勿泄露</strong>)</li>
</ul>
</li>
</ul>
</li>
<li>
<strong>使用 API 接口</strong>
<ul className="list-disc pl-4 md:pl-5 mt-2 space-y-1 text-muted-foreground">
<li>使用 API 接口创建积分流转服务</li>
<li>参考文档:<a href="/docs/api" className="text-primary hover:underline">API 接口文档</a></li>
</ul>
</li>
</ol>
<h3 id="3-2-online" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">3.2 使用在线服务</h3>
<ul className="list-disc pl-4 md:pl-5 space-y-2 mb-4">
<li><strong>适用场景:</strong>无代码开发基础,或只用于简单的积分服务。</li>
<li><strong>操作步骤:</strong>
<ol className="list-decimal pl-4 md:pl-5 mt-2 space-y-1 text-muted-foreground">
<li>前往 <a href="/merchant" className="text-primary hover:underline">集市中心</a> 创建应用,获取 API 凭证</li>
<li>选择应用,点击 <strong>在线收款</strong> 功能</li>
<li>创建在线积分服务</li>
<li>获取唯一积分服务链接</li>
<li>发送给您所服务的客户使用</li>
</ol>
</li>
</ul>
<h3 id="3-3-new-api" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">3.3 快速集成 New API</h3>
<ul className="list-disc pl-4 md:pl-5 space-y-2 mb-4">
<li><strong>适用场景:</strong>New API 站点,LINUX DO Credit 兼容 EasyPay 协议,公益站站长可直接集成。</li>
<li><strong>操作步骤:</strong>
<ol className="list-decimal pl-4 md:pl-5 mt-2 space-y-2 text-muted-foreground">
<li>
前往 <a href="/merchant" className="text-primary hover:underline">集市中心</a>,点击 <strong>创建应用</strong>,填写 New API 站点信息:
<div className="my-4">
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead>字段</DocsTableHead>
<DocsTableHead>值</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell>应用名称</DocsTableCell>
<DocsTableCell><code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">您的应用名称</code></DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell>应用主页</DocsTableCell>
<DocsTableCell><code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">https://{"{您的 New API 域名}"}</code></DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell>回调地址</DocsTableCell>
<DocsTableCell><code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">https://{"{您的 New API 域名}"}/console/log</code></DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell>通知地址</DocsTableCell>
<DocsTableCell><code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">https://{"{您的 New API 域名}"}/api/user/epay/notify</code></DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
</div>
</li>
<li>前往 New API 站点的系统设置,找到 <strong>支付设置</strong>。</li>
<li>
配置 LINUX DO Credit 平台参数:
<div className="my-4">
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead>参数</DocsTableHead>
<DocsTableHead>值</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell>支付地址</DocsTableCell>
<DocsTableCell><code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">https://credit.linux.do/epay/pay</code></DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell>易支付商户ID</DocsTableCell>
<DocsTableCell>您的 <code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">Client ID</code></DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell>易支付商户密钥</DocsTableCell>
<DocsTableCell>您的 <code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">Client Secret</code></DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell>回调地址</DocsTableCell>
<DocsTableCell><code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">https://{"{您的 New API 域名}"}</code></DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
</div>
</li>
<li>
配置充值方式(JSON 格式):
<div className="my-4">
<CodeBlock
code={`[
{"color":"rgba(var(--semi-blue-5), 1)","name":"支付宝","type":"alipay"},
{"color":"rgba(var(--semi-green-5), 1)","name":"微信","type":"wxpay"},
{"color":"black","name":"Linux Do Credit","type":"epay"}
]`}
language="json"
/>
</div>
</li>
</ol>
</li>
</ul>
</div>
),
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: (
<div className="space-y-4 text-sm leading-relaxed">
<p>您可以在任意支持 LINUX DO Credit 的平台下使用积分。在其他平台点击使用 LINUX DO Credit 积分时,会自动跳转到 LINUX DO Credit 的积分流转服务页面,您只需要确认积分流转信息无误,并选择使用 LINUX DO Credit 进行账户认证,即可完成整个交易服务。</p>
</div>
)
},
{
value: "fees",
title: "5. 服务(手续)费",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<h3 id="5-1-rules" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">5.1 规则说明</h3>
<p>为了更好的维持 LINUX DO Credit 平台的积分服务机制,保证社区积分的生态可持续发展,我们会按照规范进行不同程度的服务(手续)费用收取。</p>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>承担方:</strong>服务(手续)费默认<strong>由服务方承担</strong></li>
<li><strong>消费方使用:</strong>不会产生额外费用</li>
<li><strong>服务方实收:</strong>订单金额 - 服务(手续)费</li>
</ul>
<div className="mt-4">
<p className="font-mono text-xs mb-2 text-muted-foreground">计算公式:</p>
<CodeBlock
code={`手续费 = 订单金额 × 当前费率
商家实际到账 = 订单金额 - 手续费`}
language="text"
/>
</div>
<h3 id="5-2-dynamic" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">5.2 动态费率</h3>
<p>费率并非固定不变,会根据以下因素动态调整:</p>
<ul className="list-disc pl-4 md:pl-5 space-y-1">
<li>服务方平台等级</li>
<li>服务方平台积分</li>
<li>LINUX DO Credit 平台活动</li>
</ul>
</div>
),
children: [
{ value: "5-1-rules", title: "5.1 规则说明" },
{ value: "5-2-dynamic", title: "5.2 动态费率" },
]
},
{
value: "dispute",
title: "6. 争议处理",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>为了保障服务方与消费方的合法权益,当积分服务出现纠纷时,可使用争议功能。</p>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li>作为服务方,您需要及时响应消费方的争议请求:
<ol className="list-decimal pl-4 md:pl-5 mt-1 text-muted-foreground">
<li>在集市中心或通知中查看到 <strong>待处理的争议</strong></li>
<li>查看消费方理由,选择操作:
<ul className="list-[circle] pl-5 mt-1">
<li><strong>同意:</strong>认可消费方诉求,积分原路退回给消费方</li>
<li><strong>拒绝:</strong>如果您认为已履约,请提交相关证据</li>
</ul>
</li>
</ol>
</li>
</ul>
<div className="bg-yellow-500/10 border border-yellow-500/20 text-yellow-600 dark:text-yellow-400 px-3 py-2 rounded-lg mt-4">
<p className="m-0 text-sm">
<strong>重要:</strong>建议服务方与消费方优先沟通解决。长时间未处理的争议会由 LINUX DO Credit 平台介入仲裁,这可能会影响您的服务方信誉。
</p>
</div>
</div>
)
},
{
value: "community-balance",
title: "7. 社区积分",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>您的 LINUX DO Credit 平台基础积分主要由 <strong>社区积分 (Community Balance)</strong> 划转而来。</p>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>基本获取方式:</strong>通过在 LINUX DO 社区的活跃行为获得:
<ul className="list-[circle] pl-4 md:pl-5 mt-1 text-muted-foreground">
<li>获赞、解决问题等社区贡献</li>
<li>详情可见 <a href="https://linux.do/t/topic/1409175" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">LINUX DO 社区</a></li>
</ul>
</li>
<li><strong>划转规则:</strong>
<ul className="list-[circle] pl-4 md:pl-5 mt-1 text-muted-foreground">
<li>划转时间:社区积分每日凌晨 <strong>00:00</strong> 自动划转至可用余额</li>
<li>限制说明:划转前不可用于任何积分服务</li>
<li>服务费用:目前不收取任何划转 <strong>服务费</strong></li>
</ul>
</li>
</ul>
</div>
)
},
{
value: "settings",
title: "8. 账户设置",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>您可以在 <strong>设置 (Settings)</strong> 页面管理您的账户信息。</p>
<h3 className="text-base md:text-lg font-semibold text-foreground mt-4 mb-2">功能列表</h3>
<ul className="list-disc pl-4 md:pl-5 space-y-1">
<li><strong>个人资料:</strong>查看当前的账户信息和会员等级</li>
<li><strong>安全设置:</strong>修改认证密码</li>
<li><strong>外观设置:</strong>切换页面主题、界面外观</li>
</ul>
</div>
)
},
{
value: "scripts",
title: "9. 辅助脚本",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>为了方便用户随时查看当前的实时积分收入,我们提供了开源的 Userscript 脚本。</p>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>功能:</strong>在 LINUX DO 显示实时积分收入,支持拖拽,不影响界面。</li>
<li><strong>获取:</strong>仅需安装 Tampermonkey 插件即可使用。</li>
<li><strong>安装:</strong>
<a
href="https://linux.do/t/topic/1365853"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
「LINUX DO Credit」实时积分收入脚本
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-external-link"><path d="M15 3h6v6" /><path d="M10 14 21 3" /><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></svg>
</a>
</li>
</ul>
<div className="bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mt-2">
<p className="text-xs text-muted-foreground m-0">
注:脚本完全开源且安全,仅通过官方 API 获取公开数据,不涉及任何敏感权限。
</p>
</div>
</div>
)
}
]
@@ -0,0 +1,416 @@
"use client"
import * as React from "react"
import Link from "next/link"
import { ArrowLeft, Calendar, Check, Copy, 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 "./types"
interface LegalPageLayoutProps {
title: string
lastUpdated: string
sections: PolicySection[]
description?: React.ReactNode
}
export function LegalPageLayout({
title,
lastUpdated,
sections,
description,
}: LegalPageLayoutProps) {
const [activeSection, setActiveSection] = React.useState<string>(sections[0]?.value || "")
const [hasCopied, setHasCopied] = React.useState(false)
const { scrollYProgress } = useScroll()
const contentRef = React.useRef<HTMLDivElement | null>(null)
const scaleX = useSpring(scrollYProgress, {
stiffness: 100,
damping: 30,
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) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveSection(entry.target.id)
}
})
},
{ rootMargin: "-20% 0px -35% 0px" }
)
allSectionIds.forEach((id) => {
const element = document.getElementById(id)
if (element) observer.observe(element)
})
return () => observer.disconnect()
}, [sections, allSectionIds])
const convertInlineNodes = (nodes: ChildNode[]): string => {
return nodes.map((node) => {
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent || ""
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return ""
}
const element = node as HTMLElement
const tagName = element.tagName.toLowerCase()
if (tagName === "code") {
return `\`${element.textContent?.trim() || ""}\``
}
if (tagName === "strong" || tagName === "b") {
return `**${convertInlineNodes(Array.from(element.childNodes))}**`
}
if (tagName === "em" || tagName === "i") {
return `*${convertInlineNodes(Array.from(element.childNodes))}*`
}
if (tagName === "a") {
const text = convertInlineNodes(Array.from(element.childNodes)) || element.textContent || ""
const href = element.getAttribute("href") || ""
return href ? `[${text}](${href})` : text
}
if (tagName === "br") {
return "\n"
}
return convertInlineNodes(Array.from(element.childNodes))
}).join("")
}
const convertList = (
list: HTMLOListElement | HTMLUListElement,
indentLevel: number
): string => {
const isOrdered = list.tagName.toLowerCase() === "ol"
const items = Array.from(list.children).filter((child) => child.tagName.toLowerCase() === "li")
return items.map((item, index) => {
const prefix = isOrdered ? `${index + 1}. ` : "- "
const indent = " ".repeat(indentLevel)
const parts: string[] = []
const itemElement = item as HTMLElement
const childNodes = Array.from(itemElement.childNodes)
const nestedLists: HTMLElement[] = []
childNodes.forEach((child) => {
if (child.nodeType === Node.ELEMENT_NODE) {
const childElement = child as HTMLElement
if (childElement.tagName.toLowerCase() === "ul" || childElement.tagName.toLowerCase() === "ol") {
nestedLists.push(childElement)
return
}
}
parts.push(convertInlineNodes([child]).trim())
})
const baseLine = `${indent}${prefix}${parts.join("").trim()}`
const nestedMarkdown = nestedLists.map((nested) => convertList(nested as HTMLOListElement | HTMLUListElement, indentLevel + 1))
return [baseLine, ...nestedMarkdown].filter(Boolean).join("\n")
}).join("\n")
}
const convertTable = (table: HTMLTableElement): string => {
const rows = Array.from(table.querySelectorAll("tr")).map((row) =>
Array.from(row.querySelectorAll("th,td")).map((cell) => (cell.textContent || "").trim())
)
if (!rows.length) return ""
const header = rows[0]
const body = rows.slice(1)
const headerLine = `| ${header.join(" | ")} |`
const separatorLine = `| ${header.map(() => "---").join(" | ")} |`
const bodyLines = body.map((row) => `| ${row.join(" | ")} |`)
return [headerLine, separatorLine, ...bodyLines].join("\n")
}
const convertBlockNode = (node: HTMLElement, indentLevel: number): string => {
const tagName = node.tagName.toLowerCase()
if (tagName === "h3") return `### ${node.textContent?.trim() || ""}`
if (tagName === "h4") return `#### ${node.textContent?.trim() || ""}`
if (tagName === "p") return convertInlineNodes(Array.from(node.childNodes)).trim()
if (tagName === "ul" || tagName === "ol") return convertList(node as HTMLOListElement | HTMLUListElement, indentLevel)
if (tagName === "pre") {
const code = node.textContent || ""
return `\`\`\`\n${code.trim()}\n\`\`\``
}
if (tagName === "table") return convertTable(node as HTMLTableElement)
if (tagName === "div") {
const hasTable = node.querySelector("table")
const hasList = node.querySelector("ul,ol")
const hasPre = node.querySelector("pre")
if (hasTable || hasList || hasPre) {
return Array.from(node.children)
.map((child) => convertBlockNode(child as HTMLElement, indentLevel))
.filter(Boolean)
.join("\n\n")
}
const text = convertInlineNodes(Array.from(node.childNodes)).trim()
return text
}
return convertInlineNodes(Array.from(node.childNodes)).trim()
}
const buildMarkdown = (): string => {
const contentRoot = contentRef.current
if (!contentRoot) return ""
const lines: string[] = []
lines.push(`# ${title}`)
lines.push("")
lines.push(`最后更新:${lastUpdated}`)
if (description) {
const descriptionElement = contentRoot.querySelector("[data-docs-description]")
if (descriptionElement) {
const descriptionText = convertInlineNodes(Array.from(descriptionElement.childNodes)).trim()
if (descriptionText) {
lines.push("")
lines.push(descriptionText)
}
}
}
lines.push("")
const sectionNodes = Array.from(contentRoot.querySelectorAll("[data-docs-section]")) as HTMLElement[]
sectionNodes.forEach((section) => {
const heading = section.querySelector("h2")
if (heading?.textContent) {
lines.push(`## ${heading.textContent.trim()}`)
lines.push("")
}
const prose = section.querySelector("[data-docs-content]")
if (prose) {
const blocks = Array.from(prose.children)
.map((child) => convertBlockNode(child as HTMLElement, 0))
.filter(Boolean)
if (blocks.length) {
lines.push(blocks.join("\n\n"))
lines.push("")
}
}
})
return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim() + "\n"
}
const handleCopyMarkdown = async () => {
const markdown = buildMarkdown()
if (!markdown) return
try {
await navigator.clipboard.writeText(markdown)
setHasCopied(true)
setTimeout(() => setHasCopied(false), 2000)
} catch {
const textArea = document.createElement("textarea")
textArea.value = markdown
textArea.style.position = "fixed"
textArea.style.opacity = "0"
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
document.execCommand("copy")
document.body.removeChild(textArea)
setHasCopied(true)
setTimeout(() => setHasCopied(false), 2000)
}
}
const scrollToSection = (e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
e.preventDefault()
const element = document.getElementById(id)
if (element) {
const offset = 80
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"
})
}
}
return (
<div className="min-h-screen bg-background relative selection:bg-primary/20">
<motion.div
className="fixed top-0 left-0 right-0 h-1 bg-primary origin-left z-50"
style={{ scaleX }}
/>
<div className="fixed inset-0 pointer-events-none">
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-primary/5 rounded-full blur-[120px] -translate-y-1/2" />
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-blue-500/5 rounded-full blur-[120px] translate-y-1/2" />
</div>
<div ref={contentRef} className="container relative z-10 max-w-7xl mx-auto px-4 md:px-6 py-8 lg:py-16">
<header className="mb-8 md:mb-12">
<Link href="/" className="inline-block mb-8">
<Button
variant="ghost"
className="gap-2 pl-0 text-muted-foreground hover:text-foreground hover:bg-transparent -ml-2"
>
<ArrowLeft className="w-4 h-4" />
返回首页
</Button>
</Link>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<h1 className="text-2xl md:text-3xl lg:text-5xl font-bold tracking-tight text-foreground mb-6">
{title}
</h1>
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground mb-8">
<span className="flex items-center gap-1.5 bg-muted/50 px-3 py-1 rounded-full border border-border/50">
<FileText className="w-3.5 h-3.5" />
{sections.length} 个内容
</span>
<span className="flex items-center gap-1.5 bg-muted/50 px-3 py-1 rounded-full border border-border/50">
<Calendar className="w-3.5 h-3.5" />
最后更新:{lastUpdated}
</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleCopyMarkdown}
className="h-8 gap-2 text-xs"
>
{hasCopied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
{hasCopied ? "已复制" : "复制 Markdown"}
</Button>
</div>
{description && (
<div className="w-full p-4 md:p-6 rounded-2xl bg-muted/30 border border-border/50 backdrop-blur-sm overflow-hidden">
<div data-docs-description className="text-sm md:text-base text-muted-foreground leading-relaxed m-0 break-all">
{description}
</div>
</div>
)}
</motion.div>
</header>
<div className="grid lg:grid-cols-12 gap-8 lg:gap-12 relative">
<aside className="hidden lg:block lg:col-span-3">
<div className="sticky top-24">
<div className="space-y-2 max-h-[calc(100vh-8rem)] overflow-y-auto pr-4 scrollbar-thin">
<div className="text-sm font-semibold text-foreground mb-4 pl-3">目录</div>
<nav className="space-y-1">
{sections.map((section) => (
<div key={section.value} className="space-y-1">
<a
href={`#${ section.value }`}
onClick={(e) => scrollToSection(e, section.value)}
className={cn(
"group flex items-center justify-between py-2 px-3 text-sm rounded-lg transition-all duration-200",
activeSection === section.value
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
)}
>
<span className="truncate">{section.title}</span>
{activeSection === section.value && (
<motion.div
layoutId="active-dot"
className="w-1.5 h-1.5 rounded-full bg-primary"
/>
)}
</a>
{section.children && (
<div className="pl-4 space-y-1">
{section.children.map((child) => (
<a
key={child.value}
href={`#${ child.value }`}
onClick={(e) => scrollToSection(e, child.value)}
className={cn(
"block py-1.5 px-3 text-xs rounded-lg transition-all duration-200 truncate",
activeSection === child.value
? "text-primary font-medium bg-primary/5"
: "text-muted-foreground/80 hover:text-foreground hover:bg-muted/30"
)}
>
{child.title}
</a>
))}
</div>
)}
</div>
))}
</nav>
</div>
</div>
</aside>
<main className="lg:col-span-9 lg:col-start-4 space-y-8 md:space-y-16 pb-20">
{sections.map((section, index) => (
<motion.section
key={section.value}
id={section.value}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "100px" }}
transition={{ duration: 0.3 }}
className="scroll-mt-24 group"
data-docs-section
>
<div className="sticky top-0 z-20 flex items-center gap-4 py-6 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 mb-2 -mx-4 px-4 md:-mx-0 md:px-0">
<span className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 text-primary text-sm font-bold opacity-50 group-hover:opacity-100 transition-opacity flex-shrink-0">
{index + 1}
</span>
<h2 className="text-xl md:text-2xl font-semibold tracking-tight text-foreground group-hover:text-primary transition-colors duration-300">
{section.title.replace(/^\d+\.\s*/, '')}
</h2>
</div>
<div
data-docs-content
className={cn(
"prose prose-sm md:prose-base prose-neutral dark:prose-invert max-w-none",
"prose-p:text-muted-foreground prose-p:leading-7",
"prose-li:text-muted-foreground",
"prose-strong:text-foreground",
"pl-4 md:pl-12 border-l border-border/30 group-hover:border-primary/20 transition-colors duration-500"
)}>
{section.content}
</div>
</motion.section>
))}
<div className="pt-16 border-t border-border mt-20">
<div className="bg-muted/30 rounded-2xl p-8 text-center">
<p className="text-muted-foreground text-sm mb-4">
对这些内容有疑问?
</p>
<div className="flex justify-center gap-4">
<Link href="https://github.com/linux-do/credit/issues" target="_blank">
<Button variant="outline" className="h-9 text-xs">
联系支持
</Button>
</Link>
<Link href="https://linux.do" target="_blank">
<Button variant="outline" className="h-9 text-xs">
访问社区
</Button>
</Link>
</div>
</div>
</div>
</main>
</div>
</div >
</div >
)
}
+110
View File
@@ -0,0 +1,110 @@
import { type PolicySection } from "./types"
/**
* ------------------------------------------------------------------
* 隐私政策 (PRIVACY POLICY)
* ------------------------------------------------------------------
*/
export const privacySections: PolicySection[] = [
{
value: "collection-details",
title: "1. 详细信息收集说明",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>我们仅遵循合法、正当、必要的原则,收集为您提供服务所必需的信息。我们的数据收集范围严格限制如下:</p>
<div className="space-y-3">
<div>
<span className="font-semibold text-foreground">1.1 身份鉴权信息:</span>
<p className="mt-1 text-muted-foreground">当您通过 LINUX DO Connect 登录时,我们会获取您的社区 OpenID(唯一标识符)、加密后的用户名及头像 URL。<strong>我们不收集您的手机号、真实姓名或身份证件信息。</strong></p>
</div>
<div>
<span className="font-semibold text-foreground">1.2 服务日志信息:</span>
<p className="mt-1 text-muted-foreground">为保障系统运行安全及满足法律合规要求,我们会自动收集您的操作日志,包括 IP 地址、访问日期和时间、API 调用记录、User-Agent(浏览器/设备类型)。</p>
</div>
<div>
<span className="font-semibold text-foreground">1.3 交易与资产信息:</span>
<p className="mt-1 text-muted-foreground">若您使用支付功能,我们将记录您的商户订单号、交易金额、交易时间、交易状态摘要。这些信息是账务核对的必要依据。</p>
</div>
</div>
</div>
),
},
{
value: "storage-security",
title: "2. 数据存储与安全保护",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>我们深知数据安全的重要性,并采取业界领先的技术措施保护您的数据:</p>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>存储地点:</strong>依照法规要求,我们收集和产生的用户个人信息,<strong>存储在独立</strong>的服务器上。我们不会将您的数据传输至境外管辖区。</li>
<li><strong>加密技术:</strong>敏感数据(如 支付密码)在数据库中均采用高强度加密算法存储。数据传输全链路采用 SSL/TLS 1.3 协议进行加密,防止网络嗅探。</li>
<li><strong>隔离机制:</strong>本平台数据与外部网络物理隔离,且独立于 LINUX DO 社区论坛主数据库,确保单一系统故障不会波及全局数据安全。</li>
<li><strong>访问控制:</strong>我们实行严格的最小权限原则(Least Privilege),仅有核心运维人员经授权后方可访问必要的维护数据,且所有操作均有审计日志留存。</li>
</ul>
</div>
),
},
{
value: "usage-rules",
title: "3. 信息使用规范",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>我们收集的信息将仅用于以下目的:</p>
<ul className="list-disc pl-4 md:pl-5 space-y-1">
<li><strong>身份识别:</strong>用于确认您的社区身份,展示您的个人中心数据。</li>
<li><strong>业务功能:</strong>处理您的支付指令、API 请求、回调通知及账单生成。</li>
<li><strong>安全风控:</strong>利用 IP 及行为日志进行反作弊、反欺诈分析,识别恶意攻击行为,保护平台及其他用户的安全。</li>
<li><strong>客户支持:</strong>在您发起工单或申诉时,查询相关日志以协助您解决问题。</li>
</ul>
<p><strong>禁止用途:</strong>我们承诺<strong>绝不</strong>利用您的数据进行用户画像分析、个性化广告推送或商业营销。</p>
</div>
),
},
{
value: "sharing-disclosure",
title: "4. 信息共享与对外披露",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p><strong>4.1 共享原则:</strong>我们坚持<strong>数据零共享</strong>策略。除以下极端情况外,我们不会向任何第三方(包括且不限于关联公司、支付宝、微信、银行、广告商)共享您的个人信息:</p>
<ul className="list-disc pl-4 md:pl-5 space-y-1 text-muted-foreground">
<li>事先获得您的明确授权或同意;</li>
<li>根据适用的法律法规、法律程序的要求、强制性的行政或司法要求所必须的情况下进行提供。</li>
</ul>
<p><strong>4.2 转让与公开披露:</strong>我们不会将您的个人信息转让给任何公司、组织和个人。我们仅在法律法规强制要求,或为了保护平台及用户与公众的人身财产安全免受侵害时,才会公开披露您的信息。</p>
</div>
),
},
{
value: "user-rights",
title: "5. 您的权利与数据管理",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>依照《中华人民共和国个人信息保护法》,您对您的个人信息享有完整的控制权:</p>
<div className="space-y-3">
<div>
<span className="font-semibold text-foreground">5.1 查阅与复制权:</span>
<p className="mt-1 text-muted-foreground">您可以随时登录开发者后台,查阅您的概览信息、API Key 状态及历史交易账单。您可以通过“导出账单”功能获取您的数据副本。</p>
</div>
<div>
<span className="font-semibold text-foreground">5.2 删除与遗忘权:</span>
<p className="mt-1 text-muted-foreground">若您决定停止使用本服务,在结清所有应付费用及余额后,您可以申请<strong>注销账户</strong>。注销后,我们将立即删除您的所有敏感信息或进行匿名化处理,法律法规规定需保留的日志除外。</p>
</div>
<div>
<span className="font-semibold text-foreground">5.3 纠正与更正权:</span>
<p className="mt-1 text-muted-foreground">若您发现您的信息有误,您有权要求我们更正或补充。您可以在设置页面直接修改您的信息,或通过客服提交工单。</p>
</div>
</div>
</div>
),
},
{
value: "policy-update",
title: "6. 政策更新与通知",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>随着业务的发展或法律法规的变动,我们可能会适时修订本《隐私政策》。</p>
<p>当条款发生重大变更时(例如收集范围扩大、使用目的改变),我们会通过站内信、公告或弹窗等显著方式通知您。若您在政策更新后继续使用本服务,即表示您同意接受更新后的隐私政策约束。</p>
</div>
),
},
]
+115
View File
@@ -0,0 +1,115 @@
import { type PolicySection } from "./types"
export const TERMS_LAST_UPDATED = "2025-12-22"
/**
* ------------------------------------------------------------------
* 服务条款 (TERMS OF SERVICE)
* ------------------------------------------------------------------
*/
export const termsSections: PolicySection[] = [
{
value: "contract-establishment",
title: "1. 缔约申明与服务综述",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p><strong>1.1 缔约主体:</strong>本《服务协议》(以下简称“本协议”)是您(以下亦称“社区用户”、“开发者”、“消费方”或“服务方”)与 LINUX DO Credit 平台运营团队(以下简称“平台”、“我们”)之间关于使用平台服务所订立的具有法律约束力的契约。</p>
<p><strong>1.2 审慎阅读:</strong>请您务必审慎阅读、充分理解各条款内容,特别是<strong>免除或者限制责任的条款、争议解决和法律适用条款</strong>。各免责或限责条款将以粗体标识,您应重点阅读。如您不同意本协议的任何内容,请立即停止注册或使用本服务。</p>
<p><strong>1.3 协议构成:</strong>本协议内容包括协议正文及所有我们已经发布或将来可能发布的各类规则、声明、说明。所有规则为本协议不可分割的组成部分,与协议正文具有同等法律效力。</p>
</div>
),
},
{
value: "service-definition",
title: "2. 服务定义与性质界定",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p><strong>2.1 社区技术服务:</strong>LINUX DO Credit 是基于 LINUX DO 社区生态构建的独立价值交换协议与技术系统。我们仅提供 API 接口调用、数据路由、账单管理等<strong>纯技术服务</strong>。</p>
<p><strong>2.2 非金融机构申明:</strong></p>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>非银行机构:</strong>我们不是商业银行、持牌支付机构(如支付宝、微信支付、银联)或清算机构。</li>
<li><strong>不提供资金沉淀:</strong>平台不设立资金池,不提供真实法币存取款、转账汇款或支付结算服务。所有涉及资金流转的行为均发生于社区用户与社区支付渠道之间,不涉及真实货币。</li>
<li><strong>不提供金融服务:</strong>平台不提供任何金融服务,包括但不限于贷款、融资、投资、理财、保险等金融服务。</li>
<li><strong>不提供积分兑现:</strong>平台不提供任何积分兑换服务,包括但不限于积分兑换为真实货币、积分兑换为实物商品、积分兑换为服务等。</li>
<li><strong>不提供真实货币交易:</strong>平台不提供任何真实货币交易服务,包括但不限于真实货币交易为积分、真实货币交易为虚拟资产、真实货币交易为服务等。</li>
</ul>
<p><strong>2.3 服务限定:</strong>本平台建议用于仅支持虚拟商品、软件授权、技术咨询、会员订阅等<strong>无实物交付</strong>的场景。关于实物电商、物流发货或涉及线下履约的商业场景造成的任何后果我们概不负责,请妥善保管好自己的个人财产,谨防上当受骗。</p>
</div>
),
},
{
value: "account-specifications",
title: "3. 账号注册与使用规范",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p><strong>3.1 账号体系:</strong>本平台采用 LINUX DO Connect (OAuth) 授权登录体系。您必须拥有合法、有效的 LINUX DO 社区账号方可使用本服务。您的平台账号权益(包括但不限于信誉分、等级)与社区账号严格绑定。</p>
<p><strong>3.2 匿名性与真实性:</strong></p>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>无需实名:</strong>我们尊重您的隐私,不强制要求您提供居民身份证、护照或营业执照进行实名认证。</li>
<li><strong>操作真实性:</strong>您承诺注册和使用的账号是您本人操作。严禁恶意注册、挂机脚本、自动化程序注册等破坏平台公平性的行为。</li>
</ul>
<p><strong>3.3 账号安全责任:</strong>您应妥善保管您账号的支付密码、Client ID 和 Client Secret。<strong>因您保管不善可能导致账号被他人非法使用、资金损失或数据泄露的责任,由您自行承担。</strong>如发现账号异常,请立即通知我们进行冻结。</p>
</div>
),
},
{
value: "user-conduct",
title: "4. 用户行为准则(负面清单)",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>您在使用本服务时,必须严格遵守《中华人民共和国网络安全法》、《计算机信息网络国际联网安全保护管理办法》等法律法规。<strong>严禁利用本平台从事以下活动(“红线条款”):</strong></p>
<div className="bg-red-500/5 border border-red-500/20 rounded-lg p-3 md:p-4 space-y-3">
<ul className="list-disc pl-4 md:pl-5 space-y-2 text-red-600 dark:text-red-400 font-medium">
<li><strong>危害国家安全:</strong>反对宪法所确定的基本原则、危害国家安全、泄露国家秘密、颠覆国家政权、破坏国家统一的;</li>
<li><strong>非法信息服务:</strong>黑客攻击工具、DDoS 攻击服务、服务器爆破等其他非法信息服务平台;</li>
<li><strong>黄赌毒关联:</strong>制作、复制、发布、传播淫秽、色情、赌博、暴力、凶杀、恐怖或者教唆犯罪的;</li>
<li><strong>侵犯知识产权:</strong>销售盗版软件、盗版影视资源、非法游戏外挂、私服、黑号、社工库数据等;</li>
<li><strong>欺诈与虚假:</strong>进行电信诈骗、金融诈骗、传销、虚假广告虚假交易等;</li>
<li><strong>其他违法信息:</strong>涉及散布谣言、宣扬邪教/封建迷信、侮辱/诽谤他人、侵害他人合法权益的。</li>
</ul>
</div>
<p><strong>违约处理:</strong>一旦发现您违反上述规定,平台有权不经通知<strong>立即永久封禁您的账号、拦截所有 API 请求、冻结账户内所有关联价值,并依法向公安机关、网安部门移交相关线索。</strong></p>
</div>
),
},
{
value: "virtual-assets",
title: "5. 虚拟资产与交易规则",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p><strong>5.1 资产性质:</strong>平台内流转的“余额”、“积分”等均为<strong>社区虚拟积分</strong>,仅代表您在社区生态内的活跃度或贡献值。它们<strong>不具有任何货币属性</strong>,严禁兑换为法定货币,也不可用于任何非平台许可的商业交易。</p>
<p><strong>5.2 交易不可逆:</strong>鉴于网络技术的实时性,<strong>一旦积分消耗或划转指令被执行,该操作即不可撤销。</strong>请您在确认相关积分活动前,务必仔细核对服务方信息。</p>
<p><strong>5.3 规则说明:</strong>为营造良好的社区积分环境,平台有权收取一定的服务费(以积分为结算单位)进行调控,具体规则以控制台公示为准。平台保留根据社区运营状况调整积分规则的权利。</p>
</div>
),
},
{
value: "liability-limitation",
title: "6. 免责声明与不可抗力",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p><strong>6.1 基础免责:</strong>本平台服务按“现状”(As-Is)及“现有”(As-Available)状态提供。我们不保证服务一定能满足您的要求,也不保证服务不会中断,对服务的及时性、安全性、准确性都不作担保。</p>
<p><strong>6.2 不可抗力:</strong>对于因以下原因导致的服务中断、数据丢失或账号损失,平台不承担赔偿责任:</p>
<ul className="list-disc pl-4 md:pl-5 space-y-1 text-muted-foreground">
<li>自然灾害(台风、地震、海啸、洪水等);</li>
<li>政府行为、法律法规或政策调整、行政命令;</li>
<li>电信部门技术调整、通讯线路中断、海底光缆故障;</li>
<li>黑客攻击、计算机病毒侵入或发作、技术性故障;</li>
<li>社区维护、系统升级(我们将尽可能提前公告)。</li>
</ul>
<p><strong>6.3 责任上限:</strong>在任何情况下,平台对您所承担的违约赔偿责任总额不超过您在违约行为发生前 1 个月内向平台支付的费用总额。</p>
</div>
),
},
{
value: "governing-law",
title: "7. 法律适用与争议解决",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p><strong>7.1 法律适用:</strong>本协议的订立、执行、解释及争议的解决均适用<strong>中华人民共和国法律</strong>(不包括港澳台地区法律及冲突法)。</p>
<p><strong>7.2 争议解决:</strong>若您和平台发生任何争议或纠纷,首先应友好协商解决;协商不成的,您同意将纠纷或争议提交至<strong>平台运营团队所在地有管辖权的人民法院</strong>管辖。</p>
<p><strong>7.3 协议变更:</strong>我们有权根据法律法规变化或业务发展需要修改本协议。变更后的协议将在平台公示,自公示之日起生效。若您继续使用服务,视为您已接受修订后的协议。</p>
</div>
),
},
]
+11
View File
@@ -0,0 +1,11 @@
import { type ReactNode } from "react"
export type PolicySection = {
value: string
title: string
content: ReactNode
children?: {
value: string
title: string
}[]
}
@@ -0,0 +1,760 @@
"use client"
import * as React from "react"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { Spinner } from "@/components/ui/spinner"
import { AlertTriangle, X, Layers, Banknote, TicketSlash } from "lucide-react"
import { formatDateTime } from "@/lib/utils"
import type { Order, DisputeWithOrder, Dispute } from "@/lib/services"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import { DisputeService } from "@/lib/services"
import { toast } from "sonner"
import { useIsMobile } from "@/hooks/use-mobile"
import { usePublicConfig } from "@/hooks/use-public-config"
import { useTransaction } from "@/contexts/transaction-context"
import { useUser } from "@/contexts/user-context"
import { Textarea } from "@/components/ui/textarea"
import { typeConfig } from "@/components/common/general/table-filter"
import { VisuallyHidden } from "@radix-ui/react-visually-hidden"
type DisputeViewer = 'payer' | 'payee'
/**
* 活动详情弹窗
*/
export function OrderDetailDialog({ order }: { order: Order }) {
const [open, setOpen] = useState(false)
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-1 text-xs rounded-full"
onClick={() => setOpen(true)}
>
<Banknote className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>查看详情</p>
</TooltipContent>
</Tooltip>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent showCloseButton={false} className="bg-transparent border-0 shadow-none p-0 w-100">
<VisuallyHidden>
<DialogHeader>
<DialogTitle>积分流转详情</DialogTitle>
<DialogDescription>查看积分活动的详细信息</DialogDescription>
</DialogHeader>
</VisuallyHidden>
<div className="p-5 space-y-5 bg-card border border-border/50 rounded-lg shadow-xl">
<div className="flex flex-col items-center space-y-2 pb-4 border-b-2 border-dashed border-border/50">
<h3 className="font-bold text-2xl tracking-wider uppercase">{order.app_name || 'RECEIPT'}</h3>
<p className="text-[10px] text-muted-foreground font-mono uppercase tracking-widest">
{formatDateTime(order.created_at)}
</p>
</div>
<div className="text-center py-2">
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-1">Total Credits</p>
<div className="text-4xl font-black tracking-tighter flex items-start justify-center gap-1">
{parseFloat(order.amount).toFixed(2)}
</div>
</div>
<div className="space-y-0 text-sm border-y-2 border-dashed border-border/50 py-4">
<div className="flex justify-between items-center py-2 border-b border-dashed border-border/30 last:border-0">
<span className="text-muted-foreground text-xs uppercase font-medium">编号</span>
<span className="font-mono text-xs font-medium">{order.order_no}</span>
</div>
<div className="flex justify-between items-center py-2 border-b border-dashed border-border/30 last:border-0">
<span className="text-muted-foreground text-xs uppercase font-medium">类型</span>
<span className="text-xs font-medium">{typeConfig[order.type]?.label || order.type}</span>
</div>
<div className="flex justify-between items-center py-2 border-b border-dashed border-border/30 last:border-0">
<span className="text-muted-foreground text-xs uppercase font-medium">消费方</span>
<span className="text-xs font-medium">{order.payer_username}</span>
</div>
<div className="flex justify-between items-center py-2 border-b border-dashed border-border/30 last:border-0">
<span className="text-muted-foreground text-xs uppercase font-medium">服务方</span>
<span className="text-xs font-medium">{order.payee_username}</span>
</div>
<div className="flex justify-between items-center py-2 border-b border-dashed border-border/30 last:border-0">
<span className="text-muted-foreground text-xs uppercase font-medium">备注</span>
<span className="text-xs font-medium">{order.remark}</span>
</div>
{(order.status === 'success' || order.status === 'refund') && (
<div className="flex justify-between items-center py-2 border-b border-dashed border-border/30 last:border-0">
<span className="text-muted-foreground text-xs uppercase font-medium">时间</span>
<span className="font-mono text-xs">{formatDateTime(order.trade_time)}</span>
</div>
)}
</div>
<div className="pt-2 flex flex-col items-center space-y-3">
<div className="h-8 w-full max-w-[200px] flex items-end justify-between opacity-40">
{[...Array(30)].map((_, i) => (
<div
key={i}
className="bg-foreground w-[2px]"
style={{ height: `${ Math.max(40, Math.random() * 100) }%` }}
/>
))}
</div>
<p className="text-[10px] text-muted-foreground font-mono tracking-wider text-center">
LINUX DO Credit
</p>
</div>
</div>
</DialogContent>
</Dialog>
</>
)
}
/**
* 发起争议弹窗
*/
export function CreateDisputeDialog({ order, onSuccess }: { order: Order; onSuccess?: () => void }) {
const isMobile = useIsMobile()
const { config: publicConfig, loading: configLoading, error: configError } = usePublicConfig()
const { updateOrderStatus } = useTransaction()
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [reason, setReason] = useState("")
const resetForm = () => {
setReason("")
}
const handleButtonClick = async () => {
if (configLoading) {
try {
await new Promise(resolve => setTimeout(resolve, 100))
if (configLoading) {
toast.error('配置加载中', { description: '请稍候再试' })
return
}
} catch {
toast.error('配置加载失败', { description: '请刷新页面重试' })
return
}
}
if (configError || !publicConfig) {
toast.error('无法获取配置', { description: configError?.message || '请刷新页面重试' })
return
}
const tradeTime = new Date(order.trade_time)
const expiryTime = new Date(tradeTime.getTime() + publicConfig.dispute_time_window_hours * 60 * 60 * 1000)
const now = new Date()
if (now > expiryTime) {
toast.error('无法发起争议', { description: '已超过最晚发起争议时间' })
return
}
setOpen(true)
}
const handleOpenChange = (newOpen: boolean) => {
if (newOpen && !loading) {
resetForm()
}
setOpen(newOpen)
}
const handleCreateDispute = async () => {
if (!reason.trim()) {
toast.error('表单验证失败', { description: '请填写争议原因' })
return
}
if (reason.length > 100) {
toast.error('表单验证失败', { description: '争议原因不能超过 100 个字符' })
return
}
try {
setLoading(true)
const dispute = await DisputeService.createDispute({ order_id: order.id, reason: reason.trim() })
toast.success('争议已发起', { description: '请等待服务方处理' })
updateOrderStatus(order.id, {
status: 'disputing',
dispute_id: dispute.id
})
setOpen(false)
onSuccess?.()
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : '发起争议失败'
toast.error('发起争议失败', { description: errorMessage })
} finally {
setLoading(false)
}
}
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-1 text-xs rounded-full text-muted-foreground hover:text-foreground"
onClick={handleButtonClick}
>
<AlertTriangle className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>发起争议</p>
</TooltipContent>
</Tooltip>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>发起争议</DialogTitle>
<DialogDescription>
如果您对该积分活动有疑问,可以发起争议,服务方将在规定时间内处理。
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="reason" className={isMobile ? "text-sm" : ""}>
争议原因
</Label>
<Textarea
id="reason"
placeholder="请详细描述您遇到的问题..."
maxLength={100}
value={reason}
onChange={(e) => setReason(e.target.value)}
disabled={loading}
className={isMobile ? "text-sm" : ""}
/>
<p className="text-xs text-muted-foreground text-right">
{reason.length}/100
</p>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost" disabled={loading} className="h-8 text-xs">
取消
</Button>
</DialogClose>
<Button
onClick={(e) => { e.preventDefault(); handleCreateDispute() }}
disabled={loading}
className="bg-primary h-8 text-xs"
>
{loading ? <><Spinner /> 提交中</> : '确认发起'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
/**
* 取消争议弹窗
*/
export function CancelDisputeDialog({ order, onSuccess }: { order: Order; onSuccess?: () => void }) {
const { updateOrderStatus } = useTransaction()
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const handleCancelDispute = async () => {
try {
setLoading(true)
if (!order.dispute_id) {
toast.error('取消争议失败', { description: '未找到争议记录' })
return
}
await DisputeService.closeDispute({ dispute_id: order.dispute_id })
toast.success('争议已取消', { description: '争议已成功取消' })
updateOrderStatus(order.id, { status: 'success' })
setOpen(false)
onSuccess?.()
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : '取消争议失败'
toast.error('取消争议失败', { description: errorMessage })
} finally {
setLoading(false)
}
}
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-1 text-xs rounded-full text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() => setOpen(true)}
>
<X className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>争议正在进行中,点击取消</p>
</TooltipContent>
</Tooltip>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>争议取消</DialogTitle>
<DialogDescription>
您确定取消当前的争议吗?取消后争议将恢复正常。
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost" disabled={loading} className="h-8 text-xs">
取消
</Button>
</DialogClose>
<Button
onClick={(e) => { e.preventDefault(); handleCancelDispute() }}
disabled={loading}
className="bg-primary h-8 text-xs"
>
{loading ? <><Spinner /> 取消中</> : '确认取消'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
/**
* 查看争议历史弹窗
*/
export function ViewDisputeHistoryDialog({ order, viewer = 'payer' }: { order: Order; viewer?: DisputeViewer }) {
const [open, setOpen] = useState(false)
const [disputeHistory, setDisputeHistory] = useState<Dispute | null>(null)
const [fetchingHistory, setFetchingHistory] = useState(false)
const disputeConfig = React.useMemo(() => {
switch (order.status) {
case 'refused':
return {
tooltip: '争议已拒绝,点击查看',
timelineText: '服务方驳回争议',
isRed: true,
showTimestamp: true,
showContent: false,
content: null
}
case 'refund':
return {
tooltip: '争议已退款',
timelineText: '服务方已退款',
isRed: false,
showTimestamp: true,
showContent: true,
content: '退款已完成'
}
case 'disputing':
return {
tooltip: '争议处理中,点击查看',
timelineText: '争议进行中,等待服务方处理',
isRed: false,
showTimestamp: false,
showContent: false,
content: null
}
}
}, [order.status])
if (!disputeConfig) return null
const resetForm = () => {
setDisputeHistory(null)
}
const fetchDisputeHistory = async () => {
try {
setFetchingHistory(true)
if (!order.dispute_id) {
toast.error('获取争议记录失败', { description: '未找到争议记录' })
return
}
const params = {
page: 1,
page_size: 1,
dispute_id: order.dispute_id
}
const res = viewer === 'payee'
? await DisputeService.listMerchantDisputes(params)
: await DisputeService.listDisputes(params)
if (res.disputes && res.disputes.length > 0) {
setDisputeHistory(res.disputes[0])
} else {
toast.error('未找到争议记录')
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '获取争议记录失败'
toast.error('获取争议记录失败', { description: errorMessage })
} finally {
setFetchingHistory(false)
}
}
const handleOpenChange = (newOpen: boolean) => {
if (newOpen) {
setOpen(true)
resetForm()
fetchDisputeHistory()
} else {
setOpen(false)
}
}
const parseDisputeReason = (fullReason: string) => {
const match = fullReason.match(/^(.*?)\s*\[(?:服务方|受托方)拒绝理由:\s*(.*?)\]$/)
if (match) {
return { userReason: match[1].trim(), merchantReason: match[2].trim() }
}
return { userReason: fullReason, merchantReason: null }
}
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-1 text-xs rounded-full text-orange-600 hover:text-orange-700 hover:bg-orange-50"
onClick={() => handleOpenChange(true)}
>
<Layers className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>{disputeConfig.tooltip}</p>
</TooltipContent>
</Tooltip>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>争议详情</DialogTitle>
<DialogDescription>查看争议处理记录</DialogDescription>
</DialogHeader>
{fetchingHistory ? (
<div className="py-8 flex justify-center">
<Spinner className="size-6" />
</div>
) : disputeHistory ? (
<div className="py-4 relative pl-4 border-l border-border/50 space-y-8 ml-2">
<div className="relative">
<div className="absolute -left-[21px] top-1 h-2.5 w-2.5 rounded-full bg-primary ring-4 ring-background" />
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">消费方发起争议</span>
<span className="text-xs text-muted-foreground">{formatDateTime(disputeHistory.created_at)}</span>
</div>
<div className="text-sm text-muted-foreground bg-muted/50 p-3 rounded-md">
{parseDisputeReason(disputeHistory.reason).userReason}
</div>
</div>
</div>
<div className="relative">
<div className={`absolute -left-[21px] top-1 h-2.5 w-2.5 rounded-full ${ disputeConfig.isRed ? 'bg-destructive' : 'bg-primary' } ring-4 ring-background`} />
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className={`text-sm font-medium ${ disputeConfig.isRed ? 'text-destructive' : '' }`}>
{disputeConfig.timelineText}
</span>
{disputeConfig.showTimestamp && (
<span className="text-xs text-muted-foreground">
{formatDateTime(disputeHistory.updated_at)}
</span>
)}
</div>
{order.status === 'refused' && (
<div className="text-sm text-muted-foreground bg-destructive/5 border border-destructive/10 p-3 rounded-md">
{parseDisputeReason(disputeHistory.reason).merchantReason || "未提供拒绝理由"}
</div>
)}
{order.status === 'refund' && disputeConfig.showContent && disputeConfig.content && (
<div className="text-sm text-muted-foreground bg-muted/50 p-3 rounded-md">
{disputeConfig.content}
</div>
)}
</div>
</div>
</div>
) : (
<div className="py-8 text-center text-muted-foreground text-sm">
无法加载争议记录
</div>
)}
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost" className="h-8 text-xs">
关闭
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
/**
* 退款审核活动弹窗
*/
export function RefundReviewDialog({ order, onSuccess }: { order: Order; onSuccess?: () => void }) {
const { updateOrderStatus } = useTransaction()
const { refetch: refetchUser } = useUser()
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [action, setAction] = useState<'refund' | 'closed' | null>(null)
const [reason, setReason] = useState("")
const [disputeInfo, setDisputeInfo] = useState<DisputeWithOrder | null>(null)
const [fetchingDispute, setFetchingDispute] = useState(false)
const resetForm = () => {
setAction(null)
setReason("")
setDisputeInfo(null)
}
const fetchDisputeInfo = async () => {
try {
setFetchingDispute(true)
if (!order.dispute_id) {
toast.error('获取争议详情失败', { description: '未找到争议记录' })
return
}
const res = await DisputeService.listMerchantDisputes({
page: 1,
page_size: 1,
dispute_id: order.dispute_id
})
if (res.disputes && res.disputes.length > 0) {
setDisputeInfo(res.disputes[0])
} else {
toast.error('获取争议详情失败', { description: '未找到争议记录' })
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '获取争议详情失败'
toast.error('获取争议详情失败', { description: errorMessage })
} finally {
setFetchingDispute(false)
}
}
const handleOpenChange = (newOpen: boolean) => {
if (newOpen) {
setOpen(true)
resetForm()
fetchDisputeInfo()
} else if (!loading) {
setOpen(false)
}
}
const handleSubmit = async () => {
if (!action) {
toast.error('请选择处理方式')
return
}
if (action === 'closed' && !reason.trim()) {
toast.error('请填写拒绝原因')
return
}
if (reason.length > 100) {
toast.error('拒绝理由不能超过 100 个字符')
return
}
try {
setLoading(true)
if (!order.dispute_id) {
toast.error('处理失败', { description: '未找到争议记录' })
return
}
await DisputeService.refundReview({
dispute_id: order.dispute_id,
status: action,
reason: action === 'closed' ? reason.trim() : undefined,
})
toast.success('处理成功', {
description: action === 'refund' ? '已同意积分退回,争议结束' : '已拒绝积分退回,此积分活动正常进行'
})
updateOrderStatus(order.id, {
status: action === 'refund' ? 'refund' : 'refused'
})
setOpen(false)
refetchUser()
onSuccess?.()
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : '处理争议失败'
toast.error('处理争议失败', { description: errorMessage })
} finally {
setLoading(false)
}
}
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-1 text-xs rounded-full text-yellow-600 hover:text-yellow-700 hover:bg-yellow-50"
onClick={() => {
setOpen(true)
fetchDisputeInfo()
}}
>
<AlertTriangle className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>处理争议</p>
</TooltipContent>
</Tooltip>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>处理争议</DialogTitle>
<DialogDescription>
请审核此争议。您可以选择同意或拒绝退回积分。
</DialogDescription>
</DialogHeader>
{fetchingDispute ? (
<div className="py-8 flex justify-center">
<Spinner className="size-6" />
</div>
) : (
<div className="space-y-4 py-2">
{disputeInfo && (
<div className="rounded-md bg-muted/40 p-3 text-sm">
<div className="flex items-center gap-2 mb-2 text-muted-foreground text-xs">
<span>发起争议理由</span>
<span>•</span>
<span>{formatDateTime(disputeInfo.created_at)}</span>
</div>
<p className="text-sm font-medium leading-relaxed">
{disputeInfo.reason}
</p>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<Button
variant="outline"
className={`h-auto py-2 flex flex-col gap-1 hover:bg-transparent hover:border-primary ${ action === 'refund' && 'border-primary text-primary' }`}
onClick={() => setAction('refund')}
>
<div className="flex items-center gap-2">
<Banknote className="h-4 w-4" />
<span className="font-semibold">同意积分退回</span>
</div>
<span className="text-[10px] text-muted-foreground font-normal">积分全额退回,争议结束</span>
</Button>
<Button
variant="outline"
className={`h-auto py-2 flex flex-col gap-1 hover:bg-transparent hover:border-destructive ${ action === 'closed' && 'border-destructive text-destructive' }`}
onClick={() => setAction('closed')}
>
<div className="flex items-center gap-2">
<TicketSlash className="h-4 w-4" />
<span className="font-semibold">拒绝积分退回</span>
</div>
<span className="text-[10px] text-muted-foreground font-normal">拒绝积分退回,此积分活动正常进行</span>
</Button>
</div>
{action === 'closed' && (
<div className="space-y-2 animate-in fade-in slide-in-from-top-2 pt-2">
<Label htmlFor="reject-reason" className="text-xs font-medium">
拒绝原因 <span className="text-destructive">*</span>
</Label>
<Textarea
id="reject-reason"
placeholder="请说明拒绝退回的原因..."
maxLength={100}
value={reason}
onChange={(e) => setReason(e.target.value)}
disabled={loading}
className="h-9 text-sm"
/>
<p className="text-[10px] text-muted-foreground text-right">
{reason.length}/100
</p>
</div>
)}
</div>
)}
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost" disabled={loading} className="h-8 text-xs">
取消
</Button>
</DialogClose>
<Button
onClick={(e) => { e.preventDefault(); handleSubmit() }}
disabled={loading || !action}
className="bg-primary h-8 text-xs"
>
{loading ? <><Spinner /> 提交中</> : '确认处理'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
@@ -0,0 +1,241 @@
import * as React from "react"
import { useState, useEffect } from "react"
import { toast } from "sonner"
import { TablePagination, FilterSelect } from "@/components/common/general/table-filter"
import { Inbox, ListRestart, Gift, Coins, Check, Copy, ExternalLink } from "lucide-react"
import { EmptyStateWithBorder } from "@/components/layout/empty"
import { LoadingStateWithBorder } from "@/components/layout/loading"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { formatDateTime } from "@/lib/utils"
import type { RedEnvelope } from "@/lib/services"
export interface ListDataProps {
fetchData: (params: { page: number, page_size: number, type: string }) => Promise<{ list: RedEnvelope[], total: number }>
tabs: { value: string, label: string, color?: string }[]
defaultTab?: string
pageSize?: number
className?: string
emptyState?: (tab: string) => React.ReactNode
refreshTrigger?: number
}
/**
* 红包列表数据组件
*
* 提供分页、标签筛选、数据加载状态管理和网格布局,专门用于展示红包列表
*/
export function ListData({
fetchData,
tabs,
defaultTab,
pageSize = 20,
className,
emptyState,
refreshTrigger = 0
}: ListDataProps) {
const [activeTab, setActiveTab] = useState<string>(defaultTab || tabs[0]?.value)
/* 数据状态 */
const [items, setItems] = useState<RedEnvelope[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false)
const [page, setPage] = useState(1)
const [currentPageSize, setCurrentPageSize] = useState(pageSize)
const [copiedId, setCopiedId] = useState<string | null>(null)
/* 缓存页码 */
const loadData = async (type: string, p: number, s: number) => {
setLoading(true)
try {
const result = await fetchData({ page: p, page_size: s, type })
setItems(result.list)
setTotal(result.total)
} catch (error) {
toast.error('加载数据失败')
console.error(error)
} finally {
setLoading(false)
}
}
/* 初始加载和Tab切换 */
useEffect(() => {
setPage(1)
loadData(activeTab, 1, currentPageSize)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab, refreshTrigger])
const handlePageChange = (p: number) => {
setPage(p)
loadData(activeTab, p, currentPageSize)
}
const handlePageSizeChange = (s: number) => {
setCurrentPageSize(s)
setPage(1)
loadData(activeTab, 1, s)
}
const totalPages = Math.ceil(total / currentPageSize)
const getEnvelopeLink = (id: string) => {
if (typeof window !== 'undefined') {
return `${ window.location.origin }/redenvelope/${ id }`
}
return `/redenvelope/${ id }`
}
const handleCopyLink = async (link: string, envelopeId: string) => {
try {
await navigator.clipboard.writeText(link)
setCopiedId(envelopeId)
toast.success("链接已复制")
setTimeout(() => setCopiedId(null), 2000)
} catch {
toast.error("复制失败")
}
}
const getStatusBadge = (status: string) => {
switch (status) {
case 'active':
return <Badge variant="outline" className="text-[10px] px-1 py-0 h-4 leading-none text-green-600 bg-green-50/50 border-green-200">进行中</Badge>
case 'finished':
return <Badge variant="outline" className="text-[10px] px-1 py-0 h-4 leading-none text-muted-foreground bg-muted/50 border-dashed">已领完</Badge>
case 'expired':
return <Badge variant="outline" className="text-[10px] px-1 py-0 h-4 leading-none text-red-500 bg-red-50/50 border-red-200">已过期</Badge>
default:
return <Badge variant="outline" className="text-[10px] px-1 py-0 h-4 leading-none">{status}</Badge>
}
}
/* 默认空状态 */
const defaultEmptyState = (
<EmptyStateWithBorder
icon={Inbox}
description="暂无数据"
/>
)
return (
<div className={className}>
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3 mb-6">
<div className="flex items-center gap-2 flex-wrap">
<FilterSelect<string>
label="视图"
selectedValues={[activeTab]}
options={tabs.reduce((acc, tab) => ({
...acc,
[tab.value]: { label: tab.label, color: tab.color }
}), {})}
onToggleValue={(v) => setActiveTab(v)}
/>
</div>
<TablePagination
currentPage={totalPages > 0 ? page : 0}
totalPages={totalPages}
total={total}
pageSize={currentPageSize}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
onRefresh={() => loadData(activeTab, page, currentPageSize)}
loading={loading}
/>
</div>
<div className="space-y-4">
{loading && items.length === 0 ? (
<LoadingStateWithBorder icon={ListRestart} />
) : items.length === 0 ? (
emptyState ? emptyState(activeTab) : defaultEmptyState
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{items.map((envelope) => {
const isSent = activeTab === 'sent'
const link = getEnvelopeLink(envelope.id)
const claimedCount = envelope.total_count - envelope.remaining_count
return (
<Card
key={envelope.id}
className="group border-dashed hover:bg-muted/30 transition-all bg-card/50 shadow-none"
>
<CardContent className="px-4 py-1">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2.5">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 border ${ isSent
? "bg-red-50 text-red-600 border-red-100 dark:bg-red-900/20 dark:text-red-400 dark:border-red-900/30"
: "bg-amber-50 text-amber-600 border-amber-100 dark:bg-amber-900/20 dark:text-amber-400 dark:border-amber-900/30"
}`}>
{isSent ? <Gift size={16} /> : <Coins size={16} />}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold text-xs text-foreground truncate select-none">
{isSent
? (envelope.type === 'random' ? '拼手气红包' : '普通红包')
: envelope.creator_username
}
</span>
{getStatusBadge(envelope.status)}
</div>
<div className="text-[10px] text-muted-foreground truncate max-w-[140px] mt-0.5">
{envelope.greeting || "恭喜发财,大吉大利"}
</div>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-2 mb-2.5 px-2.5 py-2 bg-muted/40 rounded-md text-xs">
<div>
<div className="text-[10px] text-muted-foreground/70 mb-0.5 scale-95 origin-left">总金额</div>
<div className="font-mono font-medium text-foreground">{envelope.total_amount}</div>
</div>
<div>
<div className="text-[10px] text-muted-foreground/70 mb-0.5 scale-95 origin-left">已领取</div>
<div className="font-medium text-foreground">{claimedCount}/{envelope.total_count}</div>
</div>
</div>
<div className="flex items-center justify-between gap-2 -mb-2">
<div className="text-[10px] text-muted-foreground/60 font-mono">
{formatDateTime(envelope.created_at)}
</div>
<div className="flex gap-1 h-6">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 p-0 hover:bg-background hover:text-primary transition-all"
onClick={() => handleCopyLink(link, envelope.id)}
>
{copiedId === envelope.id ? (
<Check className="size-3 text-green-500" />
) : (
<Copy className="size-3" />
)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 p-0 hover:bg-background hover:text-primary transition-all"
onClick={() => window.open(link, '_blank')}
>
<ExternalLink className="size-3" />
</Button>
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,393 @@
import { useState } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import { ErrorInline } from "@/components/layout/error"
import { EmptyStateWithBorder } from "@/components/layout/empty"
import { LoadingStateWithBorder } from "@/components/layout/loading"
import { ListRestart, Layers, LucideIcon, Trash2 } from "lucide-react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
interface ManagePageProps<T> {
title: string
data: T[]
loading: boolean
error: Error | null
onReload: () => void
/** 获取初始编辑数据 */
getInitialEditData: (item: T) => Partial<T>
onSave: (item: T, editData: Partial<T>) => Promise<void>
onDelete: (item: T) => Promise<void>
/** 渲染表格 (Config-Driven) */
columns: {
header: string
cell: (item: T) => React.ReactNode
width?: string
align?: 'left' | 'center' | 'right'
className?: string
}[]
/** 渲染详情 */
renderDetail: (props: {
selected: T | null
hovered: T | null
editData: Partial<T>
onEditDataChange: (field: keyof T, value: T[keyof T]) => void
onSave: () => void
saving: boolean
}) => React.ReactNode
/** 空状态图标 */
emptyIcon?: LucideIcon
emptyDescription?: string
loadingDescription?: string
getId: (item: T) => string | number
}
export function ManagePage<T>({
title,
data,
loading,
error,
onReload,
getInitialEditData,
onSave,
onDelete,
columns,
renderDetail,
emptyIcon = Layers,
emptyDescription = "暂无数据",
loadingDescription = "加载中",
getId
}: ManagePageProps<T>) {
/** 悬停状态 */
const [hoveredItem, setHoveredItem] = useState<T | null>(null)
const [selectedItem, setSelectedItem] = useState<T | null>(null)
const [editData, setEditData] = useState<Partial<T>>({})
const [saving, setSaving] = useState(false)
const [deletingItem, setDeletingItem] = useState<T | null>(null)
/** 悬停处理 */
const handleHover = (item: T | null) => {
setHoveredItem(item)
}
/** 选择处理 */
const handleSelect = (item: T) => {
const itemId = getId(item)
const selectedId = selectedItem ? getId(selectedItem) : null
if (itemId === selectedId) {
setSelectedItem(null)
setEditData({})
} else {
setSelectedItem(item)
setEditData(getInitialEditData(item))
}
setHoveredItem(null)
}
/** 编辑数据处理 */
const handleEditDataChange = (field: keyof T, value: T[keyof T]) => {
setEditData(prev => ({
...prev,
[field]: value
}))
}
/** 保存处理 */
const handleSave = async () => {
if (!selectedItem) return
try {
setSaving(true)
await onSave(selectedItem, editData)
toast.success('保存成功')
} catch (error) {
toast.error('保存失败', {
description: error instanceof Error ? error.message : '未知错误'
})
} finally {
setSaving(false)
}
}
/** 删除处理 */
const handleDeleteClick = (item: T) => {
setDeletingItem(item)
}
const handleConfirmDelete = async () => {
if (!deletingItem) return
try {
await onDelete(deletingItem)
toast.success('删除成功')
setDeletingItem(null)
// 如果删除的是当前选中的项,清除选中状态
if (selectedItem && getId(selectedItem) === getId(deletingItem)) {
setSelectedItem(null)
setEditData({})
}
} catch (error) {
toast.error('删除失败', {
description: error instanceof Error ? error.message : '未知错误'
})
}
}
/** 渲染内容 */
const renderContent = () => {
if (loading && (!data || data.length === 0)) {
return (
<LoadingStateWithBorder
icon={ListRestart}
description={loadingDescription}
/>
)
}
if (error) {
return (
<div className="p-8 border border-dashed rounded-lg">
<ErrorInline
error={error}
onRetry={onReload}
className="justify-center"
/>
</div>
)
}
if (!data || data.length === 0) {
return (
<EmptyStateWithBorder
icon={emptyIcon}
description={emptyDescription}
/>
)
}
return (
<ManageTable
data={data}
columns={columns}
selected={selectedItem}
hovered={hoveredItem}
onSelect={handleSelect}
onHover={handleHover}
onDelete={handleDeleteClick}
getId={getId}
/>
)
}
return (
<div className="py-6">
<div className="flex border-b border-border pb-2 mb-6">
<div className="text-2xl font-semibold">{title}</div>
</div>
<div className="space-y-6">
<div>
<div className="mb-4">
<div className="font-semibold">配置列表</div>
</div>
{renderContent()}
</div>
<div>
{renderDetail({
selected: selectedItem,
hovered: hoveredItem,
editData,
onEditDataChange: handleEditDataChange,
onSave: handleSave,
saving
})}
</div>
</div>
<AlertDialog open={!!deletingItem} onOpenChange={(open) => !open && setDeletingItem(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除?</AlertDialogTitle>
<AlertDialogDescription>
此操作无法撤销。这将永久删除该配置。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmDelete} className="bg-red-600 hover:bg-red-700">
确认删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
/** 详情面板 props */
interface ManageDetailPanelProps {
title?: string
isEmpty: boolean
emptyDescription?: string
onSave?: () => void
saving?: boolean
children: React.ReactNode
}
/** 详情面板 */
export function ManageDetailPanel({
title = "配置信息",
isEmpty,
emptyDescription = "请选择配置查看详情",
onSave,
saving = false,
children
}: ManageDetailPanelProps) {
if (isEmpty) {
return (
<div className="space-y-4">
<div className="font-semibold mb-4">{title}</div>
<EmptyStateWithBorder
icon={Layers}
description={emptyDescription}
/>
</div>
)
}
return (
<div className="space-y-4 sticky top-6">
<div>
<div className="flex items-center justify-between mb-4">
<div className="font-semibold">{title}</div>
{onSave && (
<Button
onClick={onSave}
disabled={saving}
size="sm"
className="px-3 h-7 text-xs"
>
{saving ? (<><Spinner /> 更新中</>) : '更新'}
</Button>
)}
</div>
{children}
</div>
</div>
)
}
/** 表格面板 */
export function ManageTable<T>({
data,
columns,
selected,
hovered,
onSelect,
onHover,
onDelete,
getId
}: {
data: T[]
columns: {
header: string
cell: (item: T) => React.ReactNode
width?: string
align?: 'left' | 'center' | 'right'
className?: string
}[]
selected: T | null
hovered: T | null
onSelect: (item: T) => void
onHover: (item: T | null) => void
onDelete: (item: T) => void
getId: (item: T) => string | number
}) {
return (
<div className="border border-dashed shadow-none rounded-lg overflow-hidden">
<ScrollArea className="w-full">
<div className="relative w-full">
<table className="w-full caption-bottom text-sm">
<TableHeader>
<TableRow className="border-b border-dashed">
{columns.map((col, index) => (
<TableHead
key={index}
className={`whitespace-nowrap ${ col.width ? `w-[${ col.width }]` : '' } ${ col.align === 'center' ? 'text-center' : col.align === 'right' ? 'text-right' : '' } ${ col.className || '' }`}
>
{col.header}
</TableHead>
))}
<TableHead className="whitespace-nowrap text-center w-[120px]">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody className="animate-in fade-in duration-200">
{data.map((item) => {
const id = getId(item)
const isSelected = selected && getId(selected) === id
const isHovered = hovered && getId(hovered) === id
return (
<TableRow
key={id}
className={`border-b border-dashed cursor-pointer transition-colors ${ isSelected
? 'bg-primary/5 hover:bg-primary/10'
: isHovered
? 'bg-gray-100 hover:bg-muted/50'
: 'hover:bg-gray-100'
}`}
onMouseEnter={() => onHover(item)}
onMouseLeave={() => onHover(null)}
onClick={() => onSelect(item)}
>
{columns.map((col, index) => (
<TableCell
key={index}
className={`text-xs py-1 ${ col.align === 'center' ? 'text-center' : col.align === 'right' ? 'text-right' : '' } ${ col.className || '' }`}
>
{col.cell(item)}
</TableCell>
))}
<TableCell className="text-xs py-1 text-center">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-red-600 hover:bg-red-50"
onClick={(e) => {
e.stopPropagation()
onDelete(item)
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</table>
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More