Compare commits

..
327 Commits
Author SHA1 Message Date
Qiufeng 80607c0411 docs: 全量重写 README 为 EveryPublish 项目说明 2026-08-21 15:07:09 +08:00
Qiufeng fd944b08bd feat: EveryPublish Web 管理端重构与全平台发布接入
- 新增 React + TDesign 管理端(sau_web_react),替代历史 Vue 前端
- 后端接入 11 平台登录/发布(视频+图文),check_cookie 扩展全平台
- 异步发布任务 + 实时进度监控 + 发布日志
- Open API 开放体系(API Key 鉴权 + 7 个开放接口 + API 文档页)
- 账号单/批量校验与批量删除
- 全局配置(登录方式 + 发布静默模式)
- 统一浏览器为系统 Chrome,品牌文案统一为 EveryPublish
2026-08-21 14:41:47 +08:00
home-dev-pookz 1c66b7db4b docs: add MIT license and usage terms 2026-08-20 05:13:54 +08:00
home-dev-pookz 926be742cd fix(tencent): wire collection name into uploader 2026-08-20 04:21:03 +08:00
home-dev-pookz 9dc3cdb6d0 docs: document Weibo and Hupu CLI 2026-08-19 18:39:30 +08:00
home-dev-pookz 9fc5ec487f Merge pull request #270: Hupu uploader 2026-08-19 18:38:34 +08:00
home-dev-pookz d690c22ea6 Merge pull request #269: Weibo uploader 2026-08-19 18:33:46 +08:00
home-dev-pookz 49365fd046 Merge pull request #261: Tencent real-name verification 2026-08-19 18:31:07 +08:00
home-dev-pookz d596e18243 chore: remove unrelated empty design placeholder 2026-08-19 18:24:37 +08:00
home-dev-pookz c643471a98 docs: document current CLI platforms 2026-08-19 18:23:21 +08:00
home-dev-pookz 5b836d5f50 Merge pull request #268: Alipay uploader 2026-08-19 18:15:27 +08:00
home-dev-pookz 43bdcbb2fd Merge pull request #267: Xiaohongshu publish fixes 2026-08-19 18:14:06 +08:00
home-dev-pookz abfc13503d Merge pull request #266: Kuaishou upload fixes 2026-08-19 18:14:02 +08:00
home-dev-pookz 9969770039 Merge pull request #265: Tencent revamped editor fixes 2026-08-19 18:13:51 +08:00
home-dev-pookz 5628f76684 Merge pull request #264: Douyin flow hardening 2026-08-19 18:13:13 +08:00
home-dev-pookz 78c88bc315 Merge pull request #263: Tencent upload retry and timeout fixes 2026-08-19 18:12:58 +08:00
AlleriaandGitHub 1d65ceef11 Merge pull request #271 from carolius123/pr/baijiahao-uploader
feat(baijiahao): rewrite uploader on BaseVideoUploader and add CLI
2026-08-19 18:04:42 +08:00
AlleriaandGitHub dccaeb01c4 Merge pull request #272 from Dessalines39394/fix/star-history-chart
docs: fix broken stargazer chart by migrating to star-history.dera.page
2026-08-19 18:03:31 +08:00
Dessalines39394 b8469e8b56 docs: migrate star history links to star-history.dera.page
The GitHub stargazer API used by star-history.com has become unreliable. This switches the chart/links in README.md to our alternative instance (star-history.dera.page) which requires no API token and works for both API and frontend on the same domain.
2026-08-14 11:42:47 +00:00
suihf f64f712ab1 feat(baijiahao): rewrite uploader on BaseVideoUploader and add CLI
- Rewrite uploader/baijiahao_uploader/main.py: unify on BaseVideoUploader,
  add QR-code cookie login (headless), robust publish-page flow with
  title/desc/tags, thumbnail and collection support
- sau_cli: add 'baijiahao login/check/upload-video' subcommands
- examples: get_baijiahao_cookie_headless.py for headless QR login
2026-08-13 10:04:50 +08:00
suihf 254d0beb20 feat(hupu): add Hupu (虎扑) video uploader
- New uploader/hupu_uploader module: QR-code cookie login, direct publish
  page flow with title/desc/tags, cover upload, zone/declaration selection
- sau_cli: add 'hupu login/check/upload-video' subcommands
- utils/log.py: register hupu logger
2026-08-13 10:01:23 +08:00
suihf 466f81d3be feat(weibo): add Sina Weibo video uploader
- New uploader/weibo_uploader module: passport QR-code cookie login,
  upload flow with title/desc/tags, cover and collection support
- sau_cli: add 'weibo login/check/upload-video' subcommands
- utils/log.py: register weibo logger
2026-08-13 09:58:57 +08:00
suihf 0625f9b13b feat(alipay): add Alipay life account video uploader
- New uploader/alipay_uploader module: QR-code cookie login, upload page
  flow, title/desc/tags fill, thumbnail, collection and publish
- sau_cli: add 'alipay login/check/upload-video' subcommands
- examples: get_alipay_cookie.py and test_alipay_upload.py
- utils/log.py: register alipay logger
- utils/login_qrcode.py: robust QR decode on non-ASCII Windows paths
  (numpy imdecode with cv2.imread fallback)
2026-08-13 09:55:22 +08:00
suihf 233cb990a6 fix(kuaishou): robust desc editor focus, cookie auth and Joyride cleanup
- _focus_desc_editor: locate the description editor with multiple fallback
  strategies instead of a single brittle selector; dump page screenshot/HTML
  on failure for easier selector updates
- cookie_auth: positive verification via the upload button presence, with
  login-page / intro-page / legacy checks as fallbacks
- close_guide_overlay: also remove the Joyride portal and spotlight layers
  that intercept clicks
- add collection_name support to KSVideo
2026-08-13 09:50:21 +08:00
suihf c1617444f9 fix(tencent): adapt to Channels revamped editor and improve publish flow
- Open homepage first, then click the visible 'publish video' entry
  button instead of loading /platform/post/create directly, which gets
  bounced back to the homepage and leaves the form iframe un-mounted
- Detect expired cookies up front by watching for the /login.html
  redirect and the qrconnect iframe
- Drop the redundant confirm_thumbnail_crop call that caused a 5s
  timeout on cover upload
- Select collection by exact name after upload completes (form resets
  selections made while the video is still uploading); keep it
  unselected when no matching name is found instead of picking a
  random one
- Scroll the target option into view and fall back to forced click /
  native dispatch for headless visibility issues
- Tighten short-title length to 7~15 chars to pass platform validation
- Dismiss the occasional 'switch account' dialog that blocks the
  description box and the publish button
2026-08-13 09:49:34 +08:00
suihf ad49ebae7a fix(douyin): harden cover, collection and self-declaration flows
- Set cover via native click with retries; skip gracefully instead of failing the whole order
- Select the collection via its dedicated Semi select with overlay cleanup
- Rewrite self-declaration dialog handling (entry/dialog/option/confirm) and reuse it in upload
- Type description before tags and fill via set_input_files with robust upload input detection
- Keep SMS verification code prompt as a publish-loop guard
2026-08-13 09:48:40 +08:00
suihf 3590c526b0 fix(xiaohongshu): declare repost source and harden cover upload
- check_original_declaration: replace the original checkbox with the
  '来源转载' (repost) declaration flow: open the declaration picker, select
  '来源转载', fill the media name, confirm. Falls back to JS click when the
  element text is wrapped in non-interactive spans
- set_thumbnail: switch to the '上传封面' tab and target the upload-wrapper
  file input instead of the cover-plugin-preview selector; skip gracefully on
  failure and let the video first frame be used
2026-08-13 09:45:44 +08:00
FromTX2SJ 9342a343ab fix(tencent): 上传卡住时要说话 —— iframe 等待 / 重试上限 / 超时
三个问题都是「失败了但看起来像正常」,实测于 2026-08-11。

1. open_upload_page 不等 iframe 加载完
   上传表单在 micro/content/post/create 这个 iframe 里,domcontentloaded
   时它还是空的。紧接着找 input[type=file] 找不到,报「未找到视频号文件
   上传框」——而失败截图上左栏渲染正常、主内容区一片空白,完全不像是
   加载没完成。加一个 networkidle 等待。

2. 找上传框的重试窗口只有 20 秒
   实测这个 iframe 挂上 input 要几十秒。提到 60 秒,并在最终失败时留
   截图 + url + frames:这个错误的可能原因太多(没登录 / 落到首页 /
   iframe 没加载完 / 平台改版),只看错误字符串没法区分。

3. wait_for_upload_complete 是没有出口的 while True  ← 最严重
   上传出错就删掉重传,失败再删再传,永远循环;中间每 2 秒打一行
   「正在上传视频中...」——这条日志和真的在传一模一样,从外面完全分不出。

   实测 172MB / 上行 ~0.5Mbps:每次传到 4 分钟左右报错然后重来,
   循环了近 2 小时也不会停,进程也不会退。用户看到的只有「正在上传
   视频中」,真相是同一段视频被反复上传了 20 多次。

   改成 1 小时超时 + 3 次重试上限,两个都带现场截图报明确的错。
   进度日志从每 2 秒一行降到 30 秒一行并带上已等时长(原来 50 分钟
   刷了 1600 行同样的话)。
2026-08-11 19:55:52 +08:00
tankztz ad2419ef16 fix(tencent): wait for real-name verification 2026-08-10 02:09:51 +08:00
tankztz 8ee7bcdb8a fix(tencent): poll delayed publish entry 2026-08-07 17:04:43 +08:00
tankztz 2dfae17775 fix(tencent): support current video publish page 2026-08-06 18:57:56 +08:00
AlleriaandGitHub 008e4ff66a Update GitHub Sponsors username in FUNDING.yml 2026-07-31 12:55:06 +08:00
AlleriaandGitHub dde0eacb91 Merge pull request #250 from mechanic-Q/feat/douyin-explicit-declaration
fix(douyin): make self-declaration explicit and fail-closed (follow-up to #213)
2026-07-23 08:59:35 +08:00
AlleriaandGitHub 42bd4490ee Merge pull request #253 from snow-moonlight1/fix/tencent-qrcode-extraction
fix(tencent): 适配视频号新版 qrconnect 登录二维码 + cookie_auth 误报修复 + 中文路径二维码解码
2026-07-23 08:58:36 +08:00
snow-moonlight1 662633bb3d fix(tencent): cookie_auth 误报有效 + 上传页登录跳转前置检测
视频号 cookie 失效时, post/create 页面先正常加载, 数秒后才被前端 JS
跳转到 login.html。原 cookie_auth 在跳转前检查, 误报 'cookie 有效',
放行后在上传页报出无关错误(如 '未找到视频号文件上传框')。

- cookie_auth: 等待 8 秒确认是否被踢到 login.html, 外加 qrconnect
  登录 iframe 双保险, 消除误报
- open_upload_page: 同样的跳转检测前置, cookie 失效时报明确错误
  '请重新扫码登录后再发布'
2026-07-23 00:32:57 +08:00
snow-moonlight1 bb1f12977a fix(utils): decode QR image via np.fromfile for non-ASCII paths
cv2.imread cannot open files under non-ASCII (e.g. Chinese) paths on
Windows, so decode_qrcode_from_path() always returned None when the
project lived under a directory like D:\视频全平台同步\, and the
terminal QR re-print silently failed. Use np.fromfile + cv2.imdecode
instead, which handles arbitrary paths.
2026-07-21 14:03:11 +08:00
snow-moonlight1 f4693db9d7 fix(tencent): adapt QR code extraction to new qrconnect login iframe
The WeChat Channels login page now embeds the QR code in an
open.weixin.qq.com/connect/qrconnect iframe whose img.qrcode src is a
relative path (e.g. /connect/qrcode/xxxx) instead of an inline
data:image/ URL. The previous logic looked for a login-for-iframe
frame and only accepted data:image/ sources, so login always failed
with '未获取到视频号登录二维码地址'.

Changes:
- Detect the qrconnect iframe and resolve the relative img src,
  download the QR image via the browser context and convert it to a
  data URL so downstream save/decode logic stays unchanged
- Keep the legacy login-for-iframe and selector fallbacks
- Tolerate QR extraction failure: the login flow now continues and
  lets the user scan directly in the headed browser instead of
  aborting the whole login
2026-07-21 13:42:10 +08:00
lmr f83225c769 fix: move declaration to after runtime flags for positional compat
DouyinVideoUploadRequest dataclass 声明字段位置
被独立审查者(deleg_61d33c80)标记为P1兼容问题:
旧位置调用 ('scheduled', False, False) 会被错位解析。

把 declaration 移到最后,保留旧位置契约。

Previous-Review: deleg_61d33c80
Ref: dreammis/social-auto-upload#213(安全后续修正)
2026-07-18 14:19:42 +08:00
lmr e89d3a875a fix(douyin): require explicit self-declaration 2026-07-18 02:07:55 +08:00
AlleriaandGitHub d32280c98b Merge pull request #243 from sunshine6666666666/feat/bilibili-cover-cli
feat(bilibili): support custom video covers
2026-07-15 13:23:46 +08:00
AlleriaandGitHub 95083e04ac Merge pull request #244 from kelriclink/docs/fix-readme-toc
docs: fix README跳转错误
2026-07-15 13:23:17 +08:00
AlleriaandGitHub 723f703bf7 Merge pull request #245 from anzhen9/main
处理抖音发布视频短信验证码弹窗
2026-07-15 13:20:01 +08:00
anzhen9 395c6434bb feat(douyin): 支持终端交互输入短信验证码 2026-07-11 17:18:13 +08:00
anzhen9 be77aaef8b feat(douyin): 处理短信验证码弹窗 2026-07-11 16:52:42 +08:00
anzhen9 fa59d50030 Revert "feat(douyin): 添加短信验证码弹窗处理并移除多余浏览器参数"
This reverts commit 77b372a56e.
2026-07-11 16:47:04 +08:00
anzhen9 77b372a56e feat(douyin): 添加短信验证码弹窗处理并移除多余浏览器参数 2026-07-11 16:43:37 +08:00
kelriclink 4435d8d174 docs: fix README table of contents links 2026-07-11 15:50:57 +08:00
sunshine6666666666 945bf8e1c0 feat(bilibili): support custom video covers 2026-07-11 10:47:39 +08:00
AlleriaandGitHub 0d3f93e8ac Merge pull request #229 from DerekEXS/fix/douyin-cli-login-overhaul
fix(douyin): overhaul CLI auto-login to handle new creator center UI
2026-06-27 01:16:25 +08:00
AlleriaandGitHub 90b751f791 Merge branch 'main' into fix/douyin-cli-login-overhaul 2026-06-27 01:15:42 +08:00
AlleriaandGitHub d49bdd5daf Merge pull request #234 from Chang-Tao/fix/douyin-cookie-auth
fix(douyin): cookie_auth 容错瞬时跳转,避免间歇性误判失效
2026-06-27 01:11:12 +08:00
OpenClaw Contributor ae72b87141 fix(douyin): overhaul CLI auto-login + supporting fixes (rebased on main)
4 commits squashed into 1 due to force-push via API. See PR #229 body for full description.

Key changes:
- cookie_auth: polling loop (10s) instead of single count() snapshot (race fix)
- cookie_auth: env var DOUYIN_COOKIE_AUTH_HEADLESS for linux server headless override
- _extract_douyin_qrcode_src: 4-tier selector fallback (animate_qrcode_container)
- _wait_for_douyin_login: 2FA detection with warning
- export_douyin_cookie: USERNAME from os.environ
- ks/xiaohongshu uploader: chromium default + cdp_url support for ks
2026-06-25 18:44:55 +08:00
changyandaoc 51209a1331 fix(douyin): cookie_auth 间歇性误判失效——重试3次+宽松判定
有头校验仍会因页面慢/瞬时跳转让 wait_for_url(精确URL,5s) 超时误判,
导致 check 通过但 upload 前置校验失败。改为重试3次 + 等2.5s稳定 +
宽松判定(URL含 content/upload 且无登录文案)。
2026-06-22 18:44:00 +08:00
AlleriaandGitHub 70a58b739f Merge pull request #233 from tankztz/tankztz/xhs-configurable-creator-domain
fix: allow configuring Xiaohongshu creator domain
2026-06-21 21:20:37 +08:00
AlleriaandGitHub b0d873c850 Merge pull request #231 from nirolee/fix/youtube-shorts-autocomplete
fix(youtube): prevent #Shorts hashtag autocomplete from blocking upload
2026-06-21 21:18:49 +08:00
tankztz ea2bcb0d8a fix: allow configuring Xiaohongshu creator domain 2026-06-20 09:45:31 +08:00
nirolee 5b26106fb1 fix(youtube): prevent #Shorts hashtag autocomplete from blocking upload
Title and description were typed character-by-character, so a '#'
(e.g. '#Shorts') triggered YouTube's topic-autocomplete dropdown that
follows the caret and covers the Next/Publish buttons, stalling the
upload. Switch to fill() (one-shot insert, no per-keystroke autocomplete)
with a type() fallback, and add _dismiss_autocomplete (blur, then Escape
only when a dropdown is actually visible so the upload dialog is never
closed by mistake).
2026-06-20 03:34:55 +09:00
home-dev-pookz 1cb7d8a797 add new sponsor 2026-06-20 00:54:00 +08:00
AlleriaandGitHub 78c414d862 Merge pull request #228 from Billy775326/main
feat(douyin): 优化图文发布正文和话题输入方式,添加 --bgm 参数支持选择音乐
2026-06-19 13:53:58 +08:00
AlleriaandGitHub c9d6d5b39c Merge branch 'main' into main 2026-06-19 13:51:25 +08:00
AlleriaandGitHub 23b0cb2468 Merge pull request #227 from nirolee/feat/youtube-uploader
feat(youtube): add YouTube uploader via browser automation
2026-06-19 13:44:45 +08:00
AlleriaandGitHub 99b1db490d Merge pull request #225 from Chang-Tao/fix/uploader-dom-202606
Fix/uploader dom 202606
2026-06-19 13:43:18 +08:00
DerekEXS 42d39f2693 chore(xiaohongshu): switch default channel to chromium
Same reason as the ks uploader: bundled patchright cannot reliably resolve the system 'chrome' channel on every platform, so use the bundled 'chromium' channel by default and rely on conf.py:LOCAL_CHROME_PATH for users who want real Chrome.
2026-06-16 20:12:58 +08:00
DerekEXS dff5a3d7ea feat(ks): switch default channel to chromium and add cdp-url support
Two related changes for kuaishou uploader:

- Default browser channel is switched from 'chrome' to 'chromium'. The launcher binary that ships with patchright no longer reliably resolves the system Chrome channel on every platform, so falling back to bundled chromium avoids launch-time channel-resolution errors. The user can still force a real Chrome via conf.py:LOCAL_CHROME_PATH.

- New cdp_url parameter on ks_setup / get_ks_cookie. When set, the login flow uses connect_over_cdp() to drive the user's already-running real Chrome instead of spawning a separate browser. This is the supported way around strict anti-bot detection: real Chrome has a fingerprint that passes verification, while the bundled chromium does not. should_close_context tracks ownership so we don't close a context we didn't create.
2026-06-16 20:12:48 +08:00
DerekEXS cc963694a3 fix(export_douyin_cookie): read USERNAME from os.environ in embedded python
The shell passed USERNAME as an environment variable to the embedded python heredoc (USERNAME=$USERNAME python3 ...), but the python block referenced USERNAME as if it were already a local variable. The result was NameError on every run, so the export script could never produce a cookies/<platform>_<account>.json file.

Fix: read the env var explicitly with os.environ.get('USERNAME', '') at the top of the embedded block, then use the local USERNAME as before.
2026-06-16 20:12:39 +08:00
DerekEXS 3ed6336376 fix(douyin): overhaul CLI auto-login to handle new creator center UI
The Douyin creator center UI (https://creator.douyin.com/creator-micro/) has been refactored and breaks the existing login flow in three independent ways. This commit fixes all of them.

1. QR code selector no longer matches
   The previous selector chain assumed the QR sat in a sibling div with an aria-label of 二维码. After the refactor, the QR is rendered as a base64 PNG inside div#animate_qrcode_container and has no aria-label. The new chain is a 4-tier fallback (id -> class partial -> container partial -> aria-label) preceded by a networkidle wait so the SPA is fully hydrated before we look.

2. cookie_auth() race condition
   page.goto + wait_for_url(5000) + immediate get_by_text count() was racy: the SPA had not finished hydrating, so count() could return 0 even when the page actually shows the login card, leading to false-positive 'cookie valid' results. Replaced with a 10s polling loop that returns invalid if neither the login-card text nor the upload-page text is observed.

3. _wait_for_douyin_login() only waited for sessionid cookie (120s)
   During 2FA the cookie is not yet set; the page is still on the verification screen and the URL has already changed. The new loop detects 2FA input fields, logs a warning so the user knows to type the SMS code, and keeps polling. Timeout raised to 200s to accommodate the slower human-in-the-loop flow.

Verified the selector chain against the live creator.douyin.com page: returns a 2.9 KB base64 PNG that decodes into a valid douyin scan_login URL.
2026-06-16 19:35:53 +08:00
billy d826a19bb2 feat(douyin): 添加 --bgm 参数支持选择音乐 2026-06-16 15:51:10 +08:00
billy 9c3d8de82d feat(douyin):新增图文标题和正文字符数校验 2026-06-15 15:32:54 +08:00
billy 2c2aea32fe feat(douyin):新增--notef参数,支持从txt/md文件读取正文预编辑内容 2026-06-15 15:31:03 +08:00
billy 621b94fc05 docs(douyin):图文正文终端保留预编辑格式提交示例 2026-06-15 15:10:24 +08:00
billy 73d3b11ca8 docs(douyin):添加tags参数示例 2026-06-15 15:04:03 +08:00
billy 9677fa3d70 fix(douyin)优化图文发布正文和话题输入方式 ,tags直接拼接到description末尾一起赋值,避免hashtag解析混乱 2026-06-15 15:01:36 +08:00
nirolee aa5112e985 fix(youtube): wait for upload to finish before publishing; add optional proxy
- Wait until the upload progress reaches 100% before clicking publish. The
  browser-based upload only progresses while the page is open, so publishing
  (and closing the browser) mid-upload cut it off and left the video stuck
  partway (e.g. 76%).
- Add optional YT_PROXY (conf.py): where youtube.com is blocked, direct
  connections time out and the chromium does not use the system proxy, so
  allow pointing it at a local proxy explicitly.
2026-06-14 15:18:05 +09:00
nirolee 991bcdfdf3 feat(youtube): add YouTube uploader via browser automation
Add a YouTube uploader that follows the existing cookie-based browser
automation pattern (uploader/youtube_uploader + sau youtube CLI:
login/check/upload-video). Supports title/description/tags, thumbnail,
adding to a playlist (for series), and visibility (public/unlisted/private).

Browser automation is used instead of the official Data API on purpose:
videos uploaded by an unaudited API project are force-locked to private
and cannot be made public without Google's compliance audit, which is
impractical for personal/single-channel use. Browser automation has no
such restriction and matches how every other platform here works.
2026-06-14 01:05:35 +09:00
changyandao 914a5f0910 fix(douyin): cookie 校验改有头,避免无头被反爬墙误判失效
cookie_auth 原硬编码 headless=True,但抖音无头会撞反爬墙→content/upload 跳登录→
间歇性误报 cookie 失效(实测登录刚成功 90s 后校验即失败)。改 headless=False +
反检测 args。
2026-06-13 18:23:35 +08:00
changyandao a083ad911f fix(douyin login): 二维码提取失败不致命 + 登录成功判定放宽
- _save_douyin_qrcode 定位不到二维码元素时不再抛错中止(有头浏览器可直接扫码),仅警告并继续轮询等登录跳转
- _is_douyin_login_completed 放宽到 creator-micro 任意页(不止 /home)
- Path(image_path) 对空路径做守卫
2026-06-13 17:34:39 +08:00
changyandao b9ca544892 fix(douyin): goto 改 domcontentloaded 避免抖音 SPA load 事件超时
创作者中心发布页 load 事件常迟迟不触发,默认 goto(wait_until=load) 偶发 30s 超时。
3 处 content/upload 的 goto 改 wait_until=domcontentloaded + timeout=90000;
对应 wait_for_url 保持仅 timeout(不接受 wait_until)
2026-06-13 16:23:23 +08:00
changyandao e192fa0cb1 fix(tencent): 视频号定时时间选择器收尾容错
set_schedule_time 键入小时后改用 Enter 确认 + 收起浮层做容错(直接点
input-editor 可能被 weui-desktop-dialog 时间选择浮层遮挡),避免定时步偶发卡死
2026-06-13 15:33:01 +08:00
changyandao 5e28092f20 fix(douyin): version_2 发布页适配——标题超时/封面输入/话题上限
- 标题/描述等待 10s→120s:version_2(post/video) 要等视频上传完才渲染表单(实测约40s)
- 封面上传修正:封面弹窗有4个隐藏input,前2个是左侧AI参考图,真封面是 nth(1);旧 .first 传错位置导致封面不生效
- set_thumbnail 先清 shepherd 引导浮层,避免拦截选择封面点击
- 完成按钮用 exact 文本,避免误中完成编辑
2026-06-13 15:16:29 +08:00
changyandaoc f5cb31079c fix(uploader): 适配抖音与视频号发布页最新 DOM 结构
抖音 douyin_uploader:
- 填标题/描述:改用 input[placeholder*=填写作品标题] 与 div.zone-container[contenteditable] 定位
- 话题输入后按 Escape 收起下拉,避免浮层拦截
- '选择封面'与'发布'改用 force 点击,并移除拦截点击的新手引导(shepherd)浮层
- 封面弹窗适配:div.dy-creator-content-modal + input.semi-upload-hidden-input;横竖版切换改用'设置竖封面'按钮;完成后等弹窗关闭

视频号 tencent_uploader:
- upload_video_file 遍历所有 frame 查找文件框 + 重试等待

说明:两平台发布均需有头模式(--headed),无头下视频号上传框不渲染。
2026-06-13 12:01:24 +08:00
home-dev-pookz 90e01c0106 1 2026-06-08 15:04:24 +08:00
home-dev-pookz b7dbbd2dff 去掉社区其他入口 2026-06-08 15:01:55 +08:00
AlleriaandGitHub acbeb7cc4f Merge pull request #218 from Billy775326/main
feat(douyin): 抖音手动获取cookie
2026-06-05 08:53:47 +08:00
AlleriaandGitHub a74e8839a1 Merge pull request #219 from mrzhangguoguo/fix/xhs-tags-limit-and-original-declaration
fix(xiaohongshu): limit tags to 10 and add original declaration
2026-06-05 08:51:46 +08:00
zzxthy 8d030c7c61 fix(xiaohongshu): limit tags to 10 and add original declaration
- Add tag count validation (max 10) in both CLI and uploader layers
  - CLI: exit with error if tags > 10, prompt user to reduce
  - Uploader: truncate to first 10 with warning as fallback
  - Prevents infinite loop when XHS UI rejects excess tags

- Add check_original_declaration() to auto-check original content
  declaration before publishing (video and note modes)
  - Gracefully skips if the option is not present on the page
2026-06-04 16:33:24 +08:00
billy ff951285a3 feat(douyin): 抖音手动获取cookie 2026-06-03 10:45:21 +08:00
AlleriaandGitHub 5716d486cc Merge pull request #211 from muy97212-spec/fix/tencent-original-statement-optional
fix(tencent): 视频号「声明原创」缺失时跳过而非中止发布
2026-06-03 09:45:51 +08:00
AlleriaandGitHub 47f32ffff7 Merge pull request #212 from sebastiondev/fix/cwe22-sau-backend-upload-e0f9
fix(upload): prevent path traversal in file upload endpoints
2026-06-03 09:44:51 +08:00
AlleriaandGitHub 4f02d22ae2 Merge pull request #213 from muy97212-spec/feat/douyin-self-declaration
feat(douyin): 发布时自动选择「自主声明」;fix(xiaohongshu): 话题候选超时不再中断发布
2026-06-03 09:43:32 +08:00
AlleriaandGitHub f5c2e13df8 Merge pull request #217 from gorf/fix/xhs-upload-debug-log-syntax
fix: avoid f-string backslash syntax error in xiaohongshu uploader
2026-06-03 09:42:52 +08:00
AlleriaandGitHub ae8beca839 Merge pull request #216 from yoqu/codex/dual-cover-aspects
feat(cli): 支持双比例封面参数
2026-06-03 09:42:17 +08:00
gorfandCursor 13f7bd7a2a fix: avoid f-string backslash syntax error in xiaohongshu uploader
Move newline normalization out of the f-string expression so the CLI can import and run under Python 3.11+ without syntax errors.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 16:32:39 +08:00
yoqu aa526b6ca8 feat(cli): 支持双比例封面参数 2026-06-02 16:30:27 +08:00
智行oloandClaude Opus 4.8 7ae6943627 feat(douyin): 「自主声明」默认勾选「内容为个人观点或见解」
抖音发布页「自主声明」为必选项,此前自动化未填,是发布流程的缺口。新增
DouYinBaseUploader.set_self_declaration:点开占位「请选择自主声明」→ 弹窗
「对作品内容添加声明」中单选「内容为个人观点或见解」(exact 避免误点预览
文案) → 确定;在 DouYinVideo 贴完封面后调用。入口/弹窗均异步渲染,等不到
则 warning 跳过、继续发布,与小红书话题、视频号声明原创的容错策略一致。
当前仅覆盖视频(DouYinVideo),图文 DouYinNote 未涉及。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 20:14:58 -07:00
智行oloandClaude Opus 4.8 1b36b8bd9b fix(xiaohongshu): 话题候选等不到时跳过该标签继续发布
话题依赖小红书联想接口实时返回,网络抖动或无匹配时等不到候选框,会让整条
发布失败。改为 try/except:放宽超时(3s/2s→6s/4s),仍等不到就记 warning、
退格清掉已键入的 #tag 残留文本后 continue。标签为可选增强项,不应阻断主流程。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 19:01:47 -07:00
智行oloandClaude Opus 4.8 3ef4cd322a fix(tencent): 视频号「声明原创」缺失时跳过而非中止发布
视频号原创声明是可选项,但 apply_original_statement 在未找到声明入口时
会 raise RuntimeError 中止整个发布。在改版后的发布页(部分账号用 wujie
微前端,DOM 结构不同)经常匹配不到入口,导致本可正常发布的视频全部失败。

改为:未找到声明入口时记录 warning 并继续发布,不再中止。同时给诊断分支的
body 定位加 .first,避免页面存在多个 <body> 时 Playwright strict mode 崩溃。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 02:11:38 -07:00
AlleriaandGitHub 99a392af2c Merge pull request #205 from sunreview/main
修复了快手定时发布时间错误的问题
2026-05-30 16:20:30 +08:00
AlleriaandGitHub 91d8481ffd Merge pull request #209 from tankyhsu/fix/tencent-cli-original-declaration
fix: add Tencent CLI and improve original declaration handling
2026-05-30 16:19:50 +08:00
Sebastion d8c95b0992 fix: sanitize filenames in upload endpoints to prevent path traversal (CWE-22) 2026-05-30 08:05:12 +01:00
tianqi 1cba88de15 fix: add tencent cli and original declaration handling 2026-05-28 17:14:03 +08:00
home-dev-pookz 721476f7b4 chilltion sponsor 2026-05-17 17:15:41 +08:00
tomridder 24807633f7 修复了快手定时发布时间错误的问题 2026-05-12 09:30:58 +08:00
AlleriaandGitHub 78b77f6177 Merge pull request #202 from zzzzzy2k/bugfix/zzyBugFix
fix: 修复小红书上传只有一个标签的问题
2026-05-07 13:21:53 +08:00
zzzzzy2kandGitHub 8c30f4c6a6 fix: 修复小红书上传只有一个标签的问题 2026-05-03 14:03:10 +08:00
home-dev-pookz 34a3b3b47e ok 2026-04-24 17:03:19 +08:00
home-dev-pookz 31e27137da 1 2026-04-24 16:45:48 +08:00
home-dev-pookz 13984e60e6 change context 2026-04-24 16:39:16 +08:00
home-dev-pookz 94fedc5be4 update 2026-04-22 21:16:51 +08:00
home-dev-pookz 2eb6633bd2 腾讯重构一版
但是还没弄完...
2026-04-22 19:58:38 +08:00
home-dev-pookz 6fa68d4974 腾讯重构一版
但是还没弄完...
2026-04-22 19:55:45 +08:00
home-dev-pookz ff0285c7a6 腾讯重构一版 2026-04-22 19:04:20 +08:00
AlleriaandGitHub cccfea0232 Merge pull request #193 from ikiaichat/bugfix/xhs
fix: 修复小红书上传视频卡等待的问题,把原来的精准定点找词改成全局模糊识、多信号状态检测
2026-04-13 18:57:56 +08:00
fengxian.guo fc531e9783 fix: 修复小红书上传视频卡等待的问题,把原来的精准定点找词改成全局模糊识、多信号状态检测 2026-04-08 12:00:04 +08:00
AlleriaandGitHub 67da11bddc Merge pull request #186 from joyholmes/fix/bilibili-reuse-cached-biliup
fix: 优先复用本地缓存的 biliup,避免每次运行都强制检查 GitHub Release
2026-03-26 20:24:00 +08:00
houjingyangandjoyholmes 05adf763ba fix(bilibili): 优先复用本地缓存的 biliup,避免每次运行都强制检查 GitHub Release 2026-03-26 19:19:09 +08:00
home-dev-pookz a9049a5e47 update readme 2026-03-25 17:32:26 +08:00
home-dev-pookz 90d03dc15a 小红书重构
1. 图文上传
2. 视频上传(封面,定时)
3. cli
4. skill
5. 无头模式
2026-03-25 15:19:40 +08:00
home-dev-pookz ce9a9f102e docs: add browser cli unification plan 2026-03-25 14:31:44 +08:00
home-dev-pookz 4c2de2c870 docs: update browser cli parameter naming 2026-03-25 14:28:25 +08:00
home-dev-pookz 64d0236f5f docs: add browser cli unification design 2026-03-25 14:23:03 +08:00
home-dev-pookz 2277a5fdf9 临时留档 2026-03-25 13:55:23 +08:00
home-dev-pookz b1f9b1af61 docs: clarify account name semantics 2026-03-25 04:38:37 +08:00
home-dev-pookz 02afa4dbee docs: clarify legacy uploader examples 2026-03-25 04:33:07 +08:00
home-dev-pookz 0778e29a04 feat: finalize bilibili cli workflow 2026-03-25 04:29:35 +08:00
home-dev-pookz 03953df4ec test: isolate biliup runtime cache assertions 2026-03-25 03:49:48 +08:00
home-dev-pookz 2b6a16e76a docs: add bilibili cli guidance and attribution 2026-03-25 03:48:38 +08:00
home-dev-pookz 6d3fc819b3 feat: add bilibili upload skill 2026-03-25 03:46:17 +08:00
home-dev-pookz 1bc8ae7698 feat: add bilibili cli commands 2026-03-25 03:44:56 +08:00
home-dev-pookz 7c3e002429 feat: add biliup runtime bootstrap 2026-03-25 03:41:36 +08:00
home-dev-pookz 1fc29b27b7 docs: translate bilibili cli spec to chinese 2026-03-25 03:30:15 +08:00
home-dev-pookz 19925561cf docs: add bilibili cli design spec 2026-03-25 03:28:46 +08:00
home-dev-pookz 23230db9a9 fix 2026-03-25 03:00:34 +08:00
home-dev-pookz f01ac59dea 1. 修复快手默认有头模式 2026-03-25 02:53:01 +08:00
home-dev-pookz b415613b38 1. 重构快手
- 图文增加
- 封面增加
2. 快手skill,cli支持
2026-03-25 02:25:02 +08:00
home-dev-pookz 2934328888 linuxdo 2026-03-24 23:30:17 +08:00
home-dev-pookz 246de71ccf 1 2026-03-24 23:15:24 +08:00
home-dev-pookz 90845f50ad 重构douyin上传,增加douyin图文
修复douyin视频封面上传
2026-03-24 23:02:22 +08:00
home-dev-pookz fe6e9f4af9 重构douyin上传,增加douyin图文
修复douyin视频封面上传
2026-03-24 21:33:15 +08:00
home-dev-pookz b8b3107881 1. 更新最新可用的b站上传工具
https://github.com/biliup/biliup/releases/tag/v1.1.29
2026-03-05 14:55:34 +08:00
AlleriaandGitHub aa4ecfa6cc Merge pull request #172 from wjj9868/feat/system-optimization
feat: 深度优化自媒体发布系统及修复关键BUG
2026-03-05 14:53:05 +08:00
钟停为止 27bb20b206 fix: 解决本地 Chrome 配置(LOCAL_CHROME_PATH)未生效与平台扫码限流假死问题
## 背景与复现问题
  当前架构中,许多用户即使在 `conf.py` 中配置了 `LOCAL_CHROME_HEADLESS=False` 以及自己的
  `LOCAL_CHROME_PATH`,扫码登录时依然会调用自带的不隐身内核,这诱发了两个严重阻断:
  1. 环境变量逃逸:因为散落在 `login.py` 等文件中大多只传了 `headless` 参数,并未传递 `executable_path`,导致外部浏览器配置彻底失效。
  2. 反爬风控假死:系统直接抛出的 `webdriver=true` 会遭来抖音/小红书级别极其严厉的滑块甚至二维码阻断,最终卡死,后台报错 `监听页面跳转超时`。

  ## 本次修复
  1. **全局接管配置**:构建统一的浏览器环境变量拦截钩子 `utils/browser_hook.py`。
  2. **防风控注入**:加入 `--disable-blink-features=AutomationControlled` 隐藏驱动器指纹,免受大数据识别拦截,极大提升扫码登入与发布的通过率。
  3. **修复登录扫码挂载**:改写 `login.py` 对接,已验证能够丝滑拉起本地指纹浏览器。
2026-03-05 10:37:05 +08:00
钟停为止 23ee8d014f feat: 深度优化自媒体发布系统及修复关键BUG 2026-03-05 10:12:40 +08:00
home-dev-pookz ab2dbb0de6 1. 抖音我这边遇到必须上传封面的情况,自动设置第一个推荐封面(大概率首帧) 2025-12-09 15:01:04 +08:00
AlleriaandGitHub 35c015ea06 Merge pull request #156 from gzxy0102/fix-docker
fix: Docker环境修复
2025-11-21 09:49:00 +08:00
听风 9d1bfc6525 fix: 修复Docker中打包的前端请求地址为本地 2025-11-20 14:58:49 +08:00
AlleriaandGitHub 13fbeac4aa Merge pull request #145 from gzxy0102/main
feat: 增加Docker容器环境
2025-11-17 17:40:01 +08:00
AlleriaandGitHub 2f957b79ff Merge branch 'main' into main 2025-11-17 17:39:19 +08:00
AlleriaandGitHub fe242ecdfa Merge pull request #151 from Deroino/main
web端优化
2025-11-17 17:36:42 +08:00
听风andGitHub 5a6cd1bd06 Merge branch 'dreammis:main' into main 2025-11-16 09:54:52 +08:00
Deroino 88d2dc1ce6 docs: add project documentation in CLAUDE.md 2025-11-14 22:09:42 +08:00
Deroino 7ccaab9b75 feat(account-management): make abnormal status clickable to trigger re-login
- Added clickable status functionality for accounts with '异常' status
- Implemented handleReLogin method to trigger login flow when clicking abnormal status
- Added visual feedback with hover effects for clickable statuses
- Updated SSE flow to show different success messages for re-login vs new account
- Fixed account data handling to properly work with store objects
- Added dynamic default avatars for accounts
2025-11-13 11:47:03 +08:00
Deroino e60ce3f64a feat(account-management): optimize account loading with async validation
- Add new /getAccounts API for fast account retrieval without validation
- Update frontend to use fast API for initial load, showing accounts immediately
- Add '验证中' status with loading animation to indicate validation progress
- Implement background validation process that doesn't block UI
- Add Loading icon from Element Plus for visual feedback during validation
- Update status display logic to handle '验证中', '正常', and invalid states
- Improve user experience with immediate feedback and async validation
2025-11-13 11:31:46 +08:00
Deroino 75bbb39b43 feat(account-management): update cookie buttons text and styling
- Change button text from '下载Cookie文件/上传Cookie文件' to '下载Cookie/上传Cookie'
- Add icons to the buttons for better visual recognition
- Keep the essential 'Cookie' text for clarity while making buttons more concise
- Maintain proper button styling and layout in the account management table
2025-11-13 10:44:10 +08:00
Deroino 4688c41b9b feat(account-management): add upload and download cookie file buttons
- Add '上传Cookie文件' and '下载Cookie文件' buttons to account management page
- Implement frontend functionality for cookie file upload and download
- Add backend API endpoints for handling cookie file operations:
  - /uploadCookie: Handle cookie file upload with validation
  - /downloadCookie: Handle secure cookie file download
- Implement security measures to prevent path traversal attacks
- Add proper error handling and user feedback
- Maintain existing functionality while adding new features
2025-11-13 10:37:19 +08:00
Deroino 4ee6c2b09c feat(frontend): update draft checkbox text and position
- Change draft checkbox label to '视频号仅保存草稿(用手机发布)'
- Move draft checkbox to appear after platform selection and before title input
- Add proper CSS styling with appropriate margins for the draft section
- Maintain conditional visibility only for video channel platform (type 2)
- Preserve existing functionality while improving UI flow
2025-11-13 09:42:38 +08:00
Deroino 81b71d023d feat(wechat-channel): add draft upload functionality for video channel
- Add isDraft parameter to TencentVideo class to control publishing behavior
- Modify click_publish method to click "保存草稿" instead of "发表" when isDraft is true
- Update post_video_tencent function to accept and pass is_draft parameter
- Add is_draft parameter to /postVideo API endpoint to receive frontend data
- Add "视频号上传草稿" checkbox in frontend that appears only for video channel platform
- Update defaultTabInit to include isDraft property with default value false
- Include isDraft in publishData sent to backend API
- Implement proper URL checking for draft vs publish success confirmation

The new functionality allows users to save WeChat Channel videos as drafts
instead of immediately publishing them when the checkbox is selected.
2025-11-13 09:12:39 +08:00
Deroino c0e7b64b94 feat(frontend): add loading state to publish button to prevent duplicate clicks
- Add publishing state property to each tab to track publishing status
- Implement loading indicator on publish button with visual feedback
- Prevent duplicate clicks during active publishing process
- Update button text to show "发布中..." (Publishing...) when loading
- Ensure publishing state is properly reset in all scenarios (success, failure, validation errors)
- Add duplicate click prevention with user-friendly error message

The publish button now shows a loading spinner and prevents multiple clicks
during the publishing process, improving user experience and preventing
duplicate uploads to social media platforms.
2025-11-13 07:53:23 +08:00
Deroino 1a02b8fa50 feat(frontend): add UUID column to material management table
- Add UUID field extraction in backend /getFiles endpoint
- Extract UUID from file_path (first part before underscore)
- Add UUID column to material management table in frontend
- Set appropriate width for UUID column display
- Maintain data consistency between backend and frontend
2025-11-13 06:02:15 +08:00
Deroino d21736292c style(frontend): improve upload progress indicator visibility
- Change upload speed indicator color from white to dark gray
- Add better styling for upload file items with rounded corners
- Add hover effects and improved spacing for upload items
- Enhance overall visual appearance of upload panel
2025-11-13 05:58:17 +08:00
Deroino 91d7942a6f fix(backend): delete actual file when removing from database
- Add file deletion logic to delete_file endpoint
- Remove actual file from videoFile directory when deleting record
- Add error handling for file deletion operations
- Add logging for file deletion status
- Ensure data consistency between database and file system
2025-11-13 05:58:01 +08:00
Deroino 6b9fdb35d1 feat(frontend): Implement multi-file upload with progress and speed 2025-11-13 05:47:31 +08:00
Deroino 0cdd088d97 fix(playwright): Prevent launch error when LOCAL_CHROME_PATH is empty 2025-11-13 05:08:06 +08:00
Deroino e003383d65 feat: Add proxy to frontend dev server to fix CORS issues 2025-11-13 04:11:58 +08:00
Deroino 756dcd9126 chore: Improve error logging for /uploadSave endpoint 2025-11-13 03:47:38 +08:00
AlleriaandGitHub 80cccec24e Merge pull request #148 from leordsong/bug/fix-merge-issue
修复之前PR导致的bug
2025-11-09 22:46:17 +08:00
Leo Song 250d7424b1 修复之前PR的bug 2025-11-09 15:23:30 +08:00
AlleriaandGitHub f54f3061c4 Merge pull request #147 from Deroino/feature/windows-batch-script-clean
chore(windows): add Windows batch script for one-click startup
2025-11-08 14:42:03 +08:00
Deroino c933543e5c chore(windows): add Windows batch script for one-click startup
Add a Windows batch script to launch both backend and frontend servers with a single click.

This script:
- Starts the Python backend server in a new window
- Starts the Vue.js frontend server in another window
- Includes proper window titles and logging
- Automatically closes the launcher window after 10 seconds
2025-11-08 04:22:06 +08:00
听风 75a8e8cf29 remove: 移除Dockerfile中的80端口生命 2025-11-06 16:58:01 +08:00
听风 de2389c9b1 feat: 增加Docker容器环境 2025-11-06 14:50:19 +08:00
AlleriaandGitHub 7de1a30b92 Merge pull request #144 from leordsong/feature/douyin-productlink
重新支持抖音封面设置
2025-11-04 02:05:58 +08:00
Leo Song d20fc13631 添加注释 2025-11-03 15:19:41 +08:00
Leo Song 0235e89b9e 重新支持抖音封面设置 2025-11-03 15:17:50 +08:00
AlleriaandGitHub 595dea2f76 Merge pull request #131 from taochangle/patch-2
修复抖音cookie有效性判定
2025-10-30 09:47:44 +08:00
AlleriaandGitHub 26a1642cbf Merge pull request #137 from leordsong/feature/douyin-productlink
如果抖音商品链接不可用,关掉弹窗
2025-10-30 09:45:06 +08:00
home-dev-pookz 0ae8d86d36 1. 更新项目现状 2025-10-30 09:29:52 +08:00
AlleriaandGitHub 6d50620a29 Merge pull request #139 from andaoai/fix/cookies-directory-not-found
fix: 保存cookie前创建cookiesFile目录以防止文件不存在错误
2025-10-30 09:16:02 +08:00
Leo Song 2f95a5512c wait for elements 2025-10-24 16:31:43 +08:00
AnDaoAiandClaude 0e50d4aafd fix: 保存cookie前创建cookiesFile目录以防止文件不存在错误
- 在所有平台的cookie生成函数中添加目录创建逻辑
- 修复cookiesFile目录不存在导致cookie保存失败的问题
- 影响的函数:douyin_cookie_gen、get_tencent_cookie、get_ks_cookie和xiaohongshu_cookie_gen

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-21 16:38:46 +08:00
Leo Song 2dbedcc206 如果链接不可用,关掉弹窗 2025-10-18 13:07:27 +08:00
Leo Song da5a9890f3 Update PublishCenter.vue 2025-10-17 12:17:09 +08:00
AlleriaandGitHub 63cca3fd40 Merge pull request #135 from leordsong/feature/douyin-productlink
feature/支持抖音的购物车功能
2025-10-17 11:01:40 +08:00
AlleriaandGitHub adb6af2a71 Merge branch 'main' into feature/douyin-productlink 2025-10-17 11:01:12 +08:00
home-dev-pookz c1c8fe12dc 暂时注释掉,地理位置设定,暂时没时间解决 2025-10-17 10:51:13 +08:00
Leo Song 327ed18ba2 bug fixes 2025-10-15 16:50:58 +08:00
Leo Song 250fff19a9 [feature] support product link in douyin 2025-10-15 16:25:12 +08:00
陶欣andGitHub 74939a0c06 修复抖音cookie有效性判定 2025-08-25 17:06:54 +08:00
李淳淳 ac80cd2ddd fix: 修改小红书标题定位元素 2025-08-09 16:15:40 +08:00
home-dev-pookz 985e2d4290 qq视频dead 2025-07-15 15:43:04 +08:00
home-dev-pookz 95217cc9ed 1. tk流程最后返回最新上传视频的video_id 2025-06-07 12:14:42 +08:00
Edan's Win11 bb9e8094dd fix: 修改 readme 文件,更换 VX 二维码 2025-06-07 12:07:29 +08:00
Edan's Win11 567c6f4bc9 fix: 将 flask 脚本挪到根目录 2025-06-04 13:58:22 +08:00
Edan's Win11 d74bf8e356 fix: 修改 README.md 文件 2025-06-04 10:46:11 +08:00
Edan's Win11 61fbd42585 fix: 去掉 conf.py 文件,改为 example 示例 2025-06-04 10:42:19 +08:00
Edan's Win11 9c74c9946c fix: 修改 README.md 文件 2025-06-04 10:37:56 +08:00
Edan's Win11 a6d6115372 fix: 修改 README.md 文件 2025-06-04 10:29:19 +08:00
Edan's Win11 089c33efe5 fix: 修改 README.md 文件 2025-06-04 10:17:25 +08:00
Edan's Win11 913032d461 fix: 修改 README.md 文件 2025-06-04 10:13:07 +08:00
Edan's Win11 bde08fa9fa fix: 增加前端小红书逻辑 2025-06-03 23:11:30 +08:00
Edan's Win11 d0244fec26 fix: 修改后端 api 脚本 2025-06-03 23:08:30 +08:00
Edan's Win11 5247c64670 feat: 增加后端的 README.md 文件 2025-06-03 23:04:36 +08:00
Edan's Win11 86fb7f6563 feat: 增加 xiaohongshu 的登录和发布相关脚本 2025-06-03 23:02:47 +08:00
Edan's Win11 8be9a09432 fix: 去掉注释代码 2025-06-03 22:58:45 +08:00
Edan's Win11 6279da2526 fix: 更新 requirements 依赖包 2025-06-03 22:41:37 +08:00
Edan's Win11 8416e3ee14 feat: 随便写个前端的 README 文件 2025-06-03 21:30:25 +08:00
Edan's Win11 f9fc180ffc feat: 发布中心页面 2025-06-03 21:30:05 +08:00
Edan's Win11 753bc701b5 feat: 素材管理页面 2025-06-03 21:29:53 +08:00
Edan's Win11 1b553ebe7a feat: 账号管理页面 2025-06-03 21:29:42 +08:00
Edan's Win11 2b0a027bde feat: 工作台和首页 2025-06-03 21:29:22 +08:00
Edan's Win11 a99df48860 feat: 增加 request 请求封装 2025-06-03 21:29:04 +08:00
Edan's Win11 55670eb2e4 feat: 增加 styles 样式文件 2025-06-03 21:28:48 +08:00
Edan's Win11 c3b677f539 feat: 新增 pinia 状态管理 2025-06-03 21:27:42 +08:00
Edan's Win11 525d1b03de feat: 新增 api 接口 2025-06-03 21:27:10 +08:00
Edan's Win11 6c425eec54 feat: 新增 vite 图表(先用着 2025-06-03 21:26:35 +08:00
Edan's Win11 e09ce3ec51 feat: 增加 env 配置 2025-06-03 21:24:56 +08:00
Edan's Win11 476ebc26b8 fix: 删除旧的文件 2025-06-03 21:24:37 +08:00
Edan's Win11 2962fb7c5c fix: 修改路由 2025-06-03 21:24:10 +08:00
Edan's Win11 a4364d7b75 fix: 修改第三方库的引入 2025-06-03 21:23:56 +08:00
Edan's Win11 5c14cdd531 fix: 修改首页内容 2025-06-03 21:23:01 +08:00
Edan's Win11 4e9b6cb1de fix: 修改启动配置 2025-06-03 21:22:15 +08:00
Edan's Win11 ecbcf1bcd2 fix: 修改 pgk 2025-06-03 21:22:04 +08:00
Edan's Win11 e8f3730fda fix: 更新 .gitignore 文件 2025-06-03 17:23:34 +08:00
Edan's Win11 f120ee5e66 fix: 修改了 api 的文件夹名称 2025-06-03 17:10:16 +08:00
Edan's Win11 77c62a2b0f fix: 将 sau_backend 目录的文件,再挪回根目录,以防止大家迁移出现问题 2025-06-03 17:02:28 +08:00
Edan's Win11 75aa3f75a3 fix: 修改 requirements 依赖包 2025-06-03 16:52:07 +08:00
Edan's Win11 c38f25a58d feat: 增加后端 api 启动脚本 2025-06-03 16:51:16 +08:00
Edan's Win11 f7b871b0b9 fix: 增加 postVideo 脚本 2025-06-03 16:48:35 +08:00
Edan's Win11 30a01bfe0c fix: 增加 login 脚本 2025-06-03 16:48:21 +08:00
Edan's Win11 2708aaf8b7 fix: 增加 auth 脚本 2025-06-03 16:48:10 +08:00
Edan's Win11 ea25ace3da fix: 增加 myUtils 文件夹 2025-06-03 16:43:56 +08:00
Edan's Win11 c12a637ab6 feat: 增加 createTable.py 创建数据库脚本 2025-06-03 16:41:27 +08:00
Edan's Win11 80036f7a50 fix: 更新 .gitignore 文件 2025-06-03 16:40:30 +08:00
Edan's Win11 7ac4188006 fix: 增加图标 2025-06-03 16:38:42 +08:00
Edan's Win11 da05e50a56 fix: 增加图标 2025-06-03 16:38:34 +08:00
Edan's Win11 079e70e074 fix: 更新 .gitignore 文件 2025-06-03 16:38:06 +08:00
Edan's Win11 c97e87db06 fix: 修改 README.md 名称 2025-06-03 16:31:18 +08:00
Edan's Win11 88ca0fbbd3 fix: 更新 READEME 文件中的人员信息 2025-06-03 16:28:36 +08:00
Edan's Win11 24e2111ee3 fix: 将 READEME.md 改为小写文件后缀 2025-06-03 16:05:32 +08:00
Edan's Win11 d9dc9406ae fix: 完善 reademe 文件 2025-06-03 16:04:33 +08:00
Edan's Win11 7ea177c4cb feat: 增加前端管理界面(第一版) 2025-06-03 16:02:43 +08:00
Edan's Win11 5c91a68d8c fix: 将所有后端文件都迁移到 sau_backend 文件夹内 2025-06-03 15:28:08 +08:00
Edan's Win11 794e8feaea feat: 新增 ai2video 方法 2025-06-02 20:30:03 +08:00
Edan's Win11 8d72bfad1c fix: 修改项目介绍 2025-05-23 22:44:19 +08:00
Edan's Win11 0da054ee3b docs: 修改 readme 文件 2025-05-23 22:32:27 +08:00
home-dev-pookz 6c57f6d64a 是dy账号之间有不同页面了吗,验证部分怎么会出现两种情况
还是我错觉了,不管了加上去再说
2025-04-22 16:25:19 +08:00
home-dev-pookz 7b7f67303a 修复抖音判断页面的等待时间3秒 2025-04-22 11:44:45 +08:00
home-dev-pookz 0012f69df2 fix tk upload 2025-04-22 09:51:01 +08:00
home-dev-pookz 32e5fa980c fix tk upload 2025-04-18 09:19:17 +08:00
home-dev-pookz 2facef7509 add 百家號上傳實現 2025-04-12 18:42:45 +08:00
AlleriaandGitHub 8381d720a5 Update README.MD 2025-04-12 18:11:19 +08:00
AlleriaandGitHub 38efb62686 Update README.MD 2025-04-12 18:10:44 +08:00
AlleriaandGitHub ca23f90ea9 Update upload_video_to_douyin.py 2025-02-11 15:22:57 +08:00
AlleriaandGitHub 12b8baa135 Delete .DS_Store 2025-01-08 11:12:07 +08:00
home@pookz.com 6e9298fd7f fix:粗糙修复,兼容两种跳转页面 2025-01-08 11:09:46 +08:00
AlleriaandGitHub 34d190fa75 Merge pull request #79 from zeayal/main
fix douyin
2025-01-08 11:05:42 +08:00
zeayal 79f8070b6a fix douyin upload 2025-01-04 21:08:59 +08:00
zeayal 210470ba41 fix douyin
修复抖音无法发布问题
2025-01-04 17:30:25 +08:00
home@pookz.com eaac0dff52 fix:粗糙修复,封面上传 2024-11-22 14:10:26 +08:00
home@pookz.com e7150226c6 fix:粗糙修复,封面上传,加入了1个timeout,没那么多时间让其更合理,有愿意贡献的提pr优化 2024-11-11 17:17:28 +08:00
home@pookz.com 021ed2096b 1. change 重新上传 locator 2024-11-02 13:03:33 +08:00
AlleriaandGitHub 6c60ee64a6 Merge pull request #63 from 14790897/main
fix: 快手上传视频修复
2024-10-18 02:21:03 +08:00
liuweiqing e13d56e863 fix: 快手上传视频修复 2024-10-17 16:04:42 +08:00
pookz@stme 19c9ec3938 1. fix schedule picker
2. fix thumbnail picker
2024-10-09 16:49:50 +08:00
pookz@stme f247d1ab05 fix kuaishou 2024-09-24 11:00:52 +08:00
AlleriaandGitHub f5b96d24c7 Merge pull request #51 from dreammis/feat/add_douyin_cover
1. douyin 上传封面部分更稳定
2024-09-03 17:41:08 +08:00
pookz@stme 1e35b061c9 1. douyin 上传封面部分更稳定
2. 位置不知道为何出现奇怪的情况,暂时先不解决
2024-09-03 17:39:59 +08:00
AlleriaandGitHub 575c38a6b5 Merge pull request #50 from dreammis/feat/add_douyin_cover
1. 修复视频号cookie验证规则
2024-09-03 16:55:44 +08:00
pookz@stme 79db56e0e2 1. 修复视频号cookie验证规则 2024-09-03 16:54:24 +08:00
AlleriaandGitHub 309077a567 Merge pull request #47 from forrestsocool/feat/add_douyin_cover
Feat/add douyin cover
2024-08-23 15:29:23 +08:00
pookz@stme cd1a82fbb9 1. 统一命名thumbnail
2. 更换完成定位
2024-08-23 15:14:37 +08:00
forrestsocool 57eda1b7fd clean code 2024-08-22 16:25:21 +08:00
forrestsocool 7c9f5d2ec4 add douyin cover upload 2024-08-22 16:22:44 +08:00
AlleriaandGitHub f402717363 Merge pull request #44 from snailRun-hard/main
fix: 抖音web页面改版,无法获取上传按钮的问题
2024-08-16 17:59:32 +08:00
snailRun-hard 5e7ea3c5ba fix: 抖音web页面改版,兼容一下页面元素无法获取的问题 2024-08-16 17:58:52 +08:00
gaoxu 76d2c33963 fix: 抖音web页面改版,无法获取上传按钮的问题 2024-08-16 14:39:32 +08:00
pookz@stme e09fb06477 fix: 抖音因为设置地理位置可能造成的阻塞 2024-08-14 10:44:17 +08:00
pookz@stme 9f812ea537 readme fix 2024-08-13 16:39:11 +08:00
pookz@stme 693fee7b4e readme fix 2024-08-13 12:57:06 +08:00
pookz@stme 44f2700fd0 1. add kuaishou cli 2024-08-12 17:17:01 +08:00
pookz@stme 3158eb9a4c 1. add kuaishou uploader
2. refactor the code
2024-08-08 18:38:13 +08:00
AlleriaandGitHub 707c467a89 Merge pull request #42 from dreammis/cli
fix douyin upload
2024-08-07 09:12:11 +08:00
pookz@stme ee5e920e22 fix douyin upload 2024-08-07 09:13:20 +08:00
pookz@stme 82bfcee904 fix tencent 2024-07-18 18:04:12 +08:00
AlleriaandGitHub a242ebd053 Merge pull request #38 from dreammis/cli
fix tencent
2024-07-18 18:03:54 +08:00
pookz@stme 6b9d7990aa 1. fix douyin publish status 2024-07-12 11:54:16 +08:00
pookz@stme 743f5a8dc2 1. tk chrome iframe or div
2. tk click publish
2024-07-04 11:04:54 +08:00
pookz@stme e40fd39b67 1. tk(chrome support)
2. tk add thumbnail support
2024-07-03 16:58:19 +08:00
pookz@stme 71220564a6 1. fix 2024-06-28 15:12:01 +08:00
pookz@stme 1757e4d582 1. remove unnecessary keyborad action 2024-06-17 18:13:35 +08:00
pookz@stme a39618f7c1 1. 抖音创作者中心改版 2024-06-17 11:43:11 +08:00
pookz@stme 38bcc315bb 1. use loguru for clean console message 2024-06-14 16:19:10 +08:00
pookz@stme 096f5f87c2 1. add stealth to avoid detect 2024-06-14 14:51:24 +08:00
pookz@stme 5ff8adda06 1. add cli support.
2. change LOCAL_CHROME_PATH to conf file
3. change douyin gen cookie url
2024-06-13 18:23:28 +08:00
pookz@stme e088d76516 1. add cli support.
2. change LOCAL_CHROME_PATH to conf file
3. change douyin gen cookie url
2024-06-13 18:13:21 +08:00
pookz@stme d9d31a4d59 1. add detail roadmap 2024-06-13 12:34:57 +08:00
pookz@stme b67aec919e 1. readme 2024-06-13 09:33:10 +08:00
home@pookz.com b78f3900c3 1. add xhs pypi
2. fix xhs hashtag
3. qrcode login?
2024-06-13 00:12:10 +08:00
pookz@stme 5b62e8c6dc 1. star history 2024-06-12 18:34:53 +08:00
pookz@stme 70651aa7e7 1. fix tk url
2. fix tk des(undone)
2024-06-12 18:32:29 +08:00
AlleriaandGitHub 2b37cfbe0b Update README.MD 2024-06-12 09:52:31 +08:00
pookz@stme b0b7ebd379 fix tiktok bug 2024-05-29 09:46:43 +08:00
pookz@stme 715bfd0bb7 maybe the element don't show? 2024-05-22 12:56:23 +08:00
pookz@stme 72a54ebf33 fix tk change 2024-05-20 10:45:16 +08:00
pookz@stme ab388b5b2d 1 2024-05-06 12:51:53 +08:00
AlleriaandGitHub 22a59ca2f4 Update README.MD 2024-04-14 10:33:42 +08:00
pookz@stme 3e1be1b398 1. update README.MD
2. add xhs common trouble
2024-04-10 13:39:39 +08:00
pookz@stme de17838e62 upload for tiktok 2024-04-10 13:23:02 +08:00
pookz@stme f5f9e7d5e1 1 2024-03-29 15:24:34 +08:00
pookz@stme 62761508ef add readme 2024-03-29 15:21:53 +08:00
pookz@stme d2e3ea8450 add bilibili uploader 2024-03-29 15:14:44 +08:00
pookz@stme 98907985d9 fix tencent video schedule select 2024-03-15 18:40:49 +08:00
pookz@stme c4218dc6f3 add always qrcode 2023-12-27 15:56:23 +08:00
pookz@stme 5d590d6005 1 2023-12-27 13:51:25 +08:00
pookz@stme 7e65320ec2 change readme 2023-12-21 16:59:17 +08:00
pookz@stme 2c436cd3b6 change readme 2023-12-21 16:56:23 +08:00
pookz@stme 8077db5f2b 1. add tencent video uploader 2023-12-21 16:49:58 +08:00
pookz@stme 6bc2431acf 1. add new douyin support
2. update group qr-code
2023-12-18 11:30:58 +08:00
pookz@stme b10c66b622 fix douyin video pic bug 2023-12-15 13:17:56 +08:00
pookz@stme 44da1829d8 1. readme 2023-12-08 13:37:15 +08:00
pookz@stme cda03bdaae 1. readme 2023-12-08 13:34:39 +08:00
pookz@stme eec0a68e3a 1. add xhs
2. change douyin third-pard(checkbox)
3. add douyin demo
4. add group-qr
2023-12-08 13:28:33 +08:00
pookz@stme 3d9ace9fa8 remove unnecessary requirement 2023-12-08 11:34:12 +08:00
pookz@stme 4203c5fe07 fix error installation 2023-12-08 11:32:10 +08:00
AlleriaandGitHub c9ef49208f Delete .idea directory 2023-12-04 18:28:48 +08:00
pookz@stme 6ada1eeb5f upload douyin uploader 2023-12-04 18:29:44 +08:00
pookz@stme 24382f9bcb 1 2023-12-04 15:54:34 +08:00
pookz@stme 2ebbb3db97 1 2023-12-04 15:54:19 +08:00
514 changed files with 28147 additions and 33845 deletions
+8
View File
@@ -0,0 +1,8 @@
.venv
.git
.idea
cookies
conf.py
sau_frontend/dist
sau_frontend/node_modules
sau_frontend/package-lock.json
+3
View File
@@ -0,0 +1,3 @@
# These are supported funding model platforms
github: dreammis
+185 -36
View File
@@ -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
View File
@@ -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"]
+21
View File
@@ -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.
-40
View File
@@ -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 验收"
+85 -47
View File
@@ -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。除第三方组件外,代码可在遵守许可证条款的前提下用于商业软件。
View File
-4
View File
@@ -1,4 +0,0 @@
# 打包路径 根据项目不同按需配置
VITE_BASE_URL = /
NODE_ENV=production
-79
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1,4 +0,0 @@
files.extend-exclude = [
"package-lock.json",
"src/configs/color.ts"
]
-20
View File
@@ -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"
-52
View File
@@ -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 -->"
-22
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
-8
View File
@@ -1,8 +0,0 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
if [[ `uname` == 'Darwin' ]]; then
PATH="/usr/local/bin:$PATH"
fi
npm run lint:fix
-106
View File
@@ -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
-97
View File
@@ -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)。
-97
View File
@@ -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

-38
View File
@@ -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>
-8239
View File
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;
-17
View File
@@ -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',
},
};
-16
View File
@@ -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>
);
});
-116
View File
@@ -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;
-341
View File
@@ -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>
);
}
-416
View File
@@ -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;
-268
View File
@@ -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>
);
}
-98
View File
@@ -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>
);
}
-96
View File
@@ -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>
);
}
-164
View File
@@ -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>
);
}
-131
View File
@@ -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>
);
}
-84
View File
@@ -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;
}
}
-44
View File
@@ -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>
);
});
-214
View File
@@ -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>
);
}
-198
View File
@@ -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>
);
}
-233
View File
@@ -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>
);
}
-277
View File
@@ -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>
);
}
-6
View File
@@ -1,6 +0,0 @@
import React from 'react';
import ErrorPage from 'components/ErrorPage';
const UnAuthorized = () => <ErrorPage code={403} />;
export default React.memo(UnAuthorized);
-6
View File
@@ -1,6 +0,0 @@
import React from 'react';
import ErrorPage from 'components/ErrorPage';
const NotFound = () => <ErrorPage code={404} />;
export default React.memo(NotFound);
-6
View File
@@ -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;
}
}
-126
View File
@@ -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>
);
}
-24
View File
@@ -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;
-25
View File
@@ -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;
-19
View File
@@ -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;
-144
View File
@@ -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;
-86
View File
@@ -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;
-69
View File
@@ -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;
}
-20
View File
@@ -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
-24
View File
@@ -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=
-45
View File
@@ -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)
}
-72
View File
@@ -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")
}
}
-116
View File
@@ -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"
}
-231
View File
@@ -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...)
}
-64
View File
@@ -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")
}
}
-144
View File
@@ -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)
}
-39
View File
@@ -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")
}
}
-87
View File
@@ -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
}
-46
View File
@@ -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)
}
}
-116
View File
@@ -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)
}
}
-139
View File
@@ -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)
}
-83
View File
@@ -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
}
-334
View File
@@ -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")
}
}
-173
View File
@@ -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")
}
-13
View File
@@ -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);
-50
View File
@@ -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");
-200
View File
@@ -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);
-5285
View File
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