Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3585c39bab | ||
|
|
0daa9782c9 | ||
|
|
9abf7f8213 | ||
|
|
33b7d57498 | ||
|
|
cc8845dcec | ||
|
|
bf25d8beac |
@@ -1,8 +0,0 @@
|
||||
.venv
|
||||
.git
|
||||
.idea
|
||||
cookies
|
||||
conf.py
|
||||
sau_frontend/dist
|
||||
sau_frontend/node_modules
|
||||
sau_frontend/package-lock.json
|
||||
@@ -1,3 +0,0 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: dreammis
|
||||
@@ -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/
|
||||
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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 验收"
|
||||
@@ -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。
|
||||
- 所有任务、挑战和素材错误必须能在页面和日志中定位。
|
||||
- 不做刷量、绕过平台风控、矩阵养号、私信轰炸等功能。
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# 打包路径 根据项目不同按需配置
|
||||
VITE_BASE_URL = /
|
||||
|
||||
NODE_ENV=production
|
||||
@@ -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: 请填写
|
||||
@@ -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
|
||||
@@ -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 已提供或无须提供
|
||||
@@ -0,0 +1,4 @@
|
||||
files.extend-exclude = [
|
||||
"package-lock.json",
|
||||
"src/configs/color.ts"
|
||||
]
|
||||
@@ -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"
|
||||
@@ -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 -->"
|
||||
@@ -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
|
||||
@@ -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 仓库。请确保选择准确的版本。
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
if [[ `uname` == 'Darwin' ]]; then
|
||||
PATH="/usr/local/bin:$PATH"
|
||||
fi
|
||||
|
||||
npm run lint:fix
|
||||
@@ -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
|
||||
@@ -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)。
|
||||
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 85 KiB |
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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'];
|
||||
@@ -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({
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||