Compare commits
327
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80607c0411 | ||
|
|
fd944b08bd | ||
|
|
1c66b7db4b | ||
|
|
926be742cd | ||
|
|
9dc3cdb6d0 | ||
|
|
9fc5ec487f | ||
|
|
d690c22ea6 | ||
|
|
49365fd046 | ||
|
|
d596e18243 | ||
|
|
c643471a98 | ||
|
|
5b836d5f50 | ||
|
|
43bdcbb2fd | ||
|
|
abfc13503d | ||
|
|
9969770039 | ||
|
|
5628f76684 | ||
|
|
78c88bc315 | ||
|
|
1d65ceef11 | ||
|
|
dccaeb01c4 | ||
|
|
b8469e8b56 | ||
|
|
f64f712ab1 | ||
|
|
254d0beb20 | ||
|
|
466f81d3be | ||
|
|
0625f9b13b | ||
|
|
233cb990a6 | ||
|
|
c1617444f9 | ||
|
|
ad49ebae7a | ||
|
|
3590c526b0 | ||
|
|
9342a343ab | ||
|
|
ad2419ef16 | ||
|
|
8ee7bcdb8a | ||
|
|
2dfae17775 | ||
|
|
008e4ff66a | ||
|
|
dde0eacb91 | ||
|
|
42bd4490ee | ||
|
|
662633bb3d | ||
|
|
bb1f12977a | ||
|
|
f4693db9d7 | ||
|
|
f83225c769 | ||
|
|
e89d3a875a | ||
|
|
d32280c98b | ||
|
|
95083e04ac | ||
|
|
723f703bf7 | ||
|
|
395c6434bb | ||
|
|
be77aaef8b | ||
|
|
fa59d50030 | ||
|
|
77b372a56e | ||
|
|
4435d8d174 | ||
|
|
945bf8e1c0 | ||
|
|
0d3f93e8ac | ||
|
|
90b751f791 | ||
|
|
d49bdd5daf | ||
|
|
ae72b87141 | ||
|
|
51209a1331 | ||
|
|
70a58b739f | ||
|
|
b0d873c850 | ||
|
|
ea2bcb0d8a | ||
|
|
5b26106fb1 | ||
|
|
1cb7d8a797 | ||
|
|
78c414d862 | ||
|
|
c9d6d5b39c | ||
|
|
23b0cb2468 | ||
|
|
99b1db490d | ||
|
|
42d39f2693 | ||
|
|
dff5a3d7ea | ||
|
|
cc963694a3 | ||
|
|
3ed6336376 | ||
|
|
d826a19bb2 | ||
|
|
9c3d8de82d | ||
|
|
2c2aea32fe | ||
|
|
621b94fc05 | ||
|
|
73d3b11ca8 | ||
|
|
9677fa3d70 | ||
|
|
aa5112e985 | ||
|
|
991bcdfdf3 | ||
|
|
914a5f0910 | ||
|
|
a083ad911f | ||
|
|
b9ca544892 | ||
|
|
e192fa0cb1 | ||
|
|
5e28092f20 | ||
|
|
f5cb31079c | ||
|
|
90e01c0106 | ||
|
|
b7dbbd2dff | ||
|
|
acbeb7cc4f | ||
|
|
a74e8839a1 | ||
|
|
8d030c7c61 | ||
|
|
ff951285a3 | ||
|
|
5716d486cc | ||
|
|
47f32ffff7 | ||
|
|
4f02d22ae2 | ||
|
|
f5c2e13df8 | ||
|
|
ae8beca839 | ||
|
|
13f7bd7a2a | ||
|
|
aa526b6ca8 | ||
|
|
7ae6943627 | ||
|
|
1b36b8bd9b | ||
|
|
3ef4cd322a | ||
|
|
99a392af2c | ||
|
|
91d8481ffd | ||
|
|
d8c95b0992 | ||
|
|
1cba88de15 | ||
|
|
721476f7b4 | ||
|
|
24807633f7 | ||
|
|
78b77f6177 | ||
|
|
8c30f4c6a6 | ||
|
|
34a3b3b47e | ||
|
|
31e27137da | ||
|
|
13984e60e6 | ||
|
|
94fedc5be4 | ||
|
|
2eb6633bd2 | ||
|
|
6fa68d4974 | ||
|
|
ff0285c7a6 | ||
|
|
cccfea0232 | ||
|
|
fc531e9783 | ||
|
|
67da11bddc | ||
|
|
05adf763ba | ||
|
|
a9049a5e47 | ||
|
|
90d03dc15a | ||
|
|
ce9a9f102e | ||
|
|
4c2de2c870 | ||
|
|
64d0236f5f | ||
|
|
2277a5fdf9 | ||
|
|
b1f9b1af61 | ||
|
|
02afa4dbee | ||
|
|
0778e29a04 | ||
|
|
03953df4ec | ||
|
|
2b6a16e76a | ||
|
|
6d3fc819b3 | ||
|
|
1bc8ae7698 | ||
|
|
7c3e002429 | ||
|
|
1fc29b27b7 | ||
|
|
19925561cf | ||
|
|
23230db9a9 | ||
|
|
f01ac59dea | ||
|
|
b415613b38 | ||
|
|
2934328888 | ||
|
|
246de71ccf | ||
|
|
90845f50ad | ||
|
|
fe6e9f4af9 | ||
|
|
b8b3107881 | ||
|
|
aa4ecfa6cc | ||
|
|
27bb20b206 | ||
|
|
23ee8d014f | ||
|
|
ab2dbb0de6 | ||
|
|
35c015ea06 | ||
|
|
9d1bfc6525 | ||
|
|
13fbeac4aa | ||
|
|
2f957b79ff | ||
|
|
fe242ecdfa | ||
|
|
5a6cd1bd06 | ||
|
|
88d2dc1ce6 | ||
|
|
7ccaab9b75 | ||
|
|
e60ce3f64a | ||
|
|
75bbb39b43 | ||
|
|
4688c41b9b | ||
|
|
4ee6c2b09c | ||
|
|
81b71d023d | ||
|
|
c0e7b64b94 | ||
|
|
1a02b8fa50 | ||
|
|
d21736292c | ||
|
|
91d7942a6f | ||
|
|
6b9fdb35d1 | ||
|
|
0cdd088d97 | ||
|
|
e003383d65 | ||
|
|
756dcd9126 | ||
|
|
80cccec24e | ||
|
|
250d7424b1 | ||
|
|
f54f3061c4 | ||
|
|
c933543e5c | ||
|
|
75a8e8cf29 | ||
|
|
de2389c9b1 | ||
|
|
7de1a30b92 | ||
|
|
d20fc13631 | ||
|
|
0235e89b9e | ||
|
|
595dea2f76 | ||
|
|
26a1642cbf | ||
|
|
0ae8d86d36 | ||
|
|
6d50620a29 | ||
|
|
2f95a5512c | ||
|
|
0e50d4aafd | ||
|
|
2dbedcc206 | ||
|
|
da5a9890f3 | ||
|
|
63cca3fd40 | ||
|
|
adb6af2a71 | ||
|
|
c1c8fe12dc | ||
|
|
327ed18ba2 | ||
|
|
250fff19a9 | ||
|
|
74939a0c06 | ||
|
|
ac80cd2ddd | ||
|
|
985e2d4290 | ||
|
|
95217cc9ed | ||
|
|
bb9e8094dd | ||
|
|
567c6f4bc9 | ||
|
|
d74bf8e356 | ||
|
|
61fbd42585 | ||
|
|
9c74c9946c | ||
|
|
a6d6115372 | ||
|
|
089c33efe5 | ||
|
|
913032d461 | ||
|
|
bde08fa9fa | ||
|
|
d0244fec26 | ||
|
|
5247c64670 | ||
|
|
86fb7f6563 | ||
|
|
8be9a09432 | ||
|
|
6279da2526 | ||
|
|
8416e3ee14 | ||
|
|
f9fc180ffc | ||
|
|
753bc701b5 | ||
|
|
1b553ebe7a | ||
|
|
2b0a027bde | ||
|
|
a99df48860 | ||
|
|
55670eb2e4 | ||
|
|
c3b677f539 | ||
|
|
525d1b03de | ||
|
|
6c425eec54 | ||
|
|
e09ce3ec51 | ||
|
|
476ebc26b8 | ||
|
|
2962fb7c5c | ||
|
|
a4364d7b75 | ||
|
|
5c14cdd531 | ||
|
|
4e9b6cb1de | ||
|
|
ecbcf1bcd2 | ||
|
|
e8f3730fda | ||
|
|
f120ee5e66 | ||
|
|
77c62a2b0f | ||
|
|
75aa3f75a3 | ||
|
|
c38f25a58d | ||
|
|
f7b871b0b9 | ||
|
|
30a01bfe0c | ||
|
|
2708aaf8b7 | ||
|
|
ea25ace3da | ||
|
|
c12a637ab6 | ||
|
|
80036f7a50 | ||
|
|
7ac4188006 | ||
|
|
da05e50a56 | ||
|
|
079e70e074 | ||
|
|
c97e87db06 | ||
|
|
88ca0fbbd3 | ||
|
|
24e2111ee3 | ||
|
|
d9dc9406ae | ||
|
|
7ea177c4cb | ||
|
|
5c91a68d8c | ||
|
|
794e8feaea | ||
|
|
8d72bfad1c | ||
|
|
0da054ee3b | ||
|
|
6c57f6d64a | ||
|
|
7b7f67303a | ||
|
|
0012f69df2 | ||
|
|
32e5fa980c | ||
|
|
2facef7509 | ||
|
|
8381d720a5 | ||
|
|
38efb62686 | ||
|
|
ca23f90ea9 | ||
|
|
12b8baa135 | ||
|
|
6e9298fd7f | ||
|
|
34d190fa75 | ||
|
|
79f8070b6a | ||
|
|
210470ba41 | ||
|
|
eaac0dff52 | ||
|
|
e7150226c6 | ||
|
|
021ed2096b | ||
|
|
6c60ee64a6 | ||
|
|
e13d56e863 | ||
|
|
19c9ec3938 | ||
|
|
f247d1ab05 | ||
|
|
f5b96d24c7 | ||
|
|
1e35b061c9 | ||
|
|
575c38a6b5 | ||
|
|
79db56e0e2 | ||
|
|
309077a567 | ||
|
|
cd1a82fbb9 | ||
|
|
57eda1b7fd | ||
|
|
7c9f5d2ec4 | ||
|
|
f402717363 | ||
|
|
5e7ea3c5ba | ||
|
|
76d2c33963 | ||
|
|
e09fb06477 | ||
|
|
9f812ea537 | ||
|
|
693fee7b4e | ||
|
|
44f2700fd0 | ||
|
|
3158eb9a4c | ||
|
|
707c467a89 | ||
|
|
ee5e920e22 | ||
|
|
82bfcee904 | ||
|
|
a242ebd053 | ||
|
|
6b9d7990aa | ||
|
|
743f5a8dc2 | ||
|
|
e40fd39b67 | ||
|
|
71220564a6 | ||
|
|
1757e4d582 | ||
|
|
a39618f7c1 | ||
|
|
38bcc315bb | ||
|
|
096f5f87c2 | ||
|
|
5ff8adda06 | ||
|
|
e088d76516 | ||
|
|
d9d31a4d59 | ||
|
|
b67aec919e | ||
|
|
b78f3900c3 | ||
|
|
5b62e8c6dc | ||
|
|
70651aa7e7 | ||
|
|
2b37cfbe0b | ||
|
|
b0b7ebd379 | ||
|
|
715bfd0bb7 | ||
|
|
72a54ebf33 | ||
|
|
ab388b5b2d | ||
|
|
22a59ca2f4 | ||
|
|
3e1be1b398 | ||
|
|
de17838e62 | ||
|
|
f5f9e7d5e1 | ||
|
|
62761508ef | ||
|
|
d2e3ea8450 | ||
|
|
98907985d9 | ||
|
|
c4218dc6f3 | ||
|
|
5d590d6005 | ||
|
|
7e65320ec2 | ||
|
|
2c436cd3b6 | ||
|
|
8077db5f2b | ||
|
|
6bc2431acf | ||
|
|
b10c66b622 | ||
|
|
44da1829d8 | ||
|
|
cda03bdaae | ||
|
|
eec0a68e3a | ||
|
|
3d9ace9fa8 | ||
|
|
4203c5fe07 | ||
|
|
c9ef49208f | ||
|
|
6ada1eeb5f | ||
|
|
24382f9bcb | ||
|
|
2ebbb3db97 |
@@ -0,0 +1,8 @@
|
||||
.venv
|
||||
.git
|
||||
.idea
|
||||
cookies
|
||||
conf.py
|
||||
sau_frontend/dist
|
||||
sau_frontend/node_modules
|
||||
sau_frontend/package-lock.json
|
||||
@@ -0,0 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: dreammis
|
||||
+185
-36
@@ -1,42 +1,191 @@
|
||||
# macOS
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
.idea/
|
||||
|
||||
.DS_Store
|
||||
|
||||
# 依赖(所有层级)
|
||||
# ignore cookie file
|
||||
tencent_uploader/*.json
|
||||
youtube_uploader/*.json
|
||||
douyin_uploader/*.json
|
||||
bilibili_uploader/*.json
|
||||
tk_uploader/*.json
|
||||
|
||||
# 配置文件
|
||||
conf.py
|
||||
|
||||
# 账号文件
|
||||
cookies
|
||||
|
||||
# Frontend
|
||||
.vite/
|
||||
dist/
|
||||
node_modules/
|
||||
package-lock.json
|
||||
|
||||
# 顶层 dist(仅保留部署说明与编排文件,二进制/zip 不入库)
|
||||
/dist/*
|
||||
!/dist/deploy/
|
||||
/dist/deploy/*
|
||||
!/dist/deploy/deploy-README.md
|
||||
!/dist/deploy/docker-compose.yml
|
||||
!/dist/deploy/.env.example
|
||||
# database
|
||||
db/database.db
|
||||
|
||||
# 前端构建产物
|
||||
apps/web/dist/
|
||||
# 临时文件夹
|
||||
cookiesFile
|
||||
uploadFile
|
||||
videoFile
|
||||
|
||||
# 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/
|
||||
/uploader/bilibili_uploader/qrcode.png
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
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"]
|
||||
@@ -0,0 +1,21 @@
|
||||
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.
|
||||
@@ -1,40 +0,0 @@
|
||||
.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,72 +1,110 @@
|
||||
# EveryPublish
|
||||
|
||||
EveryPublish 是一个本机部署的多平台内容发布 Web 控制台:用户打开浏览器登录,在网页内完成账号、素材、任务、审核、挑战和结果管理。
|
||||
EveryPublish 是一个**多平台内容自动发布系统**,帮助内容创作者和运营者将视频/图文一键发布到多个国内外主流社交媒体平台。它由一个 Flask 后端(平台自动化)和一个 React + TDesign 管理端(Web 控制台)组成,支持扫码登录、素材管理、异步发布任务与实时进度监控,并通过 Open API 向外部内容创作系统开放接入能力。
|
||||
|
||||
> 当前交付边界:Web-only V1。macOS/Windows 客户端、Agent 配对和设备管理暂停,不进入本阶段验收。
|
||||
## 功能特性
|
||||
|
||||
## 当前架构
|
||||
- **11 个平台接入**:抖音、快手、小红书、视频号、B站、百家号、支付宝生活号、微博、虎扑、YouTube、TikTok
|
||||
- **视频 + 图文发布**:小红书/抖音/快手/视频号支持图文,其余平台支持视频
|
||||
- **扫码登录 + Cookie 管理**:扫码登录、Cookie 导入导出、健康校验
|
||||
- **异步发布任务**:发布立即返回任务 ID,后台执行,前端实时显示步骤/百分比/日志
|
||||
- **发布日志**:历史任务记录与详细日志追踪
|
||||
- **Open API 开放体系**:API Key 鉴权 + 素材/发布/账号/平台查询等开放接口 + 内置 API 文档页
|
||||
- **全局配置**:登录方式(扫码/浏览器)、发布静默模式(headless 无感运行)
|
||||
- **账号批量操作**:单账号/批量 Cookie 校验、批量删除
|
||||
|
||||
## 技术架构
|
||||
|
||||
```text
|
||||
浏览器 React + TDesign
|
||||
│ REST / Browser WebSocket
|
||||
浏览器 React 18 + TDesign + Redux Toolkit + Vite
|
||||
│ REST / SSE / API
|
||||
▼
|
||||
Go server :8090
|
||||
├─ MySQL / Redis
|
||||
├─ 本地素材 StorageDriver
|
||||
├─ WebExecutor(mock 联调)
|
||||
└─ PlatformAdapter(真实平台逐个接入)
|
||||
Flask 后端 :5409
|
||||
├─ SQLite(账号/素材/设置/API Key)
|
||||
├─ 平台 uploader(Playwright / Patchright 浏览器自动化)
|
||||
└─ 异步任务队列(发布 / 校验)
|
||||
```
|
||||
|
||||
服务器运行在当前设备时,发布执行所需的浏览器 profile、cookie 和素材均留在该设备;不需要安装客户端或输入配对码。
|
||||
## 支持平台
|
||||
|
||||
## 本地启动
|
||||
| 平台 | 视频 | 图文 | 定时 | 登录方式 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 抖音 | ✅ | ✅ | ✅ | 扫码 |
|
||||
| 快手 | ✅ | ✅ | ✅ | 扫码 |
|
||||
| 小红书 | ✅ | ✅ | ✅ | 扫码 |
|
||||
| 视频号 | ✅ | ✅ | ✅ | 扫码 |
|
||||
| B站 | ✅ | — | ✅ | 扫码(biliup) |
|
||||
| 百家号 | ✅ | — | — | 扫码 |
|
||||
| 支付宝生活号 | ✅ | — | — | 扫码 |
|
||||
| 微博 | ✅ | — | — | 扫码 |
|
||||
| 虎扑 | ✅ | — | — | QQ 扫码 |
|
||||
| YouTube | ✅ | — | — | 浏览器交互 |
|
||||
| TikTok | ✅ | — | ✅ | 浏览器交互 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 后端(Flask,端口 5409)
|
||||
|
||||
```bash
|
||||
make infra-up
|
||||
make run
|
||||
cd apps/web && npm run dev -- --port 3013
|
||||
# 建议 Python 3.12
|
||||
uv venv --python 3.12 .venv && source .venv/bin/activate
|
||||
uv pip install -e ".[web]"
|
||||
pip install playwright==1.52.0 xhs pillow
|
||||
playwright install chromium
|
||||
cp conf.example.py conf.py # 配置本地 Chrome 路径
|
||||
python sau_backend.py
|
||||
```
|
||||
|
||||
本地联调默认使用 `EXECUTOR_MODE=mock`,不会访问真实平台。要联调已接入的 B 站 adapter,启动服务前设置 `EXECUTOR_MODE=web`;二维码扫码或 Cookie 导入成功后,任务才会走真实平台链路。没有人工扫码时,使用 adapter HTTP stub 和 mock 账号完成自动化验证,不把 mock URL 当成真实发布。
|
||||
### 前端(React,端口 5174)
|
||||
|
||||
- Go API/Browser WS:`http://127.0.0.1:8090`
|
||||
- Vite Web:`http://127.0.0.1:3013`
|
||||
- 生产构建:`cd apps/web && npm run build`,设置 `STATIC_DIR=apps/web/dist` 后由 Go server 托管
|
||||
```bash
|
||||
cd sau_web_react
|
||||
npm install
|
||||
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
|
||||
|
||||
| 文档 | 说明 |
|
||||
|---|---|
|
||||
| [docs/web-only-v1-plan.md](docs/web-only-v1-plan.md) | 当前 V1 范围、页面、执行器和验收 |
|
||||
| [docs/web-only-logic-diagrams.md](docs/web-only-logic-diagrams.md) | Web-only 系统、登录、上传、执行、扫码逻辑图 |
|
||||
| [docs/reference-repositories-analysis.md](docs/reference-repositories-analysis.md) | 三个 MIT 参考仓库的拉取记录和借鉴边界 |
|
||||
| [docs/system-design.md](docs/system-design.md) | 当前系统设计 |
|
||||
| [docs/delivery-plan.md](docs/delivery-plan.md) | 当前交付排期 |
|
||||
| [docs/page-wireframes.md](docs/page-wireframes.md) | 网页 ASCII 线框 |
|
||||
| [docs/api.md](docs/api.md) | REST API |
|
||||
| [docs/progress.md](docs/progress.md) | 当前进度和剩余风险 |
|
||||
对外开放的接口统一以 `/open/v1` 为前缀,通过 `X-API-Key` 或 `Authorization: Bearer` 鉴权:
|
||||
|
||||
- `GET /open/v1/platforms` — 平台能力
|
||||
- `GET /open/v1/accounts` — 账号列表
|
||||
- `GET/POST/DELETE /open/v1/materials` — 素材管理
|
||||
- `POST /open/v1/publish` — 提交发布任务
|
||||
- `GET /open/v1/publish/{taskId}` — 查询任务状态
|
||||
|
||||
API Key 在系统设置/API 文档页生成与管理,详见内置「API 文档」页面。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
├── sau_backend.py # Flask 后端主入口
|
||||
├── myUtils/ # 账号登录/发布封装
|
||||
├── uploader/ # 各平台自动化(Playwright/Patchright)
|
||||
├── utils/ # 配置/日志/二维码等工具
|
||||
├── sau_web_react/ # React + TDesign 管理端
|
||||
└── db/ # SQLite 数据库
|
||||
```
|
||||
|
||||
## 参考项目
|
||||
|
||||
- `references/social-auto-upload`(MIT,dreammis,CLI/uploader/QR 参考)
|
||||
- `references/social-auto-upload-web-ui`(MIT,DevilJie,Web/SSE/任务队列参考)
|
||||
- `references/MediaPublishPlatform`(MIT,funfan0517,批量上传/定时/发布记录参考)
|
||||
本项目基于 [dreammis/social-auto-upload](https://github.com/dreammis/social-auto-upload)(MIT License)的 uploader 与 CLI 能力进行接入与封装,保留其 MIT 版权说明。依赖的第三方组件(biliup、Playwright、Patchright 等)受各自许可证约束。
|
||||
|
||||
参考代码只读保存,新增实现必须保留 MIT 版权说明;当前工程不混入 Vue/Flask/SQLite 三套运行时。
|
||||
## 许可证
|
||||
|
||||
## 开发原则
|
||||
|
||||
- mock executor 只能证明业务状态机,不冒充真实平台成功。
|
||||
- 真实扫码无法执行时,状态必须停在“等待扫码”,不能自动标记 active。
|
||||
- 所有任务、挑战和素材错误必须能在页面和日志中定位。
|
||||
- 不做刷量、绕过平台风控、矩阵养号、私信轰炸等功能。
|
||||
本项目采用 MIT License。除第三方组件外,代码可在遵守许可证条款的前提下用于商业软件。
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
# 打包路径 根据项目不同按需配置
|
||||
VITE_BASE_URL = /
|
||||
|
||||
NODE_ENV=production
|
||||
@@ -1,79 +0,0 @@
|
||||
name: 反馈 Bug
|
||||
description: 通过 github 模板进行 Bug 反馈。
|
||||
title: "[组件名称] 描述问题的标题"
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
# 欢迎你的参与
|
||||
tdesign-react-starter 的 Issue 列表接受 bug 报告或是新功能请求。也可加入官方社区:<img width="80px" src="https://user-images.githubusercontent.com/15634204/157386871-bf84c2ea-a02f-4c1c-b6fd-577450cdbcf7.png" />
|
||||
|
||||
在发布一个 Issue 前,请确保:
|
||||
- 在 [常见问题](https://tdesign.tencent.com/about/faq)、[更新日志](https://tdesign.tencent.com/about/release) 和 [旧Issue列表](https://github.com/Tencent/tdesign-react-starter/issues?q=is%3Aissue) 中搜索过你的问题。(你的问题可能已有人提出,也可能已在最新版本中被修正)
|
||||
- 如果你发现一个已经关闭的旧 Issue 在最新版本中仍然存在,不要在旧 Issue 下面留言,请建一个新的 issue。
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: tdesign-react-starter 版本
|
||||
description: 请检查在最新项目版本中能否重现此 issue。
|
||||
placeholder: 请填写
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: reproduce
|
||||
attributes:
|
||||
label: 重现链接
|
||||
description: 请提供尽可能精简的 CodePen、CodeSandbox 或 GitHub 仓库的链接。请不要填无关链接,否则你的 Issue 将被关闭。
|
||||
placeholder: 请填写
|
||||
|
||||
- type: textarea
|
||||
id: reproduceSteps
|
||||
attributes:
|
||||
label: 重现步骤
|
||||
description: 请清晰的描述重现该 Issue 的步骤,这能帮助我们快速定位问题。没有清晰重现步骤将不会被修复,标有 'need reproduction' 的 Issue 在 7 天内不提供相关步骤,将被关闭。
|
||||
placeholder: 请填写
|
||||
|
||||
- type: textarea
|
||||
id: expect
|
||||
attributes:
|
||||
label: 期望结果
|
||||
placeholder: 请填写
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: 实际结果
|
||||
placeholder: 请填写
|
||||
|
||||
- type: input
|
||||
id: frameworkVersion
|
||||
attributes:
|
||||
label: 框架版本
|
||||
placeholder: Vue(3.2.0)
|
||||
|
||||
- type: input
|
||||
id: browsersVersion
|
||||
attributes:
|
||||
label: 浏览器版本
|
||||
placeholder: Chrome(8.213.231.123)
|
||||
|
||||
- type: input
|
||||
id: systemVersion
|
||||
attributes:
|
||||
label: 系统版本
|
||||
placeholder: MacOS(11.2.3)
|
||||
|
||||
- type: input
|
||||
id: nodeVersion
|
||||
attributes:
|
||||
label: Node版本
|
||||
placeholder: 请填写
|
||||
|
||||
- type: textarea
|
||||
id: remarks
|
||||
attributes:
|
||||
label: 补充说明
|
||||
description: 可以是遇到这个 bug 的业务场景、上下文等信息。
|
||||
placeholder: 请填写
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
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,表单提交更加严格。
|
||||
@@ -1,30 +0,0 @@
|
||||
name: 反馈新功能
|
||||
description: 通过 github 模板进行新功能反馈。
|
||||
title: "[组件名称] 描述问题的标题"
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
# 欢迎你的参与
|
||||
tdesign-react-starter 的 Issue 列表接受 bug 报告或是新功能请求。也可加入官方社区:<img width="80px" src="https://user-images.githubusercontent.com/15634204/157386871-bf84c2ea-a02f-4c1c-b6fd-577450cdbcf7.png" />
|
||||
|
||||
在发布一个 Issue 前,请确保:
|
||||
- 在 [常见问题](https://tdesign.tencent.com/about/faq)、[更新日志](https://tdesign.tencent.com/about/release) 和 [旧Issue列表](https://github.com/Tencent/tdesign-react-starter/issues?q=is%3Aissue) 中搜索过你的问题。(你的问题可能已有人提出,也可能已在最新版本中被修正)
|
||||
- 如果你发现一个已经关闭的旧 Issue 在最新版本中仍然存在,不要在旧 Issue 下面留言,请建一个新的 issue。
|
||||
|
||||
- type: textarea
|
||||
id: functionContent
|
||||
attributes:
|
||||
label: 这个功能解决了什么问题
|
||||
description: 请详尽说明这个需求的用例和场景。最重要的是:解释清楚是怎样的用户体验需求催生了这个功能上的需求。我们将考虑添加在现有 API 无法轻松实现的功能。新功能的用例也应当足够常见。
|
||||
placeholder: 请填写
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: functionalExpectations
|
||||
attributes:
|
||||
label: 你建议的方案是什么
|
||||
placeholder: 请填写
|
||||
validations:
|
||||
required: true
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
<!--
|
||||
首先,感谢你的贡献!😄
|
||||
请阅读并遵循 [TDesign 贡献指南](https://github.com/Tencent/tdesign/blob/main/docs/contributing.md),填写以下 pull request 的信息。
|
||||
PR 在维护者审核通过后会合并,谢谢!
|
||||
-->
|
||||
|
||||
### 🤔 这个 PR 的性质是?
|
||||
|
||||
- [ ] 日常 bug 修复
|
||||
- [ ] 新特性提交
|
||||
- [ ] 文档改进
|
||||
- [ ] 演示代码改进
|
||||
- [ ] 组件样式/交互改进
|
||||
- [ ] CI/CD 改进
|
||||
- [ ] 重构
|
||||
- [ ] 代码风格优化
|
||||
- [ ] 测试用例
|
||||
- [ ] 分支合并
|
||||
- [ ] 其他
|
||||
|
||||
### 🔗 相关 Issue
|
||||
|
||||
<!--
|
||||
1. 描述相关需求的来源,如相关的 issue 讨论链接。
|
||||
-->
|
||||
|
||||
### 💡 需求背景和解决方案
|
||||
|
||||
<!--
|
||||
1. 要解决的具体问题。
|
||||
2. 列出最终的 API 实现和用法。
|
||||
3. 涉及UI/交互变动需要有截图或 GIF。
|
||||
-->
|
||||
|
||||
### 📝 更新日志
|
||||
|
||||
<!--
|
||||
从用户角度描述具体变化,以及可能的 breaking change 和其他风险。
|
||||
-->
|
||||
|
||||
- fix(组件名称): 处理问题或特性描述 ...
|
||||
|
||||
- [ ] 本条 PR 不需要纳入 Changelog
|
||||
|
||||
### ☑️ 请求合并前的自查清单
|
||||
|
||||
⚠️ 请自检并全部**勾选全部选项**。⚠️
|
||||
|
||||
- [ ] 文档已补充或无须补充
|
||||
- [ ] 代码演示已提供或无须提供
|
||||
- [ ] TypeScript 定义已补充或无须补充
|
||||
- [ ] Changelog 已提供或无须提供
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
files.extend-exclude = [
|
||||
"package-lock.json",
|
||||
"src/configs/color.ts"
|
||||
]
|
||||
Vendored
-20
@@ -1,20 +0,0 @@
|
||||
# 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"
|
||||
@@ -1,52 +0,0 @@
|
||||
# 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 -->"
|
||||
@@ -1,22 +0,0 @@
|
||||
# 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
|
||||
@@ -1,19 +0,0 @@
|
||||
# force copy from tencent/tdesign
|
||||
# 当在 issue 的 comment 回复类似 `Duplicate of #111` 这样的话,issue 将被自动打上 重复提交标签 并且 cloese
|
||||
name: Issue Mark Duplicate
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
|
||||
jobs:
|
||||
mark-duplicate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: mark-duplicate
|
||||
uses: actions-cool/issues-helper@v2
|
||||
with:
|
||||
actions: "mark-duplicate"
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
duplicate-labels: "duplicate"
|
||||
close-issue: true
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
# force copy from tencent/tdesign
|
||||
# 当被打上 Need Reproduce 标签时候,自动提示需要重现实例
|
||||
|
||||
name: ISSUE_REPLY
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
|
||||
jobs:
|
||||
issue-reply:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Need Reproduce
|
||||
if: github.event.label.name == 'Need Reproduce'
|
||||
uses: actions-cool/issues-helper@v2
|
||||
with:
|
||||
actions: 'create-comment'
|
||||
issue-number: ${{ github.event.issue.number }}
|
||||
body: |
|
||||
你好 @${{ github.event.issue.user.login }}, 我们需要你提供一个在线的重现实例以便于我们帮你排查问题。你可以通过点击 [此处](https://codesandbox.io/) 创建一个 codesandbox 或者提供一个最小化的 GitHub 仓库。请确保选择准确的版本。
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
# force copy from tencent/tdesign
|
||||
name: pr-spell-check
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
run:
|
||||
name: Spell Check with Typos
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Check spelling
|
||||
uses: crate-ci/typos@master
|
||||
with:
|
||||
config: .github/configs/typos_config.toml
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
# 文件名建议统一为 preview-publish
|
||||
# 应用 preview.yml 的 demo
|
||||
name: PREVIEW_PUBLISH
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["MAIN_PULL_REQUEST"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
call-preview:
|
||||
uses: TDesignOteam/workflows/.github/workflows/reusable-pr-preview.yml@main
|
||||
secrets:
|
||||
TDESIGN_SURGE_TOKEN: ${{ secrets.TDESIGN_SURGE_TOKEN }}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
# 文件名建议统一为 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
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
if [[ `uname` == 'Darwin' ]]; then
|
||||
PATH="/usr/local/bin:$PATH"
|
||||
fi
|
||||
|
||||
npm run lint:fix
|
||||
@@ -1,106 +0,0 @@
|
||||
|
||||
# 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
|
||||
@@ -1,97 +0,0 @@
|
||||
<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)。
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
<p style="display:flex; justify-content: center">
|
||||
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://tdesign.tencent.com/starter/react/#/dashboard/base" target="_blank">
|
||||
<img alt="TDesign Logo" width="200" src="https://tdesign.gtimg.com/starter/brand-logo.svg">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://nodejs.org/en/about/releases/"><img src="https://img.shields.io/node/v/vite.svg" alt="node compatibility"></a>
|
||||
<a href="https://github.com/Tencent/tdesign-react/blob/develop/LICENSE">
|
||||
<img src="https://img.shields.io/npm/l/tdesign-react.svg?sanitize=true" alt="License">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
English | [简体中文](./README-zh_CN.md)
|
||||
### Introduction
|
||||
|
||||
TDesign React Starter is a TDesign-based project developed with `React`, `Vite`. It can be customized theme configuration, and aims to provide project out-of-the-box, configuration-style middle and background projects.
|
||||
|
||||
<p>
|
||||
<a href="http://tdesign.tencent.com/starter/react/">Live Preview</a>
|
||||
·
|
||||
<a href="https://tdesign.tencent.com/starter/docs/react/get-started">Documentation</a>
|
||||
</p>
|
||||
|
||||
<img src="docs/docs-starter.png">
|
||||
|
||||
### Features
|
||||
|
||||
- Various provided pages for develop
|
||||
- Complete directory structure for develop
|
||||
- Code specification configuration
|
||||
- Support dark mode
|
||||
- Custom theme colors
|
||||
- Various space layouts
|
||||
- Mock data scheme
|
||||
|
||||
### Usage
|
||||
|
||||
> Initialize project with our CLI tool `tdesign-starter-cli`
|
||||
|
||||
```bash
|
||||
## install tdesign-starter-cli
|
||||
npm i tdesign-starter-cli@latest -g
|
||||
|
||||
## create project
|
||||
td-starter init
|
||||
```
|
||||
|
||||
### Develop
|
||||
|
||||
```bash
|
||||
## install dependencies
|
||||
npm install
|
||||
|
||||
## set up
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
## build
|
||||
npm run build
|
||||
|
||||
## build for test
|
||||
npm run build:test
|
||||
```
|
||||
|
||||
|
||||
### Contributing Guide
|
||||
|
||||
We welcome contributions to our project. Create your [Issue](https://github.com/tencent/tdesign-react-starter/issues/new/choose) or Submit your [Pull Request](https://github.com/Tencent/tdesign-react-starter/pulls).
|
||||
|
||||
#### Pull Request
|
||||
|
||||
1. Fork it!
|
||||
2. Create your branch: `git checkout -b feat/xxxx`
|
||||
3. Commit: `git commit -a 'feat(project): describe'`
|
||||
4. Push: `git push origin feat/xxxx`
|
||||
5. New Pull Request `pull request`
|
||||
|
||||
#### Commit Specification
|
||||
|
||||
- [Angular Convention](https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-angular)
|
||||
|
||||
### Compatibility
|
||||
|
||||
| [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/edge/edge_48x48.png" alt="IE / Edge" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br> IE / Edge | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/firefox/firefox_48x48.png" alt="Firefox" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Firefox | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/chrome/chrome_48x48.png" alt="Chrome" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Chrome | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/safari/safari_48x48.png" alt="Safari" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Safari |
|
||||
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Edge >=84 | Firefox >=83 | Chrome >=84 | Safari >=14.1 |
|
||||
|
||||
### License
|
||||
|
||||
The MIT License. Please see [the license file](LICENSE) for more information.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB |
@@ -1,38 +0,0 @@
|
||||
<!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>
|
||||
Generated
-8239
File diff suppressed because it is too large
Load Diff
@@ -1,20 +0,0 @@
|
||||
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;
|
||||
@@ -1,17 +0,0 @@
|
||||
export default {
|
||||
mock: {
|
||||
API: '/api/v1',
|
||||
},
|
||||
development: {
|
||||
API: '/api/v1',
|
||||
},
|
||||
test: {
|
||||
API: '/api/v1',
|
||||
},
|
||||
release: {
|
||||
API: '/api/v1',
|
||||
},
|
||||
site: {
|
||||
API: '/api/v1',
|
||||
},
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
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'];
|
||||
@@ -1,82 +0,0 @@
|
||||
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>
|
||||
);
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
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;
|
||||
@@ -1,341 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,416 +0,0 @@
|
||||
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;
|
||||
@@ -1,268 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
.loginHeader {
|
||||
height: 64px;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
backdrop-filter: blur(5px);
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 188px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.operationsContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.t-button {
|
||||
margin-left: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.operationsButton {
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
padding: 6px;
|
||||
box-sizing: content-box;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Button } from 'tdesign-react';
|
||||
import { LogoGithubIcon, HelpCircleIcon, SettingIcon } from 'tdesign-icons-react';
|
||||
import { useAppDispatch } from 'modules/store';
|
||||
import { toggleSetting } from 'modules/global';
|
||||
|
||||
import LogoFullIcon from 'assets/svg/assets-logo-full.svg?component';
|
||||
import Style from './index.module.less';
|
||||
|
||||
export default function Header() {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const navToGitHub = () => {
|
||||
window.open('https://github.com/tencent/tdesign-react-starter');
|
||||
};
|
||||
|
||||
const navToHelper = () => {
|
||||
window.open('http://tdesign.tencent.com/starter/docs/react/get-started');
|
||||
};
|
||||
|
||||
const toggleSettingPanel = () => {
|
||||
dispatch(toggleSetting());
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<header className={Style.loginHeader}>
|
||||
<LogoFullIcon className={Style.logo} />
|
||||
<div className={Style.operationsContainer}>
|
||||
<Button
|
||||
className={Style.operationsButton}
|
||||
theme='default'
|
||||
shape='square'
|
||||
variant='text'
|
||||
onClick={navToGitHub}
|
||||
>
|
||||
<LogoGithubIcon className={Style.icon} />
|
||||
</Button>
|
||||
<Button
|
||||
className={Style.operationsButton}
|
||||
theme='default'
|
||||
shape='square'
|
||||
variant='text'
|
||||
onClick={navToHelper}
|
||||
>
|
||||
<HelpCircleIcon className={Style.icon} />
|
||||
</Button>
|
||||
<Button
|
||||
className={Style.operationsButton}
|
||||
theme='default'
|
||||
shape='square'
|
||||
variant='text'
|
||||
onClick={toggleSettingPanel}
|
||||
>
|
||||
<SettingIcon className={Style.icon} />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
.itemContainer {
|
||||
width: 400px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.checkContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
color: var(--td-text-color-secondary);
|
||||
|
||||
&.rememberPwd {
|
||||
margin-bottom: 16px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
:global {
|
||||
.t-checkbox__label {
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--td-brand-color);
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tipContainer {
|
||||
width: 192px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
:global {
|
||||
.tip {
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
|
||||
.refresh {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--td-brand-color);
|
||||
|
||||
.t-icon {
|
||||
font-size: 14px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.checkContainerTip {
|
||||
float: right;
|
||||
font-size: 14px;
|
||||
color: var(--td-brand-color);
|
||||
}
|
||||
|
||||
.verificationBtn {
|
||||
flex-shrink: 0;
|
||||
width: 102px;
|
||||
height: 40px;
|
||||
margin-left: 11px;
|
||||
}
|
||||
|
||||
.btnContainer {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.switchContainer {
|
||||
margin-top: 24px;
|
||||
|
||||
:global {
|
||||
.tip {
|
||||
font-size: 14px;
|
||||
color: var(--td-brand-color);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-right: 14px;
|
||||
|
||||
&:not(:last-child) {
|
||||
&::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 1px;
|
||||
height: 12px;
|
||||
background: var(--td-gray-color-3);
|
||||
margin-left: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button, Form, FormInstanceFunctions, Input, MessagePlugin, SubmitContext } from 'tdesign-react';
|
||||
import { BrowseIcon, BrowseOffIcon, LockOnIcon, MailIcon } from 'tdesign-icons-react';
|
||||
import classnames from 'classnames';
|
||||
import { useAppDispatch } from 'modules/store';
|
||||
import { login } from 'modules/user';
|
||||
import { connectWS } from 'utils/ws';
|
||||
|
||||
import Style from './index.module.less';
|
||||
|
||||
const { FormItem } = Form;
|
||||
|
||||
export default function Login() {
|
||||
const [showPsw, toggleShowPsw] = useState(false);
|
||||
const formRef = useRef<FormInstanceFunctions>();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const onSubmit = async (e: SubmitContext) => {
|
||||
if (e.validateResult === true) {
|
||||
try {
|
||||
const formValue = (formRef.current?.getFieldsValue?.(true) || {}) as { email: string; password: string };
|
||||
await dispatch(login(formValue)).unwrap();
|
||||
connectWS();
|
||||
MessagePlugin.success('登录成功');
|
||||
navigate('/dashboard/base');
|
||||
} catch (err: any) {
|
||||
MessagePlugin.error(err?.response?.data?.message || '登录失败,请检查邮箱与密码');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Form ref={formRef} className={classnames(Style.itemContainer, 'login-password')} labelWidth={0} onSubmit={onSubmit}>
|
||||
<FormItem
|
||||
name='email'
|
||||
rules={[
|
||||
{ required: true, message: '邮箱必填', type: 'error' },
|
||||
{ email: true, message: '请输入正确的邮箱', type: 'warning' },
|
||||
]}
|
||||
>
|
||||
<Input size='large' placeholder='请输入邮箱' prefixIcon={<MailIcon />}></Input>
|
||||
</FormItem>
|
||||
<FormItem name='password' rules={[{ required: true, message: '密码必填', type: 'error' }]}>
|
||||
<Input
|
||||
size='large'
|
||||
type={showPsw ? 'text' : 'password'}
|
||||
placeholder='请输入密码'
|
||||
prefixIcon={<LockOnIcon />}
|
||||
suffixIcon={
|
||||
showPsw ? (
|
||||
<BrowseIcon onClick={() => toggleShowPsw((current) => !current)} />
|
||||
) : (
|
||||
<BrowseOffIcon onClick={() => toggleShowPsw((current) => !current)} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem className={Style.btnContainer}>
|
||||
<Button block size='large' type='submit'>
|
||||
登录
|
||||
</Button>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
.itemContainer {
|
||||
width: 400px;
|
||||
margin-top: 48px;
|
||||
}
|
||||
|
||||
.verificationBtn {
|
||||
flex-shrink: 0;
|
||||
width: 102px;
|
||||
height: 40px;
|
||||
margin-left: 11px;
|
||||
}
|
||||
|
||||
.switchContainer {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.switchTip {
|
||||
font-size: 14px;
|
||||
color: var(--td-brand-color);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-right: 14px;
|
||||
}
|
||||
|
||||
.checkContainer {
|
||||
font-size: 14px;
|
||||
color: var(--td-text-color-secondary);
|
||||
:global {
|
||||
.tip {
|
||||
float: right;
|
||||
font-size: 14px;
|
||||
color: var(--td-brand-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Form, MessagePlugin, Input, Checkbox, Button, FormInstanceFunctions, SubmitContext } from 'tdesign-react';
|
||||
import { LockOnIcon, MailIcon, BrowseOffIcon, BrowseIcon, UserIcon } from 'tdesign-icons-react';
|
||||
import classnames from 'classnames';
|
||||
import { useAppDispatch } from 'modules/store';
|
||||
import { register } from 'modules/user';
|
||||
import { connectWS } from 'utils/ws';
|
||||
|
||||
import Style from './index.module.less';
|
||||
|
||||
const { FormItem } = Form;
|
||||
|
||||
export default function Register() {
|
||||
const [showPsw, toggleShowPsw] = useState(false);
|
||||
const formRef = useRef<FormInstanceFunctions>();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const onSubmit = async (e: SubmitContext) => {
|
||||
if (e.validateResult === true) {
|
||||
const formValue = (formRef.current?.getFieldsValue?.(true) || {}) as {
|
||||
email: string;
|
||||
password: string;
|
||||
nickname: string;
|
||||
checked: boolean;
|
||||
};
|
||||
if (!formValue.checked) {
|
||||
MessagePlugin.error('请同意服务协议与隐私声明');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const nickname = formValue.nickname || formValue.email.split('@')[0];
|
||||
await dispatch(register({ email: formValue.email, password: formValue.password, nickname })).unwrap();
|
||||
connectWS();
|
||||
MessagePlugin.success('注册成功,已自动登录');
|
||||
navigate('/dashboard/base');
|
||||
} catch (err: any) {
|
||||
MessagePlugin.error(err?.response?.data?.message || '注册失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Form ref={formRef} className={classnames(Style.itemContainer, 'register-email')} labelWidth={0} onSubmit={onSubmit}>
|
||||
<FormItem name='nickname'>
|
||||
<Input size='large' placeholder='请输入昵称(选填)' prefixIcon={<UserIcon />} />
|
||||
</FormItem>
|
||||
<FormItem
|
||||
name='email'
|
||||
rules={[
|
||||
{ required: true, message: '邮箱必填', type: 'error' },
|
||||
{ email: true, message: '请输入正确的邮箱', type: 'warning' },
|
||||
]}
|
||||
>
|
||||
<Input type='text' size='large' placeholder='请输入邮箱' prefixIcon={<MailIcon />} />
|
||||
</FormItem>
|
||||
<FormItem
|
||||
name='password'
|
||||
rules={[
|
||||
{ required: true, message: '密码必填', type: 'error' },
|
||||
{ min: 8, message: '密码至少 8 位', type: 'warning' },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
size='large'
|
||||
type={showPsw ? 'text' : 'password'}
|
||||
clearable
|
||||
placeholder='请输入密码(至少 8 位)'
|
||||
prefixIcon={<LockOnIcon />}
|
||||
suffixIcon={
|
||||
showPsw ? (
|
||||
<BrowseIcon onClick={() => toggleShowPsw((current) => !current)} />
|
||||
) : (
|
||||
<BrowseOffIcon onClick={() => toggleShowPsw((current) => !current)} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem className={Style.checkContainer} name='checked' initialData={false}>
|
||||
<Checkbox>我已阅读并同意</Checkbox> <span className='tip'>EveryPublish 服务协议</span> 和
|
||||
<span className='tip'>隐私声明</span>
|
||||
</FormItem>
|
||||
<FormItem>
|
||||
<Button block size='large' type='submit'>
|
||||
注册
|
||||
</Button>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
const useCountdown = (duration: number) => {
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [isSetup, toggleSetup] = useState(false);
|
||||
const timer = useRef<NodeJS.Timeout>();
|
||||
|
||||
useEffect(() => {
|
||||
if (isSetup) {
|
||||
setCountdown(duration);
|
||||
timer.current = setInterval(() => {
|
||||
setCountdown((current) => current - 1);
|
||||
}, 1000);
|
||||
} else clearInterval(timer.current as NodeJS.Timeout);
|
||||
}, [isSetup]);
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown === 0) {
|
||||
toggleSetup(false);
|
||||
}
|
||||
}, [countdown]);
|
||||
|
||||
useEffect(() => () => clearInterval(timer.current as NodeJS.Timeout), []);
|
||||
|
||||
function setupCountdown() {
|
||||
toggleSetup(true);
|
||||
}
|
||||
|
||||
return { countdown, setupCountdown };
|
||||
};
|
||||
|
||||
export default useCountdown;
|
||||
@@ -1,67 +0,0 @@
|
||||
.loginWrapper {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-size: cover;
|
||||
background-position: 100%;
|
||||
position: relative;
|
||||
&.dark {
|
||||
background-color: var(--td-bg-color-page);
|
||||
background-image: url('assets/image/assets-login-bg-black.png');
|
||||
}
|
||||
&.light {
|
||||
background-color: white;
|
||||
background-image: url('assets/image/assets-login-bg-white.png');
|
||||
}
|
||||
}
|
||||
|
||||
.loginContainer {
|
||||
position: absolute;
|
||||
top: 22%;
|
||||
left: 5%;
|
||||
min-height: 500px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 36px;
|
||||
line-height: 44px;
|
||||
color: var(--td-text-color-primary);
|
||||
margin-top: 4px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.subTitle {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.tip {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
font-size: 14px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
|
||||
&.registerTip {
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
|
||||
&.loginTip {
|
||||
color: var(--td-text-color-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.copyright {
|
||||
font-size: 14px;
|
||||
position: absolute;
|
||||
left: 5%;
|
||||
bottom: 64px;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
|
||||
@media screen and (max-height: 762px) {
|
||||
.copyright {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import React, { memo, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import Login from './components/Login';
|
||||
import Register from './components/Register';
|
||||
import LoginHeader from './components/Header';
|
||||
import { useAppSelector } from 'modules/store';
|
||||
import { selectGlobal } from 'modules/global';
|
||||
|
||||
import Style from './index.module.less';
|
||||
|
||||
export default memo(() => {
|
||||
const [type, setType] = useState('login');
|
||||
const globalState = useAppSelector(selectGlobal);
|
||||
const { theme } = globalState;
|
||||
const handleSwitchLoginType = () => {
|
||||
setType(type === 'register' ? 'login' : 'register');
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(Style.loginWrapper, { [Style.light]: theme === 'light', [Style.dark]: theme !== 'light' })}
|
||||
>
|
||||
<LoginHeader />
|
||||
<div className={Style.loginContainer}>
|
||||
<div className={Style.titleContainer}>
|
||||
<h1 className={Style.title}>登录到</h1>
|
||||
<h1 className={Style.title}>EveryPublish 发布中台</h1>
|
||||
<div className={Style.subTitle}>
|
||||
<p className={classNames(Style.tip, Style.registerTip)}>
|
||||
{type === 'register' ? '已有账号?' : '没有账号吗?'}
|
||||
</p>
|
||||
<p className={classNames(Style.tip, Style.loginTip)} onClick={handleSwitchLoginType}>
|
||||
{type === 'register' ? '登录' : '注册新账号'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{type === 'login' ? <Login /> : <Register />}
|
||||
</div>
|
||||
<footer className={Style.copyright}>
|
||||
Copyright @ 2026-{new Date().getFullYear()} EveryPublish. All Rights Reserved
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,214 +0,0 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Button, Card, Dialog, Input, MessagePlugin, Popconfirm, Select, Space, Table, Tag, Upload } from 'tdesign-react';
|
||||
import type { RequestMethodResponse, UploadFile } from 'tdesign-react';
|
||||
import dayjs from 'dayjs';
|
||||
import request from 'utils/request';
|
||||
|
||||
interface IMaterial {
|
||||
id: number;
|
||||
name: string;
|
||||
kind: string;
|
||||
size: number;
|
||||
sha256: string;
|
||||
group: string;
|
||||
tags: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function fmtSize(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB';
|
||||
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return bytes + ' B';
|
||||
}
|
||||
|
||||
export default function MaterialList() {
|
||||
const [list, setList] = useState<IMaterial[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [kind, setKind] = useState('');
|
||||
const [group, setGroup] = useState('');
|
||||
const [urlDialog, setUrlDialog] = useState<{ visible: boolean; url: string }>({ visible: false, url: '' });
|
||||
const [preview, setPreview] = useState<{ visible: boolean; url: string; kind: string; name: string }>({ visible: false, url: '', kind: '', name: '' });
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request.get('/materials?size=100' + (kind ? '&kind=' + kind : '') + (group ? '&group=' + encodeURIComponent(group) : ''));
|
||||
setList((res.data.list || []) as IMaterial[]);
|
||||
} catch (e) {
|
||||
MessagePlugin.error('素材加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [kind, group]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const uploadMethod = async (files: UploadFile | UploadFile[]): Promise<RequestMethodResponse> => {
|
||||
const f = (Array.isArray(files) ? files : [files])[0];
|
||||
if (!f?.raw) return { status: 'fail', error: 'no file', response: {} };
|
||||
const fd = new FormData();
|
||||
fd.append('file', f.raw as File);
|
||||
if (group.trim()) fd.append('group', group.trim());
|
||||
try {
|
||||
await request.post('/materials', fd);
|
||||
return { status: 'success', response: {} };
|
||||
} catch (e) {
|
||||
return { status: 'fail', error: 'upload failed', response: {} };
|
||||
}
|
||||
};
|
||||
|
||||
const onUploadSuccess = () => {
|
||||
MessagePlugin.success('上传成功');
|
||||
load();
|
||||
};
|
||||
|
||||
const onUploadFail = () => {
|
||||
MessagePlugin.error('上传失败');
|
||||
};
|
||||
|
||||
const getURL = async (m: IMaterial) => {
|
||||
try {
|
||||
const res = await request.get('/materials/' + m.id + '/url');
|
||||
setUrlDialog({ visible: true, url: res.data.url || '' });
|
||||
} catch (e) {
|
||||
MessagePlugin.error('生成链接失败');
|
||||
}
|
||||
};
|
||||
|
||||
const openPreview = async (m: IMaterial) => {
|
||||
try {
|
||||
const res = await request.get('/materials/' + m.id + '/url');
|
||||
setPreview({ visible: true, url: (res.data.url || '') + '?inline=1', kind: m.kind, name: m.name });
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.response?.data?.message || '预览链接生成失败');
|
||||
}
|
||||
};
|
||||
|
||||
const copyURL = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(urlDialog.url);
|
||||
MessagePlugin.success('已复制(10 分钟有效,一次性)');
|
||||
} catch (e) {
|
||||
MessagePlugin.error('复制失败');
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (m: IMaterial) => {
|
||||
try {
|
||||
await request.delete('/materials/' + m.id);
|
||||
MessagePlugin.success('已删除');
|
||||
load();
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.response?.data?.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ colKey: 'name', title: '名称', ellipsis: true },
|
||||
{
|
||||
colKey: 'kind',
|
||||
title: '类型',
|
||||
width: 90,
|
||||
cell: ({ row }: { row: IMaterial }) => (
|
||||
<Tag theme={row.kind === 'video' ? 'primary' : 'success'} variant='light'>
|
||||
{row.kind === 'video' ? '视频' : '图片'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{ colKey: 'size', title: '大小', width: 110, cell: ({ row }: { row: IMaterial }) => <span>{fmtSize(row.size)}</span> },
|
||||
{
|
||||
colKey: 'sha256',
|
||||
title: 'SHA256',
|
||||
width: 150,
|
||||
cell: ({ row }: { row: IMaterial }) => <span title={row.sha256}>{row.sha256.slice(0, 12)}...</span>,
|
||||
},
|
||||
{ colKey: 'group', title: '分组', width: 100, cell: ({ row }: { row: IMaterial }) => <span>{row.group || '-'}</span> },
|
||||
{
|
||||
colKey: 'createdAt',
|
||||
title: '上传时间',
|
||||
width: 160,
|
||||
cell: ({ row }: { row: IMaterial }) => <span>{dayjs(row.createdAt).format('MM-DD HH:mm')}</span>,
|
||||
},
|
||||
{
|
||||
colKey: 'op',
|
||||
title: '操作',
|
||||
width: 220,
|
||||
cell: ({ row }: { row: IMaterial }) => (
|
||||
<Space>
|
||||
<Button size='small' variant='text' onClick={() => openPreview(row)}>
|
||||
预览
|
||||
</Button>
|
||||
<Button size='small' variant='text' theme='primary' onClick={() => getURL(row)}>
|
||||
获取直链
|
||||
</Button>
|
||||
<Popconfirm content='确认删除该素材?' onConfirm={() => remove(row)}>
|
||||
<Button size='small' variant='text' theme='danger'>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card title='素材库'>
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 16, alignItems: 'center' }}>
|
||||
<Upload
|
||||
autoUpload
|
||||
multiple
|
||||
theme='file-input'
|
||||
placeholder='点击上传视频或图片(同文件自动去重)'
|
||||
requestMethod={uploadMethod}
|
||||
onSuccess={onUploadSuccess}
|
||||
onFail={onUploadFail}
|
||||
/>
|
||||
<Select
|
||||
value={kind}
|
||||
onChange={(v) => setKind(v as string)}
|
||||
options={[
|
||||
{ label: '全部类型', value: '' },
|
||||
{ label: '视频', value: 'video' },
|
||||
{ label: '图片', value: 'image' },
|
||||
]}
|
||||
style={{ width: 140 }}
|
||||
/>
|
||||
<Input placeholder='按分组筛选' value={group} onChange={(v) => setGroup(v as string)} style={{ width: 160 }} />
|
||||
<Button variant='outline' onClick={load}>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<Table data={list} columns={columns} rowKey='id' loading={loading} hover />
|
||||
<Dialog
|
||||
header='签名直链(10 分钟 · 一次性)'
|
||||
visible={urlDialog.visible}
|
||||
onClose={() => setUrlDialog({ visible: false, url: '' })}
|
||||
footer={null}
|
||||
>
|
||||
<Space style={{ width: '100%' }}>
|
||||
<Input value={urlDialog.url} style={{ width: 420 }} />
|
||||
<Button theme='primary' onClick={copyURL}>
|
||||
复制
|
||||
</Button>
|
||||
</Space>
|
||||
</Dialog>
|
||||
<Dialog
|
||||
header={'预览 · ' + preview.name}
|
||||
visible={preview.visible}
|
||||
onClose={() => setPreview({ visible: false, url: '', kind: '', name: '' })}
|
||||
footer={null}
|
||||
>
|
||||
<div style={{ textAlign: 'center', minHeight: 120 }}>
|
||||
{preview.kind === 'video' ? (
|
||||
<video controls src={preview.url} style={{ maxWidth: '100%', maxHeight: 420 }} />
|
||||
) : (
|
||||
<img src={preview.url} alt={preview.name} style={{ maxWidth: '100%', maxHeight: 420, objectFit: 'contain' }} />
|
||||
)}
|
||||
<div style={{ fontSize: 12, color: '#888', marginTop: 8 }}>预览链接为一次性链接,关闭后需重新生成。</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button, Card, Dialog, Form, FormInstanceFunctions, Input, MessagePlugin, Popconfirm, Select, Space, Table, Tag } from 'tdesign-react';
|
||||
import dayjs from 'dayjs';
|
||||
import request from 'utils/request';
|
||||
|
||||
const { FormItem } = Form;
|
||||
|
||||
interface IMember {
|
||||
id: number;
|
||||
userId: number;
|
||||
role: string;
|
||||
email: string;
|
||||
nickname: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const ROLE_LABEL: Record<string, string> = {
|
||||
owner: '所有者',
|
||||
admin: '管理员',
|
||||
operator: '运营',
|
||||
reviewer: '审核',
|
||||
viewer: '访客',
|
||||
};
|
||||
|
||||
const ROLE_THEME: Record<string, string> = {
|
||||
owner: 'primary',
|
||||
admin: 'warning',
|
||||
operator: 'success',
|
||||
reviewer: 'default',
|
||||
viewer: 'default',
|
||||
};
|
||||
|
||||
const ROLE_OPTIONS = ['admin', 'operator', 'reviewer', 'viewer'].map((r) => ({ label: ROLE_LABEL[r], value: r }));
|
||||
|
||||
export default function MemberList() {
|
||||
const [list, setList] = useState<IMember[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inviteVisible, setInviteVisible] = useState(false);
|
||||
const [inviteLink, setInviteLink] = useState('');
|
||||
const [inviting, setInviting] = useState(false);
|
||||
const formRef = useRef<FormInstanceFunctions>();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request.get('/members');
|
||||
setList((res.data.list || []) as IMember[]);
|
||||
} catch (e) {
|
||||
MessagePlugin.error('成员列表加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const onInvite = async () => {
|
||||
const value = (formRef.current?.getFieldsValue?.(true) || {}) as { email: string; role: string };
|
||||
if (!value.email || !value.role) return;
|
||||
setInviting(true);
|
||||
try {
|
||||
const res = await request.post('/members/invite', { email: value.email, role: value.role });
|
||||
setInviteLink(res.data.link || '');
|
||||
MessagePlugin.success('邀请已生成');
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.response?.data?.message || '邀请失败');
|
||||
} finally {
|
||||
setInviting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(inviteLink);
|
||||
MessagePlugin.success('已复制邀请链接');
|
||||
} catch (e) {
|
||||
MessagePlugin.error('复制失败,请手动复制');
|
||||
}
|
||||
};
|
||||
|
||||
const changeRole = async (member: IMember, role: string) => {
|
||||
try {
|
||||
await request.put('/members/' + member.id + '/role', { role });
|
||||
MessagePlugin.success('角色已更新');
|
||||
load();
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.response?.data?.message || '更新失败');
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (member: IMember) => {
|
||||
try {
|
||||
await request.delete('/members/' + member.id);
|
||||
MessagePlugin.success('已移除');
|
||||
load();
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.response?.data?.message || '移除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ colKey: 'nickname', title: '成员', cell: ({ row }: { row: IMember }) => <span>{row.nickname || '-'}</span> },
|
||||
{ colKey: 'email', title: '邮箱', ellipsis: true },
|
||||
{
|
||||
colKey: 'role',
|
||||
title: '角色',
|
||||
width: 140,
|
||||
cell: ({ row }: { row: IMember }) =>
|
||||
row.role === 'owner' ? (
|
||||
<Tag theme='primary'>{ROLE_LABEL[row.role]}</Tag>
|
||||
) : (
|
||||
<Select
|
||||
size='small'
|
||||
value={row.role}
|
||||
options={ROLE_OPTIONS}
|
||||
onChange={(v) => changeRole(row, v as string)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
colKey: 'createdAt',
|
||||
title: '加入时间',
|
||||
width: 160,
|
||||
cell: ({ row }: { row: IMember }) => <span>{dayjs(row.createdAt).format('YYYY-MM-DD HH:mm')}</span>,
|
||||
},
|
||||
{
|
||||
colKey: 'op',
|
||||
title: '操作',
|
||||
width: 100,
|
||||
cell: ({ row }: { row: IMember }) =>
|
||||
row.role === 'owner' ? null : (
|
||||
<Popconfirm content='确认移除该成员?' onConfirm={() => remove(row)}>
|
||||
<Button size='small' variant='text' theme='danger'>
|
||||
移除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title='成员管理'
|
||||
actions={
|
||||
<Button
|
||||
theme='primary'
|
||||
onClick={() => {
|
||||
setInviteLink('');
|
||||
setInviteVisible(true);
|
||||
}}
|
||||
>
|
||||
邀请成员
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table data={list} columns={columns} rowKey='id' loading={loading} hover />
|
||||
<Dialog
|
||||
header='邀请成员'
|
||||
visible={inviteVisible}
|
||||
onClose={() => setInviteVisible(false)}
|
||||
confirmBtn={null}
|
||||
cancelBtn={null}
|
||||
>
|
||||
<Form ref={formRef} labelWidth={70} onSubmit={onInvite}>
|
||||
<FormItem label='邮箱' name='email' rules={[{ required: true, message: '邮箱必填', type: 'error' }]}>
|
||||
<Input placeholder='被邀请人登录邮箱' />
|
||||
</FormItem>
|
||||
<FormItem label='角色' name='role' initialData='viewer' rules={[{ required: true, message: '角色必填', type: 'error' }]}>
|
||||
<Select options={ROLE_OPTIONS} />
|
||||
</FormItem>
|
||||
<FormItem>
|
||||
<Space>
|
||||
<Button theme='primary' type='submit' loading={inviting}>
|
||||
生成邀请链接
|
||||
</Button>
|
||||
<Button variant='outline' onClick={() => setInviteVisible(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
</Space>
|
||||
</FormItem>
|
||||
</Form>
|
||||
{inviteLink && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div style={{ fontSize: 13, color: '#888', marginBottom: 6 }}>将链接发给成员(24 小时内有效,登录同邮箱账号后访问即可加入)</div>
|
||||
<Space>
|
||||
<Input value={inviteLink} style={{ width: 420 }} />
|
||||
<Button theme='primary' variant='outline' onClick={copyLink}>
|
||||
复制链接
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Badge, Button, Card, MessagePlugin, Space, Table, Tag } from 'tdesign-react';
|
||||
import dayjs from 'dayjs';
|
||||
import request from 'utils/request';
|
||||
import { onWS } from 'utils/ws';
|
||||
|
||||
interface INotification {
|
||||
id: number;
|
||||
kind: string;
|
||||
title: string;
|
||||
content: string;
|
||||
read: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const KIND_LABEL: Record<string, string> = { task: '任务', challenge: '挑战', system: '系统' };
|
||||
|
||||
export default function NotificationList() {
|
||||
const [list, setList] = useState<INotification[]>([]);
|
||||
const [unread, setUnread] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request.get('/notifications');
|
||||
setList((res.data.list || []) as INotification[]);
|
||||
setUnread(res.data.unread || 0);
|
||||
} catch (e) {
|
||||
MessagePlugin.error('加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const offTask = onWS('task.status', () => load());
|
||||
const offNotification = onWS('notification.new', () => load());
|
||||
return () => {
|
||||
offTask();
|
||||
offNotification();
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
const markRead = async (n: INotification) => {
|
||||
try {
|
||||
await request.post('/notifications/' + n.id + '/read');
|
||||
load();
|
||||
} catch (e) {
|
||||
MessagePlugin.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const readAll = async () => {
|
||||
try {
|
||||
await request.post('/notifications/read-all');
|
||||
MessagePlugin.success('已全部标记已读');
|
||||
load();
|
||||
} catch (e) {
|
||||
MessagePlugin.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
colKey: 'title',
|
||||
title: '通知',
|
||||
cell: ({ row }: { row: INotification }) => (
|
||||
<div>
|
||||
<Space>
|
||||
{!row.read && <Badge dot />}
|
||||
<b>{row.title}</b>
|
||||
<Tag size='small' variant='light'>{KIND_LABEL[row.kind] || row.kind}</Tag>
|
||||
</Space>
|
||||
<div style={{ fontSize: 13, color: '#888', marginTop: 4 }}>{row.content}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
colKey: 'createdAt',
|
||||
title: '时间',
|
||||
width: 160,
|
||||
cell: ({ row }: { row: INotification }) => <span>{dayjs(row.createdAt).format('MM-DD HH:mm')}</span>,
|
||||
},
|
||||
{
|
||||
colKey: 'op',
|
||||
title: '操作',
|
||||
width: 100,
|
||||
cell: ({ row }: { row: INotification }) =>
|
||||
row.read ? null : (
|
||||
<Button size='small' variant='text' theme='primary' onClick={() => markRead(row)}>
|
||||
标记已读
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={'通知(未读 ' + unread + ')'}
|
||||
actions={
|
||||
<Button variant='outline' onClick={readAll}>
|
||||
全部已读
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table data={list} columns={columns} rowKey='id' loading={loading} hover />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Button, Card, Calendar, DatePicker, Drawer, MessagePlugin, Space, Tag } from 'tdesign-react';
|
||||
import dayjs from 'dayjs';
|
||||
import request from 'utils/request';
|
||||
|
||||
interface ITask {
|
||||
id: number;
|
||||
title: string;
|
||||
status: string;
|
||||
content?: string;
|
||||
tags?: string;
|
||||
accountIds?: string;
|
||||
materialIds?: string;
|
||||
scheduleAt?: string | null;
|
||||
}
|
||||
|
||||
const THEME: Record<string, string> = { success: 'success', failed: 'danger', cancelled: 'default' };
|
||||
|
||||
export default function CalendarPage() {
|
||||
const [tasks, setTasks] = useState<ITask[]>([]);
|
||||
const [map, setMap] = useState<Record<string, ITask[]>>({});
|
||||
const [selected, setSelected] = useState<ITask | null>(null);
|
||||
const [scheduleAt, setScheduleAt] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await request.get('/tasks?size=300');
|
||||
const list = (res.data.list || []) as ITask[];
|
||||
setTasks(list);
|
||||
const m: Record<string, ITask[]> = {};
|
||||
list.forEach((t) => {
|
||||
if (!t.scheduleAt) return;
|
||||
const key = dayjs(t.scheduleAt).format('YYYY-MM-DD');
|
||||
if (!m[key]) m[key] = [];
|
||||
m[key].push(t);
|
||||
});
|
||||
setMap(m);
|
||||
} catch (e) {
|
||||
MessagePlugin.error('排期加载失败');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const openTask = async (task: ITask) => {
|
||||
try {
|
||||
const res = await request.get('/tasks/' + task.id);
|
||||
const row = res.data as ITask;
|
||||
setSelected(row);
|
||||
setScheduleAt(row.scheduleAt || '');
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.response?.data?.message || '任务详情加载失败');
|
||||
}
|
||||
};
|
||||
|
||||
const saveSchedule = async (nextSchedule = scheduleAt) => {
|
||||
if (!selected || !['draft', 'rejected'].includes(selected.status)) return;
|
||||
const parseIDs = (raw?: string): number[] => {
|
||||
try {
|
||||
const value = JSON.parse(raw || '[]');
|
||||
return Array.isArray(value) ? value.map(Number).filter((id) => Number.isFinite(id)) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
const parseTags = (raw?: string): string[] => {
|
||||
try {
|
||||
const value = JSON.parse(raw || '[]');
|
||||
return Array.isArray(value) ? value.map(String) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
setSaving(true);
|
||||
try {
|
||||
await request.put('/tasks/' + selected.id, {
|
||||
title: selected.title,
|
||||
content: selected.content || '',
|
||||
tags: parseTags(selected.tags),
|
||||
accountIds: parseIDs(selected.accountIds),
|
||||
materialIds: parseIDs(selected.materialIds),
|
||||
scheduleAt: nextSchedule ? dayjs(nextSchedule).valueOf() : null,
|
||||
});
|
||||
MessagePlugin.success(nextSchedule ? '定时已更新' : '已改为立即发布');
|
||||
setSelected(null);
|
||||
load();
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.response?.data?.message || '定时更新失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title='排期日历' subtitle='按计划发布时间查看任务'>
|
||||
<Calendar
|
||||
format='YYYY-MM-DD'
|
||||
cell={(cellData: any) => {
|
||||
const list = map[cellData?.formattedDate] || [];
|
||||
const dayNum = cellData?.date ? cellData.date.getDate() : cellData?.day;
|
||||
return (
|
||||
<div>
|
||||
<div>{dayNum}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
|
||||
{list.slice(0, 3).map((t) => (
|
||||
<Tag key={t.id} size='small' theme={(THEME[t.status] || 'primary') as any} variant='light' onClick={() => openTask(t)}>
|
||||
{t.title.slice(0, 6)}
|
||||
</Tag>
|
||||
))}
|
||||
{list.length > 3 && <span style={{ fontSize: 11, color: '#888' }}>+{list.length - 3}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginTop: 12, fontSize: 13, color: '#888' }}>共 {tasks.filter((t) => t.scheduleAt).length} 个定时任务;立即发布的任务不在此展示。</div>
|
||||
<Drawer header={selected ? `任务 #${selected.id} 排期` : '任务排期'} visible={!!selected} onClose={() => setSelected(null)} size='420px'>
|
||||
{selected && (
|
||||
<div style={{ lineHeight: 2 }}>
|
||||
<div><b>标题</b>:{selected.title}</div>
|
||||
<div><b>状态</b>:{selected.status}</div>
|
||||
{['draft', 'rejected'].includes(selected.status) ? (
|
||||
<>
|
||||
<div style={{ marginTop: 12, marginBottom: 6 }}>计划发布时间</div>
|
||||
<DatePicker enableTimePicker value={scheduleAt} onChange={(v) => setScheduleAt(v as string)} clearable style={{ width: '100%' }} />
|
||||
<Space style={{ marginTop: 16 }}>
|
||||
<Button theme='primary' loading={saving} onClick={() => saveSchedule()}>保存排期</Button>
|
||||
<Button variant='outline' loading={saving} onClick={() => { setScheduleAt(''); saveSchedule(''); }}>改为立即发布</Button>
|
||||
</Space>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ marginTop: 12, color: '#888' }}>只有草稿或驳回任务可以编辑排期。</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Button, Card, DatePicker, Form, Input, MessagePlugin, Radio, Space, Steps, Table, Tag, TagInput, Textarea } from 'tdesign-react';
|
||||
import dayjs from 'dayjs';
|
||||
import request from 'utils/request';
|
||||
import { PLATFORM_LABEL } from 'constants/platforms';
|
||||
|
||||
const { StepItem } = Steps;
|
||||
|
||||
interface IMaterial { id: number; name: string; kind: string; size: number; createdAt: string; }
|
||||
interface IAccount { id: number; platform: string; accountName: string; remark: string; status: string; }
|
||||
|
||||
export default function PublishNew() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const editingID = searchParams.get('taskId');
|
||||
const [step, setStep] = useState(0);
|
||||
const [materials, setMaterials] = useState<IMaterial[]>([]);
|
||||
const [accounts, setAccounts] = useState<IAccount[]>([]);
|
||||
const [selMats, setSelMats] = useState<(string | number)[]>([]);
|
||||
const [selAccs, setSelAccs] = useState<(string | number)[]>([]);
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [scheduleMode, setScheduleMode] = useState('now');
|
||||
const [scheduleAt, setScheduleAt] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const m = await request.get('/materials?size=100');
|
||||
setMaterials((m.data.list || []) as IMaterial[]);
|
||||
const a = await request.get('/accounts?size=100');
|
||||
setAccounts(((a.data.list || []) as IAccount[]).filter((x) => x.status === 'active'));
|
||||
} catch (e) {
|
||||
MessagePlugin.error('数据加载失败');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingID) return;
|
||||
const parse = <T,>(raw: string | undefined, fallback: T[]): T[] => {
|
||||
try {
|
||||
const value = JSON.parse(raw || '[]');
|
||||
return Array.isArray(value) ? value as T[] : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
request.get('/tasks/' + editingID).then((res) => {
|
||||
const task = res.data;
|
||||
setTitle(task.title || '');
|
||||
setContent(task.content || '');
|
||||
setTags(parse<string>(task.tags, []));
|
||||
setSelAccs(parse<number>(task.accountIds, []));
|
||||
setSelMats(parse<number>(task.materialIds, []));
|
||||
setScheduleMode(task.scheduleAt ? 'scheduled' : 'now');
|
||||
setScheduleAt(task.scheduleAt || '');
|
||||
}).catch((e: any) => MessagePlugin.error(e?.response?.data?.message || '草稿加载失败'));
|
||||
}, [editingID]);
|
||||
|
||||
const submit = async () => {
|
||||
if (!title.trim()) {
|
||||
MessagePlugin.warning('请填写标题');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const payload = {
|
||||
title: title.trim(),
|
||||
content,
|
||||
tags,
|
||||
accountIds: selAccs.map(Number),
|
||||
materialIds: selMats.map(Number),
|
||||
scheduleAt: scheduleMode === 'scheduled' && scheduleAt ? dayjs(scheduleAt).valueOf() : null,
|
||||
priority: 5,
|
||||
};
|
||||
if (editingID) {
|
||||
await request.put('/tasks/' + editingID, payload);
|
||||
MessagePlugin.success('草稿已更新,可在任务列表提交审核');
|
||||
} else {
|
||||
await request.post('/tasks', payload);
|
||||
MessagePlugin.success('草稿已创建,可在任务列表提交审核');
|
||||
}
|
||||
navigate('/publish/tasks');
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.response?.data?.message || '创建失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const matColumns = [
|
||||
{ colKey: 'name', title: '素材' },
|
||||
{
|
||||
colKey: 'kind',
|
||||
title: '类型',
|
||||
width: 90,
|
||||
cell: ({ row }: { row: IMaterial }) => (
|
||||
<Tag theme={row.kind === 'video' ? 'primary' : 'success'} variant='light'>{row.kind === 'video' ? '视频' : '图片'}</Tag>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const accColumns = [
|
||||
{
|
||||
colKey: 'platform',
|
||||
title: '平台',
|
||||
width: 100,
|
||||
cell: ({ row }: { row: IAccount }) => <Tag theme='primary' variant='light'>{PLATFORM_LABEL[row.platform] || row.platform}</Tag>,
|
||||
},
|
||||
{ colKey: 'accountName', title: '账号', cell: ({ row }: { row: IAccount }) => <span>{row.remark || row.accountName || PLATFORM_LABEL[row.platform] || row.platform}</span> },
|
||||
];
|
||||
|
||||
const steps = ['选择素材', '选择账号', '填写内容', '确认提交'];
|
||||
|
||||
return (
|
||||
<Card title='新建发布'>
|
||||
<Steps current={step} onChange={(cur) => setStep(cur as number)} style={{ marginBottom: 24 }}>
|
||||
{steps.map((s) => (
|
||||
<StepItem key={s} title={s} />
|
||||
))}
|
||||
</Steps>
|
||||
{step === 0 && (
|
||||
<div>
|
||||
<div style={{ fontSize: 13, color: '#888', marginBottom: 12 }}>勾选要发布的素材(可到「素材库」上传)</div>
|
||||
<Table
|
||||
data={materials}
|
||||
columns={matColumns}
|
||||
rowKey='id'
|
||||
selectedRowKeys={selMats}
|
||||
onSelectChange={(keys) => setSelMats(keys)}
|
||||
hover
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{step === 1 && (
|
||||
<div>
|
||||
<div style={{ fontSize: 13, color: '#888', marginBottom: 12 }}>勾选发布账号(仅展示已绑定账号)</div>
|
||||
<Table
|
||||
data={accounts}
|
||||
columns={accColumns}
|
||||
rowKey='id'
|
||||
selectedRowKeys={selAccs}
|
||||
onSelectChange={(keys) => setSelAccs(keys)}
|
||||
hover
|
||||
/>
|
||||
{accounts.length === 0 && (
|
||||
<div style={{ color: '#e34d59', fontSize: 13, marginTop: 8 }}>暂无已绑定账号,请先到「账号管理」完成绑定</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<div style={{ maxWidth: 640 }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 13, color: '#888', marginBottom: 6 }}>标题 *</div>
|
||||
<Input value={title} onChange={(v) => setTitle(v as string)} placeholder='发布标题' />
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 13, color: '#888', marginBottom: 6 }}>正文</div>
|
||||
<Textarea value={content} onChange={(v) => setContent(v as string)} rows={4} placeholder='正文内容' />
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 13, color: '#888', marginBottom: 6 }}>话题标签</div>
|
||||
<TagInput value={tags} onChange={(v) => setTags(v as string[])} placeholder='输入后回车添加,如 #新品' />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, color: '#888', marginBottom: 6 }}>发布时间</div>
|
||||
<Radio.Group value={scheduleMode} onChange={(v) => setScheduleMode(v as string)}>
|
||||
<Radio value='now'>立即发布(审核通过后)</Radio>
|
||||
<Radio value='scheduled'>定时发布</Radio>
|
||||
</Radio.Group>
|
||||
{scheduleMode === 'scheduled' && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<DatePicker enableTimePicker value={scheduleAt} onChange={(v) => setScheduleAt(v as string)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<div style={{ fontSize: 14, lineHeight: 2.2, maxWidth: 640 }}>
|
||||
<div><b>素材</b>:{selMats.length} 个({materials.filter((m) => selMats.includes(m.id)).map((m) => m.name).join('、')})</div>
|
||||
<div><b>账号</b>:{selAccs.length} 个({accounts.filter((a) => selAccs.includes(a.id)).map((a) => (PLATFORM_LABEL[a.platform] || a.platform) + '-' + (a.remark || a.accountName || '')).join('、')})</div>
|
||||
<div><b>标题</b>:{title}</div>
|
||||
<div><b>标签</b>:{tags.join('、') || '-'}</div>
|
||||
<div><b>时间</b>:{scheduleMode === 'scheduled' && scheduleAt ? dayjs(scheduleAt).format('YYYY-MM-DD HH:mm') : '审核通过后立即'}</div>
|
||||
<div style={{ fontSize: 13, color: '#888', marginTop: 8 }}>提交后为草稿状态,需在任务列表提交审核;审核通过后由本机 Web 执行器排队处理。</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Space>
|
||||
{step > 0 && (
|
||||
<Button variant='outline' onClick={() => setStep(step - 1)}>
|
||||
上一步
|
||||
</Button>
|
||||
)}
|
||||
{step < 3 && (
|
||||
<Button
|
||||
theme='primary'
|
||||
onClick={() => {
|
||||
if (step === 0 && selMats.length === 0) {
|
||||
MessagePlugin.warning('请至少选择一个素材');
|
||||
return;
|
||||
}
|
||||
if (step === 1 && selAccs.length === 0) {
|
||||
MessagePlugin.warning('请至少选择一个账号');
|
||||
return;
|
||||
}
|
||||
if (step === 2 && !title.trim()) {
|
||||
MessagePlugin.warning('请填写标题');
|
||||
return;
|
||||
}
|
||||
setStep(step + 1);
|
||||
}}
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<Button theme='primary' loading={submitting} onClick={submit}>
|
||||
创建草稿
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button, Card, Dialog, Drawer, Input, MessagePlugin, Select, Space, Table, Tag, Textarea } from 'tdesign-react';
|
||||
import dayjs from 'dayjs';
|
||||
import request from 'utils/request';
|
||||
import { onWS } from 'utils/ws';
|
||||
import { useAppSelector } from 'modules/store';
|
||||
import { selectUser } from 'modules/user';
|
||||
|
||||
interface ITask {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
status: string;
|
||||
scheduleAt?: string | null;
|
||||
createdAt: string;
|
||||
publishedAt?: string | null;
|
||||
publishedUrls: string;
|
||||
receipts: string;
|
||||
errorMessage: string;
|
||||
reviewNote: string;
|
||||
accountIds: string;
|
||||
materialIds: string;
|
||||
tags: string;
|
||||
}
|
||||
|
||||
interface ITaskEvent {
|
||||
id: number;
|
||||
type: string;
|
||||
status: string;
|
||||
message: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
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' },
|
||||
};
|
||||
|
||||
export default function Tasks() {
|
||||
const navigate = useNavigate();
|
||||
const userState = useAppSelector(selectUser);
|
||||
const canOperate = ['owner', 'admin', 'operator'].includes(userState.memberRole || '');
|
||||
const canReview = ['owner', 'admin', 'reviewer'].includes(userState.memberRole || '');
|
||||
const [list, setList] = useState<ITask[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [status, setStatus] = useState('');
|
||||
const [detail, setDetail] = useState<ITask | null>(null);
|
||||
const [rejectTarget, setRejectTarget] = useState<ITask | null>(null);
|
||||
const [rejectNote, setRejectNote] = useState('');
|
||||
const [total, setTotal] = useState(0);
|
||||
const [events, setEvents] = useState<ITaskEvent[]>([]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request.get('/tasks?size=100' + (status ? '&status=' + status : ''));
|
||||
setList((res.data.list || []) as ITask[]);
|
||||
setTotal(res.data.total || 0);
|
||||
} catch (e) {
|
||||
MessagePlugin.error('任务加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const off = onWS('task.status', () => load());
|
||||
return off;
|
||||
}, [load]);
|
||||
|
||||
const act = async (task: ITask, action: string, body?: any) => {
|
||||
try {
|
||||
await request.post('/tasks/' + task.id + '/' + action, body || {});
|
||||
MessagePlugin.success('操作成功');
|
||||
load();
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.response?.data?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const removeTask = async (task: ITask) => {
|
||||
try {
|
||||
await request.delete('/tasks/' + task.id);
|
||||
MessagePlugin.success('已删除');
|
||||
load();
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.response?.data?.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const openDetail = async (task: ITask) => {
|
||||
setDetail(task);
|
||||
try {
|
||||
const res = await request.get('/tasks/' + task.id + '/events');
|
||||
setEvents((res.data.list || []) as ITaskEvent[]);
|
||||
} catch (e) {
|
||||
setEvents([]);
|
||||
MessagePlugin.error('任务时间线加载失败');
|
||||
}
|
||||
};
|
||||
|
||||
const renderActions = (row: ITask) => {
|
||||
const btns: React.ReactNode[] = [];
|
||||
if (canOperate && row.status === 'draft') {
|
||||
btns.push(
|
||||
<Button key='edit' size='small' variant='text' theme='primary' onClick={() => navigate('/publish/new?taskId=' + row.id)}>
|
||||
编辑
|
||||
</Button>,
|
||||
);
|
||||
btns.push(
|
||||
<Button key='submit' size='small' variant='text' theme='primary' onClick={() => act(row, 'submit')}>
|
||||
提交审核
|
||||
</Button>,
|
||||
);
|
||||
btns.push(
|
||||
<Button key='del' size='small' variant='text' theme='danger' onClick={() => removeTask(row)}>
|
||||
删除
|
||||
</Button>,
|
||||
);
|
||||
}
|
||||
if (canReview && row.status === 'pending_review') {
|
||||
btns.push(
|
||||
<Button key='approve' size='small' variant='text' theme='success' onClick={() => act(row, 'approve')}>
|
||||
通过
|
||||
</Button>,
|
||||
);
|
||||
btns.push(
|
||||
<Button key='reject' size='small' variant='text' theme='danger' onClick={() => setRejectTarget(row)}>
|
||||
驳回
|
||||
</Button>,
|
||||
);
|
||||
}
|
||||
if (canOperate && row.status === 'rejected') {
|
||||
btns.push(
|
||||
<Button key='edit-rejected' size='small' variant='text' theme='primary' onClick={() => navigate('/publish/new?taskId=' + row.id)}>
|
||||
编辑
|
||||
</Button>,
|
||||
);
|
||||
btns.push(
|
||||
<Button key='resubmit' size='small' variant='text' theme='primary' onClick={() => act(row, 'resubmit')}>
|
||||
重新提交
|
||||
</Button>,
|
||||
);
|
||||
}
|
||||
if (canOperate && row.status === 'failed') {
|
||||
btns.push(
|
||||
<Button key='retry' size='small' variant='text' theme='primary' onClick={() => act(row, 'retry')}>
|
||||
重试
|
||||
</Button>,
|
||||
);
|
||||
}
|
||||
if (canOperate && ['queued', 'dispatched', 'running', 'suspended', 'pending_review', 'draft', 'rejected'].includes(row.status)) {
|
||||
btns.push(
|
||||
<Button key='cancel' size='small' variant='text' onClick={() => act(row, 'cancel')}>
|
||||
取消
|
||||
</Button>,
|
||||
);
|
||||
}
|
||||
btns.push(
|
||||
<Button key='detail' size='small' variant='text' onClick={() => openDetail(row)}>
|
||||
详情
|
||||
</Button>,
|
||||
);
|
||||
return <Space size={0}>{btns}</Space>;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ 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: 150,
|
||||
cell: ({ row }: { row: ITask }) => <span>{dayjs(row.createdAt).format('MM-DD HH:mm')}</span>,
|
||||
},
|
||||
{ colKey: 'op', title: '操作', width: 280, cell: ({ row }: { row: ITask }) => renderActions(row) },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={'发布任务(共 ' + total + ' 条)'}
|
||||
actions={
|
||||
<Space>
|
||||
<Select
|
||||
value={status}
|
||||
onChange={(v) => setStatus(v as string)}
|
||||
options={[
|
||||
{ label: '全部状态', value: '' },
|
||||
...Object.keys(STATUS_MAP).map((k) => ({ label: STATUS_MAP[k].label, value: k })),
|
||||
]}
|
||||
style={{ width: 140 }}
|
||||
/>
|
||||
<Button theme='primary' onClick={() => navigate('/publish/new')}>
|
||||
新建发布
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Table data={list} columns={columns} rowKey='id' loading={loading} hover />
|
||||
<Drawer
|
||||
header={'任务 #' + (detail?.id || '') + ' 详情'}
|
||||
visible={!!detail}
|
||||
onClose={() => { setDetail(null); setEvents([]); }}
|
||||
size='420px'
|
||||
>
|
||||
{detail && (
|
||||
<div style={{ fontSize: 14, lineHeight: 2 }}>
|
||||
<div><b>标题</b>:{detail.title}</div>
|
||||
<div><b>状态</b>:{(STATUS_MAP[detail.status] || {}).label || detail.status}</div>
|
||||
<div><b>内容</b>:{detail.content || '-'}</div>
|
||||
<div><b>标签</b>:{detail.tags || '-'}</div>
|
||||
<div><b>计划发布</b>:{detail.scheduleAt ? dayjs(detail.scheduleAt).format('YYYY-MM-DD HH:mm') : '立即'}</div>
|
||||
<div><b>失败原因</b>:{detail.errorMessage || '-'}</div>
|
||||
<div><b>驳回批注</b>:{detail.reviewNote || '-'}</div>
|
||||
<div><b>发布链接</b>:{detail.publishedUrls === 'null' || !detail.publishedUrls ? '-' : detail.publishedUrls}</div>
|
||||
<div><b>创建时间</b>:{dayjs(detail.createdAt).format('YYYY-MM-DD HH:mm')}</div>
|
||||
<div style={{ marginTop: 16, fontWeight: 600 }}>状态时间线</div>
|
||||
{events.length === 0 ? (
|
||||
<div style={{ color: '#888' }}>暂无事件</div>
|
||||
) : events.map((event) => (
|
||||
<div key={event.id} style={{ borderLeft: '2px solid var(--td-brand-color)', paddingLeft: 10, marginTop: 8 }}>
|
||||
<div>{event.message}</div>
|
||||
<div style={{ color: '#888', fontSize: 12 }}>{dayjs(event.createdAt).format('MM-DD HH:mm:ss')} · {event.status}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
<Dialog
|
||||
header='驳回任务(附批注)'
|
||||
visible={!!rejectTarget}
|
||||
onClose={() => setRejectTarget(null)}
|
||||
confirmBtn={
|
||||
<Button
|
||||
theme='danger'
|
||||
onClick={() => {
|
||||
if (rejectTarget) act(rejectTarget, 'reject', { note: rejectNote });
|
||||
setRejectTarget(null);
|
||||
setRejectNote('');
|
||||
}}
|
||||
>
|
||||
确认驳回
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Textarea value={rejectNote} onChange={(v) => setRejectNote(v as string)} placeholder='驳回原因(将通知创建人)' rows={3} />
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import React from 'react';
|
||||
import ErrorPage from 'components/ErrorPage';
|
||||
|
||||
const UnAuthorized = () => <ErrorPage code={403} />;
|
||||
|
||||
export default React.memo(UnAuthorized);
|
||||
@@ -1,6 +0,0 @@
|
||||
import React from 'react';
|
||||
import ErrorPage from 'components/ErrorPage';
|
||||
|
||||
const NotFound = () => <ErrorPage code={404} />;
|
||||
|
||||
export default React.memo(NotFound);
|
||||
@@ -1,6 +0,0 @@
|
||||
import React from 'react';
|
||||
import ErrorPage from 'components/ErrorPage';
|
||||
|
||||
const ServerError = () => <ErrorPage code={500} />;
|
||||
|
||||
export default React.memo(ServerError);
|
||||
@@ -1,28 +0,0 @@
|
||||
.Content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 75vh;
|
||||
padding: 24px;
|
||||
min-height: 400px;
|
||||
color: var(--td-brand-color);
|
||||
|
||||
.title {
|
||||
font-weight: 500;
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
margin-top: 8px;
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
.description {
|
||||
margin: 8px 0 32px;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
|
||||
.rightButton {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Button, Card, Dialog, Drawer, MessagePlugin, Space, Table, Tag, Textarea } from 'tdesign-react';
|
||||
import dayjs from 'dayjs';
|
||||
import request from 'utils/request';
|
||||
import { onWS } from 'utils/ws';
|
||||
import { useAppSelector } from 'modules/store';
|
||||
import { selectUser } from 'modules/user';
|
||||
|
||||
interface ITask {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
accountIds: string;
|
||||
materialIds: string;
|
||||
tags: string;
|
||||
}
|
||||
|
||||
export default function ReviewList() {
|
||||
const userState = useAppSelector(selectUser);
|
||||
const canReview = ['owner', 'admin', 'reviewer'].includes(userState.memberRole || '');
|
||||
const [list, setList] = useState<ITask[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [preview, setPreview] = useState<ITask | null>(null);
|
||||
const [rejectTarget, setRejectTarget] = useState<ITask | null>(null);
|
||||
const [note, setNote] = useState('');
|
||||
|
||||
const countIDs = (raw: string) => {
|
||||
try {
|
||||
const parsed = JSON.parse(raw || '[]');
|
||||
return Array.isArray(parsed) ? parsed.length : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request.get('/tasks?status=pending_review&size=100');
|
||||
setList((res.data.list || []) as ITask[]);
|
||||
} catch (e) {
|
||||
MessagePlugin.error('加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const off = onWS('task.status', () => load());
|
||||
return off;
|
||||
}, [load]);
|
||||
|
||||
const act = async (task: ITask, action: string, body?: any) => {
|
||||
try {
|
||||
await request.post('/tasks/' + task.id + '/' + action, body || {});
|
||||
MessagePlugin.success(action === 'approve' ? '已通过,任务进入队列' : '已驳回');
|
||||
load();
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.response?.data?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ colKey: 'id', title: 'ID', width: 70 },
|
||||
{ colKey: 'title', title: '标题', ellipsis: true },
|
||||
{
|
||||
colKey: 'createdAt',
|
||||
title: '提交时间',
|
||||
width: 160,
|
||||
cell: ({ row }: { row: ITask }) => <span>{dayjs(row.createdAt).format('MM-DD HH:mm')}</span>,
|
||||
},
|
||||
{
|
||||
colKey: 'op',
|
||||
title: '操作',
|
||||
width: 220,
|
||||
cell: ({ row }: { row: ITask }) => (
|
||||
<Space size={0}>
|
||||
<Button size='small' variant='text' onClick={() => setPreview(row)}>
|
||||
预览
|
||||
</Button>
|
||||
{canReview && <Button size='small' variant='text' theme='success' onClick={() => act(row, 'approve')}>通过</Button>}
|
||||
{canReview && <Button size='small' variant='text' theme='danger' onClick={() => setRejectTarget(row)}>驳回</Button>}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card title='审核中心' subtitle='待审核任务(通过后由本机 Web 执行器处理)'>
|
||||
<Table data={list} columns={columns} rowKey='id' loading={loading} hover />
|
||||
<Drawer header={'任务 #' + (preview?.id || '') + ' 预览'} visible={!!preview} onClose={() => setPreview(null)} size='420px'>
|
||||
{preview && (
|
||||
<div style={{ fontSize: 14, lineHeight: 2 }}>
|
||||
<div><b>标题</b>:{preview.title}</div>
|
||||
<div><b>内容</b>:{preview.content || '-'}</div>
|
||||
<div><b>标签</b>:{preview.tags || '-'}</div>
|
||||
<div><b>账号数</b>:{countIDs(preview.accountIds)}</div>
|
||||
<div><b>素材数</b>:{countIDs(preview.materialIds)}</div>
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
<Dialog
|
||||
header='驳回(附批注)'
|
||||
visible={!!rejectTarget}
|
||||
onClose={() => setRejectTarget(null)}
|
||||
confirmBtn={
|
||||
<Button
|
||||
theme='danger'
|
||||
onClick={() => {
|
||||
if (rejectTarget) act(rejectTarget, 'reject', { note });
|
||||
setRejectTarget(null);
|
||||
setNote('');
|
||||
}}
|
||||
>
|
||||
确认驳回
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Textarea value={note} onChange={(v) => setNote(v as string)} placeholder='驳回原因' rows={3} />
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Button, Card, Input, MessagePlugin, Space } from 'tdesign-react';
|
||||
import request from 'utils/request';
|
||||
import { useAppSelector } from 'modules/store';
|
||||
import { selectUser } from 'modules/user';
|
||||
|
||||
interface IWorkspace {
|
||||
id: number;
|
||||
name: string;
|
||||
plan: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function SettingProfile() {
|
||||
const userState = useAppSelector(selectUser);
|
||||
const [workspaces, setWorkspaces] = useState<IWorkspace[]>([]);
|
||||
const [name, setName] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [switching, setSwitching] = useState(0);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await request.get('/workspaces');
|
||||
const list = (res.data.list || []) as IWorkspace[];
|
||||
setWorkspaces(list);
|
||||
const cur = list.find((w) => w.id === userState.workspaceId) || list[0];
|
||||
if (cur) setName(cur.name);
|
||||
} catch (e) {
|
||||
MessagePlugin.error('加载失败');
|
||||
}
|
||||
}, [userState.workspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const save = async () => {
|
||||
if (!name.trim()) {
|
||||
MessagePlugin.warning('名称不能为空');
|
||||
return;
|
||||
}
|
||||
const cur = workspaces.find((w) => w.id === userState.workspaceId) || workspaces[0];
|
||||
if (!cur) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await request.put('/workspaces/' + cur.id, { name: name.trim() });
|
||||
MessagePlugin.success('已保存');
|
||||
load();
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.response?.data?.message || '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const switchWorkspace = async (workspaceId: number) => {
|
||||
if (workspaceId === userState.workspaceId) return;
|
||||
setSwitching(workspaceId);
|
||||
try {
|
||||
const res = await request.post('/auth/switch-workspace/' + workspaceId);
|
||||
localStorage.setItem('ep_token', res.data.accessToken);
|
||||
localStorage.setItem('ep_refresh', res.data.refreshToken);
|
||||
window.location.reload();
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.response?.data?.message || '切换工作空间失败');
|
||||
} finally {
|
||||
setSwitching(0);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Space direction='vertical' style={{ width: '100%' }} size={16}>
|
||||
<Card title='工作区资料'>
|
||||
<div style={{ maxWidth: 560 }}>
|
||||
<div style={{ marginBottom: 16, fontSize: 13, color: '#888' }}>工作空间名称</div>
|
||||
<Space>
|
||||
<Input value={name} onChange={(v) => setName(v as string)} style={{ width: 320 }} />
|
||||
<Button theme='primary' onClick={save} loading={saving}>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title='我的账号'>
|
||||
<div style={{ maxWidth: 560, fontSize: 14, lineHeight: 2.2 }}>
|
||||
<div>邮箱:{userState.userInfo.email || '-'}</div>
|
||||
<div>昵称:{userState.userInfo.nickname || '-'}</div>
|
||||
<div>工作空间角色:{userState.memberRole || '-'}</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title='我的工作空间'>
|
||||
<div style={{ fontSize: 14, lineHeight: 2 }}>
|
||||
{workspaces.map((w) => (
|
||||
<Space key={w.id}>
|
||||
<span>{w.name}({w.plan}){w.id === userState.workspaceId ? ' ← 当前' : ''}</span>
|
||||
{w.id !== userState.workspaceId && (
|
||||
<Button size='small' variant='text' loading={switching === w.id} onClick={() => switchWorkspace(w.id)}>
|
||||
切换
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Card, Input, MessagePlugin, Space, Switch, Tag } from 'tdesign-react';
|
||||
import request from 'utils/request';
|
||||
|
||||
export default function SettingSecurity() {
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const changePassword = async () => {
|
||||
if (newPassword.length < 8) {
|
||||
MessagePlugin.warning('新密码至少 8 位');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await request.post('/auth/password', { currentPassword, newPassword });
|
||||
MessagePlugin.success('密码已更新,请重新登录');
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.response?.data?.message || '密码更新失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Space direction='vertical' style={{ width: '100%' }} size={16}>
|
||||
<Card title='两步验证(2FA)'>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, fontSize: 14 }}>
|
||||
<Switch disabled value={false} />
|
||||
<span>默认关闭</span>
|
||||
<Tag theme='warning' variant='light'>
|
||||
二期接入 TOTP 验证码
|
||||
</Tag>
|
||||
</div>
|
||||
<div style={{ marginTop: 12, fontSize: 13, color: '#888' }}>开启后登录需额外输入动态验证码,进一步保护账号。</div>
|
||||
</Card>
|
||||
<Card title='修改密码'>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 420 }}>
|
||||
<Input type='password' value={currentPassword} onChange={(v) => setCurrentPassword(v as string)} placeholder='当前密码' />
|
||||
<Input type='password' value={newPassword} onChange={(v) => setNewPassword(v as string)} placeholder='新密码(至少 8 位)' />
|
||||
<Button theme='primary' loading={saving} onClick={changePassword}>更新密码</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { lazy } from 'react';
|
||||
import { ShopIcon } from 'tdesign-icons-react';
|
||||
import { IRouter } from '../index';
|
||||
|
||||
const admin: IRouter[] = [
|
||||
{
|
||||
path: '/admin',
|
||||
meta: { title: '平台管理', Icon: ShopIcon, admin: true },
|
||||
children: [
|
||||
{
|
||||
path: 'tenant',
|
||||
Component: lazy(() => import('pages/Admin/Tenant')),
|
||||
meta: { title: '租户管理' },
|
||||
},
|
||||
{
|
||||
path: 'docs',
|
||||
Component: lazy(() => import('pages/Admin/Docs')),
|
||||
meta: { title: '开发文档' },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default admin;
|
||||
@@ -1,25 +0,0 @@
|
||||
import { lazy } from 'react';
|
||||
import { IRouter } from '../index';
|
||||
|
||||
// 登录页:独立全屏页,不进侧边菜单
|
||||
const result: IRouter[] = [
|
||||
{
|
||||
path: '/login',
|
||||
meta: {
|
||||
title: '登录页',
|
||||
hidden: true,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'index',
|
||||
Component: lazy(() => import('pages/Login')),
|
||||
isFullPage: true,
|
||||
meta: {
|
||||
title: '登录中心',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default result;
|
||||
@@ -1,19 +0,0 @@
|
||||
import { lazy } from 'react';
|
||||
import { IRouter } from '../index';
|
||||
|
||||
const otherRoutes: IRouter[] = [
|
||||
{
|
||||
path: '/403',
|
||||
Component: lazy(() => import('pages/Result/403')),
|
||||
},
|
||||
{
|
||||
path: '/500',
|
||||
Component: lazy(() => import('pages/Result/500')),
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
Component: lazy(() => import('pages/Result/404')),
|
||||
},
|
||||
];
|
||||
|
||||
export default otherRoutes;
|
||||
@@ -1,144 +0,0 @@
|
||||
import { lazy } from 'react';
|
||||
import {
|
||||
DashboardIcon,
|
||||
TaskIcon,
|
||||
FolderIcon,
|
||||
UserCircleIcon,
|
||||
CheckCircleIcon,
|
||||
SecuredIcon,
|
||||
NotificationIcon,
|
||||
UsergroupIcon,
|
||||
SettingIcon,
|
||||
TimeIcon,
|
||||
} from 'tdesign-icons-react';
|
||||
import { IRouter } from '../index';
|
||||
|
||||
const workspace: IRouter[] = [
|
||||
{
|
||||
path: '/dashboard',
|
||||
meta: { title: '工作台', Icon: DashboardIcon, single: true },
|
||||
children: [
|
||||
{
|
||||
path: 'base',
|
||||
Component: lazy(() => import('pages/Dashboard/Base')),
|
||||
meta: { title: '概览' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/publish',
|
||||
meta: { title: '发布管理', Icon: TaskIcon },
|
||||
children: [
|
||||
{
|
||||
path: 'tasks',
|
||||
Component: lazy(() => import('pages/Publish/Tasks')),
|
||||
meta: { title: '任务列表' },
|
||||
},
|
||||
{
|
||||
path: 'new',
|
||||
Component: lazy(() => import('pages/Publish/New')),
|
||||
meta: { title: '新建发布' },
|
||||
},
|
||||
{
|
||||
path: 'calendar',
|
||||
Component: lazy(() => import('pages/Publish/Calendar')),
|
||||
meta: { title: '排期日历' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/material',
|
||||
meta: { title: '素材库', Icon: FolderIcon, single: true },
|
||||
children: [
|
||||
{
|
||||
path: 'list',
|
||||
Component: lazy(() => import('pages/Material/List')),
|
||||
meta: { title: '素材列表' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/account',
|
||||
meta: { title: '账号管理', Icon: UserCircleIcon, single: true },
|
||||
children: [
|
||||
{
|
||||
path: 'list',
|
||||
Component: lazy(() => import('pages/Account/List')),
|
||||
meta: { title: '账号台账' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/review',
|
||||
meta: { title: '审核中心', Icon: CheckCircleIcon, single: true },
|
||||
children: [
|
||||
{
|
||||
path: 'list',
|
||||
Component: lazy(() => import('pages/Review/List')),
|
||||
meta: { title: '待审列表' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/challenge',
|
||||
meta: { title: '挑战中心', Icon: SecuredIcon, single: true },
|
||||
children: [
|
||||
{
|
||||
path: 'list',
|
||||
Component: lazy(() => import('pages/Challenge/List')),
|
||||
meta: { title: '挑战列表' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/notification',
|
||||
meta: { title: '通知', Icon: NotificationIcon, single: true },
|
||||
children: [
|
||||
{
|
||||
path: 'list',
|
||||
Component: lazy(() => import('pages/Notification/List')),
|
||||
meta: { title: '通知列表' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/member',
|
||||
meta: { title: '成员管理', Icon: UsergroupIcon, single: true },
|
||||
children: [
|
||||
{
|
||||
path: 'list',
|
||||
Component: lazy(() => import('pages/Member/List')),
|
||||
meta: { title: '成员列表' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/setting',
|
||||
meta: { title: '设置', Icon: SettingIcon },
|
||||
children: [
|
||||
{
|
||||
path: 'profile',
|
||||
Component: lazy(() => import('pages/Setting/Profile')),
|
||||
meta: { title: '工作区资料' },
|
||||
},
|
||||
{
|
||||
path: 'security',
|
||||
Component: lazy(() => import('pages/Setting/Security')),
|
||||
meta: { title: '安全' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/audit',
|
||||
meta: { title: '审计日志', Icon: TimeIcon, single: true },
|
||||
children: [
|
||||
{
|
||||
path: 'list',
|
||||
Component: lazy(() => import('pages/Audit/List')),
|
||||
meta: { title: '日志列表' },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default workspace;
|
||||
@@ -1,86 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import proxy from '../configs/host';
|
||||
|
||||
const env = import.meta.env.MODE || 'development';
|
||||
const API_HOST = proxy[env].API;
|
||||
|
||||
const SUCCESS_CODE = 0;
|
||||
const TIMEOUT = 15000;
|
||||
|
||||
export const instance = axios.create({
|
||||
baseURL: API_HOST,
|
||||
timeout: TIMEOUT,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
// 附带 JWT
|
||||
instance.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('ep_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = 'Bearer ' + token;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// 401 时用 refresh token 换新并重试原请求(单飞:并发请求共享一次刷新)
|
||||
let refreshPromise: Promise<string> | null = null;
|
||||
|
||||
const refreshTokens = (): Promise<string> => {
|
||||
if (!refreshPromise) {
|
||||
const refreshToken = localStorage.getItem('ep_refresh');
|
||||
if (!refreshToken) {
|
||||
return Promise.reject(new Error('no refresh token'));
|
||||
}
|
||||
refreshPromise = axios
|
||||
.post('/api/v1/auth/refresh', { refreshToken })
|
||||
.then((res) => {
|
||||
const data = res.data?.data;
|
||||
localStorage.setItem('ep_token', data.accessToken);
|
||||
localStorage.setItem('ep_refresh', data.refreshToken);
|
||||
return data.accessToken as string;
|
||||
})
|
||||
.finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
}
|
||||
return refreshPromise;
|
||||
};
|
||||
|
||||
instance.interceptors.response.use(
|
||||
// eslint-disable-next-line consistent-return
|
||||
(response) => {
|
||||
if (response.status === 200) {
|
||||
const { data } = response;
|
||||
if (data.code === SUCCESS_CODE) {
|
||||
return data;
|
||||
}
|
||||
const businessError = new Error(data.message || '请求失败') as Error & { response?: { data: unknown } };
|
||||
businessError.response = { data };
|
||||
return Promise.reject(businessError);
|
||||
}
|
||||
return Promise.reject(response?.data);
|
||||
},
|
||||
async (error) => {
|
||||
const config = error?.config;
|
||||
const resp = error?.response;
|
||||
const retried = config ? (config as any).__retried : false;
|
||||
if (resp?.status === 401 && resp?.data?.code === 1002 && config && !retried && localStorage.getItem('ep_refresh')) {
|
||||
(config as any).__retried = true;
|
||||
try {
|
||||
const token = await refreshTokens();
|
||||
config.headers.Authorization = 'Bearer ' + token;
|
||||
return instance(config);
|
||||
} catch (e) {
|
||||
localStorage.removeItem('ep_token');
|
||||
localStorage.removeItem('ep_refresh');
|
||||
if (window.location.pathname.indexOf('/login') === -1) {
|
||||
window.location.href = '/login/index';
|
||||
}
|
||||
return Promise.reject(e);
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export default instance;
|
||||
@@ -1,69 +0,0 @@
|
||||
let sock: WebSocket | null = null;
|
||||
let retryTimer: number | null = null;
|
||||
let stopped = false;
|
||||
|
||||
type Listener = (payload: any) => void;
|
||||
const listeners = new Map<string, Set<Listener>>();
|
||||
|
||||
function emit(type: string, payload: any) {
|
||||
listeners.get(type)?.forEach((fn) => fn(payload));
|
||||
}
|
||||
|
||||
export function connectWS() {
|
||||
stopped = false;
|
||||
if (stopped) return;
|
||||
const token = localStorage.getItem('ep_token');
|
||||
if (!token) return;
|
||||
if (sock && (sock.readyState === WebSocket.OPEN || sock.readyState === WebSocket.CONNECTING)) return;
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const url = proto + '://' + window.location.host + '/ws/browser?token=' + encodeURIComponent(token);
|
||||
try {
|
||||
sock = new WebSocket(url);
|
||||
} catch (e) {
|
||||
scheduleRetry();
|
||||
return;
|
||||
}
|
||||
sock.onopen = () => emit('connection.status', { online: true });
|
||||
sock.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
const set = listeners.get(msg.type);
|
||||
set?.forEach((fn: Listener) => fn(msg.payload));
|
||||
} catch (err) {
|
||||
// 忽略非法消息
|
||||
}
|
||||
};
|
||||
sock.onclose = () => {
|
||||
sock = null;
|
||||
emit('connection.status', { online: false });
|
||||
if (localStorage.getItem('ep_token')) scheduleRetry();
|
||||
};
|
||||
sock.onerror = () => {
|
||||
sock?.close();
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleRetry() {
|
||||
if (retryTimer || stopped) return;
|
||||
retryTimer = window.setTimeout(() => {
|
||||
retryTimer = null;
|
||||
connectWS();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
export function onWS(type: string, fn: Listener): () => void {
|
||||
if (!listeners.has(type)) listeners.set(type, new Set());
|
||||
const set = listeners.get(type) as Set<Listener>;
|
||||
set.add(fn);
|
||||
return () => set.delete(fn);
|
||||
}
|
||||
|
||||
export function disconnectWS() {
|
||||
stopped = true;
|
||||
if (retryTimer) {
|
||||
window.clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
}
|
||||
sock?.close();
|
||||
sock = null;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
module everypublish/agentcore
|
||||
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
everypublish/shared v0.0.0
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/go-rod/rod v0.116.2
|
||||
github.com/go-rod/stealth v0.4.9
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/ysmood/fetchup v0.2.3 // indirect
|
||||
github.com/ysmood/goob v0.4.0 // indirect
|
||||
github.com/ysmood/got v0.40.0 // indirect
|
||||
github.com/ysmood/gson v0.7.3 // indirect
|
||||
github.com/ysmood/leakless v0.9.0 // indirect
|
||||
)
|
||||
|
||||
replace everypublish/shared => ../../shared
|
||||
@@ -1,24 +0,0 @@
|
||||
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/go-rod/rod v0.113.0/go.mod h1:aiedSEFg5DwG/fnNbUOTPMTTWX3MRj6vIs/a684Mthw=
|
||||
github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA=
|
||||
github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg=
|
||||
github.com/go-rod/stealth v0.4.9 h1:X2PmQk4DUF2wzw6GOsWjW/glb8K5ebnftbEvLh7MlZ4=
|
||||
github.com/go-rod/stealth v0.4.9/go.mod h1:eAzyvw8c0iAd5nJJsSWeh0fQ5z94vCIfdi1hUmYDimc=
|
||||
github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ=
|
||||
github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns=
|
||||
github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ=
|
||||
github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18=
|
||||
github.com/ysmood/gop v0.0.2/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk=
|
||||
github.com/ysmood/gop v0.2.0 h1:+tFrG0TWPxT6p9ZaZs+VY+opCvHU8/3Fk6BaNv6kqKg=
|
||||
github.com/ysmood/gop v0.2.0/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk=
|
||||
github.com/ysmood/got v0.34.1/go.mod h1:yddyjq/PmAf08RMLSwDjPyCvHvYed+WjHnQxpH851LM=
|
||||
github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q=
|
||||
github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg=
|
||||
github.com/ysmood/gotrace v0.6.0 h1:SyI1d4jclswLhg7SWTL6os3L1WOKeNn/ZtzVQF8QmdY=
|
||||
github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM=
|
||||
github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE=
|
||||
github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg=
|
||||
github.com/ysmood/leakless v0.8.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
|
||||
github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU=
|
||||
github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
|
||||
@@ -1,45 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Config 客户端配置(data/config.json)
|
||||
type Config struct {
|
||||
Server string `json:"server"`
|
||||
DeviceID uint64 `json:"deviceId"`
|
||||
Name string `json:"name"`
|
||||
Proxy string `json:"proxy"`
|
||||
SimMS int `json:"simMs"`
|
||||
FakeAll bool `json:"fakeAll"` // 全平台模拟执行(无真实账号联调用)
|
||||
}
|
||||
|
||||
// Default 默认配置
|
||||
func Default() *Config {
|
||||
return &Config{Server: "http://127.0.0.1:8090", SimMS: 50}
|
||||
}
|
||||
|
||||
// Load 读取配置(不存在则返回默认)
|
||||
func Load(dir string) (*Config, error) {
|
||||
cfg := Default()
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "config.json"))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return cfg, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
_ = json.Unmarshal(raw, cfg)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Save 持久化配置
|
||||
func (c *Config) Save(dir string) error {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, _ := json.MarshalIndent(c, "", " ")
|
||||
return os.WriteFile(filepath.Join(dir, "config.json"), raw, 0o600)
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Identity 设备身份:Ed25519 密钥对 + 服务器分配的设备 ID
|
||||
type Identity struct {
|
||||
DeviceID uint64 `json:"deviceId"`
|
||||
Name string `json:"name"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
priv ed25519.PrivateKey `json:"-"`
|
||||
}
|
||||
|
||||
// LoadOrCreate 加载或生成本机设备身份(私钥 hex 文件 0600)
|
||||
func LoadOrCreate(dir string, name string) (*Identity, error) {
|
||||
keyPath := filepath.Join(dir, "device.key")
|
||||
idPath := filepath.Join(dir, "identity.json")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var priv ed25519.PrivateKey
|
||||
if raw, err := os.ReadFile(keyPath); err == nil {
|
||||
seed, err := hex.DecodeString(string(raw))
|
||||
if err != nil || len(seed) != ed25519.SeedSize {
|
||||
return nil, errors.New("device key corrupted")
|
||||
}
|
||||
priv = ed25519.NewKeyFromSeed(seed)
|
||||
} else {
|
||||
_, priv, err = ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = os.WriteFile(keyPath, []byte(hex.EncodeToString(priv.Seed())), 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
id := &Identity{
|
||||
Name: name,
|
||||
PublicKey: base64.StdEncoding.EncodeToString(priv.Public().(ed25519.PublicKey)),
|
||||
priv: priv,
|
||||
}
|
||||
if raw, err := os.ReadFile(idPath); err == nil {
|
||||
_ = json.Unmarshal(raw, id)
|
||||
id.priv = priv
|
||||
if id.Name == "" {
|
||||
id.Name = name
|
||||
}
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// Save 持久化设备 ID(配对成功后调用)
|
||||
func (i *Identity) Save(dir string) error {
|
||||
raw, err := json.MarshalIndent(i, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(dir, "identity.json"), raw, 0o600)
|
||||
}
|
||||
|
||||
// Sign 对载荷签名(hello 握手用)
|
||||
func (i *Identity) Sign(payload []byte) string {
|
||||
return base64.StdEncoding.EncodeToString(ed25519.Sign(i.priv, payload))
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadOrCreatePersist(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
id1, err := LoadOrCreate(dir, "test-device")
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
// 再次加载应得到同一公钥
|
||||
id2, err := LoadOrCreate(dir, "test-device")
|
||||
if err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if id1.PublicKey != id2.PublicKey {
|
||||
t.Fatal("public key should persist across reloads")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignVerify(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
id, _ := LoadOrCreate(dir, "d")
|
||||
payload := []byte("nonce-1|123456")
|
||||
sigB64 := id.Sign(payload)
|
||||
sig, _ := base64.StdEncoding.DecodeString(sigB64)
|
||||
pub, _ := base64.StdEncoding.DecodeString(id.PublicKey)
|
||||
if !ed25519.Verify(ed25519.PublicKey(pub), payload, sig) {
|
||||
t.Fatal("signature should verify")
|
||||
}
|
||||
if ed25519.Verify(ed25519.PublicKey(pub), []byte("tampered"), sig) {
|
||||
t.Fatal("tampered payload should fail verify")
|
||||
}
|
||||
}
|
||||
@@ -1,458 +0,0 @@
|
||||
// bilibili B站执行器(无浏览器投稿 + API 扫码登录)。
|
||||
// 协议依据:ForgQi/biliup(MIT)bili_webup.py 的 upos/bda2 上传链路。
|
||||
package bilibili
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"everypublish/agentcore/internal/exec"
|
||||
"everypublish/agentcore/internal/vault"
|
||||
"everypublish/shared/proto"
|
||||
)
|
||||
|
||||
// Executor B站投稿执行器
|
||||
type Executor struct {
|
||||
Vault *vault.Vault
|
||||
HTTP *http.Client
|
||||
MemberBase string // 投稿 API 基址(测试可替换)
|
||||
PassportBase string // 登录 API 基址(测试可替换)
|
||||
UpOSScheme string // upos 上传协议(默认 https,测试 http)
|
||||
PollTimeout time.Duration // 扫码轮询总时长(默认 3 分钟)
|
||||
}
|
||||
|
||||
// New 构造执行器
|
||||
func New(v *vault.Vault) *Executor {
|
||||
return &Executor{
|
||||
Vault: v,
|
||||
HTTP: &http.Client{Timeout: 30 * time.Second},
|
||||
MemberBase: "https://member.bilibili.com",
|
||||
PassportBase: "https://passport.bilibili.com",
|
||||
UpOSScheme: "https",
|
||||
PollTimeout: 3 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// Platform 平台标识
|
||||
func (e *Executor) Platform() string { return "bilibili" }
|
||||
|
||||
// credentials 凭据(保险库 JSON)
|
||||
type credentials struct {
|
||||
Cookies string `json:"cookies"`
|
||||
}
|
||||
|
||||
// Execute 发布:下载素材 → upos 上传 → 投稿 → 返回作品链接
|
||||
func (e *Executor) Execute(ctx context.Context, task exec.Task) exec.Result {
|
||||
fail := func(err string) exec.Result {
|
||||
return exec.Result{TaskID: task.TaskID, Status: "failed", Error: err}
|
||||
}
|
||||
creds, err := e.creds(task.AccountID)
|
||||
if err != nil {
|
||||
return fail("B站账号未登录(本地无凭据),请先在客户端完成扫码绑定")
|
||||
}
|
||||
if len(task.MaterialURLs) == 0 {
|
||||
return fail("任务缺少素材")
|
||||
}
|
||||
videoPath, cleanup, err := downloadFile(ctx, e.HTTP, task.MaterialURLs[0])
|
||||
if err != nil {
|
||||
return fail("素材下载失败: " + err.Error())
|
||||
}
|
||||
defer cleanup()
|
||||
part, err := e.uploadVideo(ctx, creds.Cookies, videoPath)
|
||||
if err != nil {
|
||||
return fail("视频上传失败: " + err.Error())
|
||||
}
|
||||
bvid, err := e.submit(ctx, creds.Cookies, task, part)
|
||||
if err != nil {
|
||||
return fail("投稿提交失败: " + err.Error())
|
||||
}
|
||||
return exec.Result{
|
||||
TaskID: task.TaskID,
|
||||
Status: "success",
|
||||
PublishedURL: "https://www.bilibili.com/video/" + bvid,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Executor) creds(accountID string) (*credentials, error) {
|
||||
raw, err := e.Vault.Load(accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var c credentials
|
||||
if err = json.Unmarshal(raw, &c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.Cookies == "" {
|
||||
return nil, fmt.Errorf("empty cookies")
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// uploadVideo 上传视频(upos/bda2 分片),返回投稿所需的 part 信息
|
||||
func (e *Executor) uploadVideo(ctx context.Context, cookies, filePath string) (map[string]interface{}, error) {
|
||||
stat, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := filepath.Base(filePath)
|
||||
total := stat.Size()
|
||||
|
||||
// 1) preupload 获取上传地址
|
||||
q := url.Values{}
|
||||
q.Set("r", "upos")
|
||||
q.Set("profile", "ugcupos/bup")
|
||||
q.Set("ssl", "0")
|
||||
q.Set("version", "2.8.12")
|
||||
q.Set("build", "2081200")
|
||||
q.Set("name", name)
|
||||
q.Set("size", strconv.FormatInt(total, 10))
|
||||
preURL := e.MemberBase + "/preupload?upcdn=bda2&probe_version=20221109&" + q.Encode()
|
||||
var pre struct {
|
||||
OK int `json:"OK"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Auth string `json:"auth"`
|
||||
BizID int `json:"biz_id"`
|
||||
UposURI string `json:"upos_uri"`
|
||||
ChunkSize int `json:"chunk_size"`
|
||||
}
|
||||
if err = e.getJSON(ctx, preURL, cookies, &pre); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pre.OK != 1 || pre.UposURI == "" {
|
||||
return nil, fmt.Errorf("preupload 返回异常")
|
||||
}
|
||||
upURL := e.UpOSScheme + ":" + pre.Endpoint + "/" + strings.TrimPrefix(pre.UposURI, "upos://")
|
||||
authH := map[string]string{"X-Upos-Auth": pre.Auth}
|
||||
|
||||
// 2) 申请 upload_id
|
||||
var up struct {
|
||||
UploadID string `json:"upload_id"`
|
||||
}
|
||||
if err = e.postJSON(ctx, upURL+"?uploads&output=json", cookies, authH, nil, &up); err != nil {
|
||||
return nil, fmt.Errorf("申请 upload_id 失败: %w", err)
|
||||
}
|
||||
|
||||
// 3) 分片上传
|
||||
chunkSize := pre.ChunkSize
|
||||
if chunkSize <= 0 {
|
||||
chunkSize = 7 * 1024 * 1024
|
||||
}
|
||||
chunks := int((total + int64(chunkSize) - 1) / int64(chunkSize))
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
parts := make([]map[string]interface{}, 0, chunks)
|
||||
buf := make([]byte, chunkSize)
|
||||
for i := 0; i < chunks; i++ {
|
||||
n, rerr := io.ReadFull(f, buf)
|
||||
if rerr != nil && rerr != io.ErrUnexpectedEOF && rerr != io.EOF {
|
||||
return nil, rerr
|
||||
}
|
||||
start := i * chunkSize
|
||||
end := start + n
|
||||
pq := url.Values{}
|
||||
pq.Set("uploadId", up.UploadID)
|
||||
pq.Set("partNumber", strconv.Itoa(i+1))
|
||||
pq.Set("chunk", strconv.Itoa(i))
|
||||
pq.Set("chunks", strconv.Itoa(chunks))
|
||||
pq.Set("size", strconv.Itoa(n))
|
||||
pq.Set("start", strconv.Itoa(start))
|
||||
pq.Set("end", strconv.Itoa(end))
|
||||
pq.Set("total", strconv.FormatInt(total, 10))
|
||||
if err = e.putBody(ctx, upURL+"?"+pq.Encode(), cookies, authH, buf[:n]); err != nil {
|
||||
return nil, fmt.Errorf("分片 %d 上传失败: %w", i+1, err)
|
||||
}
|
||||
parts = append(parts, map[string]interface{}{"partNumber": i + 1, "eTag": "etag"})
|
||||
}
|
||||
|
||||
// 4) 合并分片
|
||||
mq := url.Values{}
|
||||
mq.Set("name", name)
|
||||
mq.Set("uploadId", up.UploadID)
|
||||
mq.Set("biz_id", strconv.Itoa(pre.BizID))
|
||||
mq.Set("output", "json")
|
||||
mq.Set("profile", "ugcupos/bup")
|
||||
var merged struct {
|
||||
OK int `json:"OK"`
|
||||
}
|
||||
if err = e.postJSON(ctx, upURL+"?"+mq.Encode(), cookies, authH, map[string]interface{}{"parts": parts}, &merged); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if merged.OK != 1 {
|
||||
return nil, fmt.Errorf("分片合并返回异常")
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"title": strings.TrimSuffix(name, filepath.Ext(name)),
|
||||
"filename": strings.TrimSuffix(filepath.Base(pre.UposURI), filepath.Ext(filepath.Base(pre.UposURI))),
|
||||
"desc": "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// submit 投稿(x/vu/web/add)
|
||||
func (e *Executor) submit(ctx context.Context, cookies string, task exec.Task, part map[string]interface{}) (string, error) {
|
||||
csrf := cookieValue(cookies, "bili_jct")
|
||||
// 风控预热(失败不影响)
|
||||
_, _ = e.get(ctx, e.MemberBase+"/x/geetest/pre/add", cookies)
|
||||
tag := strings.Join(task.Tags, ",")
|
||||
if tag == "" {
|
||||
tag = "日常"
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"title": truncateRunes(task.Title, 80),
|
||||
"desc": task.Content,
|
||||
"desc_v2": []map[string]interface{}{{"raw_text": task.Content, "type": 1, "biz_id": ""}},
|
||||
"copyright": 1,
|
||||
"source": "",
|
||||
"tid": 174,
|
||||
"tag": tag,
|
||||
"dynamic": "",
|
||||
"videos": []map[string]interface{}{part},
|
||||
}
|
||||
var ret struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
Bvid string `json:"bvid"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := e.postJSON(ctx, e.MemberBase+"/x/vu/web/add?csrf="+csrf, cookies, nil, body, &ret); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if ret.Code != 0 {
|
||||
return "", fmt.Errorf("code=%d %s", ret.Code, ret.Message)
|
||||
}
|
||||
return ret.Data.Bvid, nil
|
||||
}
|
||||
|
||||
// SolveChallenge B站扫码登录:生成二维码 → onQR 展示 → 轮询 → 保存 cookie
|
||||
func (e *Executor) SolveChallenge(ctx context.Context, ch proto.Challenge, onQR func(qrToken, qrURL string)) (bool, string) {
|
||||
key, qrURL, err := e.generateQR(ctx)
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
if onQR != nil {
|
||||
onQR(key, qrURL)
|
||||
}
|
||||
deadline := time.Now().Add(e.PollTimeout)
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false, ""
|
||||
default:
|
||||
}
|
||||
code, cookieURL, perr := e.pollQR(ctx, key)
|
||||
if perr != nil {
|
||||
return false, ""
|
||||
}
|
||||
switch code {
|
||||
case 0:
|
||||
cookies := cookiesFromCrossDomainURL(cookieURL)
|
||||
if cookies == "" {
|
||||
return false, ""
|
||||
}
|
||||
_ = e.Vault.Save(ch.AccountID, []byte(`{"cookies":"`+jsonEscape(cookies)+`"}`))
|
||||
return true, "confirmed"
|
||||
case 86090:
|
||||
case 86101:
|
||||
default:
|
||||
return false, ""
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// generateQR 生成登录二维码
|
||||
func (e *Executor) generateQR(ctx context.Context) (key, qrURL string, err error) {
|
||||
var ret struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
URL string `json:"url"`
|
||||
QrcodeKey string `json:"qrcode_key"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err = e.getJSON(ctx, e.PassportBase+"/x/passport-login/web/qrcode/generate", "", &ret); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if ret.Code != 0 || ret.Data.QrcodeKey == "" {
|
||||
return "", "", fmt.Errorf("生成二维码失败 code=%d", ret.Code)
|
||||
}
|
||||
return ret.Data.QrcodeKey, ret.Data.URL, nil
|
||||
}
|
||||
|
||||
// pollQR 轮询扫码状态:86101 未扫 / 86090 已扫待确认 / 0 成功(返回跨域 cookie URL)
|
||||
func (e *Executor) pollQR(ctx context.Context, key string) (int, string, error) {
|
||||
var ret struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := e.getJSON(ctx, e.PassportBase+"/x/passport-login/web/qrcode/poll?qrcode_key="+key, "", &ret); err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
return ret.Code, ret.Data.URL, nil
|
||||
}
|
||||
|
||||
// ---- HTTP 辅助 ----
|
||||
|
||||
func (e *Executor) get(ctx context.Context, rawURL, cookies string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cookies != "" {
|
||||
req.Header.Set("Cookie", cookies)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36")
|
||||
req.Header.Set("Referer", "https://member.bilibili.com")
|
||||
resp, err := e.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func (e *Executor) getJSON(ctx context.Context, rawURL, cookies string, out interface{}) error {
|
||||
raw, err := e.get(ctx, rawURL, cookies)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(raw, out)
|
||||
}
|
||||
|
||||
func (e *Executor) postJSON(ctx context.Context, rawURL, cookies string, extraH map[string]string, body interface{}, out interface{}) error {
|
||||
var buf bytes.Buffer
|
||||
if body != nil {
|
||||
raw, _ := json.Marshal(body)
|
||||
buf.Write(raw)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, rawURL, &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cookies != "" {
|
||||
req.Header.Set("Cookie", cookies)
|
||||
}
|
||||
for k, v := range extraH {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36")
|
||||
req.Header.Set("Referer", "https://member.bilibili.com")
|
||||
resp, err := e.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("http %d", resp.StatusCode)
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(raw, out)
|
||||
}
|
||||
|
||||
func (e *Executor) putBody(ctx context.Context, rawURL, cookies string, extraH map[string]string, data []byte) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, rawURL, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cookies != "" {
|
||||
req.Header.Set("Cookie", cookies)
|
||||
}
|
||||
for k, v := range extraH {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := e.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("http %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadFile 下载素材到临时文件
|
||||
func downloadFile(ctx context.Context, client *http.Client, rawURL string) (string, func(), error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", nil, fmt.Errorf("下载 http %d", resp.StatusCode)
|
||||
}
|
||||
tmp, err := os.CreateTemp("", "ep-material-*")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
_, err = io.Copy(tmp, resp.Body)
|
||||
tmp.Close()
|
||||
if err != nil {
|
||||
os.Remove(tmp.Name())
|
||||
return "", nil, err
|
||||
}
|
||||
return tmp.Name(), func() { _ = os.Remove(tmp.Name()) }, nil
|
||||
}
|
||||
|
||||
// cookieValue 从 cookie 串取值
|
||||
func cookieValue(cookies, key string) string {
|
||||
for _, part := range strings.Split(cookies, ";") {
|
||||
part = strings.TrimSpace(part)
|
||||
if i := strings.Index(part, "="); i > 0 && part[:i] == key {
|
||||
return part[i+1:]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// cookiesFromCrossDomainURL 从跨域跳转 URL 提取登录 cookie
|
||||
func cookiesFromCrossDomainURL(rawURL string) string {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
q := u.Query()
|
||||
sess := q.Get("SESSDATA")
|
||||
jct := q.Get("bili_jct")
|
||||
dede := q.Get("DedeUserID")
|
||||
if sess == "" {
|
||||
return ""
|
||||
}
|
||||
return "SESSDATA=" + sess + "; bili_jct=" + jct + "; DedeUserID=" + dede
|
||||
}
|
||||
|
||||
func truncateRunes(s string, max int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
return string(r[:max])
|
||||
}
|
||||
|
||||
func jsonEscape(s string) string {
|
||||
raw, _ := json.Marshal(s)
|
||||
return string(raw[1 : len(raw)-1])
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
package bilibili
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"everypublish/agentcore/internal/exec"
|
||||
"everypublish/agentcore/internal/vault"
|
||||
"everypublish/shared/proto"
|
||||
)
|
||||
|
||||
// stubBili 桩:preupload + upos(uploads/分片/合并)+ 投稿 add,记录关键请求
|
||||
func stubBili(t *testing.T) (*httptest.Server, *struct {
|
||||
PartQuery string
|
||||
PartsLen int
|
||||
AddBody map[string]interface{}
|
||||
AddURL string
|
||||
XUposAuth string
|
||||
}) {
|
||||
rec := &struct {
|
||||
PartQuery string
|
||||
PartsLen int
|
||||
AddBody map[string]interface{}
|
||||
AddURL string
|
||||
XUposAuth string
|
||||
}{}
|
||||
var srv *httptest.Server
|
||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.URL.Path == "/preupload":
|
||||
// endpoint 指向桩服务器自身
|
||||
endpoint := "//" + strings.TrimPrefix(srv.URL, "http://")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"OK": 1, "endpoint": endpoint, "auth": "auth-x", "biz_id": 1000,
|
||||
"upos_uri": "upos://video/av123", "chunk_size": 5,
|
||||
})
|
||||
case strings.Contains(r.URL.RawQuery, "uploads"):
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"upload_id": "upload-1"})
|
||||
case r.Method == http.MethodPut:
|
||||
rec.PartQuery = r.URL.RawQuery
|
||||
rec.XUposAuth = r.Header.Get("X-Upos-Auth")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case r.Method == http.MethodPost && strings.Contains(r.URL.RawQuery, "output=json"):
|
||||
var body map[string]interface{}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if parts, ok := body["parts"].([]interface{}); ok {
|
||||
rec.PartsLen = len(parts)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]int{"OK": 1})
|
||||
case r.URL.Path == "/x/vu/web/add":
|
||||
rec.AddURL = r.URL.RawQuery
|
||||
_ = json.NewDecoder(r.Body).Decode(&rec.AddBody)
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": 0, "data": map[string]string{"bvid": "BV1TEST123"},
|
||||
})
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
return srv, rec
|
||||
}
|
||||
|
||||
func TestExecuteUploadFlow(t *testing.T) {
|
||||
// 素材服务器
|
||||
matSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("0123456789abcdef"))
|
||||
}))
|
||||
defer matSrv.Close()
|
||||
|
||||
srv, rec := stubBili(t)
|
||||
defer srv.Close()
|
||||
|
||||
v, _ := vault.Open(t.TempDir())
|
||||
_ = v.Save("77", []byte(`{"cookies":"SESSDATA=sess; bili_jct=csrf-x; DedeUserID=1"}`))
|
||||
|
||||
e := New(v)
|
||||
e.MemberBase = srv.URL
|
||||
e.PassportBase = srv.URL
|
||||
e.UpOSScheme = "http"
|
||||
|
||||
res := e.Execute(context.Background(), exec.Task{
|
||||
TaskID: "9", AccountID: "77", Title: "测试标题", Content: "简介",
|
||||
Tags: []string{"测试", "日常"}, MaterialURLs: []string{matSrv.URL + "/v.mp4"},
|
||||
})
|
||||
if res.Status != "success" {
|
||||
t.Fatalf("expect success, got %+v", res)
|
||||
}
|
||||
if res.PublishedURL != "https://www.bilibili.com/video/BV1TEST123" {
|
||||
t.Fatalf("published url wrong: %s", res.PublishedURL)
|
||||
}
|
||||
// rec.PartQuery 记录最后一片(partNumber=4 size=1 start=15 end=16)
|
||||
if !strings.Contains(rec.PartQuery, "partNumber=4") || !strings.Contains(rec.PartQuery, "chunks=4") ||
|
||||
!strings.Contains(rec.PartQuery, "total=16") || !strings.Contains(rec.PartQuery, "size=1") {
|
||||
t.Fatalf("chunk query wrong: %s", rec.PartQuery)
|
||||
}
|
||||
if rec.PartsLen != 4 {
|
||||
t.Fatalf("expect 4 merged parts, got %d", rec.PartsLen)
|
||||
}
|
||||
if rec.AddURL != "csrf=csrf-x" {
|
||||
t.Fatalf("add csrf wrong: %s", rec.AddURL)
|
||||
}
|
||||
if rec.AddBody["title"] != "测试标题" || rec.AddBody["copyright"] != float64(1) {
|
||||
t.Fatalf("add body wrong: %+v", rec.AddBody)
|
||||
}
|
||||
if videos, ok := rec.AddBody["videos"].([]interface{}); !ok || len(videos) != 1 {
|
||||
t.Fatal("videos missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolveChallengeQRLogin(t *testing.T) {
|
||||
pollAttempts := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "generate"):
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": 0, "data": map[string]string{"url": "https://qr.example/x", "qrcode_key": "key-1"},
|
||||
})
|
||||
case strings.Contains(r.URL.Path, "poll"):
|
||||
pollAttempts++
|
||||
if pollAttempts == 1 {
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 86101, "data": map[string]string{}})
|
||||
} else {
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]string{
|
||||
"url": "https://passport.biligame.com/x/passport-login/web/crossDomain?SESSDATA=sess123&bili_jct=jct456&DedeUserID=789",
|
||||
},
|
||||
})
|
||||
}
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
v, _ := vault.Open(t.TempDir())
|
||||
e := New(v)
|
||||
e.MemberBase = srv.URL
|
||||
e.PassportBase = srv.URL
|
||||
e.PollTimeout = 10 * time.Second
|
||||
|
||||
var gotQR string
|
||||
solved, value := e.SolveChallenge(context.Background(), proto.Challenge{ChallengeID: "1", AccountID: "77"}, func(token, qrURL string) {
|
||||
gotQR = qrURL
|
||||
})
|
||||
if !solved || value != "confirmed" {
|
||||
t.Fatalf("expect solved, got %v %s", solved, value)
|
||||
}
|
||||
if gotQR == "" {
|
||||
t.Fatal("onQR should be called")
|
||||
}
|
||||
raw, err := v.Load("77")
|
||||
if err != nil {
|
||||
t.Fatalf("vault should have cookies: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), "SESSDATA=sess123") {
|
||||
t.Fatalf("cookies not saved: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollTimeoutUnscanned(t *testing.T) {
|
||||
// 一直未扫码:应超时返回未解决
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if strings.Contains(r.URL.Path, "generate") {
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 0, "data": map[string]string{"url": "u", "qrcode_key": "k"}})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 86101, "data": map[string]string{}})
|
||||
}))
|
||||
defer srv.Close()
|
||||
v, _ := vault.Open(t.TempDir())
|
||||
e := New(v)
|
||||
e.MemberBase = srv.URL
|
||||
e.PassportBase = srv.URL
|
||||
e.PollTimeout = 3 * time.Second
|
||||
if solved, _ := e.SolveChallenge(context.Background(), proto.Challenge{AccountID: "1"}, nil); solved {
|
||||
t.Fatal("unscanned should timeout unsolved")
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
// cdnbase 三平台执行器公共底座:保险库 + 浏览器档案目录 + 代理 + 执行入口。
|
||||
package cdnbase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
|
||||
"everypublish/agentcore/internal/exec"
|
||||
"everypublish/agentcore/internal/exec/cdp"
|
||||
"everypublish/agentcore/internal/vault"
|
||||
"everypublish/shared/proto"
|
||||
)
|
||||
|
||||
// Platform 三平台执行器公共底座
|
||||
type Platform struct {
|
||||
Vault *vault.Vault
|
||||
RootDir string // 浏览器档案根目录(每账号子目录)
|
||||
Proxy string // 账号固定 IP 代理
|
||||
ChromePath string
|
||||
Headless bool
|
||||
ReceiptDir string // 截图回执目录
|
||||
Name string // 平台名(日志)
|
||||
PublishCfg cdp.PublishConfig
|
||||
LoginCfg cdp.QRLoginConfig
|
||||
LoginDomain string // 登录 cookie 域名
|
||||
}
|
||||
|
||||
// credentials 保险库凭据
|
||||
type credentials struct {
|
||||
Cookies string `json:"cookies"`
|
||||
}
|
||||
|
||||
// loadCookies 读账号 cookie(无则空,触发扫码)
|
||||
func (p *Platform) loadCookies(accountID string) string {
|
||||
raw, err := p.Vault.Load(accountID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var c credentials
|
||||
if json.Unmarshal(raw, &c) != nil {
|
||||
return ""
|
||||
}
|
||||
return c.Cookies
|
||||
}
|
||||
|
||||
// profileDir 账号独立档案目录
|
||||
func (p *Platform) profileDir(accountID string) string {
|
||||
return filepath.Join(p.RootDir, p.Name, accountID)
|
||||
}
|
||||
|
||||
// Publish 通用发布入口(下载素材 → 启动浏览器 → 发布 → 回传)
|
||||
func (p *Platform) Publish(ctx context.Context, task exec.Task) exec.Result {
|
||||
fail := func(err string) exec.Result {
|
||||
return exec.Result{TaskID: task.TaskID, Status: "failed", Error: err}
|
||||
}
|
||||
cookies := p.loadCookies(task.AccountID)
|
||||
if len(task.MaterialURLs) == 0 {
|
||||
return fail("任务缺少素材")
|
||||
}
|
||||
videoPath, cleanup, err := cdp.DownloadFile(ctx, task.MaterialURLs[0])
|
||||
if err != nil {
|
||||
return fail("素材下载失败: " + err.Error())
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
br, err := cdp.Launch(ctx, cdp.Options{
|
||||
UserDataDir: p.profileDir(task.AccountID),
|
||||
Proxy: p.Proxy,
|
||||
Headless: p.Headless,
|
||||
ChromePath: p.ChromePath,
|
||||
})
|
||||
if err != nil {
|
||||
return fail("浏览器启动失败: " + err.Error())
|
||||
}
|
||||
defer br.Close()
|
||||
|
||||
cfg := p.PublishCfg
|
||||
cfg.Platform = p.Name
|
||||
cfg.ReceiptDir = p.ReceiptDir
|
||||
res, err := br.Publish(ctx, cfg, videoPath, task.Title, task.Content, task.Tags, cookies, p.LoginDomain)
|
||||
if err != nil {
|
||||
return fail("发布失败: " + err.Error())
|
||||
}
|
||||
return exec.Result{
|
||||
TaskID: task.TaskID,
|
||||
Status: "success",
|
||||
PublishedURL: res.URL,
|
||||
Receipts: res.Receipts,
|
||||
}
|
||||
}
|
||||
|
||||
// SolveChallenge CDP 扫码登录:二维码截图经 onQR 展示 → 轮询登录 → 保存 cookie
|
||||
func (p *Platform) SolveChallenge(ctx context.Context, ch proto.Challenge, onQR func(qrToken, qrURL string)) (bool, string) {
|
||||
br, err := cdp.Launch(ctx, cdp.Options{
|
||||
UserDataDir: p.profileDir(ch.AccountID),
|
||||
Proxy: p.Proxy,
|
||||
Headless: p.Headless,
|
||||
ChromePath: p.ChromePath,
|
||||
})
|
||||
if err != nil {
|
||||
cdp.Log("%s: 扫码浏览器启动失败: %v", p.Name, err)
|
||||
return false, ""
|
||||
}
|
||||
defer br.Close()
|
||||
cookies, err := br.QRLogin(ctx, p.LoginCfg, onQR)
|
||||
if err != nil {
|
||||
cdp.Log("%s: 扫码登录失败: %v", p.Name, err)
|
||||
return false, ""
|
||||
}
|
||||
if cookies == "" {
|
||||
return false, ""
|
||||
}
|
||||
_ = p.Vault.Save(ch.AccountID, []byte(`{"cookies":"`+cdp.EscapeJSON(cookies)+`"}`))
|
||||
return true, "confirmed"
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
// cdp 共享浏览器基础设施:独立档案启动(每账号一档案)、代理、指纹、扫码登录。
|
||||
// 一期驱动 rod-launcher 管理的 Chromium;ChromePath 给定则驱动系统 Chrome/Edge。
|
||||
package cdp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-rod/rod"
|
||||
"github.com/go-rod/rod/lib/launcher"
|
||||
"github.com/go-rod/rod/lib/proto"
|
||||
rodstealth "github.com/go-rod/stealth"
|
||||
)
|
||||
|
||||
// Options 浏览器启动选项
|
||||
type Options struct {
|
||||
UserDataDir string // 每账号独立档案目录
|
||||
Proxy string // 账号固定 IP 代理(http://ip:port)
|
||||
Headless bool
|
||||
ChromePath string // 系统 Chrome/Edge 可执行文件(空=自动探测/下载)
|
||||
}
|
||||
|
||||
// Browser 浏览器封装
|
||||
type Browser struct {
|
||||
rod *rod.Browser
|
||||
Page *rod.Page
|
||||
}
|
||||
|
||||
// defaultChromePath 常见系统浏览器路径(Windows 优先,macOS 兜底)
|
||||
func defaultChromePath() string {
|
||||
candidates := []string{
|
||||
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
||||
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
||||
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
|
||||
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
|
||||
}
|
||||
if runtime.GOOS == "darwin" {
|
||||
candidates = append(candidates, "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome")
|
||||
}
|
||||
for _, p := range candidates {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Launch 启动浏览器(独立档案 + 指纹 + 反自动化)
|
||||
func Launch(ctx context.Context, opts Options) (*Browser, error) {
|
||||
l := launcher.New()
|
||||
if opts.UserDataDir != "" {
|
||||
if err := os.MkdirAll(opts.UserDataDir, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l = l.UserDataDir(opts.UserDataDir)
|
||||
}
|
||||
if opts.Proxy != "" {
|
||||
l = l.Set("proxy-server", opts.Proxy)
|
||||
}
|
||||
bin := opts.ChromePath
|
||||
if bin == "" {
|
||||
bin = defaultChromePath()
|
||||
}
|
||||
if bin != "" {
|
||||
l = l.Bin(bin)
|
||||
}
|
||||
l = l.Headless(opts.Headless).
|
||||
Set("no-first-run").
|
||||
Set("no-default-browser-check").
|
||||
Set("disable-blink-features", "AutomationControlled").
|
||||
Set("window-size", "1440,900")
|
||||
url, err := l.Launch()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
browser := rod.New().ControlURL(url)
|
||||
if err = browser.Connect(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 指纹/反自动化(stealth 注入)
|
||||
page, err := rodstealth.Page(browser)
|
||||
if err != nil {
|
||||
browser.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &Browser{rod: browser, Page: page}, nil
|
||||
}
|
||||
|
||||
// Close 关闭浏览器
|
||||
func (b *Browser) Close() {
|
||||
_ = b.rod.Close()
|
||||
}
|
||||
|
||||
// Screenshot 截图回执(PNG 本地保存,返回路径;二期上传服务器)
|
||||
func (b *Browser) Screenshot(receiptDir, name string) string {
|
||||
if err := os.MkdirAll(receiptDir, 0o755); err != nil {
|
||||
return ""
|
||||
}
|
||||
path := filepath.Join(receiptDir, name)
|
||||
_ = b.Page.MustWaitLoad().MustScreenshot(path)
|
||||
return path
|
||||
}
|
||||
|
||||
// InjectCookies 注入登录 cookie(免重复扫码)
|
||||
func (b *Browser) InjectCookies(cookieStr, domain string) error {
|
||||
for _, part := range strings.Split(cookieStr, ";") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
i := strings.Index(part, "=")
|
||||
if i <= 0 {
|
||||
continue
|
||||
}
|
||||
name, value := part[:i], part[i+1:]
|
||||
cookie := &proto.NetworkCookieParam{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Domain: domain,
|
||||
Path: "/",
|
||||
}
|
||||
_ = b.rod.SetCookies([]*proto.NetworkCookieParam{cookie})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cookies 导出当前浏览器 cookie(域名过滤)
|
||||
func (b *Browser) Cookies(domain string) string {
|
||||
cookies, err := b.rod.GetCookies()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(cookies))
|
||||
for _, c := range cookies {
|
||||
if domain != "" && !strings.Contains(c.Domain, domain) {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, c.Name+"="+c.Value)
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// QRLoginConfig 扫码登录配置
|
||||
type QRLoginConfig struct {
|
||||
LoginURL string
|
||||
LoginPathMarker string // 登录页 URL 特征(登录成功=URL 离开该特征)
|
||||
QRSelector string // 二维码元素选择器
|
||||
SuccessWait time.Duration
|
||||
}
|
||||
|
||||
// QRLogin 通用扫码登录:打开登录页 → 截图二维码经 onQR 展示 → 轮询 URL 直到登录成功
|
||||
func (b *Browser) QRLogin(ctx context.Context, cfg QRLoginConfig, onQR func(qrToken, qrURL string)) (string, error) {
|
||||
if err := b.Page.Navigate(cfg.LoginURL); err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = b.Page.WaitLoad()
|
||||
// 截图二维码 → 临时 PNG → file:// URI → onQR(壳端 BitmapImage 展示)
|
||||
qrEl, err := b.Page.Element(cfg.QRSelector)
|
||||
if err == nil {
|
||||
raw, serr := qrEl.Screenshot(proto.PageCaptureScreenshotFormatPng, 100)
|
||||
if serr == nil && len(raw) > 0 {
|
||||
qrPath := filepath.Join(os.TempDir(), "ep-qr-"+time.Now().Format("150405")+".png")
|
||||
if werr := os.WriteFile(qrPath, raw, 0o600); werr == nil && onQR != nil {
|
||||
onQR("", "file://"+qrPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 轮询登录状态
|
||||
deadline := time.Now().Add(cfg.SuccessWait)
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", fmt.Errorf("cancelled")
|
||||
default:
|
||||
}
|
||||
info, ierr := b.Page.Info()
|
||||
if ierr == nil && !strings.Contains(info.URL, cfg.LoginPathMarker) {
|
||||
return b.Cookies(""), nil
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
return "", fmt.Errorf("扫码登录超时")
|
||||
}
|
||||
|
||||
// DownloadFile 下载素材到临时文件(返回路径与清理函数)
|
||||
func DownloadFile(ctx context.Context, rawURL string) (string, func(), error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
client := &http.Client{Timeout: 10 * time.Minute}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", nil, fmt.Errorf("下载 http %d", resp.StatusCode)
|
||||
}
|
||||
tmp, err := os.CreateTemp("", "ep-material-*")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
_, err = io.Copy(tmp, resp.Body)
|
||||
tmp.Close()
|
||||
if err != nil {
|
||||
os.Remove(tmp.Name())
|
||||
return "", nil, err
|
||||
}
|
||||
return tmp.Name(), func() { _ = os.Remove(tmp.Name()) }, nil
|
||||
}
|
||||
|
||||
// EscapeJSON 转义字符串供嵌入 JSON
|
||||
func EscapeJSON(s string) string {
|
||||
raw, _ := json.Marshal(s)
|
||||
return string(raw[1 : len(raw)-1])
|
||||
}
|
||||
|
||||
// Log 调试输出
|
||||
func Log(format string, args ...interface{}) {
|
||||
log.Printf("[cdp] "+format, args...)
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package cdp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEscapeJSON(t *testing.T) {
|
||||
s := EscapeJSON("SESSDATA=ab\"c; bili_jct=d")
|
||||
// 转义后应可嵌入 JSON 且还原一致
|
||||
var decoded string
|
||||
if err := json.Unmarshal([]byte(`"`+s+`"`), &decoded); err != nil {
|
||||
t.Fatalf("escaped string not valid json: %v", err)
|
||||
}
|
||||
if decoded != "SESSDATA=ab\"c; bili_jct=d" {
|
||||
t.Fatalf("roundtrip mismatch: %q", decoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadFile(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("video-bytes-123"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
path, cleanup, err := DownloadFile(context.Background(), srv.URL+"/v.mp4")
|
||||
if err != nil {
|
||||
t.Fatalf("download: %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil || string(raw) != "video-bytes-123" {
|
||||
t.Fatalf("content mismatch: %s %v", raw, err)
|
||||
}
|
||||
cleanup()
|
||||
if _, err = os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatal("cleanup should remove temp file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadFileHTTPError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusGone)
|
||||
_, _ = io.WriteString(w, "gone")
|
||||
}))
|
||||
defer srv.Close()
|
||||
if _, _, err := DownloadFile(context.Background(), srv.URL); err == nil {
|
||||
t.Fatal("410 should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultChromePath(t *testing.T) {
|
||||
// 至少不 panic、返回字符串
|
||||
p := defaultChromePath()
|
||||
if strings.TrimSpace(p) == "" && p != "" {
|
||||
t.Fatal("unexpected")
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
// Publish 通用发布流程:进上传页 → 注入 cookie → 传视频 → 填表单 → 点发布 → 等成功跳转 → 截图回执。
|
||||
// 各平台选择器差异经 PublishConfig 配置;平台改版时只需调整配置。
|
||||
package cdp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-rod/rod"
|
||||
"github.com/go-rod/rod/lib/proto"
|
||||
)
|
||||
|
||||
// PublishConfig 发布流程配置(选择器按平台填写)
|
||||
type PublishConfig struct {
|
||||
Platform string
|
||||
UploadURL string
|
||||
SuccessMarker string // 发布成功后的 URL 特征
|
||||
FileInputSel string
|
||||
TitleSel string
|
||||
DescSel string
|
||||
PublishBtnSel string
|
||||
FormWait time.Duration // 等视频处理出表单的最长时间
|
||||
SuccessWait time.Duration
|
||||
ReceiptDir string
|
||||
}
|
||||
|
||||
// PublishResult 发布结果
|
||||
type PublishResult struct {
|
||||
URL string
|
||||
Receipts []string
|
||||
}
|
||||
|
||||
// Publish 执行通用发布流程
|
||||
func (b *Browser) Publish(ctx context.Context, cfg PublishConfig, videoPath, title, content string, tags []string, cookieStr, cookieDomain string) (*PublishResult, error) {
|
||||
if err := b.Page.Navigate(cfg.UploadURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = b.Page.WaitLoad()
|
||||
// 注入登录 cookie(有则免扫码)
|
||||
if cookieStr != "" {
|
||||
_ = b.InjectCookies(cookieStr, cookieDomain)
|
||||
if err := b.Page.Navigate(cfg.UploadURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = b.Page.WaitLoad()
|
||||
}
|
||||
|
||||
// 1) 传视频
|
||||
fileInput, err := b.Page.Element(cfg.FileInputSel)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("找不到上传入口(%s): %w", cfg.FileInputSel, err)
|
||||
}
|
||||
if err = fileInput.SetFiles([]string{videoPath}); err != nil {
|
||||
return nil, fmt.Errorf("选择文件失败: %w", err)
|
||||
}
|
||||
Log("%s: 视频已选择,等待平台处理...", cfg.Platform)
|
||||
|
||||
// 2) 等表单渲染(视频转码后出现标题输入框)
|
||||
titleEl, err := b.Page.ElementR(cfg.TitleSel, "")
|
||||
if err != nil {
|
||||
titleEl, err = b.waitElement(ctx, cfg.TitleSel, cfg.FormWait)
|
||||
}
|
||||
if err != nil {
|
||||
b.Screenshot(cfg.ReceiptDir, cfg.Platform+"-form-fail.png")
|
||||
return nil, fmt.Errorf("等表单渲染超时(%s): %w", cfg.TitleSel, err)
|
||||
}
|
||||
|
||||
// 3) 填标题/描述(话题并入描述)
|
||||
if err = titleEl.Input(title); err != nil {
|
||||
return nil, fmt.Errorf("填标题失败: %w", err)
|
||||
}
|
||||
desc := content
|
||||
if len(tags) > 0 {
|
||||
for _, t := range tags {
|
||||
t = strings.TrimSpace(t)
|
||||
if t != "" && !strings.HasPrefix(t, "#") {
|
||||
t = "#" + t
|
||||
}
|
||||
desc += " " + t
|
||||
}
|
||||
}
|
||||
if descEl, derr := b.Page.Element(cfg.DescSel); derr == nil {
|
||||
_ = descEl.Input(desc)
|
||||
}
|
||||
|
||||
// 4) 点发布
|
||||
btn, err := b.waitElement(ctx, cfg.PublishBtnSel, 15*time.Second)
|
||||
if err != nil {
|
||||
b.Screenshot(cfg.ReceiptDir, cfg.Platform+"-btn-fail.png")
|
||||
return nil, fmt.Errorf("找不到发布按钮(%s): %w", cfg.PublishBtnSel, err)
|
||||
}
|
||||
if err = btn.Click(proto.InputMouseButtonLeft, 1); err != nil {
|
||||
return nil, fmt.Errorf("点发布失败: %w", err)
|
||||
}
|
||||
Log("%s: 已点发布,等待结果...", cfg.Platform)
|
||||
|
||||
// 5) 等成功跳转
|
||||
if err = b.waitURL(ctx, cfg.SuccessMarker, cfg.SuccessWait); err != nil {
|
||||
b.Screenshot(cfg.ReceiptDir, cfg.Platform+"-publish-fail.png")
|
||||
return nil, err
|
||||
}
|
||||
info, _ := b.Page.Info()
|
||||
receipt := b.Screenshot(cfg.ReceiptDir, cfg.Platform+"-success.png")
|
||||
Log("%s: 发布成功 %s", cfg.Platform, info.URL)
|
||||
return &PublishResult{URL: info.URL, Receipts: []string{receipt}}, nil
|
||||
}
|
||||
|
||||
// waitElement 轮询等待元素出现
|
||||
func (b *Browser) waitElement(ctx context.Context, selector string, timeout time.Duration) (*rod.Element, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("cancelled")
|
||||
default:
|
||||
}
|
||||
el, err := b.Page.Element(selector)
|
||||
if err == nil {
|
||||
return el, nil
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
return nil, fmt.Errorf("timeout waiting %s", selector)
|
||||
}
|
||||
|
||||
// waitURL 轮询等待 URL 命中特征
|
||||
func (b *Browser) waitURL(ctx context.Context, marker string, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("cancelled")
|
||||
default:
|
||||
}
|
||||
info, err := b.Page.Info()
|
||||
if err == nil && strings.Contains(info.URL, marker) {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
return fmt.Errorf("timeout waiting url %s", marker)
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// douyin 抖音执行器(CDP:creator.douyin.com)。选择器参考 dreammis/social-auto-upload(2026-06 版)。
|
||||
package douyin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"everypublish/agentcore/internal/exec"
|
||||
"everypublish/agentcore/internal/exec/cdnbase"
|
||||
"everypublish/agentcore/internal/exec/cdp"
|
||||
"everypublish/agentcore/internal/vault"
|
||||
"everypublish/shared/proto"
|
||||
)
|
||||
|
||||
// Executor 抖音发布执行器
|
||||
type Executor struct {
|
||||
base *cdnbase.Platform
|
||||
}
|
||||
|
||||
// New 构造执行器(rootDir=浏览器档案根目录,proxy=账号固定IP代理)
|
||||
func New(v *vault.Vault, rootDir, proxy, chromePath string, headless bool) *Executor {
|
||||
p := &cdnbase.Platform{
|
||||
Vault: v,
|
||||
RootDir: rootDir,
|
||||
Proxy: proxy,
|
||||
ChromePath: chromePath,
|
||||
Headless: headless,
|
||||
ReceiptDir: rootDir + "/receipts",
|
||||
Name: "douyin",
|
||||
LoginDomain: ".douyin.com",
|
||||
PublishCfg: cdp.PublishConfig{
|
||||
UploadURL: "https://creator.douyin.com/creator-micro/content/upload",
|
||||
SuccessMarker: "creator-micro/content/manage",
|
||||
FileInputSel: "input[type=\"file\"]",
|
||||
TitleSel: "input[placeholder*=\"填写作品标题\"]",
|
||||
DescSel: "div.zone-container[contenteditable=\"true\"]",
|
||||
PublishBtnSel: "button:has-text(\"发布\")",
|
||||
FormWait: 180 * time.Second, // 视频转码渲染表单较慢
|
||||
SuccessWait: 120 * time.Second,
|
||||
},
|
||||
LoginCfg: cdp.QRLoginConfig{
|
||||
LoginURL: "https://creator.douyin.com/",
|
||||
LoginPathMarker: "creator.douyin.com/?",
|
||||
QRSelector: ".qrcode-v2 img, .qrcode img, img[src*=qrcode]",
|
||||
SuccessWait: 3 * time.Minute,
|
||||
},
|
||||
}
|
||||
return &Executor{base: p}
|
||||
}
|
||||
|
||||
// Platform 平台标识
|
||||
func (e *Executor) Platform() string { return "douyin" }
|
||||
|
||||
// Execute 发布
|
||||
func (e *Executor) Execute(ctx context.Context, task exec.Task) exec.Result {
|
||||
return e.base.Publish(ctx, task)
|
||||
}
|
||||
|
||||
// SolveChallenge 扫码登录(QR 截图经 onQR 展示)
|
||||
func (e *Executor) SolveChallenge(ctx context.Context, ch proto.Challenge, onQR func(qrToken, qrURL string)) (bool, string) {
|
||||
return e.base.SolveChallenge(ctx, ch, onQR)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package exec_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"everypublish/agentcore/internal/exec"
|
||||
"everypublish/agentcore/internal/exec/fake"
|
||||
"everypublish/shared/proto"
|
||||
)
|
||||
|
||||
func TestFakeExecutor(t *testing.T) {
|
||||
e := fake.Executor{SimMS: 5}
|
||||
start := time.Now()
|
||||
res := e.Execute(context.Background(), exec.Task{TaskID: "1", Platform: "douyin", Title: "t"})
|
||||
if time.Since(start) < 5*time.Millisecond {
|
||||
t.Fatal("should simulate delay")
|
||||
}
|
||||
if res.Status != "success" || res.PublishedURL == "" {
|
||||
t.Fatalf("expect success, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeSolver(t *testing.T) {
|
||||
e := fake.Executor{SimMS: 1}
|
||||
solved, value := e.SolveChallenge(context.Background(), proto.Challenge{ChallengeID: "1"}, nil)
|
||||
if !solved || value == "" {
|
||||
t.Fatalf("expect solved, got %v %s", solved, value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryFallback(t *testing.T) {
|
||||
r := exec.NewRegistry()
|
||||
r.SetFallback(fake.Executor{SimMS: 1})
|
||||
if r.Get("douyin") == nil {
|
||||
t.Fatal("fallback should serve any platform")
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"everypublish/shared/proto"
|
||||
)
|
||||
|
||||
// Task 执行任务(由 task.push 转换而来)
|
||||
type Task struct {
|
||||
TaskID string
|
||||
Platform string
|
||||
AccountID string
|
||||
AccountName string
|
||||
Title string
|
||||
Content string
|
||||
Tags []string
|
||||
MaterialURLs []string
|
||||
ScheduleAt int64
|
||||
Priority int
|
||||
}
|
||||
|
||||
// Result 执行结果(回传 task.result)
|
||||
type Result struct {
|
||||
TaskID string
|
||||
Status string
|
||||
PublishedURL string
|
||||
Receipts []string
|
||||
Error string
|
||||
}
|
||||
|
||||
// Executor 平台执行器(每个平台一个实现;D10 起接入真实平台)
|
||||
type Executor interface {
|
||||
Platform() string
|
||||
Execute(ctx context.Context, task Task) Result
|
||||
}
|
||||
|
||||
// ChallengeSolver 能自动完成挑战的平台执行器(扫码/验证码)。
|
||||
// onQR:平台生成二维码时回调(把 qrToken/qrUrl 推给壳展示,可为 nil)。
|
||||
type ChallengeSolver interface {
|
||||
SolveChallenge(ctx context.Context, ch proto.Challenge, onQR func(qrToken, qrURL string)) (solved bool, value string)
|
||||
}
|
||||
|
||||
// Registry 执行器注册表(并发安全)
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
m map[string]Executor
|
||||
fallback Executor
|
||||
}
|
||||
|
||||
// NewRegistry 创建注册表
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{m: make(map[string]Executor)}
|
||||
}
|
||||
|
||||
// Register 注册平台执行器
|
||||
func (r *Registry) Register(e Executor) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.m[e.Platform()] = e
|
||||
}
|
||||
|
||||
// SetFallback 注册兜底执行器(未接入平台用)
|
||||
func (r *Registry) SetFallback(e Executor) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.fallback = e
|
||||
}
|
||||
|
||||
// Get 取执行器(无匹配返回 nil)
|
||||
func (r *Registry) Get(platform string) Executor {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
if e, ok := r.m[platform]; ok {
|
||||
return e
|
||||
}
|
||||
return r.fallback
|
||||
}
|
||||
|
||||
// Solve 取挑战解决器(nil 表示不支持自动解决)
|
||||
func (r *Registry) Solve(platform string) ChallengeSolver {
|
||||
if s, ok := r.Get(platform).(ChallengeSolver); ok {
|
||||
return s
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
// fake 假执行器:D8 闭环验收用,模拟平台执行(延迟后返回成功)。
|
||||
package fake
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"everypublish/agentcore/internal/exec"
|
||||
"everypublish/shared/proto"
|
||||
)
|
||||
|
||||
// Executor 假执行器(可作兜底:任何平台都返回成功)
|
||||
type Executor struct {
|
||||
SimMS int
|
||||
}
|
||||
|
||||
// Platform 空串表示通用(注册为兜底)
|
||||
func (e Executor) Platform() string { return "" }
|
||||
|
||||
// Execute 模拟执行:延迟 SimMS 后返回成功
|
||||
func (e Executor) Execute(ctx context.Context, task exec.Task) exec.Result {
|
||||
select {
|
||||
case <-time.After(time.Duration(e.SimMS) * time.Millisecond):
|
||||
case <-ctx.Done():
|
||||
return exec.Result{TaskID: task.TaskID, Status: "failed", Error: "cancelled"}
|
||||
}
|
||||
return exec.Result{
|
||||
TaskID: task.TaskID,
|
||||
Status: "success",
|
||||
PublishedURL: "https://example.com/fake/" + task.TaskID,
|
||||
Receipts: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
// SolveChallenge 模拟扫码:延迟后返回已解决
|
||||
func (e Executor) SolveChallenge(ctx context.Context, ch proto.Challenge, onQR func(qrToken, qrURL string)) (bool, string) {
|
||||
if onQR != nil {
|
||||
onQR("fake-qr-token", "https://example.com/fake-qr")
|
||||
}
|
||||
select {
|
||||
case <-time.After(time.Duration(e.SimMS) * time.Millisecond):
|
||||
case <-ctx.Done():
|
||||
return false, ""
|
||||
}
|
||||
return true, "fake-qr-token"
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// kuaishou 快手执行器(CDP:cp.kuaishou.com)。
|
||||
package kuaishou
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"everypublish/agentcore/internal/exec"
|
||||
"everypublish/agentcore/internal/exec/cdnbase"
|
||||
"everypublish/agentcore/internal/exec/cdp"
|
||||
"everypublish/agentcore/internal/vault"
|
||||
"everypublish/shared/proto"
|
||||
)
|
||||
|
||||
// Executor 快手发布执行器
|
||||
type Executor struct {
|
||||
base *cdnbase.Platform
|
||||
}
|
||||
|
||||
// New 构造执行器
|
||||
func New(v *vault.Vault, rootDir, proxy, chromePath string, headless bool) *Executor {
|
||||
p := &cdnbase.Platform{
|
||||
Vault: v,
|
||||
RootDir: rootDir,
|
||||
Proxy: proxy,
|
||||
ChromePath: chromePath,
|
||||
Headless: headless,
|
||||
ReceiptDir: rootDir + "/receipts",
|
||||
Name: "kuaishou",
|
||||
LoginDomain: ".kuaishou.com",
|
||||
PublishCfg: cdp.PublishConfig{
|
||||
UploadURL: "https://cp.kuaishou.com/article/publish/video",
|
||||
SuccessMarker: "article/manage/video",
|
||||
FileInputSel: "input[type=\"file\"]",
|
||||
TitleSel: "input[placeholder*=\"标题\"]",
|
||||
DescSel: "[contenteditable=\"true\"]",
|
||||
PublishBtnSel: "button:has-text(\"发布\")",
|
||||
FormWait: 180 * time.Second,
|
||||
SuccessWait: 120 * time.Second,
|
||||
},
|
||||
LoginCfg: cdp.QRLoginConfig{
|
||||
LoginURL: "https://cp.kuaishou.com/",
|
||||
LoginPathMarker: "passport.kuaishou.com",
|
||||
QRSelector: ".qrcode-img, img[src*=qrcode], .qrcode img",
|
||||
SuccessWait: 3 * time.Minute,
|
||||
},
|
||||
}
|
||||
return &Executor{base: p}
|
||||
}
|
||||
|
||||
// Platform 平台标识
|
||||
func (e *Executor) Platform() string { return "kuaishou" }
|
||||
|
||||
// Execute 发布
|
||||
func (e *Executor) Execute(ctx context.Context, task exec.Task) exec.Result {
|
||||
return e.base.Publish(ctx, task)
|
||||
}
|
||||
|
||||
// SolveChallenge 扫码登录
|
||||
func (e *Executor) SolveChallenge(ctx context.Context, ch proto.Challenge, onQR func(qrToken, qrURL string)) (bool, string) {
|
||||
return e.base.SolveChallenge(ctx, ch, onQR)
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// xiaohongshu 小红书执行器(CDP:creator.xiaohongshu.com)。
|
||||
package xiaohongshu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"everypublish/agentcore/internal/exec"
|
||||
"everypublish/agentcore/internal/exec/cdnbase"
|
||||
"everypublish/agentcore/internal/exec/cdp"
|
||||
"everypublish/agentcore/internal/vault"
|
||||
"everypublish/shared/proto"
|
||||
)
|
||||
|
||||
// Executor 小红书发布执行器
|
||||
type Executor struct {
|
||||
base *cdnbase.Platform
|
||||
}
|
||||
|
||||
// New 构造执行器
|
||||
func New(v *vault.Vault, rootDir, proxy, chromePath string, headless bool) *Executor {
|
||||
p := &cdnbase.Platform{
|
||||
Vault: v,
|
||||
RootDir: rootDir,
|
||||
Proxy: proxy,
|
||||
ChromePath: chromePath,
|
||||
Headless: headless,
|
||||
ReceiptDir: rootDir + "/receipts",
|
||||
Name: "xiaohongshu",
|
||||
LoginDomain: ".xiaohongshu.com",
|
||||
PublishCfg: cdp.PublishConfig{
|
||||
UploadURL: "https://creator.xiaohongshu.com/publish/publish",
|
||||
SuccessMarker: "publish/success",
|
||||
FileInputSel: "input[type=\"file\"]",
|
||||
TitleSel: "input[placeholder*=\"填写标题\"]",
|
||||
DescSel: "#post-textarea",
|
||||
PublishBtnSel: "button:has-text(\"发布\")",
|
||||
FormWait: 180 * time.Second,
|
||||
SuccessWait: 120 * time.Second,
|
||||
},
|
||||
LoginCfg: cdp.QRLoginConfig{
|
||||
LoginURL: "https://creator.xiaohongshu.com/login",
|
||||
LoginPathMarker: "creator.xiaohongshu.com/login",
|
||||
QRSelector: ".qrcode-img, img[src*=qrcode], .qrcode img",
|
||||
SuccessWait: 3 * time.Minute,
|
||||
},
|
||||
}
|
||||
return &Executor{base: p}
|
||||
}
|
||||
|
||||
// Platform 平台标识
|
||||
func (e *Executor) Platform() string { return "xiaohongshu" }
|
||||
|
||||
// Execute 发布
|
||||
func (e *Executor) Execute(ctx context.Context, task exec.Task) exec.Result {
|
||||
return e.base.Publish(ctx, task)
|
||||
}
|
||||
|
||||
// SolveChallenge 扫码登录
|
||||
func (e *Executor) SolveChallenge(ctx context.Context, ch proto.Challenge, onQR func(qrToken, qrURL string)) (bool, string) {
|
||||
return e.base.SolveChallenge(ctx, ch, onQR)
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
// localserver 本机控制服务:仅 127.0.0.1,token 鉴权,供 WinUI 壳调用。
|
||||
// 端点:/status /challenges /challenges/{id} /pair;端口与 token 写入 data/local.json。
|
||||
package localserver
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"everypublish/shared/proto"
|
||||
)
|
||||
|
||||
// CoreState 核心状态接口(由 wsclient.Client 实现)
|
||||
type CoreState interface {
|
||||
Online() bool
|
||||
SessionID() string
|
||||
Challenges() []proto.Challenge
|
||||
SolveChallenge(challengeID, value string) error
|
||||
}
|
||||
|
||||
// PairFunc 配对回调(main 提供:配对成功保存身份并启动连接)
|
||||
type PairFunc func(code, name string) error
|
||||
|
||||
// Info 本地服务信息(写 local.json 供壳读取)
|
||||
type Info struct {
|
||||
Port int `json:"port"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// ExtraFunc 额外状态字段(server/deviceId/name 等,闭包实时取值)
|
||||
type ExtraFunc func() map[string]interface{}
|
||||
|
||||
// Server 本机控制服务
|
||||
type Server struct {
|
||||
state CoreState
|
||||
pair PairFunc
|
||||
token string
|
||||
dir string
|
||||
extra ExtraFunc
|
||||
mu sync.Mutex
|
||||
addr string
|
||||
}
|
||||
|
||||
// New 构造控制服务(extra 可空)
|
||||
func New(state CoreState, pair PairFunc, token, dir string, extra ExtraFunc) *Server {
|
||||
return &Server{state: state, pair: pair, token: token, dir: dir, extra: extra}
|
||||
}
|
||||
|
||||
// Listen 绑定随机端口并服务,同时写 data/local.json
|
||||
func (s *Server) Listen() error {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
s.mu.Lock()
|
||||
s.addr = ln.Addr().String()
|
||||
s.mu.Unlock()
|
||||
raw, _ := json.Marshal(Info{Port: port, Token: s.token})
|
||||
_ = os.WriteFile(filepath.Join(s.dir, "local.json"), raw, 0o600)
|
||||
log.Printf("[local] control server %s (local.json written)", s.addr)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/status", s.handleStatus)
|
||||
mux.HandleFunc("/challenges", s.handleChallenges)
|
||||
mux.HandleFunc("/challenges/", s.handleSolve)
|
||||
mux.HandleFunc("/pair", s.handlePair)
|
||||
return http.Serve(ln, mux)
|
||||
}
|
||||
|
||||
// Addr 实际监听地址(Listen 后可用)
|
||||
func (s *Server) Addr() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.addr
|
||||
}
|
||||
|
||||
func (s *Server) auth(r *http.Request) bool {
|
||||
// 仅接受请求头;token 不进 URL(避免泄露进日志/历史)。常数时间比较防时序侧信道。
|
||||
got := r.Header.Get("X-Agent-Token")
|
||||
if len(got) != len(s.token) {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(got), []byte(s.token)) == 1
|
||||
}
|
||||
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.auth(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
m := map[string]interface{}{
|
||||
"online": s.state.Online(),
|
||||
"session": s.state.SessionID(),
|
||||
}
|
||||
if s.extra != nil {
|
||||
for k, v := range s.extra() {
|
||||
m[k] = v
|
||||
}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(m)
|
||||
}
|
||||
|
||||
func (s *Server) handleChallenges(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.auth(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"list": s.state.Challenges()})
|
||||
}
|
||||
|
||||
func (s *Server) handleSolve(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.auth(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
id := strings.TrimPrefix(r.URL.Path, "/challenges/")
|
||||
var req struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if err := s.state.SolveChallenge(id, req.Value); err != nil {
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 1, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 0})
|
||||
}
|
||||
|
||||
func (s *Server) handlePair(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if !s.auth(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 1, "message": "请求格式不合法"})
|
||||
return
|
||||
}
|
||||
if req.Code == "" {
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 1, "message": "配对码必填"})
|
||||
return
|
||||
}
|
||||
if s.pair == nil {
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 1, "message": "配对服务未启用"})
|
||||
return
|
||||
}
|
||||
if err := s.pair(req.Code, req.Name); err != nil {
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 1, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 0})
|
||||
}
|
||||
|
||||
// RandToken 生成随机 token
|
||||
func RandToken() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
package localserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"everypublish/shared/proto"
|
||||
)
|
||||
|
||||
type fakeState struct {
|
||||
online bool
|
||||
chs []proto.Challenge
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeState) Online() bool { return f.online }
|
||||
func (f *fakeState) SessionID() string { return "s1" }
|
||||
func (f *fakeState) Challenges() []proto.Challenge { return f.chs }
|
||||
func (f *fakeState) SolveChallenge(id, value string) error { return f.err }
|
||||
|
||||
func startTestServer(t *testing.T, state CoreState, pair PairFunc) (*Server, string) {
|
||||
t.Helper()
|
||||
token := RandToken()
|
||||
s := New(state, pair, token, t.TempDir(), nil)
|
||||
go func() { _ = s.Listen() }()
|
||||
for i := 0; i < 50; i++ {
|
||||
if s.Addr() != "" {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if s.Addr() == "" {
|
||||
t.Fatal("server did not start")
|
||||
}
|
||||
return s, token
|
||||
}
|
||||
|
||||
func get(t *testing.T, base, path, token string) (int, map[string]interface{}) {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest("GET", "http://"+base+path, nil)
|
||||
if token != "" {
|
||||
req.Header.Set("X-Agent-Token", token)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var m map[string]interface{}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&m)
|
||||
return resp.StatusCode, m
|
||||
}
|
||||
|
||||
func TestStatusAuth(t *testing.T) {
|
||||
s, token := startTestServer(t, &fakeState{online: true}, nil)
|
||||
code, _ := get(t, s.Addr(), "/status", "")
|
||||
if code != 401 {
|
||||
t.Fatalf("no token should be 401, got %d", code)
|
||||
}
|
||||
code, m := get(t, s.Addr(), "/status", token)
|
||||
if code != 200 || m["online"] != true {
|
||||
t.Fatalf("status wrong: %d %v", code, m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairCallback(t *testing.T) {
|
||||
var gotCode, gotName string
|
||||
s, token := startTestServer(t, &fakeState{}, func(code, name string) error {
|
||||
gotCode, gotName = code, name
|
||||
return nil
|
||||
})
|
||||
// 未带 token → 401
|
||||
req, _ := http.NewRequest("POST", "http://"+s.Addr()+"/pair", bytes.NewBufferString(`{"code":"AB12CD","name":"PC"}`))
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("pair: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 401 {
|
||||
t.Fatalf("pair without token should 401, got %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
// 带 token → 回调被调用
|
||||
req2, _ := http.NewRequest("POST", "http://"+s.Addr()+"/pair", bytes.NewBufferString(`{"code":"AB12CD","name":"PC"}`))
|
||||
req2.Header.Set("X-Agent-Token", token)
|
||||
resp2, err := http.DefaultClient.Do(req2)
|
||||
if err != nil {
|
||||
t.Fatalf("pair: %v", err)
|
||||
}
|
||||
resp2.Body.Close()
|
||||
if gotCode != "AB12CD" || gotName != "PC" {
|
||||
t.Fatalf("callback not invoked: %q %q", gotCode, gotName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairErrorPropagates(t *testing.T) {
|
||||
s, token := startTestServer(t, &fakeState{}, func(code, name string) error {
|
||||
return errors.New("配对码无效")
|
||||
})
|
||||
req, _ := http.NewRequest("POST", "http://"+s.Addr()+"/pair", bytes.NewBufferString(`{"code":"X"}`))
|
||||
req.Header.Set("X-Agent-Token", token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("pair: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var m map[string]interface{}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&m)
|
||||
if m["code"] != float64(1) {
|
||||
t.Fatalf("expect error code 1, got %v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolve(t *testing.T) {
|
||||
s, token := startTestServer(t, &fakeState{err: nil}, nil)
|
||||
code, m := get(t, s.Addr(), "/challenges", token)
|
||||
if code != 200 {
|
||||
t.Fatalf("challenges: %d", code)
|
||||
}
|
||||
_ = m
|
||||
req, _ := http.NewRequest("POST", "http://"+s.Addr()+"/challenges/9", bytes.NewBufferString(`{"value":"123456"}`))
|
||||
req.Header.Set("X-Agent-Token", token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("solve: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var m2 map[string]interface{}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&m2)
|
||||
if m2["code"] != float64(0) {
|
||||
t.Fatalf("solve should succeed, got %v", m2)
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
package pairing
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func httpJSON(method, url, token string, body interface{}) (int, []byte, error) {
|
||||
var buf bytes.Buffer
|
||||
if body != nil {
|
||||
raw, _ := json.Marshal(body)
|
||||
buf.Write(raw)
|
||||
}
|
||||
req, err := http.NewRequest(method, url, &buf)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, raw, nil
|
||||
}
|
||||
|
||||
func field(raw []byte, key string) string {
|
||||
var m map[string]interface{}
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
if d, ok := m["data"].(map[string]interface{}); ok {
|
||||
if v, ok := d[key]; ok {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case float64:
|
||||
return strconv.FormatInt(int64(t), 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ginH map[string]interface{}
|
||||
|
||||
// AutoPair 开发便利:登录 → 取配对码 → 配对(无 UI 场景闭环用)
|
||||
func AutoPair(server, email, password, deviceName, pubKeyB64 string) (uint64, error) {
|
||||
code, raw, err := httpJSON("POST", server+"/api/v1/auth/login", "", ginH{"email": email, "password": password})
|
||||
if err != nil || code != 200 {
|
||||
return 0, fmt.Errorf("login failed: %s", string(raw))
|
||||
}
|
||||
token := field(raw, "accessToken")
|
||||
code, raw, err = httpJSON("POST", server+"/api/v1/agent/pair-code", token, nil)
|
||||
if err != nil || code != 200 {
|
||||
return 0, fmt.Errorf("pair-code failed: %s", string(raw))
|
||||
}
|
||||
pairCode := field(raw, "code")
|
||||
return PairWithCode(server, pairCode, deviceName, "core", "0.2.0", pubKeyB64)
|
||||
}
|
||||
|
||||
// PairWithCode 凭配对码注册设备,返回服务器分配的设备 ID
|
||||
func PairWithCode(server, pairCode, deviceName, osName, version, pubKeyB64 string) (uint64, error) {
|
||||
pairCode = strings.ToUpper(strings.TrimSpace(pairCode))
|
||||
deviceName = strings.TrimSpace(deviceName)
|
||||
if deviceName == "" {
|
||||
deviceName = "macOS Agent"
|
||||
}
|
||||
pubKeyB64 = strings.TrimSpace(pubKeyB64)
|
||||
if pubKeyB64 == "" {
|
||||
return 0, fmt.Errorf("本机设备公钥为空,请重启客户端后重试")
|
||||
}
|
||||
pubKey, keyErr := base64.StdEncoding.DecodeString(pubKeyB64)
|
||||
if keyErr != nil || len(pubKey) != ed25519.PublicKeySize {
|
||||
return 0, fmt.Errorf("本机设备公钥格式错误,请重启客户端后重试")
|
||||
}
|
||||
code, raw, err := httpJSON("POST", server+"/api/v1/agent/pair", "", ginH{
|
||||
"code": pairCode, "deviceName": deviceName, "os": osName, "version": version, "publicKey": pubKeyB64,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("配对请求失败:%w", err)
|
||||
}
|
||||
if code != http.StatusOK {
|
||||
return 0, fmt.Errorf("%s", responseMessage(raw, "配对失败"))
|
||||
}
|
||||
id, _ := strconv.ParseUint(field(raw, "deviceId"), 10, 64)
|
||||
if id == 0 {
|
||||
return 0, fmt.Errorf("pair response missing deviceId")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func responseMessage(raw []byte, fallback string) string {
|
||||
var envelope struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &envelope); err == nil && strings.TrimSpace(envelope.Message) != "" {
|
||||
return envelope.Message
|
||||
}
|
||||
if text := strings.TrimSpace(string(raw)); text != "" {
|
||||
return text
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package pairing
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPairWithCodeSendsNormalizedPayload(t *testing.T) {
|
||||
pub, _, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/agent/pair" || r.Method != http.MethodPost {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
DeviceName string `json:"deviceName"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body.Code != "AB23CD" || body.DeviceName != "macOS Agent" {
|
||||
t.Fatalf("payload was not normalized: %+v", body)
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(body.PublicKey)
|
||||
if err != nil || len(decoded) != ed25519.PublicKeySize {
|
||||
t.Fatalf("invalid public key: %q", body.PublicKey)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":0,"message":"ok","data":{"deviceId":42}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
id, err := PairWithCode(server.URL, " ab23cd ", "", "core", "0.3.0", base64.StdEncoding.EncodeToString(pub))
|
||||
if err != nil || id != 42 {
|
||||
t.Fatalf("PairWithCode() = %d, %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairWithCodeRejectsMissingPublicKey(t *testing.T) {
|
||||
_, err := PairWithCode("http://127.0.0.1:1", "AB23CD", "Mac", "core", "0.3.0", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "公钥") {
|
||||
t.Fatalf("expected actionable public key error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairWithCodePreservesServerMessage(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"code":1001,"data":null,"message":"设备公钥格式不合法"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
pub, _, _ := ed25519.GenerateKey(rand.Reader)
|
||||
_, err := PairWithCode(server.URL, "AB23CD", "Mac", "core", "0.3.0", base64.StdEncoding.EncodeToString(pub))
|
||||
if err == nil || !strings.Contains(err.Error(), "设备公钥格式不合法") {
|
||||
t.Fatalf("expected server message, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Vault 凭据保险库:AES-256-GCM 加密的 JSON 文件(主密钥单独 0600 文件)。
|
||||
// 二期:Windows 上主密钥改用 DPAPI 保护,文件格式不变。
|
||||
type Vault struct {
|
||||
dir string
|
||||
key []byte
|
||||
}
|
||||
|
||||
type vaultFile struct {
|
||||
Version int `json:"version"`
|
||||
Entries map[string]string `json:"entries"`
|
||||
}
|
||||
|
||||
// Open 打开/创建保险库(主密钥 data/master.key,32 字节)
|
||||
func Open(dir string) (*Vault, error) {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keyPath := filepath.Join(dir, "master.key")
|
||||
var key []byte
|
||||
if raw, err := os.ReadFile(keyPath); err == nil {
|
||||
key = make([]byte, base64.RawStdEncoding.DecodedLen(len(raw)))
|
||||
if _, err = base64.RawStdEncoding.Decode(key, raw); err != nil || len(key) != 32 {
|
||||
return nil, errors.New("master key corrupted")
|
||||
}
|
||||
} else {
|
||||
key = make([]byte, 32)
|
||||
if _, err = rand.Read(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = os.WriteFile(keyPath, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &Vault{dir: dir, key: key}, nil
|
||||
}
|
||||
|
||||
func (v *Vault) path() string { return filepath.Join(v.dir, "vault.enc") }
|
||||
|
||||
// load 读取并解密全部条目
|
||||
func (v *Vault) load() (map[string]string, error) {
|
||||
raw, err := os.ReadFile(v.path())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var f vaultFile
|
||||
if err = json.Unmarshal(raw, &f); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if f.Entries == nil {
|
||||
f.Entries = map[string]string{}
|
||||
}
|
||||
return f.Entries, nil
|
||||
}
|
||||
|
||||
// Save 加密保存单条凭据(cookie/token 等,值为 JSON 字符串)
|
||||
func (v *Vault) Save(accountID string, plaintext []byte) error {
|
||||
entries, err := v.load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
block, err := aes.NewCipher(v.key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err = rand.Read(nonce); err != nil {
|
||||
return err
|
||||
}
|
||||
sealed := gcm.Seal(nonce, nonce, plaintext, nil)
|
||||
entries[accountID] = base64.StdEncoding.EncodeToString(sealed)
|
||||
return v.persist(entries)
|
||||
}
|
||||
|
||||
// Load 解密读取单条凭据
|
||||
func (v *Vault) Load(accountID string) ([]byte, error) {
|
||||
entries, err := v.load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sealedB64, ok := entries[accountID]
|
||||
if !ok {
|
||||
return nil, errors.New("entry not found")
|
||||
}
|
||||
sealed, err := base64.StdEncoding.DecodeString(sealedB64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, err := aes.NewCipher(v.key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(sealed) < gcm.NonceSize() {
|
||||
return nil, errors.New("entry corrupted")
|
||||
}
|
||||
nonce, ciphertext := sealed[:gcm.NonceSize()], sealed[gcm.NonceSize():]
|
||||
return gcm.Open(nil, nonce, ciphertext, nil)
|
||||
}
|
||||
|
||||
// Delete 删除单条凭据
|
||||
func (v *Vault) Delete(accountID string) error {
|
||||
entries, err := v.load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
delete(entries, accountID)
|
||||
return v.persist(entries)
|
||||
}
|
||||
|
||||
func (v *Vault) persist(entries map[string]string) error {
|
||||
raw, err := json.MarshalIndent(vaultFile{Version: 1, Entries: entries}, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(v.path(), raw, 0o600)
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSaveLoadRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
v, err := Open(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
secret := []byte(`{"cookies":"session=abc","token":"xyz"}`)
|
||||
if err = v.Save("acc-1", secret); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
// 文件不应包含明文
|
||||
raw, _ := os.ReadFile(filepath.Join(dir, "vault.enc"))
|
||||
if stringContains(string(raw), "session=abc") {
|
||||
t.Fatal("vault file must not contain plaintext")
|
||||
}
|
||||
got, err := v.Load("acc-1")
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if string(got) != string(secret) {
|
||||
t.Fatalf("roundtrip mismatch: %s vs %s", got, secret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReopenPersists(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
v1, _ := Open(dir)
|
||||
_ = v1.Save("acc-2", []byte("data-2"))
|
||||
v2, err := Open(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
got, err := v2.Load("acc-2")
|
||||
if err != nil || string(got) != "data-2" {
|
||||
t.Fatalf("reopen mismatch: %s %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTamperFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
v, _ := Open(dir)
|
||||
_ = v.Save("acc-3", []byte("data-3"))
|
||||
path := filepath.Join(dir, "vault.enc")
|
||||
raw, _ := os.ReadFile(path)
|
||||
raw[len(raw)/2] ^= 0xff
|
||||
_ = os.WriteFile(path, raw, 0o600)
|
||||
if _, err := v.Load("acc-3"); err == nil {
|
||||
t.Fatal("tampered ciphertext should fail auth")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
v, _ := Open(dir)
|
||||
_ = v.Save("acc-4", []byte("d4"))
|
||||
if err := v.Delete("acc-4"); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if _, err := v.Load("acc-4"); err == nil {
|
||||
t.Fatal("deleted entry should not load")
|
||||
}
|
||||
}
|
||||
|
||||
func stringContains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && indexOf(s, sub) >= 0
|
||||
}
|
||||
|
||||
func indexOf(s, sub string) int {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -1,334 +0,0 @@
|
||||
package wsclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"everypublish/agentcore/internal/device"
|
||||
"everypublish/agentcore/internal/exec"
|
||||
"everypublish/shared/proto"
|
||||
)
|
||||
|
||||
// Client WSS 客户端:指数退避重连 + 任务幂等重放 + 挑战自动处理
|
||||
type Client struct {
|
||||
ServerURL string
|
||||
Identity *device.Identity
|
||||
Registry *exec.Registry
|
||||
ExecTimeout time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
writeMu sync.Mutex
|
||||
conn *websocket.Conn
|
||||
backoff time.Duration
|
||||
executed map[string]exec.Result
|
||||
|
||||
chMu sync.Mutex
|
||||
challenges map[string]proto.Challenge // 活跃挑战(WinUI 壳展示)
|
||||
online bool
|
||||
sessionID string
|
||||
}
|
||||
|
||||
// New 构造客户端
|
||||
func New(serverURL string, id *device.Identity, registry *exec.Registry) *Client {
|
||||
return &Client{
|
||||
ServerURL: serverURL,
|
||||
Identity: id,
|
||||
Registry: registry,
|
||||
ExecTimeout: 5 * time.Minute,
|
||||
executed: make(map[string]exec.Result),
|
||||
challenges: make(map[string]proto.Challenge),
|
||||
}
|
||||
}
|
||||
|
||||
// Run 主循环:断线按 1s/5s/15s/60s 指数退避重连,握手成功重置
|
||||
func (c *Client) Run(ctx context.Context) error {
|
||||
for {
|
||||
err := c.session(ctx)
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
c.stepBackoff()
|
||||
log.Printf("[ws] connection lost (%v), reconnect in %s", err, c.backoff)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-time.After(c.backoff):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) stepBackoff() {
|
||||
switch c.backoff {
|
||||
case 0:
|
||||
c.backoff = time.Second
|
||||
case time.Second:
|
||||
c.backoff = 5 * time.Second
|
||||
case 5 * time.Second:
|
||||
c.backoff = 15 * time.Second
|
||||
default:
|
||||
c.backoff = 60 * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
// session 单次连接会话:握手 → 心跳 → 消息循环
|
||||
func (c *Client) session(ctx context.Context) error {
|
||||
wsURL := "ws" + strings.TrimPrefix(c.ServerURL, "http") + "/ws/agent"
|
||||
conn, _, err := websocket.Dial(ctx, wsURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close(websocket.StatusNormalClosure, "bye")
|
||||
defer func() {
|
||||
c.mu.Lock()
|
||||
c.online = false
|
||||
c.mu.Unlock()
|
||||
}()
|
||||
c.mu.Lock()
|
||||
c.conn = conn
|
||||
c.mu.Unlock()
|
||||
|
||||
// hello 握手(Ed25519 签名)
|
||||
nonce := randHex(16)
|
||||
ts := time.Now().UnixMilli()
|
||||
sig := c.Identity.Sign([]byte(nonce + "|" + strconv.FormatInt(ts, 10)))
|
||||
hello := proto.NewEnvelope(randHex(8), proto.TypeHello, proto.DeviceHello{
|
||||
DeviceID: strconv.FormatUint(c.Identity.DeviceID, 10),
|
||||
Nonce: nonce,
|
||||
TS: ts,
|
||||
Sig: sig,
|
||||
Version: "0.2.0",
|
||||
})
|
||||
if err = c.send(conn, hello); err != nil {
|
||||
return err
|
||||
}
|
||||
rctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
_, raw, err := conn.Read(rctx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var ackEnv proto.Envelope
|
||||
_ = json.Unmarshal(raw, &ackEnv)
|
||||
if ackEnv.Type != proto.TypeHelloAck {
|
||||
return fmt.Errorf("expect hello.ack, got %s", ackEnv.Type)
|
||||
}
|
||||
var ack proto.HelloAck
|
||||
_ = json.Unmarshal(ackEnv.Payload, &ack)
|
||||
c.mu.Lock()
|
||||
c.backoff = 0 // 握手成功重置退避
|
||||
c.online = true
|
||||
c.sessionID = ack.SessionID
|
||||
c.mu.Unlock()
|
||||
log.Printf("[ws] online, session %s", ack.SessionID)
|
||||
|
||||
// 心跳(15s,服务器 90s 超时检测)
|
||||
hbCtx, hbCancel := context.WithCancel(ctx)
|
||||
defer hbCancel()
|
||||
go c.heartbeatLoop(hbCtx)
|
||||
|
||||
// 消息循环
|
||||
for {
|
||||
typ, raw, err := conn.Read(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if typ != websocket.MessageText {
|
||||
continue
|
||||
}
|
||||
var env proto.Envelope
|
||||
if err = json.Unmarshal(raw, &env); err != nil {
|
||||
continue
|
||||
}
|
||||
c.handle(conn, env)
|
||||
}
|
||||
}
|
||||
|
||||
// handle 分发服务端消息
|
||||
func (c *Client) handle(conn *websocket.Conn, env proto.Envelope) {
|
||||
switch env.Type {
|
||||
case proto.TypeTaskPush:
|
||||
var push proto.TaskPush
|
||||
if err := json.Unmarshal(env.Payload, &push); err != nil {
|
||||
return
|
||||
}
|
||||
c.handleTaskPush(conn, env, push)
|
||||
case proto.TypeChallenge:
|
||||
var ch proto.Challenge
|
||||
if err := json.Unmarshal(env.Payload, &ch); err != nil {
|
||||
return
|
||||
}
|
||||
c.handleChallenge(conn, env, ch)
|
||||
case proto.TypeHeartbeatAck:
|
||||
// 忽略
|
||||
default:
|
||||
log.Printf("[ws] ignore msg %s", env.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// handleTaskPush 任务下发:幂等(已执行过直接重放结果)+ 执行 + 回传
|
||||
func (c *Client) handleTaskPush(conn *websocket.Conn, env proto.Envelope, push proto.TaskPush) {
|
||||
c.mu.Lock()
|
||||
if res, ok := c.executed[push.TaskID]; ok {
|
||||
c.mu.Unlock()
|
||||
log.Printf("[ws] task %s already executed, replay result", push.TaskID)
|
||||
_ = c.send(conn, proto.NewEnvelope(env.ID, proto.TypeTaskAck, proto.TaskAck{TaskID: push.TaskID, Accept: true}))
|
||||
_ = c.send(conn, proto.NewEnvelope(randHex(8), proto.TypeTaskResult, proto.TaskResult{
|
||||
TaskID: res.TaskID, Status: res.Status, PublishedURL: res.PublishedURL,
|
||||
Receipts: res.Receipts, Error: res.Error, FinishedAt: time.Now().UnixMilli(),
|
||||
}))
|
||||
return
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
log.Printf("[ws] TASK push %s platform=%s title=%s", push.TaskID, push.Platform, push.Title)
|
||||
_ = c.send(conn, proto.NewEnvelope(env.ID, proto.TypeTaskAck, proto.TaskAck{TaskID: push.TaskID, Accept: true}))
|
||||
|
||||
task := exec.Task{
|
||||
TaskID: push.TaskID, Platform: push.Platform, AccountID: push.AccountID, AccountName: push.AccountName,
|
||||
Title: push.Title, Content: push.Content, Tags: push.Tags, MaterialURLs: push.MaterialURLs,
|
||||
ScheduleAt: push.ScheduleAt, Priority: push.Priority,
|
||||
}
|
||||
var res exec.Result
|
||||
e := c.Registry.Get(push.Platform)
|
||||
if e == nil {
|
||||
res = exec.Result{TaskID: push.TaskID, Status: "failed", Error: "平台执行器未接入: " + push.Platform}
|
||||
} else {
|
||||
eCtx, cancel := context.WithTimeout(context.Background(), c.ExecTimeout)
|
||||
res = e.Execute(eCtx, task)
|
||||
cancel()
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.executed[push.TaskID] = res
|
||||
if len(c.executed) > 1024 {
|
||||
c.executed = make(map[string]exec.Result)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
_ = c.send(conn, proto.NewEnvelope(randHex(8), proto.TypeTaskResult, proto.TaskResult{
|
||||
TaskID: res.TaskID, Status: res.Status, PublishedURL: res.PublishedURL,
|
||||
Receipts: res.Receipts, Error: res.Error, FinishedAt: time.Now().UnixMilli(),
|
||||
}))
|
||||
log.Printf("[ws] TASK %s done status=%s", push.TaskID, res.Status)
|
||||
}
|
||||
|
||||
// handleChallenge 挑战:ack + 有自动解决器则异步解决(60s 超时),否则留人工
|
||||
func (c *Client) handleChallenge(conn *websocket.Conn, env proto.Envelope, ch proto.Challenge) {
|
||||
log.Printf("[ws] CHALLENGE %s kind=%s prompt=%s", ch.ChallengeID, ch.Kind, ch.Prompt)
|
||||
c.chMu.Lock()
|
||||
c.challenges[ch.ChallengeID] = ch
|
||||
c.chMu.Unlock()
|
||||
_ = c.send(conn, proto.NewEnvelope(env.ID, proto.TypeChallengeAck, proto.ChallengeAck{ChallengeID: ch.ChallengeID, Action: "accept"}))
|
||||
solver := c.Registry.Solve(ch.Platform)
|
||||
if solver == nil {
|
||||
log.Printf("[ws] challenge %s 无自动解决器,等待人工处理", ch.ChallengeID)
|
||||
return
|
||||
}
|
||||
// 平台生成二维码时更新挑战记录(壳经 /challenges 展示)
|
||||
onQR := func(qrToken, qrURL string) {
|
||||
c.chMu.Lock()
|
||||
if cur, ok := c.challenges[ch.ChallengeID]; ok {
|
||||
cur.QRToken = qrToken
|
||||
cur.QRURL = qrURL
|
||||
c.challenges[ch.ChallengeID] = cur
|
||||
}
|
||||
c.chMu.Unlock()
|
||||
}
|
||||
go func() {
|
||||
sCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
if solved, value := solver.SolveChallenge(sCtx, ch, onQR); solved {
|
||||
_ = c.send(conn, proto.NewEnvelope(randHex(8), proto.TypeChallengeSolve, proto.ChallengeSolve{ChallengeID: ch.ChallengeID, Value: value}))
|
||||
c.chMu.Lock()
|
||||
delete(c.challenges, ch.ChallengeID)
|
||||
c.chMu.Unlock()
|
||||
log.Printf("[ws] CHALLENGE %s solved", ch.ChallengeID)
|
||||
} else {
|
||||
log.Printf("[ws] CHALLENGE %s 未解决,等待人工", ch.ChallengeID)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// heartbeatLoop 心跳(15s)
|
||||
func (c *Client) heartbeatLoop(ctx context.Context) {
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
c.mu.Lock()
|
||||
conn := c.conn
|
||||
c.mu.Unlock()
|
||||
if conn == nil {
|
||||
continue
|
||||
}
|
||||
_ = c.send(conn, proto.NewEnvelope(randHex(8), proto.TypeHeartbeat, proto.Heartbeat{DeviceID: strconv.FormatUint(c.Identity.DeviceID, 10), TS: time.Now().UnixMilli()}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// send 统一写出口(写锁串行化,避免并发写)
|
||||
func (c *Client) send(conn *websocket.Conn, msg *proto.Envelope) error {
|
||||
raw, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
return conn.Write(wctx, websocket.MessageText, raw)
|
||||
}
|
||||
|
||||
// Challenges 当前活跃挑战快照(WinUI 壳轮询展示)
|
||||
func (c *Client) Challenges() []proto.Challenge {
|
||||
c.chMu.Lock()
|
||||
defer c.chMu.Unlock()
|
||||
list := make([]proto.Challenge, 0, len(c.challenges))
|
||||
for _, ch := range c.challenges {
|
||||
list = append(list, ch)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// SolveChallenge 人工完成挑战(验证码/确认),经 WSS 回传服务器
|
||||
func (c *Client) SolveChallenge(challengeID, value string) error {
|
||||
c.mu.Lock()
|
||||
conn := c.conn
|
||||
c.mu.Unlock()
|
||||
if conn == nil {
|
||||
return errors.New("offline")
|
||||
}
|
||||
return c.send(conn, proto.NewEnvelope(randHex(8), proto.TypeChallengeSolve, proto.ChallengeSolve{ChallengeID: challengeID, Value: value}))
|
||||
}
|
||||
|
||||
// Online 当前是否在线
|
||||
func (c *Client) Online() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.online
|
||||
}
|
||||
|
||||
// SessionID 当前会话 ID
|
||||
func (c *Client) SessionID() string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.sessionID
|
||||
}
|
||||
|
||||
func randHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
package wsclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"everypublish/agentcore/internal/device"
|
||||
"everypublish/agentcore/internal/exec"
|
||||
"everypublish/shared/proto"
|
||||
)
|
||||
|
||||
// countingExec 计数执行器(验证幂等:只执行一次)
|
||||
type countingExec struct{ n int32 }
|
||||
|
||||
func (c *countingExec) Platform() string { return "" }
|
||||
func (c *countingExec) Execute(ctx context.Context, task exec.Task) exec.Result {
|
||||
atomic.AddInt32(&c.n, 1)
|
||||
return exec.Result{TaskID: task.TaskID, Status: "success", PublishedURL: "https://ex.com/" + task.TaskID}
|
||||
}
|
||||
func (c *countingExec) SolveChallenge(ctx context.Context, ch proto.Challenge) (bool, string) {
|
||||
return true, "tok"
|
||||
}
|
||||
|
||||
// verifyHello 校验客户端 hello 签名
|
||||
func verifyHello(t *testing.T, id *device.Identity, raw []byte) {
|
||||
t.Helper()
|
||||
var env proto.Envelope
|
||||
_ = json.Unmarshal(raw, &env)
|
||||
var hello proto.DeviceHello
|
||||
_ = json.Unmarshal(env.Payload, &hello)
|
||||
pub, _ := base64.StdEncoding.DecodeString(id.PublicKey)
|
||||
sig, _ := base64.StdEncoding.DecodeString(hello.Sig)
|
||||
if !ed25519.Verify(ed25519.PublicKey(pub), []byte(hello.Nonce+"|"+strconv.FormatInt(hello.TS, 10)), sig) {
|
||||
t.Errorf("hello signature invalid")
|
||||
}
|
||||
}
|
||||
|
||||
// sendAck 回 hello.ack
|
||||
func sendAck(r *http.Request, conn *websocket.Conn) {
|
||||
ack := proto.NewEnvelope("ack1", proto.TypeHelloAck, proto.HelloAck{ServerNonce: "n", SessionID: "s1", ServerTS: 1})
|
||||
rawAck, _ := json.Marshal(ack)
|
||||
_ = conn.Write(r.Context(), websocket.MessageText, rawAck)
|
||||
}
|
||||
|
||||
// startStub 启动 stub:验签 → ack → 推任务1 → 收结果 → 再推任务1(重放)→ 收重放结果
|
||||
func startStub(t *testing.T, id *device.Identity) (*httptest.Server, chan proto.TaskResult, chan struct{}) {
|
||||
results := make(chan proto.TaskResult, 8)
|
||||
connected := make(chan struct{}, 8)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := websocket.Accept(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close(websocket.StatusNormalClosure, "")
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
_, raw, err := conn.Read(ctx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
verifyHello(t, id, raw)
|
||||
sendAck(r, conn)
|
||||
connected <- struct{}{}
|
||||
|
||||
push := proto.NewEnvelope("p1", proto.TypeTaskPush, proto.TaskPush{TaskID: "1", Platform: "douyin", Title: "t1"})
|
||||
rawPush, _ := json.Marshal(push)
|
||||
_ = conn.Write(r.Context(), websocket.MessageText, rawPush)
|
||||
|
||||
gotFirst := false
|
||||
for {
|
||||
_, raw, err = conn.Read(r.Context())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var env2 proto.Envelope
|
||||
if json.Unmarshal(raw, &env2) != nil {
|
||||
continue
|
||||
}
|
||||
if env2.Type == proto.TypeTaskResult {
|
||||
var res proto.TaskResult
|
||||
_ = json.Unmarshal(env2.Payload, &res)
|
||||
results <- res
|
||||
if !gotFirst {
|
||||
gotFirst = true
|
||||
push2 := proto.NewEnvelope("p2", proto.TypeTaskPush, proto.TaskPush{TaskID: "1", Platform: "douyin", Title: "t1"})
|
||||
rawPush2, _ := json.Marshal(push2)
|
||||
_ = conn.Write(r.Context(), websocket.MessageText, rawPush2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
return srv, results, connected
|
||||
}
|
||||
|
||||
func TestClientTaskRoundTripAndIdempotent(t *testing.T) {
|
||||
id, _ := device.LoadOrCreate(t.TempDir(), "dev")
|
||||
id.DeviceID = 1
|
||||
registry := exec.NewRegistry()
|
||||
ce := &countingExec{}
|
||||
registry.SetFallback(ce)
|
||||
|
||||
srv, results, connected := startStub(t, id)
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, id, registry)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go func() { _ = c.Run(ctx) }()
|
||||
|
||||
select {
|
||||
case <-connected:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("handshake timeout")
|
||||
}
|
||||
|
||||
var r1 proto.TaskResult
|
||||
select {
|
||||
case r1 = <-results:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("first result timeout")
|
||||
}
|
||||
if r1.Status != "success" {
|
||||
t.Fatalf("first result should be success, got %s", r1.Status)
|
||||
}
|
||||
|
||||
var r2 proto.TaskResult
|
||||
select {
|
||||
case r2 = <-results:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("replay result timeout")
|
||||
}
|
||||
if r2.Status != "success" || r2.PublishedURL != r1.PublishedURL {
|
||||
t.Fatalf("replay should return cached result, got %+v", r2)
|
||||
}
|
||||
|
||||
if n := atomic.LoadInt32(&ce.n); n != 1 {
|
||||
t.Fatalf("executor should run exactly once, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClientReconnect 服务器断开后客户端应按退避自动重连
|
||||
func TestClientReconnect(t *testing.T) {
|
||||
id, _ := device.LoadOrCreate(t.TempDir(), "dev")
|
||||
id.DeviceID = 1
|
||||
registry := exec.NewRegistry()
|
||||
registry.SetFallback(&countingExec{})
|
||||
|
||||
var connCount int32
|
||||
connected := make(chan struct{}, 8)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := websocket.Accept(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
_, raw, err := conn.Read(ctx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
verifyHello(t, id, raw)
|
||||
sendAck(r, conn)
|
||||
connected <- struct{}{}
|
||||
n := atomic.AddInt32(&connCount, 1)
|
||||
if n == 1 {
|
||||
// 第一次连接:ack 后立刻断开,模拟服务器故障
|
||||
_ = conn.Close(websocket.StatusGoingAway, "restart")
|
||||
return
|
||||
}
|
||||
// 后续连接:正常读循环
|
||||
for {
|
||||
if _, _, err = conn.Read(r.Context()); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, id, registry)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go func() { _ = c.Run(ctx) }()
|
||||
|
||||
select {
|
||||
case <-connected:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("first connect timeout")
|
||||
}
|
||||
select {
|
||||
case <-connected:
|
||||
case <-time.After(8 * time.Second):
|
||||
t.Fatal("reconnect timeout")
|
||||
}
|
||||
if atomic.LoadInt32(&connCount) < 2 {
|
||||
t.Fatal("expected second connection")
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
// agent-core 客户端核心:设备身份 + 凭据保险库 + WSS 长连接 + 平台执行器。
|
||||
// 常驻模型:WinUI 壳 spawn 本进程 → 壳读 data/local.json(随机端口+token)→
|
||||
// 经 localhost HTTP 控制(状态/配对/挑战)。CLI 亦可独立运行(-user/-pass 自动配对)。
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"everypublish/agentcore/internal/config"
|
||||
"everypublish/agentcore/internal/device"
|
||||
"everypublish/agentcore/internal/exec"
|
||||
"everypublish/agentcore/internal/exec/bilibili"
|
||||
"everypublish/agentcore/internal/exec/douyin"
|
||||
"everypublish/agentcore/internal/exec/fake"
|
||||
"everypublish/agentcore/internal/exec/kuaishou"
|
||||
"everypublish/agentcore/internal/exec/xiaohongshu"
|
||||
"everypublish/agentcore/internal/localserver"
|
||||
"everypublish/agentcore/internal/pairing"
|
||||
"everypublish/agentcore/internal/vault"
|
||||
"everypublish/agentcore/internal/wsclient"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var (
|
||||
server = flag.String("server", "", "服务器地址(默认取配置)")
|
||||
dir = flag.String("dir", "./data", "数据目录(设备密钥/保险库/配置)")
|
||||
name = flag.String("name", "", "设备名")
|
||||
user = flag.String("user", "", "开发便利:自动配对登录邮箱")
|
||||
password = flag.String("pass", "", "开发便利:自动配对登录密码")
|
||||
simMS = flag.Int("sim", 0, "假执行器模拟耗时 ms(0 用配置值)")
|
||||
token = flag.String("local-token", "", "本机控制服务 token(默认随机)")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
cfg, err := config.Load(*dir)
|
||||
if err != nil {
|
||||
log.Fatalf("config: %v", err)
|
||||
}
|
||||
if *server != "" {
|
||||
cfg.Server = *server
|
||||
}
|
||||
if *name != "" {
|
||||
cfg.Name = *name
|
||||
}
|
||||
if *simMS > 0 {
|
||||
cfg.SimMS = *simMS
|
||||
}
|
||||
_ = cfg.Save(*dir)
|
||||
|
||||
// 设备身份(私钥本机持久化 0600)
|
||||
id, err := device.LoadOrCreate(*dir, cfg.Name)
|
||||
if err != nil {
|
||||
log.Fatalf("device: %v", err)
|
||||
}
|
||||
cfg.Name = id.Name
|
||||
|
||||
// 凭据保险库(平台 cookie/token 加密落盘)
|
||||
v, err := vault.Open(*dir)
|
||||
if err != nil {
|
||||
log.Fatalf("vault: %v", err)
|
||||
}
|
||||
|
||||
// 执行器注册:B站无浏览器投稿 + 抖音/快手/小红书 CDP(D10/D11);
|
||||
// fakeAll=true 时全部走假执行器(无真实账号的安装联调)。
|
||||
registry := exec.NewRegistry()
|
||||
if !cfg.FakeAll {
|
||||
browserRoot := filepath.Join(*dir, "browser") // 每账号独立档案
|
||||
registry.Register(bilibili.New(v))
|
||||
registry.Register(douyin.New(v, browserRoot, cfg.Proxy, "", false))
|
||||
registry.Register(kuaishou.New(v, browserRoot, cfg.Proxy, "", false))
|
||||
registry.Register(xiaohongshu.New(v, browserRoot, cfg.Proxy, "", false))
|
||||
}
|
||||
registry.SetFallback(fake.Executor{SimMS: cfg.SimMS})
|
||||
|
||||
// WSS 客户端(重连/幂等/心跳)
|
||||
client := wsclient.New(cfg.Server, id, registry)
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// 连接启动(配对后;未配对时等壳调 /pair)
|
||||
var runOnce sync.Once
|
||||
startConn := func() {
|
||||
if id.DeviceID == 0 {
|
||||
return
|
||||
}
|
||||
runOnce.Do(func() {
|
||||
go func() { _ = client.Run(ctx) }()
|
||||
})
|
||||
}
|
||||
|
||||
// 配对回调(本机控制服务 /pair 触发)
|
||||
pairFn := func(code, name string) error {
|
||||
if id.DeviceID > 0 {
|
||||
return errors.New("设备已配对")
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
name = cfg.Name
|
||||
}
|
||||
if name == "" {
|
||||
name, _ = os.Hostname()
|
||||
}
|
||||
if name == "" {
|
||||
name = "macOS Agent"
|
||||
}
|
||||
did, err := pairing.PairWithCode(cfg.Server, code, name, "core", "0.2.0", id.PublicKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id.DeviceID = did
|
||||
if name != "" {
|
||||
id.Name = name
|
||||
cfg.Name = name
|
||||
}
|
||||
if err = id.Save(*dir); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("paired device %d (name %s)", id.DeviceID, id.Name)
|
||||
startConn()
|
||||
return nil
|
||||
}
|
||||
|
||||
// 开发便利:CLI 自动配对
|
||||
if id.DeviceID == 0 && *user != "" && *password != "" {
|
||||
did, err := pairing.AutoPair(cfg.Server, *user, *password, cfg.Name, id.PublicKey)
|
||||
if err != nil {
|
||||
log.Fatalf("auto pair: %v", err)
|
||||
}
|
||||
id.DeviceID = did
|
||||
if err = id.Save(*dir); err != nil {
|
||||
log.Fatalf("save identity: %v", err)
|
||||
}
|
||||
log.Printf("auto paired device %d", id.DeviceID)
|
||||
}
|
||||
|
||||
startConn()
|
||||
|
||||
// 本机控制服务(WinUI 壳入口)
|
||||
localToken := *token
|
||||
if localToken == "" {
|
||||
localToken = localserver.RandToken()
|
||||
}
|
||||
ls := localserver.New(client, pairFn, localToken, *dir, func() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"server": cfg.Server,
|
||||
"deviceId": id.DeviceID,
|
||||
"name": id.Name,
|
||||
}
|
||||
})
|
||||
go func() {
|
||||
if err = ls.Listen(); err != nil {
|
||||
log.Fatalf("local server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if id.DeviceID == 0 {
|
||||
log.Printf("agent-core started UNPAIRED: server=%s(请在客户端输入网页端配对码)", cfg.Server)
|
||||
} else {
|
||||
log.Printf("agent-core started: server=%s device=%d", cfg.Server, id.DeviceID)
|
||||
}
|
||||
<-ctx.Done()
|
||||
log.Println("agent-core stopped")
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const p = require('path');
|
||||
const png = fs.readFileSync(p.join(__dirname, 'icons', 'app.png'));
|
||||
const hdr = Buffer.alloc(6);
|
||||
hdr.writeUInt16LE(1, 2);
|
||||
hdr.writeUInt16LE(1, 4);
|
||||
const ent = Buffer.alloc(16);
|
||||
ent.writeUInt16LE(1, 4);
|
||||
ent.writeUInt16LE(32, 6);
|
||||
ent.writeUInt32LE(png.length, 8);
|
||||
ent.writeUInt32LE(22, 12);
|
||||
fs.writeFileSync(p.join(__dirname, 'icons', 'app.ico'), Buffer.concat([hdr, ent, png]));
|
||||
console.log('app.ico bytes:', 22 + png.length);
|
||||
@@ -1,50 +0,0 @@
|
||||
// 生成 icons/app.png(512x512 纯蓝方块图标,兼容 macOS DMG)
|
||||
const zlib = require("zlib");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const W = 512, H = 512;
|
||||
const row = Buffer.alloc(1 + W * 4);
|
||||
row[0] = 0;
|
||||
for (let x = 0; x < W; x++) {
|
||||
row[1 + x * 4] = 0;
|
||||
row[2 + x * 4] = 110;
|
||||
row[3 + x * 4] = 255;
|
||||
row[4 + x * 4] = 255;
|
||||
}
|
||||
const raw = Buffer.concat(Array(H).fill(row));
|
||||
|
||||
const crcTable = [];
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
||||
crcTable[n] = c >>> 0;
|
||||
}
|
||||
function crc32(buf) {
|
||||
let c = 0xffffffff;
|
||||
for (const b of buf) c = crcTable[(c ^ b) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
function chunk(type, data) {
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(data.length);
|
||||
const t = Buffer.from(type, "ascii");
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(Buffer.concat([t, data])));
|
||||
return Buffer.concat([len, t, data, crc]);
|
||||
}
|
||||
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(W, 0);
|
||||
ihdr.writeUInt32BE(H, 4);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 6; // color type RGBA
|
||||
const png = Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
chunk("IHDR", ihdr),
|
||||
chunk("IDAT", zlib.deflateSync(raw)),
|
||||
chunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
fs.mkdirSync(path.join(__dirname, "icons"), { recursive: true });
|
||||
fs.writeFileSync(path.join(__dirname, "icons", "app.png"), png);
|
||||
console.log("icon generated:", png.length, "bytes");
|
||||
@@ -1,200 +0,0 @@
|
||||
const { app, BrowserWindow, Tray, Menu, Notification, ipcMain, shell, nativeImage } = require('electron');
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
|
||||
const isWin = process.platform === 'win32';
|
||||
const isSmoke = process.argv.includes('--smoke');
|
||||
const minimized = process.argv.includes('--minimized');
|
||||
|
||||
let win = null;
|
||||
let tray = null;
|
||||
let coreProc = null;
|
||||
let corePort = 0;
|
||||
let coreToken = '';
|
||||
let localJsonTimer = null;
|
||||
let quitting = false;
|
||||
|
||||
const dataDir = path.join(app.getPath('userData'), 'data');
|
||||
const localJsonPath = path.join(dataDir, 'local.json');
|
||||
const coreBin = isWin ? 'agent-core.exe' : 'agent-core';
|
||||
const coreExe = app.isPackaged
|
||||
? path.join(process.resourcesPath, coreBin)
|
||||
: path.join(__dirname, '..', 'dist', coreBin);
|
||||
|
||||
function startCore() {
|
||||
if (!fs.existsSync(coreExe)) {
|
||||
console.error('[core] binary not found: ' + coreExe);
|
||||
win?.webContents.send('core-error', '核心程序缺失:' + coreExe);
|
||||
return;
|
||||
}
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
try { coreProc?.kill(); } catch (e) { /* ignore */ }
|
||||
corePort = 0;
|
||||
coreToken = '';
|
||||
try { fs.rmSync(localJsonPath, { force: true }); } catch (e) { /* ignore */ }
|
||||
coreProc = spawn(coreExe, ['-dir', dataDir], { windowsHide: true });
|
||||
coreProc.on('error', (err) => {
|
||||
console.error('[core] process error: ' + err.message);
|
||||
win?.webContents.send('core-error', '核心程序启动失败:' + err.message);
|
||||
});
|
||||
coreProc.on('exit', (code) => {
|
||||
coreProc = null;
|
||||
win?.webContents.send('core-exit', code);
|
||||
});
|
||||
pollLocalJson();
|
||||
}
|
||||
|
||||
function pollLocalJson() {
|
||||
if (localJsonTimer) clearInterval(localJsonTimer);
|
||||
localJsonTimer = setInterval(() => {
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(localJsonPath, 'utf8'));
|
||||
if (info.port && info.token) {
|
||||
corePort = info.port;
|
||||
coreToken = info.token;
|
||||
clearInterval(localJsonTimer);
|
||||
win?.webContents.send('core-ready');
|
||||
}
|
||||
} catch (e) { /* 核心尚未就绪 */ }
|
||||
}, 800);
|
||||
}
|
||||
|
||||
function coreRequest(method, p, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!corePort || !coreToken) { reject(new Error('核心未就绪')); return; }
|
||||
const data = body ? JSON.stringify(body) : null;
|
||||
const req = http.request({
|
||||
host: '127.0.0.1',
|
||||
port: corePort,
|
||||
path: p,
|
||||
method: method,
|
||||
headers: {
|
||||
'X-Agent-Token': coreToken,
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': data ? Buffer.byteLength(data) : 0,
|
||||
},
|
||||
}, (res) => {
|
||||
let raw = '';
|
||||
res.on('data', (c) => { raw += c; });
|
||||
res.on('end', () => {
|
||||
try { resolve(JSON.parse(raw)); } catch (e) { resolve({ raw }); }
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(8000, () => { req.destroy(new Error('核心请求超时')); });
|
||||
if (data) req.write(data);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ---- IPC(渲染进程 → 核心/系统) ----
|
||||
ipcMain.handle('core:status', () => coreRequest('GET', '/status'));
|
||||
ipcMain.handle('core:pair', (e, code, name) => coreRequest('POST', '/pair', { code, name }));
|
||||
ipcMain.handle('core:challenges', () => coreRequest('GET', '/challenges'));
|
||||
ipcMain.handle('core:solve', (e, id, value) => coreRequest('POST', '/challenges/' + encodeURIComponent(id), { value }));
|
||||
ipcMain.handle('core:get-config', () => readConfig());
|
||||
ipcMain.handle('core:save-config', (e, cfg) => { writeConfig(cfg); startCore(); return true; });
|
||||
ipcMain.handle('core:restart', () => { startCore(); return true; });
|
||||
ipcMain.handle('app:open-data-dir', () => shell.openPath(dataDir));
|
||||
ipcMain.handle('app:open-web', (e, url) => shell.openExternal(url));
|
||||
ipcMain.handle('app:notify', (e, title, body) => {
|
||||
if (Notification.isSupported()) {
|
||||
new Notification({ title: String(title || 'EveryPublish'), body: String(body || '') }).show();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
ipcMain.handle('app:set-autostart', (e, on) => {
|
||||
app.setLoginItemSettings({ openAtLogin: !!on, openAsHidden: true, args: ['--minimized'] });
|
||||
return app.getLoginItemSettings().openAtLogin;
|
||||
});
|
||||
ipcMain.handle('app:get-autostart', () => app.getLoginItemSettings().openAtLogin);
|
||||
ipcMain.handle('app:quit', () => { quitting = true; app.quit(); });
|
||||
|
||||
function readConfig() {
|
||||
const defaults = {
|
||||
server: 'http://127.0.0.1:8090',
|
||||
webUrl: 'http://127.0.0.1:3003',
|
||||
fakeAll: false,
|
||||
};
|
||||
try { return { ...defaults, ...JSON.parse(fs.readFileSync(path.join(dataDir, 'config.json'), 'utf8')) }; }
|
||||
catch (e) { return defaults; }
|
||||
}
|
||||
function writeConfig(cfg) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dataDir, 'config.json'), JSON.stringify({ ...readConfig(), ...cfg }, null, 2), { mode: 0o600 });
|
||||
}
|
||||
|
||||
function createTray() {
|
||||
let img = nativeImage.createFromPath(path.join(__dirname, 'icons', 'app.png'));
|
||||
if (img.isEmpty()) img = nativeImage.createEmpty();
|
||||
tray = new Tray(img);
|
||||
tray.setToolTip('EveryPublish 客户端');
|
||||
tray.setContextMenu(Menu.buildFromTemplate([
|
||||
{ label: '打开主窗口', click: showWin },
|
||||
{ type: 'separator' },
|
||||
{ label: '退出', click: () => { quitting = true; app.quit(); } },
|
||||
]));
|
||||
tray.on('click', showWin);
|
||||
}
|
||||
|
||||
function showWin() {
|
||||
if (win) { win.show(); win.focus(); }
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
win = new BrowserWindow({
|
||||
width: 980, height: 660,
|
||||
minWidth: 780, minHeight: 540,
|
||||
title: 'EveryPublish 客户端',
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
win.loadFile(path.join(__dirname, 'renderer', 'index.html'));
|
||||
win.on('close', (e) => {
|
||||
if (!quitting) { e.preventDefault(); win.hide(); }
|
||||
});
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
app.setAppUserModelId('com.everypublish.client');
|
||||
if (isSmoke) {
|
||||
// 冒烟模式:启动 core → 等就绪 → 查状态 → 退出(无窗口)
|
||||
runSmoke();
|
||||
return;
|
||||
}
|
||||
createTray();
|
||||
createWindow();
|
||||
if (minimized) win?.hide();
|
||||
startCore();
|
||||
});
|
||||
|
||||
async function runSmoke() {
|
||||
startCore();
|
||||
const deadline = Date.now() + 30000;
|
||||
while (!coreToken && Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
if (!coreToken) { console.log('SMOKE_RESULT ' + JSON.stringify({ error: 'core 未就绪' })); app.exit(1); return; }
|
||||
try {
|
||||
const status = await coreRequest('GET', '/status');
|
||||
console.log('SMOKE_RESULT ' + JSON.stringify(status));
|
||||
app.exit(0);
|
||||
} catch (e) {
|
||||
console.log('SMOKE_RESULT ' + JSON.stringify({ error: String(e) }));
|
||||
app.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
app.on('before-quit', () => {
|
||||
quitting = true;
|
||||
if (localJsonTimer) clearInterval(localJsonTimer);
|
||||
try { coreProc?.kill(); } catch (e) { /* ignore */ }
|
||||
});
|
||||
app.on('window-all-closed', () => { /* 驻留托盘不退出 */ });
|
||||
app.on('activate', showWin);
|
||||
Generated
-5285
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user