Compare commits

..
6 Commits
514 changed files with 33845 additions and 28147 deletions
-8
View File
@@ -1,8 +0,0 @@
.venv
.git
.idea
cookies
conf.py
sau_frontend/dist
sau_frontend/node_modules
sau_frontend/package-lock.json
-3
View File
@@ -1,3 +0,0 @@
# These are supported funding model platforms
github: dreammis
+36 -185
View File
@@ -1,191 +1,42 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea/
# macOS
.DS_Store
# ignore cookie file
tencent_uploader/*.json
youtube_uploader/*.json
douyin_uploader/*.json
bilibili_uploader/*.json
tk_uploader/*.json
# 配置文件
conf.py
# 账号文件
cookies
# Frontend
.vite/
dist/
# 依赖(所有层级)
node_modules/
package-lock.json
# database
db/database.db
# 顶层 dist(仅保留部署说明与编排文件,二进制/zip 不入库)
/dist/*
!/dist/deploy/
/dist/deploy/*
!/dist/deploy/deploy-README.md
!/dist/deploy/docker-compose.yml
!/dist/deploy/.env.example
# 临时文件夹
cookiesFile
uploadFile
videoFile
# 前端构建产物
apps/web/dist/
/uploader/bilibili_uploader/qrcode.png
# C# / .NET 构建产物
bin/
obj/
# 二进制与归档(任意层级)
*.exe
*.blockmap
*.zip
builder-debug.yml
# 编译产物目录(无行内注释)
client/dist/
client/desktop/icons/
client/macos/release/
# 本地运行数据与密钥(绝不入库)
server/data/
server/.env
client/core/data/
client/desktop/release/
client/desktop/win-unpacked/
*.log
# 第三方参考仓库(非产品代码;含嵌套 git/密钥/236MB 二进制,不入库)
references/
-63
View File
@@ -1,63 +0,0 @@
FROM node:22.21.1 AS builder
WORKDIR /app
RUN npm config set registry https://registry.npmmirror.com
COPY sau_frontend .
RUN npm install
ENV NODE_ENV=production
ENV PATH=/app/node_modules/.bin:$PATH
# 替换前端中的地址
RUN sed -i 's#\${baseUrl}##g' /app/src/views/AccountManagement.vue
RUN sed -i "s#\${import\.meta\.env\.VITE_API_BASE_URL || 'http:\/\/localhost:5409'}##g" /app/src/api/material.js
RUN sed -i 's#localhost:5409##g' /app/.env.production
RUN npm run build
FROM python:3.10.19
WORKDIR /app
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright
RUN apt-get update && apt-get install -y --no-install-recommends libnss3 \
libnspr4 \
libdbus-1-3 \
libatk1.0-0 \
libatk-bridge2.0-0 \
libatspi2.0-0 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxrandr2 \
libgbm1 \
libxkbcommon0 \
libasound2 && rm -rf /var/lib/apt/lists/*
RUN pip config set global.index-url https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
COPY requirements.txt requirements.txt
RUN pip install -r requirements.txt
RUN playwright install chromium-headless-shell
COPY . .
COPY --from=builder /app/dist/index.html /app
COPY --from=builder /app/dist/assets /app/assets
COPY --from=builder /app/dist/vite.svg /app/assets
RUN cp conf.example.py conf.py
RUN mkdir -p /app/videoFile
RUN mkdir -p /app/cookiesFile
EXPOSE 5409
CMD ["python", "sau_backend.py"]
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2023 dreammis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+40
View File
@@ -0,0 +1,40 @@
.PHONY: run build web-build web-test web-smoke test tidy infra-up infra-down health mac-core mac-client
run: ## 本地运行服务
cd server && go run .
build: ## 编译二进制
cd server && go build -o ../dist/everypublish-server .
web-build: ## 构建网页端
cd apps/web && npm run build
web-test: ## 网页端类型检查
cd apps/web && npx tsc --noEmit
web-smoke: ## 网页端 API/Browser WS smoke(需先启动 Web-only server)
cd apps/web && npm test
test: ## 单测
cd server && go test ./...
cd server && go vet ./...
cd apps/web && npx tsc --noEmit
tidy: ## 整理依赖
cd server && go mod tidy
infra-up: ## 启动 MySQL/Redis(Docker)
docker compose -f server/docker-compose.yml up -d
infra-down: ## 停止 MySQL/Redis
docker compose -f server/docker-compose.yml down
health: ## 健康检查
curl -s http://127.0.0.1:8090/health
mac-core: ## 构建 macOS arm64 agent-core
@echo "macOS 客户端已暂停;该目标仅保留历史兼容,不属于 V1 验收"
cd client/core && GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -trimpath -ldflags '-s -w' -o ../dist/agent-core .
mac-client: ## 构建 macOS arm64 SwiftUI 原生 DMG
@echo "macOS 客户端已暂停;该目标不属于 V1 验收"
+47 -85
View File
@@ -1,110 +1,72 @@
# EveryPublish
EveryPublish 是一个**多平台内容自动发布系统**,帮助内容创作者和运营者将视频/图文一键发布到多个国内外主流社交媒体平台。它由一个 Flask 后端(平台自动化)和一个 React + TDesign 管理端(Web 控制台)组成,支持扫码登录、素材管理、异步发布任务与实时进度监控,并通过 Open API 向外部内容创作系统开放接入能力。
EveryPublish 是一个本机部署的多平台内容发布 Web 控制台:用户打开浏览器登录,在网页内完成账号、素材、任务、审核、挑战和结果管理。
## 功能特性
> 当前交付边界:Web-only V1。macOS/Windows 客户端、Agent 配对和设备管理暂停,不进入本阶段验收。
- **11 个平台接入**:抖音、快手、小红书、视频号、B站、百家号、支付宝生活号、微博、虎扑、YouTube、TikTok
- **视频 + 图文发布**:小红书/抖音/快手/视频号支持图文,其余平台支持视频
- **扫码登录 + Cookie 管理**:扫码登录、Cookie 导入导出、健康校验
- **异步发布任务**:发布立即返回任务 ID,后台执行,前端实时显示步骤/百分比/日志
- **发布日志**:历史任务记录与详细日志追踪
- **Open API 开放体系**:API Key 鉴权 + 素材/发布/账号/平台查询等开放接口 + 内置 API 文档页
- **全局配置**:登录方式(扫码/浏览器)、发布静默模式(headless 无感运行)
- **账号批量操作**:单账号/批量 Cookie 校验、批量删除
## 技术架构
## 当前架构
```text
浏览器 React 18 + TDesign + Redux Toolkit + Vite
│ REST / SSE / API
浏览器 React + TDesign
│ REST / Browser WebSocket
▼
Flask 后端 :5409
├─ SQLite(账号/素材/设置/API Key)
├─ 平台 uploader(Playwright / Patchright 浏览器自动化)
└─ 异步任务队列(发布 / 校验)
Go server :8090
├─ MySQL / Redis
├─ 本地素材 StorageDriver
├─ WebExecutor(mock 联调)
└─ PlatformAdapter(真实平台逐个接入)
```
## 支持平台
服务器运行在当前设备时,发布执行所需的浏览器 profile、cookie 和素材均留在该设备;不需要安装客户端或输入配对码。
| 平台 | 视频 | 图文 | 定时 | 登录方式 |
| --- | --- | --- | --- | --- |
| 抖音 | ✅ | ✅ | ✅ | 扫码 |
| 快手 | ✅ | ✅ | ✅ | 扫码 |
| 小红书 | ✅ | ✅ | ✅ | 扫码 |
| 视频号 | ✅ | ✅ | ✅ | 扫码 |
| B站 | ✅ | — | ✅ | 扫码(biliup) |
| 百家号 | ✅ | — | — | 扫码 |
| 支付宝生活号 | ✅ | — | — | 扫码 |
| 微博 | ✅ | — | — | 扫码 |
| 虎扑 | ✅ | — | — | QQ 扫码 |
| YouTube | ✅ | — | — | 浏览器交互 |
| TikTok | ✅ | — | ✅ | 浏览器交互 |
## 快速开始
### 后端(Flask,端口 5409)
## 本地启动
```bash
# 建议 Python 3.12
uv venv --python 3.12 .venv && source .venv/bin/activate
uv pip install -e ".[web]"
pip install playwright==1.52.0 xhs pillow
playwright install chromium
cp conf.example.py conf.py # 配置本地 Chrome 路径
python sau_backend.py
make infra-up
make run
cd apps/web && npm run dev -- --port 3013
```
### 前端(React,端口 5174)
本地联调默认使用 `EXECUTOR_MODE=mock`,不会访问真实平台。要联调已接入的 B 站 adapter,启动服务前设置 `EXECUTOR_MODE=web`;二维码扫码或 Cookie 导入成功后,任务才会走真实平台链路。没有人工扫码时,使用 adapter HTTP stub 和 mock 账号完成自动化验证,不把 mock URL 当成真实发布。
```bash
cd sau_web_react
npm install
npm run dev
```
- Go API/Browser WS:`http://127.0.0.1:8090`
- Vite Web:`http://127.0.0.1:3013`
- 生产构建:`cd apps/web && npm run build`,设置 `STATIC_DIR=apps/web/dist` 后由 Go server 托管
打开 http://127.0.0.1:5174/ ,开发环境通过 Vite 代理将 /api 转发到后端 5409。
## 网页功能
## 页面与功能
- 工作台:任务统计、执行器状态、挑战和最近任务。
- 账号管理:平台账号、扫码/Cookie 登录、健康检查、重新登录。
- 素材库:上传进度、SHA-256 去重、预览、分组和删除。
- 发布管理:新建、草稿、审核、排期、任务详情、重试和取消。
- 挑战/通知/审计:二维码、验证码、实时状态、历史追踪。
| 页面 | 路由 | 功能 |
| --- | --- | --- |
| 首页 | /dashboard | 账号/平台/素材统计、快捷操作 |
| 账号管理 | /account | 扫码登录、Cookie 导入导出、单/批量校验与删除 |
| 素材管理 | /material | 素材上传、缩略图预览、下载、删除 |
| 发布中心 | /publish | 按素材类型选渠道、多账号发布、进度监控 |
| 发布日志 | /publish-logs | 历史任务与日志 |
| API 文档 | /api-docs | Open API 说明、curl 示例、点击测试 |
| 系统设置 | /settings | 登录方式、发布静默开关 |
| 关于 | /about | 系统信息与平台能力矩阵 |
网页端不需要客户端安装、设备配对或 Agent WSS。客户端代码仅作为历史归档保留。
## Open API
## 文档入口
对外开放的接口统一以 `/open/v1` 为前缀,通过 `X-API-Key` 或 `Authorization: Bearer` 鉴权:
- `GET /open/v1/platforms` — 平台能力
- `GET /open/v1/accounts` — 账号列表
- `GET/POST/DELETE /open/v1/materials` — 素材管理
- `POST /open/v1/publish` — 提交发布任务
- `GET /open/v1/publish/{taskId}` — 查询任务状态
API Key 在系统设置/API 文档页生成与管理,详见内置「API 文档」页面。
## 目录结构
```text
├── sau_backend.py # Flask 后端主入口
├── myUtils/ # 账号登录/发布封装
├── uploader/ # 各平台自动化(Playwright/Patchright)
├── utils/ # 配置/日志/二维码等工具
├── sau_web_react/ # React + TDesign 管理端
└── db/ # SQLite 数据库
```
| 文档 | 说明 |
|---|---|
| [docs/web-only-v1-plan.md](docs/web-only-v1-plan.md) | 当前 V1 范围、页面、执行器和验收 |
| [docs/web-only-logic-diagrams.md](docs/web-only-logic-diagrams.md) | Web-only 系统、登录、上传、执行、扫码逻辑图 |
| [docs/reference-repositories-analysis.md](docs/reference-repositories-analysis.md) | 三个 MIT 参考仓库的拉取记录和借鉴边界 |
| [docs/system-design.md](docs/system-design.md) | 当前系统设计 |
| [docs/delivery-plan.md](docs/delivery-plan.md) | 当前交付排期 |
| [docs/page-wireframes.md](docs/page-wireframes.md) | 网页 ASCII 线框 |
| [docs/api.md](docs/api.md) | REST API |
| [docs/progress.md](docs/progress.md) | 当前进度和剩余风险 |
## 参考项目
本项目基于 [dreammis/social-auto-upload](https://github.com/dreammis/social-auto-upload)(MIT License)的 uploader 与 CLI 能力进行接入与封装,保留其 MIT 版权说明。依赖的第三方组件(biliup、Playwright、Patchright 等)受各自许可证约束。
- `references/social-auto-upload`(MIT,dreammis,CLI/uploader/QR 参考)
- `references/social-auto-upload-web-ui`(MIT,DevilJie,Web/SSE/任务队列参考)
- `references/MediaPublishPlatform`(MIT,funfan0517,批量上传/定时/发布记录参考)
## 许可证
参考代码只读保存,新增实现必须保留 MIT 版权说明;当前工程不混入 Vue/Flask/SQLite 三套运行时。
本项目采用 MIT License。除第三方组件外,代码可在遵守许可证条款的前提下用于商业软件。
## 开发原则
- mock executor 只能证明业务状态机,不冒充真实平台成功。
- 真实扫码无法执行时,状态必须停在“等待扫码”,不能自动标记 active。
- 所有任务、挑战和素材错误必须能在页面和日志中定位。
- 不做刷量、绕过平台风控、矩阵养号、私信轰炸等功能。
View File
+4
View File
@@ -0,0 +1,4 @@
# 打包路径 根据项目不同按需配置
VITE_BASE_URL = /
NODE_ENV=production
+79
View File
@@ -0,0 +1,79 @@
name: 反馈 Bug
description: 通过 github 模板进行 Bug 反馈。
title: "[组件名称] 描述问题的标题"
body:
- type: markdown
attributes:
value: |
# 欢迎你的参与
tdesign-react-starter 的 Issue 列表接受 bug 报告或是新功能请求。也可加入官方社区:<img width="80px" src="https://user-images.githubusercontent.com/15634204/157386871-bf84c2ea-a02f-4c1c-b6fd-577450cdbcf7.png" />
在发布一个 Issue 前,请确保:
- 在 [常见问题](https://tdesign.tencent.com/about/faq)、[更新日志](https://tdesign.tencent.com/about/release) 和 [旧Issue列表](https://github.com/Tencent/tdesign-react-starter/issues?q=is%3Aissue) 中搜索过你的问题。(你的问题可能已有人提出,也可能已在最新版本中被修正)
- 如果你发现一个已经关闭的旧 Issue 在最新版本中仍然存在,不要在旧 Issue 下面留言,请建一个新的 issue。
- type: input
id: version
attributes:
label: tdesign-react-starter 版本
description: 请检查在最新项目版本中能否重现此 issue。
placeholder: 请填写
validations:
required: true
- type: input
id: reproduce
attributes:
label: 重现链接
description: 请提供尽可能精简的 CodePen、CodeSandbox 或 GitHub 仓库的链接。请不要填无关链接,否则你的 Issue 将被关闭。
placeholder: 请填写
- type: textarea
id: reproduceSteps
attributes:
label: 重现步骤
description: 请清晰的描述重现该 Issue 的步骤,这能帮助我们快速定位问题。没有清晰重现步骤将不会被修复,标有 'need reproduction' 的 Issue 在 7 天内不提供相关步骤,将被关闭。
placeholder: 请填写
- type: textarea
id: expect
attributes:
label: 期望结果
placeholder: 请填写
- type: textarea
id: actual
attributes:
label: 实际结果
placeholder: 请填写
- type: input
id: frameworkVersion
attributes:
label: 框架版本
placeholder: Vue(3.2.0)
- type: input
id: browsersVersion
attributes:
label: 浏览器版本
placeholder: Chrome(8.213.231.123)
- type: input
id: systemVersion
attributes:
label: 系统版本
placeholder: MacOS(11.2.3)
- type: input
id: nodeVersion
attributes:
label: Node版本
placeholder: 请填写
- type: textarea
id: remarks
attributes:
label: 补充说明
description: 可以是遇到这个 bug 的业务场景、上下文等信息。
placeholder: 请填写
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: 使用 issue-helper 新建
url: https://Tencent.github.io/tdesign/issue-helper/?lang=zh-CN&repo=Tencent/tdesign
about: 使用 https://Tencent.github.io/tdesign/issue-helper/ 创建 issue,其中包含 bug 和 feature,表单提交更加严格。
@@ -0,0 +1,30 @@
name: 反馈新功能
description: 通过 github 模板进行新功能反馈。
title: "[组件名称] 描述问题的标题"
body:
- type: markdown
attributes:
value: |
# 欢迎你的参与
tdesign-react-starter 的 Issue 列表接受 bug 报告或是新功能请求。也可加入官方社区:<img width="80px" src="https://user-images.githubusercontent.com/15634204/157386871-bf84c2ea-a02f-4c1c-b6fd-577450cdbcf7.png" />
在发布一个 Issue 前,请确保:
- 在 [常见问题](https://tdesign.tencent.com/about/faq)、[更新日志](https://tdesign.tencent.com/about/release) 和 [旧Issue列表](https://github.com/Tencent/tdesign-react-starter/issues?q=is%3Aissue) 中搜索过你的问题。(你的问题可能已有人提出,也可能已在最新版本中被修正)
- 如果你发现一个已经关闭的旧 Issue 在最新版本中仍然存在,不要在旧 Issue 下面留言,请建一个新的 issue。
- type: textarea
id: functionContent
attributes:
label: 这个功能解决了什么问题
description: 请详尽说明这个需求的用例和场景。最重要的是:解释清楚是怎样的用户体验需求催生了这个功能上的需求。我们将考虑添加在现有 API 无法轻松实现的功能。新功能的用例也应当足够常见。
placeholder: 请填写
validations:
required: true
- type: textarea
id: functionalExpectations
attributes:
label: 你建议的方案是什么
placeholder: 请填写
validations:
required: true
+52
View File
@@ -0,0 +1,52 @@
<!--
首先,感谢你的贡献!😄
请阅读并遵循 [TDesign 贡献指南](https://github.com/Tencent/tdesign/blob/main/docs/contributing.md),填写以下 pull request 的信息。
PR 在维护者审核通过后会合并,谢谢!
-->
### 🤔 这个 PR 的性质是?
- [ ] 日常 bug 修复
- [ ] 新特性提交
- [ ] 文档改进
- [ ] 演示代码改进
- [ ] 组件样式/交互改进
- [ ] CI/CD 改进
- [ ] 重构
- [ ] 代码风格优化
- [ ] 测试用例
- [ ] 分支合并
- [ ] 其他
### 🔗 相关 Issue
<!--
1. 描述相关需求的来源,如相关的 issue 讨论链接。
-->
### 💡 需求背景和解决方案
<!--
1. 要解决的具体问题。
2. 列出最终的 API 实现和用法。
3. 涉及UI/交互变动需要有截图或 GIF。
-->
### 📝 更新日志
<!--
从用户角度描述具体变化,以及可能的 breaking change 和其他风险。
-->
- fix(组件名称): 处理问题或特性描述 ...
- [ ] 本条 PR 不需要纳入 Changelog
### ☑️ 请求合并前的自查清单
⚠️ 请自检并全部**勾选全部选项**。⚠️
- [ ] 文档已补充或无须补充
- [ ] 代码演示已提供或无须提供
- [ ] TypeScript 定义已补充或无须补充
- [ ] Changelog 已提供或无须提供
+4
View File
@@ -0,0 +1,4 @@
files.extend-exclude = [
"package-lock.json",
"src/configs/color.ts"
]
+20
View File
@@ -0,0 +1,20 @@
# Basic dependabot.yml file with
# minimum configuration for two package managers
version: 2
updates:
# Enable version updates for npm
- package-ecosystem: "npm"
# Look for `package.json` and `lock` files in the `root` directory
directory: "/"
# Check the npm registry for updates every day (weekdays)
schedule:
interval: "monthly"
# Enable version updates for Docker
- package-ecosystem: "docker"
# Look for a `Dockerfile` in the `root` directory
directory: "/"
# Check for updates once a week
schedule:
interval: "monthly"
+52
View File
@@ -0,0 +1,52 @@
# force copy from tencent/tdesign
name: Issue Add Assigness
on:
issues:
types: [opened, edited]
jobs:
mark-duplicate:
runs-on: ubuntu-latest
steps:
- uses: wow-actions/auto-comment@v1
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
issuesOpened: |
👋 @{{ author }},感谢给 TDesign 提出了 issue。
请根据 issue 模版确保背景信息的完善,我们将调查并尽快回复你。
# https://docs.github.com/cn/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#issues
- uses: 94dreamer/issue-assignees@main
id: assignees
with:
project_name: ${{github.event.repository.name}}
issue_title: ${{github.event.issue.title}}
- run: echo ${{ steps.assignees.outputs.contributors }}
- name: Add assigness
if: steps.assignees.outputs.contributors != ''
uses: actions-cool/issues-helper@v3
with:
actions: 'add-assignees'
token: ${{ secrets.GITHUB_TOKEN }}
issue-number: ${{ github.event.issue.number }}
assignees: ${{ steps.assignees.outputs.contributors }}
- run: |
contributors=${{ steps.assignees.outputs.contributors }}
contributorstring=${contributors//,/ @}
echo "::set-output name=string::@$contributorstring"
id: contributors
- name: 通知贡献者
if: steps.assignees.outputs.contributors != ''
uses: TDesignOteam/workflows/actions/maintain-one-comment@main
with:
token: ${{ secrets.GITHUB_TOKEN }}
body: |
♥️ 有劳 ${{ steps.contributors.outputs.string }} 尽快确认问题。
确认有效后将下一步计划和可能需要的时间回复给 @${{ github.event.issue.user.login }} 。
<!-- AUTO_ASSIGENEES_NOTIFY_HOOK -->
number: ${{ github.event.issue.number }}
body-include: "<!-- AUTO_ASSIGENEES_NOTIFY_HOOK -->"
+22
View File
@@ -0,0 +1,22 @@
# force copy from tencent/tdesign
name: Issue Help wanted
on:
issues:
types:
- labeled
jobs:
add-comment:
if: github.event.label.name == 'help wanted'
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Add comment
uses: peter-evans/create-or-update-comment@v1
with:
issue-number: ${{ github.event.issue.number }}
body: |
任何人都可以处理此问题。
**请务必在您的 `pull request` 中引用此问题。** :sparkles:
感谢你的贡献! :sparkles:
reactions: heart
@@ -0,0 +1,19 @@
# force copy from tencent/tdesign
# 当在 issue 的 comment 回复类似 `Duplicate of #111` 这样的话,issue 将被自动打上 重复提交标签 并且 cloese
name: Issue Mark Duplicate
on:
issue_comment:
types: [created, edited]
jobs:
mark-duplicate:
runs-on: ubuntu-latest
steps:
- name: mark-duplicate
uses: actions-cool/issues-helper@v2
with:
actions: "mark-duplicate"
token: ${{ secrets.GITHUB_TOKEN }}
duplicate-labels: "duplicate"
close-issue: true
+21
View File
@@ -0,0 +1,21 @@
# force copy from tencent/tdesign
# 当被打上 Need Reproduce 标签时候,自动提示需要重现实例
name: ISSUE_REPLY
on:
issues:
types: [labeled]
jobs:
issue-reply:
runs-on: ubuntu-latest
steps:
- name: Need Reproduce
if: github.event.label.name == 'Need Reproduce'
uses: actions-cool/issues-helper@v2
with:
actions: 'create-comment'
issue-number: ${{ github.event.issue.number }}
body: |
你好 @${{ github.event.issue.user.login }}, 我们需要你提供一个在线的重现实例以便于我们帮你排查问题。你可以通过点击 [此处](https://codesandbox.io/) 创建一个 codesandbox 或者提供一个最小化的 GitHub 仓库。请确保选择准确的版本。
+14
View File
@@ -0,0 +1,14 @@
# force copy from tencent/tdesign
name: pr-spell-check
on: [pull_request]
jobs:
run:
name: Spell Check with Typos
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Check spelling
uses: crate-ci/typos@master
with:
config: .github/configs/typos_config.toml
+15
View File
@@ -0,0 +1,15 @@
# 文件名建议统一为 preview-publish
# 应用 preview.yml 的 demo
name: PREVIEW_PUBLISH
on:
workflow_run:
workflows: ["MAIN_PULL_REQUEST"]
types:
- completed
jobs:
call-preview:
uses: TDesignOteam/workflows/.github/workflows/reusable-pr-preview.yml@main
secrets:
TDESIGN_SURGE_TOKEN: ${{ secrets.TDESIGN_SURGE_TOKEN }}
+14
View File
@@ -0,0 +1,14 @@
# 文件名建议统一为 pull-request.yml
# 应用 test-build.yml 的 demo
# 要求 package.json 提供 lint test site:preview
name: MAIN_PULL_REQUEST
on:
pull_request:
branches: [develop, main, site]
types: [opened, synchronize, reopened]
jobs:
call-test-build:
uses: TDesignOteam/workflows/.github/workflows/reusable-unit-test.yml@main
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
if [[ `uname` == 'Darwin' ]]; then
PATH="/usr/local/bin:$PATH"
fi
npm run lint:fix
+106
View File
@@ -0,0 +1,106 @@
# Changelog
## 0.3.1
### 🚀 Features
- 移除所有内置主题色相关代码,全部通过 tvision-color 计算获取 by @uyarn #144
- 卡片统一默认样式为无边框 by @uyarn #144
**Full Changelog**: https://github.com/Tencent/tdesign-react-starter/compare/0.3.0...0.3.1
## 0.3.0
❗ Breaking Changes
- 统一使用`.env`文件区分环境 生产环境改为`release` by @uyarn in https://github.com/Tencent/tdesign-react-starter/pull/136
🚀 Features
- 默认路由模式改为`history`路由 by @uyarn in https://github.com/Tencent/tdesign-react-starter/pull/136
**Full Changelog**: https://github.com/Tencent/tdesign-react-starter/compare/0.2.0...0.3.0
## 0.2.0
### ❗ Breaking Changes
- 升级组件库依赖至 0.43+ 更新主题色配色方案 by @uyarn in https://github.com/Tencent/tdesign-react-starter/pull/129
### 🚀 Features
- 新增自定义颜色面板选择 by @uyarn in https://github.com/Tencent/tdesign-react-starter/pull/129
### 🐞 Bug Fixes
- 修复卡片面板的标题丢失的问题 by @uyarn https://github.com/Tencent/tdesign-react-starter/pull/129
**Full Changelog**: https://github.com/Tencent/tdesign-react-starter/compare/0.1.6...0.2.0
## 0.1.6
### Features
- 升级组件库依赖至0.42+ 版本
### Bug Fixes
- 修复部分页面样式展示的缺陷
- 优化顶部菜单布局
## 0.1.5
## Features
- 新增卡片列表页 by @xucz in https://github.com/Tencent/tdesign-react-starter/pull/91
- 菜单路由配置`hidden`和`single`功能 by @xucz in https://github.com/Tencent/tdesign-react-starter/pull/95
## Bug Fixes
- 同步DatePicker组件升级的改动 by @uyarn in https://github.com/Tencent/tdesign-react-starter/pull/87
**Full Changelog**: https://github.com/Tencent/tdesign-react-starter/compare/0.1.4...0.1.5
## 0.1.4
## Features
- 增加面包屑导航
- 搜索框样式优化
- Layout组件命名语义化
- 图表主题功能优化
## 0.1.3
1. 升级tdesign-react
2. 替换为tdesign-react Card 组件
3. table样式优化
## 0.1.2
- 升级axios、tdesign @xucz
- fix: loss css token @uyarn
- 修复图表文字重叠 @xucz
## 0.1.1
## Features
- 新增登录页 #42 by @uyarn
- Logo点击跳转首页 #45 by @aouos
## 0.1.0
## Bug Fixes
- 修复页面主题色 by @xucz
- 修复页面布局
- 修复版本图表渲染问题
## Features
- 增加数据Mock by @xucz
+97
View File
@@ -0,0 +1,97 @@
<p style="display:flex; justify-content: center">
</p>
<p align="center">
<a href="https://tdesign.tencent.com/starter/react/#/dashboard/base" target="_blank">
<img alt="TDesign Logo" width="200" src="https://tdesign.gtimg.com/starter/brand-logo.svg">
</a>
</p>
<p align="center">
<a href="https://nodejs.org/en/about/releases/"><img src="https://img.shields.io/node/v/vite.svg" alt="node compatibility"></a>
<a href="https://github.com/Tencent/tdesign-react-starter/blob/develop/LICENSE">
<img src="https://img.shields.io/npm/l/tdesign-react.svg?sanitize=true" alt="License">
</a>
</p>
简体中文 | [English](./README.md)
### 项目简介
TDesign React Starter 是一个基于 tdesign-react,使用 `React`、`Vite2`开发,可进行个性化主题配置,旨在提供项目开箱即用的、配置式的中后台项目。
<img src="docs/docs-starter.png">
### 特性
- 内置多种常用的中后台页面
- 完善的目录结构
- 完善的代码规范配置
- 支持暗黑模式
- 自定义主题颜色
- 多种空间布局
- 内置 Mock 数据方案
### 开发
```bash
## 安装依赖
npm install
## 启动项目
npm run dev
## mock方式启动项目
npm run dev:mock
```
### 构建
```bash
## 构建正式环境
npm run build
## 构建测试环境
npm run build:test
```
### 其他
```bash
## 预览构建产物
npm run preview
## 代码格式检查
npm run lint
## 代码格式检查与自动修复
npm run lint:fix
```
### 如何贡献
非常欢迎您的贡献!提交您的 [Issue](https://github.com/tencent/tdesign-react-starter/issues/new/choose) 或者提交 [Pull Request](https://github.com/Tencent/tdesign-react-starter/pulls)。
#### Pull Request
1. Fork 代码!
2. 创建自己的分支: `git checkout -b feat/xxxx`
3. 提交你的修改: `git commit -a 'feat(project): describe'`
4. 推送您的分支: `git push origin feat/xxxx`
5. 提交 `pull request`
#### 贡献提交规范
- [Angular Convention](https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-angular)
### 兼容性
| [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/edge/edge_48x48.png" alt="IE / Edge" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br> IE / Edge | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/firefox/firefox_48x48.png" alt="Firefox" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Firefox | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/chrome/chrome_48x48.png" alt="Chrome" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Chrome | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/safari/safari_48x48.png" alt="Safari" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Safari |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Edge >=84 | Firefox >=83 | Chrome >=84 | Safari >=14.1 |
### 开源协议
TDesign 遵循 [MIT 协议](https://github.com/Tencent/tdesign-react-starter/LICENSE)。
+97
View File
@@ -0,0 +1,97 @@
<p style="display:flex; justify-content: center">
</p>
<p align="center">
<a href="https://tdesign.tencent.com/starter/react/#/dashboard/base" target="_blank">
<img alt="TDesign Logo" width="200" src="https://tdesign.gtimg.com/starter/brand-logo.svg">
</a>
</p>
<p align="center">
<a href="https://nodejs.org/en/about/releases/"><img src="https://img.shields.io/node/v/vite.svg" alt="node compatibility"></a>
<a href="https://github.com/Tencent/tdesign-react/blob/develop/LICENSE">
<img src="https://img.shields.io/npm/l/tdesign-react.svg?sanitize=true" alt="License">
</a>
</p>
English | [简体中文](./README-zh_CN.md)
### Introduction
TDesign React Starter is a TDesign-based project developed with `React`, `Vite`. It can be customized theme configuration, and aims to provide project out-of-the-box, configuration-style middle and background projects.
<p>
<a href="http://tdesign.tencent.com/starter/react/">Live Preview</a>
·
<a href="https://tdesign.tencent.com/starter/docs/react/get-started">Documentation</a>
</p>
<img src="docs/docs-starter.png">
### Features
- Various provided pages for develop
- Complete directory structure for develop
- Code specification configuration
- Support dark mode
- Custom theme colors
- Various space layouts
- Mock data scheme
### Usage
> Initialize project with our CLI tool `tdesign-starter-cli`
```bash
## install tdesign-starter-cli
npm i tdesign-starter-cli@latest -g
## create project
td-starter init
```
### Develop
```bash
## install dependencies
npm install
## set up
npm run dev
```
### Build
```bash
## build
npm run build
## build for test
npm run build:test
```
### Contributing Guide
We welcome contributions to our project. Create your [Issue](https://github.com/tencent/tdesign-react-starter/issues/new/choose) or Submit your [Pull Request](https://github.com/Tencent/tdesign-react-starter/pulls).
#### Pull Request
1. Fork it!
2. Create your branch: `git checkout -b feat/xxxx`
3. Commit: `git commit -a 'feat(project): describe'`
4. Push: `git push origin feat/xxxx`
5. New Pull Request `pull request`
#### Commit Specification
- [Angular Convention](https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-angular)
### Compatibility
| [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/edge/edge_48x48.png" alt="IE / Edge" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br> IE / Edge | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/firefox/firefox_48x48.png" alt="Firefox" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Firefox | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/chrome/chrome_48x48.png" alt="Chrome" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Chrome | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/safari/safari_48x48.png" alt="Safari" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Safari |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Edge >=84 | Firefox >=83 | Chrome >=84 | Safari >=14.1 |
### License
The MIT License. Please see [the license file](LICENSE) for more information.
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

+38
View File
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EveryPublish 发布中台</title>
<meta name="description" content="React UI Component" />
<meta name="theme-color" content="#ffffff" />
<link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
<meta name="msapplication-TileColor" content="#FFFFFF" />
<link rel="mask-icon" href="/favicon.svg" color="#FFFFFF" />
<!--[if IE]>
<style>
#app {
display: none;
}
</style>
<![endif]-->
</head>
<body>
<!--[if IE]>
<h1 style="position: absolute; width: 100%; text-align: center; top: 45%">
建议使用 <a href="https://www.google.cn/chrome/">Chrome</a>
</h1>
<![endif]-->
<td-horizon></td-horizon>
<td-aegis></td-aegis>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
<script>
if (window.Cypress) {
window.__REACT_DEVTOOLS_GLOBAL_HOOK__ = window.parent.__REACT_DEVTOOLS_GLOBAL_HOOK__;
}
</script>
</body>
</html>
+8239
View File
File diff suppressed because it is too large Load Diff
@@ -1,15 +1,21 @@
{
"name": "sau-web-react",
"version": "1.0.0",
"name": "@tencent/tdesign-react-starter",
"version": "0.3.1",
"private": true,
"scripts": {
"dev": "vite --mode development",
"dev:mock": "vite --open --mode mock",
"dev": "vite --open --mode development",
"dev:linux": "vite --mode development",
"build:test": "vite build --mode test",
"build": "vite build --mode release",
"build:site": "vite build --mode site",
"site:preview": "npm run build && cp -r dist _site",
"preview": "vite preview --mode test",
"test": "node ../../tools/web-smoke.mjs",
"test:coverage": "node ../../tools/web-smoke.mjs",
"lint": "eslint ./src --ext ts,tsx",
"lint:fix": "eslint ./src --ext ts,tsx --fix"
"lint:fix": "eslint ./src --ext ts,tsx --fix",
"prepare": "husky install"
},
"devDependencies": {
"@honkhonk/vite-plugin-svgr": "^1.1.0",
@@ -19,6 +25,7 @@
"@types/node": "^20.2.5",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/react-redux": "7",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^1.3.2",
"eslint": "^8.3.0",
@@ -28,6 +35,7 @@
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react": "^7.28.0",
"husky": "^8.0.1",
"less": "^4.1.3",
"prettier": "^2.5.1",
"typescript": "^4.8.4",

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Before

Width:  |  Height:  |  Size: 789 KiB

After

Width:  |  Height:  |  Size: 789 KiB

Before

Width:  |  Height:  |  Size: 972 KiB

After

Width:  |  Height:  |  Size: 972 KiB

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Before

Width:  |  Height:  |  Size: 979 B

After

Width:  |  Height:  |  Size: 979 B

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Before

Width:  |  Height:  |  Size: 958 B

After

Width:  |  Height:  |  Size: 958 B

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Before

Width:  |  Height:  |  Size: 495 B

After

Width:  |  Height:  |  Size: 495 B

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

@@ -0,0 +1,20 @@
import React from 'react';
import { Card, Tag } from 'tdesign-react';
interface IPlaceholderProps {
title: string;
desc?: string;
phase?: string;
}
const Placeholder: React.FC<IPlaceholderProps> = ({ title, desc, phase }) => (
<Card title={title} subtitle={desc || '页面空壳已就位,业务内容按排期填充'}>
<div style={{ padding: '24px 0' }}>
<Tag theme={phase ? 'warning' : 'primary'} variant='light'>
{phase || 'D5-D7 填充业务内容'}
</Tag>
</div>
</Card>
);
export default Placeholder;
+17
View File
@@ -0,0 +1,17 @@
export default {
mock: {
API: '/api/v1',
},
development: {
API: '/api/v1',
},
test: {
API: '/api/v1',
},
release: {
API: '/api/v1',
},
site: {
API: '/api/v1',
},
};
+16
View File
@@ -0,0 +1,16 @@
export const PLATFORM_LABEL: Record<string, string> = {
douyin: '抖音',
kuaishou: '快手',
xiaohongshu: '小红书',
bilibili: 'B站',
shipinhao: '视频号',
x: 'X (推特)',
instagram: 'Instagram',
whatsapp: 'WhatsApp',
youtube: 'YouTube',
};
export const PLATFORM_OPTIONS = Object.keys(PLATFORM_LABEL).map((k) => ({ label: PLATFORM_LABEL[k], value: k }));
export const DOMESTIC = ['douyin', 'kuaishou', 'xiaohongshu', 'bilibili', 'shipinhao'];
export const OVERSEAS = ['x', 'instagram', 'whatsapp', 'youtube'];
+3 -1
View File
@@ -30,7 +30,9 @@ declare module '*.svg' {
export default src as string;
}
declare module '*.svg?component' {
export default src as string;
import React from 'react';
const ReactComponent: React.FC<React.SVGProps<SVGSVGElement>>;
export default ReactComponent;
}
declare module '*.module.css' {
export default classes as { readonly [key: string]: string };
@@ -31,6 +31,10 @@ const renderRoutes: TRenderRoutes = (routes, parentPath = '', breadcrumb = []) =
}
if (Component) {
// 路由守卫:非全屏页未登录跳转登录页
if (!route.isFullPage && !localStorage.getItem('ep_token')) {
return <Route key={index} path={currentPath} element={<Navigate to='/login/index' replace />} />;
}
// 有路由菜单
return (
<Route
@@ -13,7 +13,7 @@ const Footer = () => {
return (
<TFooter>
<Row justify='center'>EveryPublish · 多平台自媒体自动化发布系统 © {new Date().getFullYear()}</Row>
<Row justify='center'>Copyright © 2022-{new Date().getFullYear()} Tencent. All Rights Reserved</Row>
</TFooter>
);
};
@@ -0,0 +1,82 @@
import React, { memo, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button, Badge, Dropdown, Space, Tag } from 'tdesign-react';
import { Icon, MailIcon, PoweroffIcon, UserCircleIcon } from 'tdesign-icons-react';
import { useAppDispatch, useAppSelector } from 'modules/store';
import { logout, selectUser } from 'modules/user';
import { disconnectWS, onWS } from 'utils/ws';
import request from 'utils/request';
import Style from './HeaderIcon.module.less';
const { DropdownMenu, DropdownItem } = Dropdown;
export default memo(() => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const userState = useAppSelector(selectUser);
const nickname = userState.userInfo.nickname || userState.userInfo.email || '未登录';
const [wsOnline, setWsOnline] = useState(false);
const [unread, setUnread] = useState(0);
useEffect(() => {
const offConnection = onWS('connection.status', (payload) => setWsOnline(Boolean(payload?.online)));
const loadNotifications = async () => {
try {
const res = await request.get('/notifications');
setUnread(Number(res.data.unread || 0));
} catch (e) {
setUnread(0);
}
};
loadNotifications();
const offNotification = onWS('notification.new', loadNotifications);
return () => {
offConnection();
offNotification();
};
}, []);
const clickHandler = (data: any) => {
if (data.value === 1) {
navigate('/setting/profile');
}
};
const handleLogout = async () => {
await dispatch(logout());
disconnectWS();
navigate('/login/index');
};
return (
<Space align='center'>
<Tag theme={wsOnline ? 'success' : 'warning'} variant='light'>
{wsOnline ? '实时连接' : '连接重试中'}
</Tag>
<Badge className={Style.badge} count={unread} dot={false} maxCount={99} shape='circle' showZero={false} size='medium'>
<Button className={Style.menuIcon} shape='square' size='large' variant='text' icon={<MailIcon />} onClick={() => navigate('/notification/list')} />
</Badge>
<Dropdown trigger={'click'} onClick={clickHandler}>
<Button variant='text' className={Style.dropdown}>
<Icon name='user-circle' className={Style.icon} />
<span className={Style.text}>{nickname}</span>
<Icon name='chevron-down' className={Style.icon} />
</Button>
<DropdownMenu>
<DropdownItem value={1}>
<div className={Style.dropItem}>
<UserCircleIcon />
<span>个人设置</span>
</div>
</DropdownItem>
<DropdownItem value={2} onClick={handleLogout}>
<div className={Style.dropItem}>
<PoweroffIcon />
<span>退出登录</span>
</div>
</DropdownItem>
</DropdownMenu>
</Dropdown>
</Space>
);
});
@@ -32,16 +32,3 @@
width: 184px;
height: 32px;
}
.logoText {
font-size: 16px;
font-weight: 600;
color: var(--td-brand-color);
white-space: nowrap;
}
.logoMiniText {
font-size: 16px;
font-weight: 600;
color: var(--td-brand-color);
}
@@ -1,10 +1,11 @@
import React, { memo, useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useNavigate, useLocation, NavigateFunction } from 'react-router-dom';
import { Menu, MenuValue } from 'tdesign-react';
import router, { IRouter } from 'router';
import { resolve } from 'utils/path';
import { useAppSelector } from 'modules/store';
import { selectGlobal } from 'modules/global';
import { selectUser } from 'modules/user';
import MenuLogo from './MenuLogo';
import Style from './Menu.module.less';
@@ -15,13 +16,17 @@ interface IMenuProps {
showOperation?: boolean;
}
const renderMenuItems = (menu: IRouter[], parentPath = '') => {
const navigate = useNavigate();
// 纯函数:递归渲染菜单(不在递归/循环内调用 Hook)
const renderMenuItems = (menu: IRouter[], navigate: NavigateFunction, isAdmin: boolean, parentPath = '') => {
return menu.map((item) => {
const { children, meta, path } = item;
if (!meta || meta?.hidden === true) {
// 无meta信息 或 hidden == true,路由不显示为菜单
return null;
}
if (meta.admin && !isAdmin) {
// 平台管理菜单仅 admin 角色可见
return null;
}
@@ -61,7 +66,7 @@ const renderMenuItems = (menu: IRouter[], parentPath = '') => {
return (
<SubMenu key={routerPath} value={routerPath} title={title} icon={Icon ? <Icon /> : undefined}>
{renderMenuItems(children, routerPath)}
{renderMenuItems(children, navigate, isAdmin, routerPath)}
</SubMenu>
);
});
@@ -73,6 +78,9 @@ const renderMenuItems = (menu: IRouter[], parentPath = '') => {
export const HeaderMenu = memo(() => {
const globalState = useAppSelector(selectGlobal);
const location = useLocation();
const navigate = useNavigate();
const userState = useAppSelector(selectUser);
const isAdmin = userState.userInfo?.role === 'admin';
const [active, setActive] = useState<MenuValue>(location.pathname); // todo
return (
@@ -83,7 +91,7 @@ export const HeaderMenu = memo(() => {
theme={globalState.theme}
onChange={(v) => setActive(v)}
>
{renderMenuItems(router)}
{renderMenuItems(router, navigate, isAdmin)}
</HeadMenu>
);
});
@@ -93,10 +101,13 @@ export const HeaderMenu = memo(() => {
*/
export default memo((props: IMenuProps) => {
const location = useLocation();
const navigate = useNavigate();
const globalState = useAppSelector(selectGlobal);
const userState = useAppSelector(selectUser);
const isAdmin = userState.userInfo?.role === 'admin';
const { version } = globalState;
const bottomText = globalState.collapsed ? version : 'EveryPublish';
const bottomText = globalState.collapsed ? version : 'EveryPublish ' + version;
return (
<Menu
@@ -109,7 +120,7 @@ export default memo((props: IMenuProps) => {
operations={props.showOperation ? <div className={Style.menuTip}>{bottomText}</div> : undefined}
logo={props.showLogo ? <MenuLogo collapsed={globalState.collapsed} /> : undefined}
>
{renderMenuItems(router)}
{renderMenuItems(router, navigate, isAdmin)}
</Menu>
);
});
@@ -1,5 +1,7 @@
import React, { memo } from 'react';
import Style from './Menu.module.less';
import FullLogo from 'assets/svg/assets-logo-full.svg?component';
import MiniLogo from 'assets/svg/assets-t-logo.svg?component';
import { useNavigate } from 'react-router-dom';
interface IProps {
@@ -15,7 +17,7 @@ export default memo((props: IProps) => {
return (
<div className={Style.menuLogo} onClick={handleClick}>
{props.collapsed ? <span className={Style.logoMiniText}>E</span> : <span className={Style.logoText}>EveryPublish</span>}
{props.collapsed ? <MiniLogo /> : <FullLogo />}
</div>
);
});
@@ -7,7 +7,7 @@
background: transparent;
}
.t-menu--scroll::-webkit-scrollbar-thumb {
border-radius: 8px;
border-radius: 6px;
border: 2px solid transparent;
background-clip: content-box;
background-color: #eeeeee;
@@ -3,6 +3,7 @@ import { Drawer, Layout } from 'tdesign-react';
import throttle from 'lodash/throttle';
import { useAppSelector, useAppDispatch } from 'modules/store';
import { selectGlobal, toggleSetting, toggleMenu, ELayout, switchTheme } from 'modules/global';
import { getUserInfo } from 'modules/user';
import Setting from './components/Setting';
import AppLayout from './components/AppLayout';
import Style from './index.module.less';
@@ -11,7 +12,14 @@ export default memo(() => {
const globalState = useAppSelector(selectGlobal);
const dispatch = useAppDispatch();
const AppContainer = AppLayout[globalState.isFullPage ? ELayout.fullPage : globalState.layout];
const layoutKey = (globalState.isFullPage ? ELayout.fullPage : globalState.layout) as ELayout;
const AppContainer = AppLayout[layoutKey];
useEffect(() => {
if (localStorage.getItem('ep_token')) {
dispatch(getUserInfo());
}
}, []);
useEffect(() => {
dispatch(switchTheme(globalState.theme));
@@ -5,6 +5,8 @@ import { BrowserRouter } from 'react-router-dom';
import store from 'modules/store';
import App from 'layouts/index';
import { connectWS } from 'utils/ws';
import 'tdesign-react/es/style/index.css';
import './styles/index.less';
@@ -26,3 +28,4 @@ const renderApp = () => {
};
renderApp();
connectWS();
@@ -2,13 +2,11 @@ import { configureStore, combineReducers } from '@reduxjs/toolkit';
import { TypedUseSelectorHook, useSelector, useDispatch } from 'react-redux';
import global from './global';
import account from './account';
import material from './material';
import user from './user';
const reducer = combineReducers({
global,
account,
material,
user,
});
export const store = configureStore({
+116
View File
@@ -0,0 +1,116 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import request from 'utils/request';
import { RootState } from '../store';
const namespace = 'user';
export const TOKEN_KEY = 'ep_token';
export const REFRESH_KEY = 'ep_refresh';
interface IUserInfo {
id?: number;
email?: string;
nickname?: string;
role?: string;
totpEnabled?: boolean;
}
const initialState = {
token: localStorage.getItem(TOKEN_KEY) || '',
refreshToken: localStorage.getItem(REFRESH_KEY) || '',
userInfo: {} as IUserInfo,
workspaceId: 0,
memberRole: '',
};
// login
export const login = createAsyncThunk(`${namespace}/login`, async (payload: { email: string; password: string }) => {
const res = await request.post('/auth/login', payload);
return res.data;
});
// register
export const register = createAsyncThunk(
`${namespace}/register`,
async (payload: { email: string; password: string; nickname: string }) => {
const res = await request.post('/auth/register', payload);
return res.data;
},
);
// getUserInfo
export const getUserInfo = createAsyncThunk(`${namespace}/getUserInfo`, async () => {
const res = await request.get('/auth/me');
return res.data;
});
// logout
export const logout = createAsyncThunk(`${namespace}/logout`, async (_, { getState }) => {
const state = getState() as RootState;
try {
await request.post('/auth/logout', { refreshToken: state.user.refreshToken });
} catch (e) {
// 令牌已失效也允许本地登出
}
});
interface ILoginResult {
accessToken: string;
refreshToken: string;
user: IUserInfo;
workspace?: { id: number; name?: string };
}
const userSlice = createSlice({
name: namespace,
initialState,
reducers: {
clear: (state) => {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_KEY);
state.token = '';
state.refreshToken = '';
state.userInfo = {};
state.workspaceId = 0;
state.memberRole = '';
},
},
extraReducers: (builder) => {
builder
.addCase(login.fulfilled, (state, action) => {
const data = action.payload as ILoginResult;
localStorage.setItem(TOKEN_KEY, data.accessToken);
localStorage.setItem(REFRESH_KEY, data.refreshToken);
state.token = data.accessToken;
state.refreshToken = data.refreshToken;
state.userInfo = data.user;
state.workspaceId = data.workspace?.id || 0;
})
.addCase(register.fulfilled, (state, action) => {
const data = action.payload as ILoginResult;
localStorage.setItem(TOKEN_KEY, data.accessToken);
localStorage.setItem(REFRESH_KEY, data.refreshToken);
state.token = data.accessToken;
state.refreshToken = data.refreshToken;
state.userInfo = data.user;
state.workspaceId = data.workspace?.id || 0;
})
.addCase(getUserInfo.fulfilled, (state, action) => {
const data = action.payload as { user: IUserInfo; workspaceId: number; memberRole: string };
state.userInfo = data.user;
state.workspaceId = data.workspaceId || state.workspaceId;
state.memberRole = data.memberRole || state.memberRole;
})
.addCase(logout.fulfilled, (state) => {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_KEY);
state.token = '';
state.refreshToken = '';
state.userInfo = {};
});
},
});
export const selectUser = (state: RootState) => state.user;
export const { clear } = userSlice.actions;
export default userSlice.reducer;
+341
View File
@@ -0,0 +1,341 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Button, Card, Dialog, Form, FormInstanceFunctions, Input, MessagePlugin, Popconfirm, QRCode, Select, Space, Table, Tag, Textarea } from 'tdesign-react';
import dayjs from 'dayjs';
import request from 'utils/request';
import { onWS } from 'utils/ws';
import { PLATFORM_LABEL, PLATFORM_OPTIONS } from 'constants/platforms';
import { useAppSelector } from 'modules/store';
import { selectUser } from 'modules/user';
const { FormItem } = Form;
interface IAccount {
id: number;
platform: string;
accountName: string;
remark: string;
avatarUrl: string;
status: string;
health: number;
ipProfile: string;
lastActiveAt?: string | null;
lastError: string;
}
const STATUS_MAP: Record<string, { label: string; theme: string }> = {
unbound: { label: '未绑定', theme: 'default' },
binding: { label: '绑定中', theme: 'warning' },
active: { label: '已绑定', theme: 'success' },
suspended: { label: '已挂起', theme: 'warning' },
expired: { label: '已过期', theme: 'danger' },
};
export default function AccountList() {
const userState = useAppSelector(selectUser);
const canOperate = ['owner', 'admin', 'operator'].includes(userState.memberRole || '');
const canAdmin = ['owner', 'admin'].includes(userState.memberRole || '');
const [list, setList] = useState<IAccount[]>([]);
const [loading, setLoading] = useState(false);
const [createVisible, setCreateVisible] = useState(false);
const [bindAccount, setBindAccount] = useState<IAccount | null>(null);
const [bindTip, setBindTip] = useState('');
const [bindChallenge, setBindChallenge] = useState<{ id: number; qrUrl: string; qrToken: string; status: string; prompt: string } | null>(null);
const [credentialsTarget, setCredentialsTarget] = useState<IAccount | null>(null);
const [credentialsValue, setCredentialsValue] = useState('');
const [credentialsSaving, setCredentialsSaving] = useState(false);
const [checkingId, setCheckingId] = useState(0);
const bindAccountRef = useRef<IAccount | null>(null);
const formRef = useRef<FormInstanceFunctions>();
const load = useCallback(async () => {
setLoading(true);
try {
const res = await request.get('/accounts?size=100');
setList((res.data.list || []) as IAccount[]);
} catch (e) {
MessagePlugin.error('账号加载失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
const off = onWS('challenge.status', (p) => {
const current = bindAccountRef.current;
if (p?.accountId && current?.id === p.accountId) {
setBindTip(p.prompt || (p.status === 'scanned' ? '已扫码,请在手机上确认登录' : p.status === 'pending' ? '等待扫码' : '登录状态已更新'));
}
if (p?.status === 'solved' && current?.id === p.accountId) {
MessagePlugin.success('账号绑定成功');
setBindAccount(null);
bindAccountRef.current = null;
setBindChallenge(null);
load();
}
});
const offAccount = onWS('account.status', () => load());
return () => { off(); offAccount(); };
}, [load]);
const openCreate = () => {
setCreateVisible(true);
};
const create = async () => {
const value = (formRef.current?.getFieldsValue?.(true) || {}) as { platform: string; remark: string };
if (!value.platform) {
MessagePlugin.warning('请选择平台');
return;
}
try {
await request.post('/accounts', {
platform: value.platform,
remark: value.remark || '',
});
MessagePlugin.success('账号已添加');
setCreateVisible(false);
load();
} catch (e: any) {
MessagePlugin.error(e?.response?.data?.message || '添加失败');
}
};
const bind = async (row: IAccount) => {
setBindAccount(row);
bindAccountRef.current = row;
setBindTip('');
try {
const res = await request.post('/accounts/' + row.id + '/bind');
setBindChallenge({
id: res.data.challengeId,
qrUrl: res.data.qrUrl || '',
qrToken: res.data.qrToken || '',
status: 'active',
prompt: res.data.prompt || '等待扫码',
});
setBindTip(res.data.prompt || '等待扫码');
} catch (e: any) {
MessagePlugin.error(e?.response?.data?.message || '发起绑定失败');
setBindAccount(null);
bindAccountRef.current = null;
setBindChallenge(null);
}
};
const unbind = async (row: IAccount) => {
try {
await request.post('/accounts/' + row.id + '/unbind');
MessagePlugin.success('账号已解绑');
load();
} catch (e: any) {
MessagePlugin.error(e?.response?.data?.message || '解绑失败');
}
};
const importCredentials = async () => {
if (!credentialsTarget || !credentialsValue.trim()) {
MessagePlugin.warning('请输入平台 Cookie 凭据');
return;
}
setCredentialsSaving(true);
try {
await request.post('/accounts/' + credentialsTarget.id + '/credentials', { cookies: credentialsValue.trim() });
MessagePlugin.success('凭据已加密保存,账号已恢复可用');
setCredentialsTarget(null);
setCredentialsValue('');
load();
} catch (e: any) {
MessagePlugin.error(e?.response?.data?.message || '凭据导入失败');
} finally {
setCredentialsSaving(false);
}
};
const checkAccount = async (row: IAccount) => {
setCheckingId(row.id);
try {
await request.post('/accounts/' + row.id + '/check');
MessagePlugin.success('账号凭据有效');
load();
} catch (e: any) {
MessagePlugin.error(e?.response?.data?.message || '账号检查失败');
load();
} finally {
setCheckingId(0);
}
};
const remove = async (row: IAccount) => {
try {
await request.delete('/accounts/' + row.id);
MessagePlugin.success('已删除');
load();
} catch (e: any) {
MessagePlugin.error(e?.response?.data?.message || '删除失败');
}
};
const columns = [
{
colKey: 'platform',
title: '平台',
width: 110,
cell: ({ row }: { row: IAccount }) => <Tag theme='primary' variant='light'>{PLATFORM_LABEL[row.platform] || row.platform}</Tag>,
},
{
colKey: 'remark',
title: '账号(备注)',
ellipsis: true,
cell: ({ row }: { row: IAccount }) => (
<div>
<div>{row.remark || '未备注'}</div>
{row.accountName && <div style={{ fontSize: 12, color: '#888' }}>{row.accountName}</div>}
</div>
),
},
{
colKey: 'status',
title: '状态',
width: 100,
cell: ({ row }: { row: IAccount }) => {
const st = STATUS_MAP[row.status] || { label: row.status, theme: 'default' };
return <Tag theme={st.theme as any}>{st.label}</Tag>;
},
},
{
colKey: 'lastActiveAt',
title: '最后活跃',
width: 150,
cell: ({ row }: { row: IAccount }) => (
<span>{row.lastActiveAt ? dayjs(row.lastActiveAt).format('MM-DD HH:mm') : '-'}</span>
),
},
{
colKey: 'op',
title: '操作',
width: 230,
cell: ({ row }: { row: IAccount }) => (
<Space size={0}>
{canOperate && (row.status === 'unbound' || row.status === 'expired' || row.status === 'active') && (
<Button size='small' variant='text' theme='primary' onClick={() => bind(row)}>
{row.status === 'active' ? '重新登录' : '登录'}
</Button>
)}
{canOperate && (row.status === 'unbound' || row.status === 'expired') && (
<Button size='small' variant='text' onClick={() => { setCredentialsTarget(row); setCredentialsValue(''); }}>
导入凭据
</Button>
)}
{canOperate && row.status === 'active' && (
<Button size='small' variant='text' loading={checkingId === row.id} onClick={() => checkAccount(row)}>
检查
</Button>
)}
{canAdmin && row.status === 'unbound' && (
<Popconfirm content='确认删除该账号?' onConfirm={() => remove(row)}>
<Button size='small' variant='text' theme='danger'>
删除
</Button>
</Popconfirm>
)}
{canAdmin && (row.status === 'active' || row.status === 'binding' || row.status === 'expired') && (
<Popconfirm content='解绑后需要重新登录,确认?' onConfirm={() => unbind(row)}>
<Button size='small' variant='text' theme='danger'>
解绑
</Button>
</Popconfirm>
)}
</Space>
),
},
];
return (
<Card
title='账号台账'
actions={
<Space>
{canOperate && <Button theme='primary' onClick={openCreate}>添加账号</Button>}
<Button variant='outline' onClick={load}>
刷新
</Button>
</Space>
}
>
<Table data={list} columns={columns} rowKey='id' loading={loading} hover />
<div style={{ marginTop: 12, fontSize: 13, color: '#888' }}>当前为 Web-only 本地模式:登录挑战和平台浏览器 profile 在运行服务器的设备上完成,不需要客户端配对。</div>
<Dialog
header='添加平台账号'
visible={createVisible}
onClose={() => setCreateVisible(false)}
footer={
<Space>
<Button variant='outline' onClick={() => setCreateVisible(false)}>
取消
</Button>
<Button theme='primary' onClick={create}>
确定
</Button>
</Space>
}
>
<Form ref={formRef} labelWidth={90}>
<FormItem label='平台' name='platform' rules={[{ required: true, message: '请选择平台', type: 'error' }]}>
<Select options={PLATFORM_OPTIONS} placeholder='选择平台' />
</FormItem>
<FormItem label='备注' name='remark'>
<Input placeholder='选填,如「品牌主号」「投放账号」' />
</FormItem>
</Form>
</Dialog>
<Dialog
header={'绑定 ' + (bindAccount ? (bindAccount.remark || PLATFORM_LABEL[bindAccount.platform] || '') : '')}
visible={!!bindAccount}
onClose={() => { setBindAccount(null); bindAccountRef.current = null; setBindChallenge(null); }}
footer={null}
>
<div style={{ padding: '8px 0 16px', textAlign: 'center' }}>
{bindChallenge?.qrUrl && !bindChallenge.qrUrl.startsWith('mock://') && (
<QRCode value={bindChallenge.qrUrl} size={220} />
)}
<div style={{ margin: '14px 0 6px', fontWeight: 600 }}>{bindTip || '正在发起登录...'}</div>
<div style={{ fontSize: 13, color: '#888', marginBottom: 14 }}>
二维码由当前服务器生成并轮询状态,不需要客户端配对码。
</div>
{bindChallenge?.qrUrl?.startsWith('mock://') && (
<Button theme='primary' onClick={async () => {
if (!bindChallenge) return;
try {
await request.post('/challenges/' + bindChallenge.id + '/solve', { value: 'web-mock-login' });
MessagePlugin.success('本地模拟登录已完成');
setBindAccount(null);
bindAccountRef.current = null;
setBindChallenge(null);
load();
} catch (e: any) {
MessagePlugin.error(e?.response?.data?.message || '模拟登录失败');
}
}}>完成本地模拟登录</Button>
)}
<div style={{ marginTop: 12 }}>
<Button variant='outline' onClick={() => { setBindAccount(null); bindAccountRef.current = null; setBindChallenge(null); }}>
稍后处理
</Button>
</div>
</div>
</Dialog>
<Dialog
header={'导入 ' + (credentialsTarget ? (credentialsTarget.remark || PLATFORM_LABEL[credentialsTarget.platform] || '') : '') + ' 凭据'}
visible={!!credentialsTarget}
onClose={() => { setCredentialsTarget(null); setCredentialsValue(''); }}
confirmBtn={<Button theme='primary' loading={credentialsSaving} onClick={importCredentials}>加密保存</Button>}
>
<div style={{ fontSize: 13, color: '#666', marginBottom: 10 }}>
仅支持已接入真实适配器的平台。凭据只写入当前服务器的加密目录,不会显示在列表、日志或响应中。
</div>
<Textarea value={credentialsValue} onChange={(v) => setCredentialsValue(v as string)} rows={5} placeholder='粘贴平台 Cookie 字符串' />
</Dialog>
</Card>
);
}
+416
View File
@@ -0,0 +1,416 @@
export interface ApiParam {
name: string;
type: string;
required: boolean;
desc: string;
}
export interface ApiErrorCode {
code: number;
desc: string;
}
export interface ApiItem {
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
path: string;
summary: string;
auth: string;
status: string;
params?: ApiParam[];
curl: string;
errors?: ApiErrorCode[];
note?: string;
}
export interface ApiGroup {
key: string;
title: string;
desc: string;
apis: ApiItem[];
}
export const BASE = 'http://127.0.0.1:8090/api/v1';
export const globalErrorCodes: ApiErrorCode[] = [
{ code: 0, desc: '成功' },
{ code: 1001, desc: '参数不合法' },
{ code: 1002, desc: '未登录 / 令牌无效或已过期' },
{ code: 1003, desc: '无权限(需更高角色)' },
{ code: 1004, desc: '资源不存在' },
{ code: 1006, desc: '需要两步验证(2FA,一期默认关闭)' },
{ code: 2001, desc: '邮箱已注册' },
{ code: 2002, desc: '邮箱或密码错误' },
{ code: 2003, desc: '刷新令牌无效或已失效' },
{ code: 2004, desc: '已是该工作空间成员' },
{ code: 2005, desc: '邀请链接无效或已过期' },
{ code: 3001, desc: '绑定中/已绑定账号不可删除' },
{ code: 3002, desc: '账号已绑定' },
{ code: 3003, desc: '挑战已结束(不可重发/挂起)' },
{ code: 3004, desc: '下载链接已过期' },
{ code: 3005, desc: '任务当前状态不允许该操作' },
{ code: 3006, desc: '当前模式或平台不支持该操作' },
{ code: 2007, desc: '平台适配器或外部服务失败' },
{ code: 5000, desc: '服务内部错误' },
];
export const apiGroups: ApiGroup[] = [
{
key: 'auth',
title: '认证 /auth',
desc: '注册、登录、令牌刷新、登出、当前用户(JWT 体系,access 15min + refresh 7d 轮换)',
apis: [
{
method: 'POST',
path: '/auth/register',
summary: '注册新账号,自动创建默认工作空间(创建者为 owner)',
auth: '无',
status: '200 成功 / 400 参数错误 / 409 邮箱已注册(2001)',
params: [
{ name: 'email', type: 'string', required: true, desc: '登录邮箱(唯一)' },
{ name: 'password', type: 'string', required: true, desc: '密码,至少 8 位' },
{ name: 'nickname', type: 'string', required: false, desc: '昵称,缺省取邮箱前缀' },
],
curl: `curl -X POST http://127.0.0.1:8090/api/v1/auth/register -H 'Content-Type: application/json' -d '{"email":"demo@everypublish.io","password":"demo-pass-2026","nickname":"演示用户"}'`,
},
{
method: 'POST',
path: '/auth/login',
summary: '登录,返回 accessToken + refreshToken + 用户信息',
auth: '无',
status: '200 成功 / 400 参数错误 / 401 邮箱或密码错误(2002) / 403 需2FA(1006)',
params: [
{ name: 'email', type: 'string', required: true, desc: '邮箱' },
{ name: 'password', type: 'string', required: true, desc: '密码' },
{ name: 'totpCode', type: 'string', required: false, desc: '2FA 验证码(默认关,开启后必填)' },
],
curl: `curl -X POST http://127.0.0.1:8090/api/v1/auth/login -H 'Content-Type: application/json' -d '{"email":"demo@everypublish.io","password":"demo-pass-2026"}'`,
},
{
method: 'POST',
path: '/auth/refresh',
summary: '刷新令牌轮换:旧 refresh 立即吊销,签发新令牌对',
auth: '无(凭 refreshToken)',
status: '200 成功 / 400 参数错误 / 401 令牌无效或已失效(2003)',
params: [{ name: 'refreshToken', type: 'string', required: true, desc: '登录/刷新返回的 refreshToken' }],
curl: `curl -X POST http://127.0.0.1:8090/api/v1/auth/refresh -H 'Content-Type: application/json' -d '{"refreshToken":"<your-refresh-token>"}'`,
},
{
method: 'POST',
path: '/auth/logout',
summary: '登出,吊销 refresh 令牌',
auth: 'Bearer',
status: '200 成功 / 401 未认证(1002)',
params: [{ name: 'refreshToken', type: 'string', required: false, desc: '要吊销的 refreshToken' }],
curl: `curl -X POST http://127.0.0.1:8090/api/v1/auth/logout -H 'Authorization: Bearer <accessToken>' -H 'Content-Type: application/json' -d '{"refreshToken":"<refresh-token>"}'`,
},
{
method: 'POST', path: '/auth/password', summary: '修改当前用户密码', auth: 'Bearer', status: '200 / 400 / 401',
params: [{ name: 'currentPassword', type: 'string', required: true, desc: '当前密码' }, { name: 'newPassword', type: 'string', required: true, desc: '新密码,至少 8 位' }],
curl: `curl -X POST http://127.0.0.1:8090/api/v1/auth/password -H 'Authorization: Bearer <accessToken>' -H 'Content-Type: application/json' -d '{"currentPassword":"old-pass","newPassword":"new-pass-2026"}'`,
},
{
method: 'GET',
path: '/auth/me',
summary: '当前用户信息 + 工作空间 + 角色',
auth: 'Bearer',
status: '200 成功 / 401 未认证(1002)',
curl: `curl http://127.0.0.1:8090/api/v1/auth/me -H 'Authorization: Bearer <accessToken>'`,
},
],
},
{
key: 'workspace',
title: '工作空间 /workspaces',
desc: '多租户工作空间(创建者 owner)',
apis: [
{
method: 'POST', path: '/workspaces', summary: '创建工作空间', auth: 'Bearer', status: '200 / 400 / 401',
params: [{ name: 'name', type: 'string', required: true, desc: '工作空间名称' }],
curl: `curl -X POST http://127.0.0.1:8090/api/v1/workspaces -H 'Authorization: Bearer <accessToken>' -H 'Content-Type: application/json' -d '{"name":"品牌工作空间"}'`,
},
{
method: 'GET', path: '/workspaces', summary: '我所在的工作空间列表', auth: 'Bearer', status: '200 / 401',
curl: `curl http://127.0.0.1:8090/api/v1/workspaces -H 'Authorization: Bearer <accessToken>'`,
},
{
method: 'PUT', path: '/workspaces/:id', summary: '修改工作空间名称(owner/admin)', auth: 'Bearer', status: '200 / 400 / 401 / 403(1003)',
params: [{ name: 'name', type: 'string', required: true, desc: '新名称' }],
curl: `curl -X PUT http://127.0.0.1:8090/api/v1/workspaces/1 -H 'Authorization: Bearer <accessToken>' -H 'Content-Type: application/json' -d '{"name":"新名称"}'`,
},
],
},
{
key: 'member',
title: '成员 /members',
desc: '邀请、加入、角色、移除(owner/admin 管理)',
apis: [
{
method: 'GET', path: '/members', summary: '成员列表(含邮箱/昵称)', auth: 'Bearer', status: '200 / 401',
curl: `curl http://127.0.0.1:8090/api/v1/members -H 'Authorization: Bearer <accessToken>'`,
},
{
method: 'POST', path: '/members/invite', summary: '生成邀请链接(24h,一次性)', auth: 'Bearer(owner/admin)', status: '200 / 400 / 401 / 403 / 409(2004)',
params: [
{ name: 'email', type: 'string', required: true, desc: '被邀请人登录邮箱' },
{ name: 'role', type: 'string', required: true, desc: 'admin/operator/reviewer/viewer' },
],
curl: `curl -X POST http://127.0.0.1:8090/api/v1/members/invite -H 'Authorization: Bearer <accessToken>' -H 'Content-Type: application/json' -d '{"email":"member@ep.io","role":"operator"}'`,
},
{
method: 'POST', path: '/members/join', summary: '接受邀请(须登录,邮箱须与邀请一致)', auth: 'Bearer', status: '200 / 400 / 401 / 403 / 409(2004) / 400(2005)',
params: [{ name: 'token', type: 'string', required: true, desc: '邀请链接中的 token' }],
curl: `curl -X POST http://127.0.0.1:8090/api/v1/members/join -H 'Authorization: Bearer <accessToken>' -H 'Content-Type: application/json' -d '{"token":"<invite-token>"}'`,
},
{
method: 'PUT', path: '/members/:id/role', summary: '修改成员角色(不可改 owner)', auth: 'Bearer(owner/admin)', status: '200 / 400 / 401 / 403 / 404',
params: [{ name: 'role', type: 'string', required: true, desc: 'admin/operator/reviewer/viewer' }],
curl: `curl -X PUT http://127.0.0.1:8090/api/v1/members/2/role -H 'Authorization: Bearer <accessToken>' -H 'Content-Type: application/json' -d '{"role":"reviewer"}'`,
},
{
method: 'DELETE', path: '/members/:id', summary: '移除成员(不可移除 owner)', auth: 'Bearer(owner/admin)', status: '200 / 401 / 403 / 404',
curl: `curl -X DELETE http://127.0.0.1:8090/api/v1/members/2 -H 'Authorization: Bearer <accessToken>'`,
},
],
},
{
key: 'account',
title: '账号台账 /accounts',
desc: '平台账号登记与网页本机登录;凭据和浏览器 profile 由当前服务器本机执行器管理',
apis: [
{
method: 'GET', path: '/accounts', summary: '账号列表(platform/status 筛选 + 分页)', auth: 'Bearer', status: '200 / 401',
params: [{ name: 'platform', type: 'string', required: false, desc: 'douyin/kuaishou/xiaohongshu/bilibili' }, { name: 'status', type: 'string', required: false, desc: 'unbound/binding/active/suspended/expired' }],
curl: `curl 'http://127.0.0.1:8090/api/v1/accounts?platform=douyin&status=active' -H 'Authorization: Bearer <accessToken>'`,
},
{
method: 'POST', path: '/accounts', summary: '新增账号(初始 unbound)', auth: 'Bearer', status: '200 / 400 / 401',
params: [
{ name: 'platform', type: 'string', required: true, desc: 'douyin/kuaishou/xiaohongshu/bilibili' },
{ name: 'accountName', type: 'string', required: false, desc: '账号名称,可在网页登录后同步' },
{ name: 'avatarUrl', type: 'string', required: false, desc: '头像 URL' },
{ name: 'ipProfile', type: 'string', required: false, desc: 'IP 画像标识(一账号固定 IP)' },
],
curl: `curl -X POST http://127.0.0.1:8090/api/v1/accounts -H 'Authorization: Bearer <accessToken>' -H 'Content-Type: application/json' -d '{"platform":"douyin","accountName":"品牌官方号"}'`,
},
{
method: 'PUT', path: '/accounts/:id', summary: '修改账号资料', auth: 'Bearer', status: '200 / 400 / 401',
params: [{ name: 'accountName', type: 'string', required: true, desc: '账号名称' }],
curl: `curl -X PUT http://127.0.0.1:8090/api/v1/accounts/1 -H 'Authorization: Bearer <accessToken>' -H 'Content-Type: application/json' -d '{"accountName":"新名称"}'`,
},
{
method: 'DELETE', path: '/accounts/:id', summary: '删除账号(仅未绑定)', auth: 'Bearer', status: '200 / 401 / 404 / 409(3001)',
curl: `curl -X DELETE http://127.0.0.1:8090/api/v1/accounts/1 -H 'Authorization: Bearer <accessToken>'`,
},
{
method: 'POST', path: '/accounts/:id/bind', summary: '发起网页登录挑战(QR/验证码)', auth: 'Bearer', status: '200 / 400 / 401 / 404',
curl: `curl -X POST http://127.0.0.1:8090/api/v1/accounts/1/bind -H 'Authorization: Bearer <accessToken>'`,
},
{
method: 'POST', path: '/accounts/:id/credentials', summary: '导入平台 Cookie(服务端加密保存)', auth: 'Bearer(owner/admin/operator)', status: '200 / 400 / 401 / 409(3006)',
params: [{ name: 'cookies', type: 'string', required: true, desc: '平台 Cookie 字符串,不会回显' }],
curl: `curl -X POST http://127.0.0.1:8090/api/v1/accounts/1/credentials -H 'Authorization: Bearer <accessToken>' -H 'Content-Type: application/json' -d '{"cookies":"SESSDATA=...; bili_jct=...; DedeUserID=..."}'`,
},
{
method: 'POST', path: '/accounts/:id/check', summary: '调用平台适配器检查凭据', auth: 'Bearer(owner/admin/operator)', status: '200 / 409(3006) / 502(2007)',
curl: `curl -X POST http://127.0.0.1:8090/api/v1/accounts/1/check -H 'Authorization: Bearer <accessToken>'`,
},
{
method: 'POST', path: '/accounts/:id/unbind', summary: '解绑并清理本机登录状态', auth: 'Bearer', status: '200 / 401 / 404',
curl: `curl -X POST http://127.0.0.1:8090/api/v1/accounts/1/unbind -H 'Authorization: Bearer <accessToken>'`,
},
],
},
{
key: 'material',
title: '素材 /materials',
desc: '素材上传(sha256 去重)与签名直链(10 分钟一次性)',
apis: [
{
method: 'GET', path: '/materials', summary: '素材列表(kind/group 筛选 + 分页)', auth: 'Bearer', status: '200 / 401',
curl: `curl 'http://127.0.0.1:8090/api/v1/materials?kind=video' -H 'Authorization: Bearer <accessToken>'`,
},
{
method: 'POST', path: '/materials', summary: '上传素材(multipart,流式 sha256,同工作区去重)', auth: 'Bearer', status: '200 / 400(1001) / 401 / 500',
params: [{ name: 'file', type: 'file', required: true, desc: '视频/图片文件(multipart 字段名 file)' }, { name: 'group', type: 'string', required: false, desc: '分组' }, { name: 'tags', type: 'string', required: false, desc: '标签 JSON 数组' }],
curl: `curl -X POST http://127.0.0.1:8090/api/v1/materials -H 'Authorization: Bearer <accessToken>' -F 'file=@/path/video.mp4' -F 'group=新品'`,
},
{
method: 'GET', path: '/materials/:id/url', summary: '生成签名直链(10 分钟、一次性)', auth: 'Bearer', status: '200 / 401 / 404',
curl: `curl http://127.0.0.1:8090/api/v1/materials/1/url -H 'Authorization: Bearer <accessToken>'`,
},
{
method: 'GET', path: '/files/:token', summary: '凭 token 下载文件(token 即凭证,一次性)', auth: '一次性 token', status: '200 / 404(1004) / 410(3004)',
curl: `curl -o out.mp4 'http://127.0.0.1:8090/api/v1/files/<token>'`,
},
{
method: 'DELETE', path: '/materials/:id', summary: '删除素材及文件', auth: 'Bearer', status: '200 / 401 / 404',
curl: `curl -X DELETE http://127.0.0.1:8090/api/v1/materials/1 -H 'Authorization: Bearer <accessToken>'`,
},
],
},
{
key: 'task',
title: '任务 /tasks',
desc: '发布任务(状态机:draft→pending_review→queued→dispatched→running→success/failed)',
apis: [
{
method: 'GET', path: '/tasks', summary: '任务列表(status/schedule 筛选 + 分页)', auth: 'Bearer', status: '200 / 401',
curl: `curl 'http://127.0.0.1:8090/api/v1/tasks?status=success' -H 'Authorization: Bearer <accessToken>'`,
},
{
method: 'POST', path: '/tasks', summary: '新建草稿', auth: 'Bearer', status: '200 / 400 / 401',
params: [
{ name: 'title', type: 'string', required: true, desc: '标题' },
{ name: 'content', type: 'string', required: false, desc: '正文' },
{ name: 'tags', type: 'string[]', required: false, desc: '话题标签' },
{ name: 'accountIds', type: 'number[]', required: true, desc: '账号 ID 数组(至少 1 个)' },
{ name: 'materialIds', type: 'number[]', required: true, desc: '素材 ID 数组(至少 1 个)' },
{ name: 'scheduleAt', type: 'number', required: false, desc: '定时发布时间 unix 毫秒,必须晚于当前时间' },
{ name: 'priority', type: 'number', required: false, desc: '优先级 1-10(默认 5)' },
],
curl: `curl -X POST http://127.0.0.1:8090/api/v1/tasks -H 'Authorization: Bearer <accessToken>' -H 'Content-Type: application/json' -d '{"title":"新品发布","accountIds":[1],"materialIds":[1]}'`,
},
{
method: 'GET', path: '/tasks/:id', summary: '任务详情', auth: 'Bearer', status: '200 / 401 / 404',
curl: `curl http://127.0.0.1:8090/api/v1/tasks/1 -H 'Authorization: Bearer <accessToken>'`,
},
{
method: 'PUT', path: '/tasks/:id', summary: '编辑草稿(仅 draft/rejected)', auth: 'Bearer', status: '200 / 400 / 401 / 404 / 409(3005)',
curl: `curl -X PUT http://127.0.0.1:8090/api/v1/tasks/1 -H 'Authorization: Bearer <accessToken>' -H 'Content-Type: application/json' -d '{"title":"新标题","accountIds":[1],"materialIds":[1]}'`,
},
{
method: 'DELETE', path: '/tasks/:id', summary: '删除草稿(仅 draft)', auth: 'Bearer', status: '200 / 401 / 404 / 409(3005)',
curl: `curl -X DELETE http://127.0.0.1:8090/api/v1/tasks/1 -H 'Authorization: Bearer <accessToken>'`,
},
{
method: 'POST', path: '/tasks/:id/submit', summary: '提交审核(draft→pending_review)', auth: 'Bearer', status: '200 / 401 / 404 / 409(3005)',
curl: `curl -X POST http://127.0.0.1:8090/api/v1/tasks/1/submit -H 'Authorization: Bearer <accessToken>'`,
},
{
method: 'POST', path: '/tasks/:id/approve', summary: '审核通过(→queued,本机 Web 执行器立即处理)', auth: 'Bearer', status: '200 / 401 / 404 / 409(3005)',
curl: `curl -X POST http://127.0.0.1:8090/api/v1/tasks/1/approve -H 'Authorization: Bearer <accessToken>'`,
},
{
method: 'POST', path: '/tasks/:id/reject', summary: '驳回(→rejected,附批注并通知创建人)', auth: 'Bearer', status: '200 / 400 / 401 / 404 / 409(3005)',
params: [{ name: 'note', type: 'string', required: false, desc: '驳回批注' }],
curl: `curl -X POST http://127.0.0.1:8090/api/v1/tasks/1/reject -H 'Authorization: Bearer <accessToken>' -H 'Content-Type: application/json' -d '{"note":"标题需修改"}'`,
},
{
method: 'POST', path: '/tasks/:id/resubmit', summary: '驳回后重新提交(→pending_review)', auth: 'Bearer', status: '200 / 401 / 404 / 409(3005)',
curl: `curl -X POST http://127.0.0.1:8090/api/v1/tasks/1/resubmit -H 'Authorization: Bearer <accessToken>'`,
},
{
method: 'POST', path: '/tasks/:id/retry', summary: '失败重试(→queued,立即下发)', auth: 'Bearer', status: '200 / 401 / 404 / 409(3005)',
curl: `curl -X POST http://127.0.0.1:8090/api/v1/tasks/1/retry -H 'Authorization: Bearer <accessToken>'`,
},
{
method: 'POST', path: '/tasks/:id/cancel', summary: '取消任务', auth: 'Bearer', status: '200 / 401 / 404 / 409(3005)',
curl: `curl -X POST http://127.0.0.1:8090/api/v1/tasks/1/cancel -H 'Authorization: Bearer <accessToken>'`,
},
{
method: 'GET', path: '/tasks/:id/events', summary: '任务状态时间线和执行日志', auth: 'Bearer', status: '200 / 401 / 404',
curl: `curl http://127.0.0.1:8090/api/v1/tasks/1/events -H 'Authorization: Bearer <accessToken>'`,
},
],
},
{
key: 'challenge',
title: '挑战 /challenges',
desc: '二次验证兜底(扫码/短信/APP确认/图形验证码;超时挂起,一键重发)',
apis: [
{
method: 'GET', path: '/challenges', summary: '挑战列表(过期自动置 expired)', auth: 'Bearer', status: '200 / 401',
curl: `curl 'http://127.0.0.1:8090/api/v1/challenges?status=active' -H 'Authorization: Bearer <accessToken>'`,
},
{
method: 'POST', path: '/challenges/:id/solve', summary: '人工完成挑战(验证码/APP确认)', auth: 'Bearer', status: '200 / 400 / 401 / 404 / 409(3003)',
params: [{ name: 'value', type: 'string', required: false, desc: '验证码或确认值' }],
curl: `curl -X POST http://127.0.0.1:8090/api/v1/challenges/1/solve -H 'Authorization: Bearer <accessToken>' -H 'Content-Type: application/json' -d '{"value":"123456"}'`,
},
{
method: 'POST', path: '/challenges/:id/resend', summary: '一键重发(expired/suspended→active)', auth: 'Bearer', status: '200 / 401 / 404 / 409(3003)',
curl: `curl -X POST http://127.0.0.1:8090/api/v1/challenges/1/resend -H 'Authorization: Bearer <accessToken>'`,
},
{
method: 'POST', path: '/challenges/:id/suspend', summary: '挂起', auth: 'Bearer', status: '200 / 401 / 404 / 409(3003)',
curl: `curl -X POST http://127.0.0.1:8090/api/v1/challenges/1/suspend -H 'Authorization: Bearer <accessToken>'`,
},
],
},
{
key: 'notification',
title: '通知 /notifications',
desc: '站内通知(任务审核/挑战/系统事件)',
apis: [
{
method: 'GET', path: '/notifications', summary: '我的通知 + 未读计数', auth: 'Bearer', status: '200 / 401',
curl: `curl http://127.0.0.1:8090/api/v1/notifications -H 'Authorization: Bearer <accessToken>'`,
},
{
method: 'POST', path: '/notifications/:id/read', summary: '标记已读', auth: 'Bearer', status: '200 / 401',
curl: `curl -X POST http://127.0.0.1:8090/api/v1/notifications/1/read -H 'Authorization: Bearer <accessToken>'`,
},
{
method: 'POST', path: '/notifications/read-all', summary: '全部已读', auth: 'Bearer', status: '200 / 401',
curl: `curl -X POST http://127.0.0.1:8090/api/v1/notifications/read-all -H 'Authorization: Bearer <accessToken>'`,
},
],
},
{
key: 'audit',
title: '审计 /audit-logs',
desc: '操作审计(登录/登出/增删改/审核/挑战,自动埋点)',
apis: [
{
method: 'GET', path: '/audit-logs', summary: '审计日志(userId/action 筛选 + 分页)', auth: 'Bearer', status: '200 / 401',
curl: `curl 'http://127.0.0.1:8090/api/v1/audit-logs?action=auth.login&size=20' -H 'Authorization: Bearer <accessToken>'`,
},
],
},
{
key: 'admin',
title: '平台管理 /admin',
desc: '仅平台管理员(role=admin)可用',
apis: [
{
method: 'GET', path: '/admin/tenants', summary: '租户列表(owner 邮箱/成员数)', auth: 'Bearer(admin)', status: '200 / 401 / 403(1003)',
curl: `curl 'http://127.0.0.1:8090/api/v1/admin/tenants' -H 'Authorization: Bearer <accessToken>'`,
},
{
method: 'PUT', path: '/admin/tenants/:id/status', summary: '启用/停用租户', auth: 'Bearer(admin)', status: '200 / 400 / 401 / 403 / 404',
params: [{ name: 'status', type: 'string', required: true, desc: 'active/suspended' }],
curl: `curl -X PUT http://127.0.0.1:8090/api/v1/admin/tenants/1/status -H 'Authorization: Bearer <accessToken>' -H 'Content-Type: application/json' -d '{"status":"suspended"}'`,
},
],
},
{
key: 'health',
title: '健康 /health',
desc: '服务健康检查(db/redis 状态)',
apis: [
{
method: 'GET', path: '/health', summary: '服务健康(db/redis)', auth: '无', status: '200',
curl: `curl http://127.0.0.1:8090/health`,
},
],
},
{
key: 'runtime',
title: '运行状态 /runtime/status',
desc: '当前执行模式、已注册适配器、数据库和 Redis 状态',
apis: [{ method: 'GET', path: '/runtime/status', summary: '读取当前网页执行器状态', auth: 'Bearer', status: '200 / 401', curl: `curl http://127.0.0.1:8090/api/v1/runtime/status -H 'Authorization: Bearer <accessToken>'` }],
},
{
key: 'ws',
title: '浏览器实时通道 /ws',
desc: '网页端 Browser WebSocket,断开后 REST 兜底',
apis: [
{
method: 'GET', path: '/ws/browser', summary: '浏览器通道(?token=JWT,订阅 task.status/challenge.status/notification.new)', auth: 'JWT(query token)', status: '101 升级 / 401',
curl: `# 前端 utils/ws.ts 自动连接;地址 ws://127.0.0.1:3013/ws/browser?token=<JWT>`,
},
],
},
];
export default apiGroups;
+268
View File
@@ -0,0 +1,268 @@
import React, { useState } from 'react';
import { Button, Card, Input, Menu, MessagePlugin, Table, Tag, Textarea } from 'tdesign-react';
import { apiGroups, globalErrorCodes, ApiItem } from './apiData';
const { MenuItem, SubMenu } = Menu;
const METHOD_THEME: Record<string, string> = {
GET: 'success',
POST: 'primary',
PUT: 'warning',
DELETE: 'danger',
};
const paramColumns = [
{ colKey: 'name', title: '参数', width: 140, ellipsis: true },
{ colKey: 'type', title: '类型', width: 100 },
{
colKey: 'required',
title: '必填',
width: 70,
cell: ({ row }: { row: any }) => (
<Tag theme={row.required ? 'danger' : 'default'} variant='light'>{row.required ? '是' : '否'}</Tag>
),
},
{ colKey: 'desc', title: '说明', ellipsis: true },
];
const errorColumns = [
{ colKey: 'code', title: '错误码', width: 100 },
{ colKey: 'desc', title: '含义' },
];
function endpointKey(gkey: string, api: ApiItem): string {
return gkey + '::' + api.method + ' ' + api.path;
}
function pathParams(path: string): string[] {
return (path.match(/:[a-zA-Z]+/g) || []).map((p) => p.slice(1));
}
function extractBody(curl: string): string {
const m = curl.match(/-d\s+'([^']*)'/);
if (m) {
try {
return JSON.stringify(JSON.parse(m[1]), null, 2);
} catch (e) {
return m[1];
}
}
return '{\n}';
}
function EndpointTest({ api, resetKey }: { api: ApiItem; resetKey: string }) {
const [pathVals, setPathVals] = useState<Record<string, string>>({});
const [query, setQuery] = useState('');
const [body, setBody] = useState(extractBody(api.curl));
const [token, setToken] = useState(() => (api.auth === '无' || api.auth === '一次性 token' ? '' : localStorage.getItem('ep_token') || ''));
const [result, setResult] = useState<{ status: number; body: string; time: number } | null>(null);
const [loading, setLoading] = useState(false);
const isWS = api.path.startsWith('/ws');
const isMultipart = api.curl.includes('-F ');
const params = pathParams(api.path);
const run = async () => {
if (isWS) return;
setLoading(true);
let url = api.path;
params.forEach((p) => {
url = url.replace(':' + p, pathVals[p] || '');
});
if (query.trim()) {
url += (url.includes('?') ? '&' : '?') + query.trim();
}
const full = (api.path === '/health' ? '' : '/api/v1') + url;
const headers: Record<string, string> = {};
if (token.trim()) headers.Authorization = 'Bearer ' + token.trim();
let options: RequestInit = { method: api.method, headers };
if ((api.method === 'POST' || api.method === 'PUT') && !isMultipart) {
headers['Content-Type'] = 'application/json';
options.body = body;
}
const t0 = performance.now();
try {
const res = await fetch(full, options);
const text = await res.text();
let pretty = text;
try {
pretty = JSON.stringify(JSON.parse(text), null, 2);
} catch (e) {
// 非 JSON 保持原文
}
setResult({ status: res.status, body: pretty, time: Math.round(performance.now() - t0) });
} catch (e: any) {
setResult({ status: 0, body: '请求失败:' + (e?.message || e), time: 0 });
} finally {
setLoading(false);
}
};
return (
<div style={{ marginTop: 16, border: '1px solid var(--td-component-border)', borderRadius: 6, overflow: 'hidden' }}>
<div style={{ padding: '10px 16px', background: 'var(--td-bg-color-container)', borderBottom: '1px solid var(--td-component-border)', fontWeight: 600 }}>
在线测试
{isWS && <span style={{ fontWeight: 400, color: 'var(--td-text-color-secondary)', marginLeft: 8 }}>(WebSocket 通道,无法用 HTTP 测试)</span>}
{isMultipart && <span style={{ fontWeight: 400, color: 'var(--td-text-color-secondary)', marginLeft: 8 }}>(multipart 上传,建议到素材库页测试)</span>}
</div>
<div style={{ padding: '12px 16px' }}>
{params.length > 0 && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 13, color: 'var(--td-text-color-secondary)', marginBottom: 6 }}>路径参数</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{params.map((p) => (
<Input
key={p}
placeholder={':' + p}
value={pathVals[p] || ''}
onChange={(v) => setPathVals((s) => ({ ...s, [p]: v as string }))}
style={{ width: 160 }}
/>
))}
</div>
</div>
)}
<div style={{ marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Input
placeholder='查询参数(如 page=1&size=10)'
value={query}
onChange={(v) => setQuery(v as string)}
style={{ width: 260 }}
/>
<Input
placeholder='Authorization Token(可留空)'
value={token}
onChange={(v) => setToken(v as string)}
style={{ width: 260 }}
/>
</div>
{(api.method === 'POST' || api.method === 'PUT') && !isMultipart && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 13, color: 'var(--td-text-color-secondary)', marginBottom: 6 }}>请求体(JSON)</div>
<Textarea value={body} onChange={(v) => setBody(v as string)} rows={4} style={{ fontFamily: 'monospace' }} />
</div>
)}
<Button theme='primary' onClick={run} loading={loading} disabled={isWS || isMultipart}>
发送测试请求
</Button>
{result && (
<div style={{ marginTop: 12 }}>
<div style={{ marginBottom: 6, fontSize: 13 }}>
响应状态:
<Tag theme={result.status >= 200 && result.status < 300 ? 'success' : 'danger'}>{result.status || '网络错误'}</Tag>
<span style={{ color: 'var(--td-text-color-secondary)', marginLeft: 8 }}>耗时 {result.time}ms</span>
</div>
<pre
style={{
background: 'var(--td-bg-color-component)',
padding: 12,
borderRadius: 4,
overflow: 'auto',
maxHeight: 320,
fontSize: 12,
lineHeight: 1.6,
margin: 0,
}}
>
{result.body}
</pre>
</div>
)}
</div>
</div>
);
}
export default function AdminDocs() {
const [selected, setSelected] = useState(() => endpointKey(apiGroups[0].key, apiGroups[0].apis[0]));
let selGroup = apiGroups[0];
let selApi: ApiItem = apiGroups[0].apis[0];
for (const g of apiGroups) {
for (const a of g.apis) {
if (endpointKey(g.key, a) === selected) {
selGroup = g;
selApi = a;
}
}
}
const copy = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
MessagePlugin.success('已复制');
} catch (e) {
MessagePlugin.error('复制失败');
}
};
return (
<Card title='开发文档(API)' subtitle='接口联调:左侧二级菜单选择端点,右侧查看文档并在线测试'>
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start' }}>
<Menu value={selected} onChange={(v) => setSelected(v as string)} style={{ width: 240, flexShrink: 0 }} theme='light'>
{apiGroups.map((g) => (
<SubMenu key={g.key} value={g.key} title={g.title}>
{g.apis.map((a) => (
<MenuItem key={endpointKey(g.key, a)} value={endpointKey(g.key, a)}>
{a.method} {a.path}
</MenuItem>
))}
</SubMenu>
))}
</Menu>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, color: 'var(--td-text-color-secondary)', marginBottom: 12 }}>{selGroup.desc}</div>
<div style={{ marginBottom: 8, border: '1px solid var(--td-component-border)', borderRadius: 6, overflow: 'hidden' }}>
<div style={{ padding: '12px 16px', background: 'var(--td-bg-color-container)', borderBottom: '1px solid var(--td-component-border)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<Tag theme={(METHOD_THEME[selApi.method] as any) || 'default'}>{selApi.method}</Tag>
<code style={{ fontFamily: 'monospace', fontSize: 13 }}>{selApi.path}</code>
<span style={{ color: 'var(--td-text-color-secondary)', fontSize: 13, marginLeft: 'auto' }}>{selApi.summary}</span>
</div>
</div>
<div style={{ padding: '12px 16px' }}>
<div style={{ fontSize: 13, lineHeight: 2 }}>
<div><b>访问方式(鉴权)</b>:{selApi.auth}</div>
<div><b>访问状态</b>:{selApi.status}</div>
</div>
{selApi.params && selApi.params.length > 0 && (
<div style={{ marginTop: 12 }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 6 }}>规定范围(参数)</div>
<Table data={selApi.params} columns={paramColumns} rowKey='name' size='small' bordered />
</div>
)}
<div style={{ marginTop: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 6 }}>
<span style={{ fontSize: 13, fontWeight: 600 }}>请求示例(CURL)</span>
<Button size='small' variant='text' theme='primary' style={{ marginLeft: 8 }} onClick={() => copy(selApi.curl)}>
复制
</Button>
</div>
<pre
style={{
background: 'var(--td-bg-color-component)',
padding: 12,
borderRadius: 4,
overflowX: 'auto',
fontSize: 12,
lineHeight: 1.6,
margin: 0,
}}
>
{selApi.curl}
</pre>
</div>
{selApi.note && (
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--td-warning-color)' }}>注:{selApi.note}</div>
)}
</div>
</div>
<EndpointTest key={selected} api={selApi} resetKey={selected} />
<div style={{ marginTop: 16 }}>
<div style={{ fontSize: 14, fontWeight: 600, margin: '12px 0 8px' }}>全局错误码</div>
<Table data={globalErrorCodes} columns={errorColumns} rowKey='code' size='small' bordered />
</div>
</div>
</div>
</Card>
);
}
+98
View File
@@ -0,0 +1,98 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Button, Card, MessagePlugin, Popconfirm, Table, Tag } from 'tdesign-react';
import dayjs from 'dayjs';
import request from 'utils/request';
interface ITenant {
id: number;
name: string;
ownerEmail: string;
plan: string;
status: string;
memberCount: number;
createdAt: string;
}
export default function AdminTenant() {
const [list, setList] = useState<ITenant[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await request.get('/admin/tenants?page=' + page + '&size=20');
setList((res.data.list || []) as ITenant[]);
setTotal(res.data.total || 0);
} catch (e: any) {
MessagePlugin.error(e?.response?.data?.message || '加载失败');
} finally {
setLoading(false);
}
}, [page]);
useEffect(() => {
load();
}, [load]);
const setStatus = async (t: ITenant, status: string) => {
try {
await request.put('/admin/tenants/' + t.id + '/status', { status });
MessagePlugin.success('已更新');
load();
} catch (e: any) {
MessagePlugin.error(e?.response?.data?.message || '操作失败');
}
};
const columns = [
{ colKey: 'id', title: 'ID', width: 70 },
{ colKey: 'name', title: '租户', ellipsis: true },
{ colKey: 'ownerEmail', title: 'Owner 邮箱', ellipsis: true },
{ colKey: 'plan', title: '套餐', width: 90 },
{ colKey: 'memberCount', title: '成员', width: 80 },
{
colKey: 'status',
title: '状态',
width: 100,
cell: ({ row }: { row: ITenant }) => (
<Tag theme={row.status === 'active' ? 'success' : 'danger'}>{row.status === 'active' ? '正常' : '已停用'}</Tag>
),
},
{
colKey: 'createdAt',
title: '创建时间',
width: 160,
cell: ({ row }: { row: ITenant }) => <span>{dayjs(row.createdAt).format('YYYY-MM-DD')}</span>,
},
{
colKey: 'op',
title: '操作',
width: 110,
cell: ({ row }: { row: ITenant }) => (
<Popconfirm
content={row.status === 'active' ? '停用后该租户成员将无法使用,确认?' : '确认恢复该租户?'}
onConfirm={() => setStatus(row, row.status === 'active' ? 'suspended' : 'active')}
>
<Button size='small' variant='text' theme={row.status === 'active' ? 'danger' : 'success'}>
{row.status === 'active' ? '停用' : '启用'}
</Button>
</Popconfirm>
),
},
];
return (
<Card title='租户管理'>
<Table
data={list}
columns={columns}
rowKey='id'
loading={loading}
hover
pagination={{ current: page, total, pageSize: 20, onChange: (p) => setPage(p.current) }}
/>
</Card>
);
}
+96
View File
@@ -0,0 +1,96 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Button, Card, MessagePlugin, Select, Table } from 'tdesign-react';
import dayjs from 'dayjs';
import request from 'utils/request';
interface IAudit {
id: number;
userId: number;
action: string;
resource: string;
detail: string;
ip: string;
createdAt: string;
}
export default function AuditList() {
const [list, setList] = useState<IAudit[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [action, setAction] = useState('');
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await request.get('/audit-logs?page=' + page + '&size=20' + (action ? '&action=' + action : ''));
setList((res.data.list || []) as IAudit[]);
setTotal(res.data.total || 0);
} catch (e) {
MessagePlugin.error('加载失败');
} finally {
setLoading(false);
}
}, [page, action]);
useEffect(() => {
load();
}, [load]);
const columns = [
{ colKey: 'id', title: 'ID', width: 70 },
{ colKey: 'userId', title: '用户', width: 80 },
{ colKey: 'action', title: '动作', width: 160 },
{ colKey: 'resource', title: '对象', width: 160 },
{ colKey: 'detail', title: '详情', ellipsis: true },
{ colKey: 'ip', title: 'IP', width: 130 },
{
colKey: 'createdAt',
title: '时间',
width: 160,
cell: ({ row }: { row: IAudit }) => <span>{dayjs(row.createdAt).format('MM-DD HH:mm:ss')}</span>,
},
];
return (
<Card title='审计日志'>
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
<Select
value={action}
onChange={(v) => {
setAction(v as string);
setPage(1);
}}
options={[
{ label: '全部动作', value: '' },
{ label: '登录', value: 'auth.login' },
{ label: '登出', value: 'auth.logout' },
{ label: '任务', value: 'task.create' },
{ label: '审核', value: 'task.approve' },
{ label: '驳回', value: 'task.reject' },
{ label: '账号绑定', value: 'account.bind' },
{ label: '素材', value: 'material.upload' },
{ label: '成员', value: 'member.invite' },
]}
style={{ width: 160 }}
/>
<Button variant='outline' onClick={load}>
刷新
</Button>
</div>
<Table
data={list}
columns={columns}
rowKey='id'
loading={loading}
hover
pagination={{
current: page,
total,
pageSize: 20,
onChange: (p) => setPage(p.current),
}}
/>
</Card>
);
}
+164
View File
@@ -0,0 +1,164 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Button, Card, Dialog, Input, MessagePlugin, Popconfirm, QRCode, Space, Table, Tag } from 'tdesign-react';
import dayjs from 'dayjs';
import request from 'utils/request';
import { onWS } from 'utils/ws';
interface IChallenge {
id: number;
accountId: number;
platform: string;
kind: string;
status: string;
qrToken: string;
qrUrl: string;
prompt: string;
expiresAt: string;
createdAt: string;
}
const KIND_LABEL: Record<string, string> = { qr: '扫码', sms: '短信验证码', confirm: '应用确认', captcha: '图形验证码', pending: '等待验证' };
const STATUS_MAP: Record<string, { label: string; theme: string }> = {
active: { label: '待处理', theme: 'warning' },
solved: { label: '已完成', theme: 'success' },
expired: { label: '已过期', theme: 'default' },
suspended: { label: '已挂起', theme: 'danger' },
};
export default function ChallengeList() {
const [list, setList] = useState<IChallenge[]>([]);
const [loading, setLoading] = useState(false);
const [qrTarget, setQrTarget] = useState<IChallenge | null>(null);
const [solveTarget, setSolveTarget] = useState<IChallenge | null>(null);
const [solveValue, setSolveValue] = useState('');
const load = useCallback(async () => {
setLoading(true);
try {
const res = await request.get('/challenges');
setList((res.data.list || []) as IChallenge[]);
} catch (e) {
MessagePlugin.error('加载失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
const off = onWS('challenge.status', () => load());
return off;
}, [load]);
const act = async (ch: IChallenge, action: string, body?: any) => {
try {
await request.post('/challenges/' + ch.id + '/' + action, body || {});
MessagePlugin.success('操作成功');
load();
} catch (e: any) {
MessagePlugin.error(e?.response?.data?.message || '操作失败');
}
};
const columns = [
{ colKey: 'id', title: 'ID', width: 70 },
{ colKey: 'platform', title: '平台', width: 90 },
{
colKey: 'kind',
title: '类型',
width: 110,
cell: ({ row }: { row: IChallenge }) => <span>{KIND_LABEL[row.kind] || row.kind}</span>,
},
{ colKey: 'prompt', title: '提示', ellipsis: true },
{
colKey: 'status',
title: '状态',
width: 100,
cell: ({ row }: { row: IChallenge }) => {
const st = STATUS_MAP[row.status] || { label: row.status, theme: 'default' };
return <Tag theme={st.theme as any}>{st.label}</Tag>;
},
},
{
colKey: 'expiresAt',
title: '过期时间',
width: 150,
cell: ({ row }: { row: IChallenge }) => <span>{dayjs(row.expiresAt).format('MM-DD HH:mm')}</span>,
},
{
colKey: 'op',
title: '操作',
width: 240,
cell: ({ row }: { row: IChallenge }) => (
<Space size={0}>
{row.kind === 'qr' && row.status === 'active' && (
<>
<Button size='small' variant='text' theme='primary' onClick={() => setQrTarget(row)}>
显示二维码
</Button>
{row.qrUrl?.startsWith('mock://') && (
<Button size='small' variant='text' onClick={() => act(row, 'solve', { value: 'web-mock-login' })}>
模拟完成
</Button>
)}
</>
)}
{(row.kind === 'sms' || row.kind === 'captcha') && row.status === 'active' && (
<Button size='small' variant='text' theme='primary' onClick={() => setSolveTarget(row)}>
输入验证码
</Button>
)}
{(row.status === 'expired' || row.status === 'suspended') && (
<Button size='small' variant='text' onClick={() => act(row, 'resend')}>
一键重发
</Button>
)}
{row.status === 'active' && (
<Popconfirm content='挂起后将停止等待本次登录,确认?' onConfirm={() => act(row, 'suspend')}>
<Button size='small' variant='text' theme='warning'>
挂起
</Button>
</Popconfirm>
)}
</Space>
),
},
];
return (
<Card title='挑战中心' subtitle='二次验证兜底:扫码/验证码/应用确认;超时自动挂起,可一键重发'>
<Table data={list} columns={columns} rowKey='id' loading={loading} hover />
<Dialog
header='扫码验证(本地渲染,不截图传输)'
visible={!!qrTarget}
onClose={() => setQrTarget(null)}
footer={null}
>
<div style={{ textAlign: 'center', padding: '12px 0' }}>
<QRCode value={qrTarget?.qrUrl?.startsWith('mock://') ? qrTarget.qrUrl : (qrTarget?.qrUrl || qrTarget?.qrToken || 'pending')} size={220} />
<div style={{ fontSize: 13, color: '#888', marginTop: 8 }}>{qrTarget?.prompt}</div>
<div style={{ fontSize: 12, color: '#999', marginTop: 6 }}>二维码只需在此页面扫码;客户端配对码不参与平台登录。</div>
</div>
</Dialog>
<Dialog
header='输入验证码'
visible={!!solveTarget}
onClose={() => setSolveTarget(null)}
confirmBtn={
<Button
theme='primary'
onClick={() => {
if (solveTarget) act(solveTarget, 'solve', { value: solveValue });
setSolveTarget(null);
setSolveValue('');
}}
>
提交
</Button>
}
>
<Input value={solveValue} onChange={(v) => setSolveValue(v as string)} placeholder='输入收到的验证码' />
</Dialog>
</Card>
);
}
+131
View File
@@ -0,0 +1,131 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Card, Col, Row, Table, Tag } from 'tdesign-react';
import dayjs from 'dayjs';
import request from 'utils/request';
import { onWS } from 'utils/ws';
const STATUS_MAP: Record<string, { label: string; theme: string }> = {
draft: { label: '草稿', theme: 'default' },
pending_review: { label: '待审核', theme: 'warning' },
rejected: { label: '已驳回', theme: 'danger' },
queued: { label: '排队中', theme: 'warning' },
dispatched: { label: '已下发', theme: 'primary' },
running: { label: '执行中', theme: 'primary' },
success: { label: '发布成功', theme: 'success' },
failed: { label: '失败', theme: 'danger' },
suspended: { label: '已挂起', theme: 'warning' },
cancelled: { label: '已取消', theme: 'default' },
};
interface ITask { id: number; title: string; status: string; scheduleAt?: string | null; createdAt: string; publishedAt?: string | null; }
interface IChallenge { id: number; status: string; }
interface IRuntime { executorMode: string; adapters: string[]; db: boolean; redis: boolean; }
function StatCard({ label, value, hint }: { label: string; value: string; hint?: string }) {
return (
<Card>
<div style={{ fontSize: 13, color: 'var(--td-text-color-secondary)' }}>{label}</div>
<div style={{ fontSize: 28, fontWeight: 600, marginTop: 4, lineHeight: 1.2 }}>{value}</div>
{hint && <div style={{ fontSize: 12, color: 'var(--td-text-color-placeholder)', marginTop: 4 }}>{hint}</div>}
</Card>
);
}
export default function DashboardBase() {
const [tasks, setTasks] = useState<ITask[]>([]);
const [challenges, setChallenges] = useState<IChallenge[]>([]);
const [runtime, setRuntime] = useState<IRuntime | null>(null);
const load = useCallback(async () => {
try {
const t = await request.get('/tasks?size=100');
setTasks((t.data.list || []) as ITask[]);
} catch (e) {
setTasks([]);
}
try {
const c = await request.get('/challenges');
setChallenges((c.data.list || []) as IChallenge[]);
} catch (e) {
setChallenges([]);
}
try {
const r = await request.get('/runtime/status');
setRuntime(r.data as IRuntime);
} catch (e) {
setRuntime(null);
}
}, []);
useEffect(() => {
load();
const offTask = onWS('task.status', () => load());
const offChallenge = onWS('challenge.status', () => load());
return () => {
offTask();
offChallenge();
};
}, [load]);
const activeChallenges = challenges.filter((c) => c.status === 'active').length;
const pendingReview = tasks.filter((t) => t.status === 'pending_review').length;
const runningTasks = tasks.filter((t) => t.status === 'running' || t.status === 'dispatched' || t.status === 'queued').length;
const recentTasks = tasks.slice(0, 8);
const taskColumns = [
{ colKey: 'id', title: 'ID', width: 70 },
{ colKey: 'title', title: '标题', ellipsis: true },
{
colKey: 'status',
title: '状态',
width: 110,
cell: ({ row }: { row: ITask }) => {
const st = STATUS_MAP[row.status] || { label: row.status, theme: 'default' };
return <Tag theme={st.theme as any}>{st.label}</Tag>;
},
},
{
colKey: 'scheduleAt',
title: '计划发布',
width: 150,
cell: ({ row }: { row: ITask }) => <span>{row.scheduleAt ? dayjs(row.scheduleAt).format('MM-DD HH:mm') : '立即'}</span>,
},
{
colKey: 'createdAt',
title: '创建时间',
width: 160,
cell: ({ row }: { row: ITask }) => <span>{dayjs(row.createdAt).format('MM-DD HH:mm')}</span>,
},
];
return (
<div>
<Row gutter={[16, 16]}>
<Col xs={12} sm={6} xl={3}>
<StatCard
label='本机执行器'
value={runtime?.executorMode === 'web' ? 'Web 真实模式' : 'Mock 联调模式'}
hint={runtime ? `适配器:${runtime.adapters.join('、') || '无'}` : '状态读取中'}
/>
</Col>
<Col xs={12} sm={6} xl={3}>
<StatCard label='待处理挑战' value={String(activeChallenges)} hint='扫码/验证码等二次验证' />
</Col>
<Col xs={12} sm={6} xl={3}>
<StatCard label='待审核任务' value={String(pendingReview)} hint='审核中心待处理' />
</Col>
<Col xs={12} sm={6} xl={3}>
<StatCard label='执行中任务' value={String(runningTasks)} hint='排队/运行中的任务' />
</Col>
</Row>
<Card title='最近任务' subtitle='实时状态推送' style={{ marginTop: 16 }}>
<Table data={recentTasks} columns={taskColumns} rowKey='id' hover />
</Card>
<Card title='执行器说明' style={{ marginTop: 16 }}>
<div style={{ color: 'var(--td-text-color-secondary)', lineHeight: 1.8 }}>
当前为 Web-only 本地模式:任务由服务器进程排队和执行,不需要客户端配对。{runtime?.executorMode === 'web' ? '已注册平台会走真实适配器。' : '当前为自动化联调模式,结果不会代表真实平台发布。'}
</div>
</Card>
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
import React, { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Button, Card, Loading, MessagePlugin } from 'tdesign-react';
import request from 'utils/request';
export default function Invite() {
const [params] = useSearchParams();
const navigate = useNavigate();
const [state, setState] = useState<'loading' | 'ok' | 'needLogin' | 'fail'>('loading');
const [msg, setMsg] = useState('');
useEffect(() => {
const token = params.get('t');
const run = async () => {
if (!token) {
setState('fail');
setMsg('链接无效');
return;
}
if (!localStorage.getItem('ep_token')) {
setState('needLogin');
return;
}
try {
await request.post('/members/join', { token });
setState('ok');
} catch (e: any) {
const m = e?.response?.data?.message || '加入失败';
if (e?.response?.status === 401) {
setState('needLogin');
return;
}
setState('fail');
setMsg(m);
}
};
run();
}, [params]);
const loginFirst = () => {
navigate('/login/index');
};
const goHome = () => {
navigate('/dashboard/base');
};
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh' }}>
<Card style={{ width: 420 }}>
{state === 'loading' && (
<div style={{ textAlign: 'center', padding: 24 }}>
<Loading text='正在加入工作空间...' />
</div>
)}
{state === 'ok' && (
<div style={{ textAlign: 'center', padding: 24 }}>
<div style={{ fontSize: 16, marginBottom: 16 }}>🎉 已成功加入工作空间</div>
<Button theme='primary' onClick={goHome}>
进入工作台
</Button>
</div>
)}
{state === 'needLogin' && (
<div style={{ textAlign: 'center', padding: 24 }}>
<div style={{ fontSize: 16, marginBottom: 8 }}>请先登录</div>
<div style={{ fontSize: 13, color: '#888', marginBottom: 16 }}>登录邮箱需与邀请邮箱一致</div>
<Button theme='primary' onClick={loginFirst}>
去登录
</Button>
</div>
)}
{state === 'fail' && (
<div style={{ textAlign: 'center', padding: 24 }}>
<div style={{ fontSize: 16, marginBottom: 16, color: '#e34d59' }}>{msg}</div>
<Button variant='outline' onClick={goHome}>
返回
</Button>
</div>
)}
</Card>
</div>
);
}

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