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
|
# macOS
|
||||||
__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/
|
|
||||||
|
|
||||||
.DS_Store
|
.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/
|
node_modules/
|
||||||
package-lock.json
|
|
||||||
|
|
||||||
# database
|
# 顶层 dist(仅保留部署说明与编排文件,二进制/zip 不入库)
|
||||||
db/database.db
|
/dist/*
|
||||||
|
!/dist/deploy/
|
||||||
|
/dist/deploy/*
|
||||||
|
!/dist/deploy/deploy-README.md
|
||||||
|
!/dist/deploy/docker-compose.yml
|
||||||
|
!/dist/deploy/.env.example
|
||||||
|
|
||||||
# 临时文件夹
|
# 前端构建产物
|
||||||
cookiesFile
|
apps/web/dist/
|
||||||
uploadFile
|
|
||||||
videoFile
|
|
||||||
|
|
||||||
/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
|
||||||
|
|
||||||
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
|
```text
|
||||||
浏览器 React 18 + TDesign + Redux Toolkit + Vite
|
浏览器 React + TDesign
|
||||||
│ REST / SSE / API
|
│ REST / Browser WebSocket
|
||||||
▼
|
▼
|
||||||
Flask 后端 :5409
|
Go server :8090
|
||||||
├─ SQLite(账号/素材/设置/API Key)
|
├─ MySQL / Redis
|
||||||
├─ 平台 uploader(Playwright / Patchright 浏览器自动化)
|
├─ 本地素材 StorageDriver
|
||||||
└─ 异步任务队列(发布 / 校验)
|
├─ WebExecutor(mock 联调)
|
||||||
|
└─ PlatformAdapter(真实平台逐个接入)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 支持平台
|
服务器运行在当前设备时,发布执行所需的浏览器 profile、cookie 和素材均留在该设备;不需要安装客户端或输入配对码。
|
||||||
|
|
||||||
| 平台 | 视频 | 图文 | 定时 | 登录方式 |
|
## 本地启动
|
||||||
| --- | --- | --- | --- | --- |
|
|
||||||
| 抖音 | ✅ | ✅ | ✅ | 扫码 |
|
|
||||||
| 快手 | ✅ | ✅ | ✅ | 扫码 |
|
|
||||||
| 小红书 | ✅ | ✅ | ✅ | 扫码 |
|
|
||||||
| 视频号 | ✅ | ✅ | ✅ | 扫码 |
|
|
||||||
| B站 | ✅ | — | ✅ | 扫码(biliup) |
|
|
||||||
| 百家号 | ✅ | — | — | 扫码 |
|
|
||||||
| 支付宝生活号 | ✅ | — | — | 扫码 |
|
|
||||||
| 微博 | ✅ | — | — | 扫码 |
|
|
||||||
| 虎扑 | ✅ | — | — | QQ 扫码 |
|
|
||||||
| YouTube | ✅ | — | — | 浏览器交互 |
|
|
||||||
| TikTok | ✅ | — | ✅ | 浏览器交互 |
|
|
||||||
|
|
||||||
## 快速开始
|
|
||||||
|
|
||||||
### 后端(Flask,端口 5409)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 建议 Python 3.12
|
make infra-up
|
||||||
uv venv --python 3.12 .venv && source .venv/bin/activate
|
make run
|
||||||
uv pip install -e ".[web]"
|
cd apps/web && npm run dev -- --port 3013
|
||||||
pip install playwright==1.52.0 xhs pillow
|
|
||||||
playwright install chromium
|
|
||||||
cp conf.example.py conf.py # 配置本地 Chrome 路径
|
|
||||||
python sau_backend.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 前端(React,端口 5174)
|
本地联调默认使用 `EXECUTOR_MODE=mock`,不会访问真实平台。要联调已接入的 B 站 adapter,启动服务前设置 `EXECUTOR_MODE=web`;二维码扫码或 Cookie 导入成功后,任务才会走真实平台链路。没有人工扫码时,使用 adapter HTTP stub 和 mock 账号完成自动化验证,不把 mock URL 当成真实发布。
|
||||||
|
|
||||||
```bash
|
- Go API/Browser WS:`http://127.0.0.1:8090`
|
||||||
cd sau_web_react
|
- Vite Web:`http://127.0.0.1:3013`
|
||||||
npm install
|
- 生产构建:`cd apps/web && npm run build`,设置 `STATIC_DIR=apps/web/dist` 后由 Go server 托管
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
打开 http://127.0.0.1:5174/ ,开发环境通过 Vite 代理将 /api 转发到后端 5409。
|
## 网页功能
|
||||||
|
|
||||||
## 页面与功能
|
- 工作台:任务统计、执行器状态、挑战和最近任务。
|
||||||
|
- 账号管理:平台账号、扫码/Cookie 登录、健康检查、重新登录。
|
||||||
|
- 素材库:上传进度、SHA-256 去重、预览、分组和删除。
|
||||||
|
- 发布管理:新建、草稿、审核、排期、任务详情、重试和取消。
|
||||||
|
- 挑战/通知/审计:二维码、验证码、实时状态、历史追踪。
|
||||||
|
|
||||||
| 页面 | 路由 | 功能 |
|
网页端不需要客户端安装、设备配对或 Agent WSS。客户端代码仅作为历史归档保留。
|
||||||
| --- | --- | --- |
|
|
||||||
| 首页 | /dashboard | 账号/平台/素材统计、快捷操作 |
|
|
||||||
| 账号管理 | /account | 扫码登录、Cookie 导入导出、单/批量校验与删除 |
|
|
||||||
| 素材管理 | /material | 素材上传、缩略图预览、下载、删除 |
|
|
||||||
| 发布中心 | /publish | 按素材类型选渠道、多账号发布、进度监控 |
|
|
||||||
| 发布日志 | /publish-logs | 历史任务与日志 |
|
|
||||||
| API 文档 | /api-docs | Open API 说明、curl 示例、点击测试 |
|
|
||||||
| 系统设置 | /settings | 登录方式、发布静默开关 |
|
|
||||||
| 关于 | /about | 系统信息与平台能力矩阵 |
|
|
||||||
|
|
||||||
## Open API
|
## 文档入口
|
||||||
|
|
||||||
对外开放的接口统一以 `/open/v1` 为前缀,通过 `X-API-Key` 或 `Authorization: Bearer` 鉴权:
|
| 文档 | 说明 |
|
||||||
|
|---|---|
|
||||||
- `GET /open/v1/platforms` — 平台能力
|
| [docs/web-only-v1-plan.md](docs/web-only-v1-plan.md) | 当前 V1 范围、页面、执行器和验收 |
|
||||||
- `GET /open/v1/accounts` — 账号列表
|
| [docs/web-only-logic-diagrams.md](docs/web-only-logic-diagrams.md) | Web-only 系统、登录、上传、执行、扫码逻辑图 |
|
||||||
- `GET/POST/DELETE /open/v1/materials` — 素材管理
|
| [docs/reference-repositories-analysis.md](docs/reference-repositories-analysis.md) | 三个 MIT 参考仓库的拉取记录和借鉴边界 |
|
||||||
- `POST /open/v1/publish` — 提交发布任务
|
| [docs/system-design.md](docs/system-design.md) | 当前系统设计 |
|
||||||
- `GET /open/v1/publish/{taskId}` — 查询任务状态
|
| [docs/delivery-plan.md](docs/delivery-plan.md) | 当前交付排期 |
|
||||||
|
| [docs/page-wireframes.md](docs/page-wireframes.md) | 网页 ASCII 线框 |
|
||||||
API Key 在系统设置/API 文档页生成与管理,详见内置「API 文档」页面。
|
| [docs/api.md](docs/api.md) | REST API |
|
||||||
|
| [docs/progress.md](docs/progress.md) | 当前进度和剩余风险 |
|
||||||
## 目录结构
|
|
||||||
|
|
||||||
```text
|
|
||||||
├── sau_backend.py # Flask 后端主入口
|
|
||||||
├── myUtils/ # 账号登录/发布封装
|
|
||||||
├── uploader/ # 各平台自动化(Playwright/Patchright)
|
|
||||||
├── utils/ # 配置/日志/二维码等工具
|
|
||||||
├── sau_web_react/ # React + TDesign 管理端
|
|
||||||
└── db/ # SQLite 数据库
|
|
||||||
```
|
|
||||||
|
|
||||||
## 参考项目
|
## 参考项目
|
||||||
|
|
||||||
本项目基于 [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",
|
"name": "@tencent/tdesign-react-starter",
|
||||||
"version": "1.0.0",
|
"version": "0.3.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --mode development",
|
|
||||||
"dev:mock": "vite --open --mode mock",
|
"dev:mock": "vite --open --mode mock",
|
||||||
|
"dev": "vite --open --mode development",
|
||||||
|
"dev:linux": "vite --mode development",
|
||||||
"build:test": "vite build --mode test",
|
"build:test": "vite build --mode test",
|
||||||
"build": "vite build --mode release",
|
"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",
|
"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": "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": {
|
"devDependencies": {
|
||||||
"@honkhonk/vite-plugin-svgr": "^1.1.0",
|
"@honkhonk/vite-plugin-svgr": "^1.1.0",
|
||||||
@@ -19,6 +25,7 @@
|
|||||||
"@types/node": "^20.2.5",
|
"@types/node": "^20.2.5",
|
||||||
"@types/react": "^18.2.0",
|
"@types/react": "^18.2.0",
|
||||||
"@types/react-dom": "^18.2.0",
|
"@types/react-dom": "^18.2.0",
|
||||||
|
"@types/react-redux": "7",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@vitejs/plugin-react": "^1.3.2",
|
"@vitejs/plugin-react": "^1.3.2",
|
||||||
"eslint": "^8.3.0",
|
"eslint": "^8.3.0",
|
||||||
@@ -28,6 +35,7 @@
|
|||||||
"eslint-plugin-import": "^2.25.4",
|
"eslint-plugin-import": "^2.25.4",
|
||||||
"eslint-plugin-prettier": "^4.0.0",
|
"eslint-plugin-prettier": "^4.0.0",
|
||||||
"eslint-plugin-react": "^7.28.0",
|
"eslint-plugin-react": "^7.28.0",
|
||||||
|
"husky": "^8.0.1",
|
||||||
"less": "^4.1.3",
|
"less": "^4.1.3",
|
||||||
"prettier": "^2.5.1",
|
"prettier": "^2.5.1",
|
||||||
"typescript": "^4.8.4",
|
"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;
|
export default src as string;
|
||||||
}
|
}
|
||||||
declare module '*.svg?component' {
|
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' {
|
declare module '*.module.css' {
|
||||||
export default classes as { readonly [key: string]: string };
|
export default classes as { readonly [key: string]: string };
|
||||||
@@ -31,6 +31,10 @@ const renderRoutes: TRenderRoutes = (routes, parentPath = '', breadcrumb = []) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (Component) {
|
if (Component) {
|
||||||
|
// 路由守卫:非全屏页未登录跳转登录页
|
||||||
|
if (!route.isFullPage && !localStorage.getItem('ep_token')) {
|
||||||
|
return <Route key={index} path={currentPath} element={<Navigate to='/login/index' replace />} />;
|
||||||
|
}
|
||||||
// 有路由菜单
|
// 有路由菜单
|
||||||
return (
|
return (
|
||||||
<Route
|
<Route
|
||||||
@@ -13,7 +13,7 @@ const Footer = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TFooter>
|
<TFooter>
|
||||||
<Row justify='center'>EveryPublish · 多平台自媒体自动化发布系统 © {new Date().getFullYear()}</Row>
|
<Row justify='center'>Copyright © 2022-{new Date().getFullYear()} Tencent. All Rights Reserved</Row>
|
||||||
</TFooter>
|
</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;
|
width: 184px;
|
||||||
height: 32px;
|
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 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 { Menu, MenuValue } from 'tdesign-react';
|
||||||
import router, { IRouter } from 'router';
|
import router, { IRouter } from 'router';
|
||||||
import { resolve } from 'utils/path';
|
import { resolve } from 'utils/path';
|
||||||
import { useAppSelector } from 'modules/store';
|
import { useAppSelector } from 'modules/store';
|
||||||
import { selectGlobal } from 'modules/global';
|
import { selectGlobal } from 'modules/global';
|
||||||
|
import { selectUser } from 'modules/user';
|
||||||
import MenuLogo from './MenuLogo';
|
import MenuLogo from './MenuLogo';
|
||||||
import Style from './Menu.module.less';
|
import Style from './Menu.module.less';
|
||||||
|
|
||||||
@@ -15,13 +16,17 @@ interface IMenuProps {
|
|||||||
showOperation?: boolean;
|
showOperation?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderMenuItems = (menu: IRouter[], parentPath = '') => {
|
// 纯函数:递归渲染菜单(不在递归/循环内调用 Hook)
|
||||||
const navigate = useNavigate();
|
const renderMenuItems = (menu: IRouter[], navigate: NavigateFunction, isAdmin: boolean, parentPath = '') => {
|
||||||
return menu.map((item) => {
|
return menu.map((item) => {
|
||||||
const { children, meta, path } = item;
|
const { children, meta, path } = item;
|
||||||
|
|
||||||
if (!meta || meta?.hidden === true) {
|
if (!meta || meta?.hidden === true) {
|
||||||
// 无meta信息 或 hidden == true,路由不显示为菜单
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.admin && !isAdmin) {
|
||||||
|
// 平台管理菜单仅 admin 角色可见
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +66,7 @@ const renderMenuItems = (menu: IRouter[], parentPath = '') => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SubMenu key={routerPath} value={routerPath} title={title} icon={Icon ? <Icon /> : undefined}>
|
<SubMenu key={routerPath} value={routerPath} title={title} icon={Icon ? <Icon /> : undefined}>
|
||||||
{renderMenuItems(children, routerPath)}
|
{renderMenuItems(children, navigate, isAdmin, routerPath)}
|
||||||
</SubMenu>
|
</SubMenu>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -73,6 +78,9 @@ const renderMenuItems = (menu: IRouter[], parentPath = '') => {
|
|||||||
export const HeaderMenu = memo(() => {
|
export const HeaderMenu = memo(() => {
|
||||||
const globalState = useAppSelector(selectGlobal);
|
const globalState = useAppSelector(selectGlobal);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const userState = useAppSelector(selectUser);
|
||||||
|
const isAdmin = userState.userInfo?.role === 'admin';
|
||||||
const [active, setActive] = useState<MenuValue>(location.pathname); // todo
|
const [active, setActive] = useState<MenuValue>(location.pathname); // todo
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -83,7 +91,7 @@ export const HeaderMenu = memo(() => {
|
|||||||
theme={globalState.theme}
|
theme={globalState.theme}
|
||||||
onChange={(v) => setActive(v)}
|
onChange={(v) => setActive(v)}
|
||||||
>
|
>
|
||||||
{renderMenuItems(router)}
|
{renderMenuItems(router, navigate, isAdmin)}
|
||||||
</HeadMenu>
|
</HeadMenu>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -93,10 +101,13 @@ export const HeaderMenu = memo(() => {
|
|||||||
*/
|
*/
|
||||||
export default memo((props: IMenuProps) => {
|
export default memo((props: IMenuProps) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const globalState = useAppSelector(selectGlobal);
|
const globalState = useAppSelector(selectGlobal);
|
||||||
|
const userState = useAppSelector(selectUser);
|
||||||
|
const isAdmin = userState.userInfo?.role === 'admin';
|
||||||
|
|
||||||
const { version } = globalState;
|
const { version } = globalState;
|
||||||
const bottomText = globalState.collapsed ? version : 'EveryPublish';
|
const bottomText = globalState.collapsed ? version : 'EveryPublish ' + version;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu
|
<Menu
|
||||||
@@ -109,7 +120,7 @@ export default memo((props: IMenuProps) => {
|
|||||||
operations={props.showOperation ? <div className={Style.menuTip}>{bottomText}</div> : undefined}
|
operations={props.showOperation ? <div className={Style.menuTip}>{bottomText}</div> : undefined}
|
||||||
logo={props.showLogo ? <MenuLogo collapsed={globalState.collapsed} /> : undefined}
|
logo={props.showLogo ? <MenuLogo collapsed={globalState.collapsed} /> : undefined}
|
||||||
>
|
>
|
||||||
{renderMenuItems(router)}
|
{renderMenuItems(router, navigate, isAdmin)}
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import React, { memo } from 'react';
|
import React, { memo } from 'react';
|
||||||
import Style from './Menu.module.less';
|
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';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
@@ -15,7 +17,7 @@ export default memo((props: IProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={Style.menuLogo} onClick={handleClick}>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
.t-menu--scroll::-webkit-scrollbar-thumb {
|
.t-menu--scroll::-webkit-scrollbar-thumb {
|
||||||
border-radius: 8px;
|
border-radius: 6px;
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
background-clip: content-box;
|
background-clip: content-box;
|
||||||
background-color: #eeeeee;
|
background-color: #eeeeee;
|
||||||
@@ -3,6 +3,7 @@ import { Drawer, Layout } from 'tdesign-react';
|
|||||||
import throttle from 'lodash/throttle';
|
import throttle from 'lodash/throttle';
|
||||||
import { useAppSelector, useAppDispatch } from 'modules/store';
|
import { useAppSelector, useAppDispatch } from 'modules/store';
|
||||||
import { selectGlobal, toggleSetting, toggleMenu, ELayout, switchTheme } from 'modules/global';
|
import { selectGlobal, toggleSetting, toggleMenu, ELayout, switchTheme } from 'modules/global';
|
||||||
|
import { getUserInfo } from 'modules/user';
|
||||||
import Setting from './components/Setting';
|
import Setting from './components/Setting';
|
||||||
import AppLayout from './components/AppLayout';
|
import AppLayout from './components/AppLayout';
|
||||||
import Style from './index.module.less';
|
import Style from './index.module.less';
|
||||||
@@ -11,7 +12,14 @@ export default memo(() => {
|
|||||||
const globalState = useAppSelector(selectGlobal);
|
const globalState = useAppSelector(selectGlobal);
|
||||||
const dispatch = useAppDispatch();
|
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(() => {
|
useEffect(() => {
|
||||||
dispatch(switchTheme(globalState.theme));
|
dispatch(switchTheme(globalState.theme));
|
||||||
@@ -5,6 +5,8 @@ import { BrowserRouter } from 'react-router-dom';
|
|||||||
import store from 'modules/store';
|
import store from 'modules/store';
|
||||||
import App from 'layouts/index';
|
import App from 'layouts/index';
|
||||||
|
|
||||||
|
import { connectWS } from 'utils/ws';
|
||||||
|
|
||||||
import 'tdesign-react/es/style/index.css';
|
import 'tdesign-react/es/style/index.css';
|
||||||
|
|
||||||
import './styles/index.less';
|
import './styles/index.less';
|
||||||
@@ -26,3 +28,4 @@ const renderApp = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
renderApp();
|
renderApp();
|
||||||
|
connectWS();
|
||||||
@@ -2,13 +2,11 @@ import { configureStore, combineReducers } from '@reduxjs/toolkit';
|
|||||||
import { TypedUseSelectorHook, useSelector, useDispatch } from 'react-redux';
|
import { TypedUseSelectorHook, useSelector, useDispatch } from 'react-redux';
|
||||||
|
|
||||||
import global from './global';
|
import global from './global';
|
||||||
import account from './account';
|
import user from './user';
|
||||||
import material from './material';
|
|
||||||
|
|
||||||
const reducer = combineReducers({
|
const reducer = combineReducers({
|
||||||
global,
|
global,
|
||||||
account,
|
user,
|
||||||
material,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const store = configureStore({
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||