feat: EveryPublish Web 管理端重构与全平台发布接入

- 新增 React + TDesign 管理端(sau_web_react),替代历史 Vue 前端
- 后端接入 11 平台登录/发布(视频+图文),check_cookie 扩展全平台
- 异步发布任务 + 实时进度监控 + 发布日志
- Open API 开放体系(API Key 鉴权 + 7 个开放接口 + API 文档页)
- 账号单/批量校验与批量删除
- 全局配置(登录方式 + 发布静默模式)
- 统一浏览器为系统 Chrome,品牌文案统一为 EveryPublish
This commit is contained in:
Qiufeng
2026-08-21 14:41:47 +08:00
parent 1c66b7db4b
commit fd944b08bd
120 changed files with 6684 additions and 210 deletions
+25
View File
@@ -116,6 +116,31 @@ async def check_cookie(type, file_path):
# 快手 # 快手
case 4: case 4:
return await cookie_auth_ks(Path(BASE_DIR / "cookiesFile" / file_path)) return await cookie_auth_ks(Path(BASE_DIR / "cookiesFile" / file_path))
case 5:
from uploader.bilibili_uploader.runtime import run_biliup_command
account_file = Path(BASE_DIR / "cookiesFile" / file_path)
if not account_file.exists():
return False
result = run_biliup_command(["-u", str(account_file), "renew"])
return result.returncode == 0
case 6:
from uploader.baijiahao_uploader.main import cookie_auth as baijiahao_cookie_auth
return await baijiahao_cookie_auth(str(Path(BASE_DIR / "cookiesFile" / file_path)))
case 7:
from uploader.alipay_uploader.main import cookie_auth as alipay_cookie_auth
return await alipay_cookie_auth(str(Path(BASE_DIR / "cookiesFile" / file_path)))
case 8:
from uploader.weibo_uploader.main import cookie_auth as weibo_cookie_auth
return await weibo_cookie_auth(str(Path(BASE_DIR / "cookiesFile" / file_path)))
case 9:
from uploader.hupu_uploader.main import cookie_auth as hupu_cookie_auth
return await hupu_cookie_auth(str(Path(BASE_DIR / "cookiesFile" / file_path)))
case 10:
from uploader.youtube_uploader.main import cookie_auth as youtube_cookie_auth
return await youtube_cookie_auth(str(Path(BASE_DIR / "cookiesFile" / file_path)))
case 11:
from uploader.tk_uploader.main_chrome import cookie_auth as tiktok_cookie_auth
return await tiktok_cookie_auth(str(Path(BASE_DIR / "cookiesFile" / file_path)))
case _: case _:
return False return False
+228 -43
View File
@@ -2,91 +2,276 @@ import asyncio
from pathlib import Path from pathlib import Path
from conf import BASE_DIR from conf import BASE_DIR
from uploader.douyin_uploader.main import DouYinVideo from uploader.douyin_uploader.main import DouYinVideo, DouYinNote
from uploader.ks_uploader.main import KSVideo from uploader.ks_uploader.main import KSVideo, KSNote
from uploader.tencent_uploader.main import TencentVideo from uploader.tencent_uploader.main import TencentVideo, TencentNote
from uploader.xiaohongshu_uploader.main import XiaoHongShuVideo from uploader.xiaohongshu_uploader.main import XiaoHongShuVideo, XiaoHongShuNote
from uploader.baijiahao_uploader.main import BaiJiaHaoVideo
from uploader.alipay_uploader.main import AlipayVideo
from uploader.weibo_uploader.main import WeiBoVideo
from uploader.hupu_uploader.main import HuPuVideo
from uploader.youtube_uploader.main import YouTubeVideo
from uploader.bilibili_uploader.runtime import run_biliup_command
from uploader.tk_uploader.main_chrome import TiktokVideo
from utils.settings import get_bool
from utils.constant import TencentZoneTypes from utils.constant import TencentZoneTypes
from utils.files_times import generate_schedule_time_next_day from utils.files_times import generate_schedule_time_next_day
def post_video_tencent(title,files,tags,account_file,category=TencentZoneTypes.LIFESTYLE.value,enableTimer=False,videos_per_day = 1, daily_times=None,start_days = 0, is_draft=False): def _publish_headless():
# 生成文件的完整路径 return get_bool("publish_headless", True)
account_file = [Path(BASE_DIR / "cookiesFile" / file) for file in account_file]
files = [Path(BASE_DIR / "videoFile" / file) for file in files]
def _resolve_files(files):
return [Path(BASE_DIR / "videoFile" / file) for file in files]
def _resolve_cookies(account_file):
return [Path(BASE_DIR / "cookiesFile" / file) for file in account_file]
def _extract_cover(video_path: Path) -> str | None:
"""提取视频第一帧作为封面(百家号发布必须提供横版封面)。"""
try:
import cv2
cap = cv2.VideoCapture(str(video_path))
ret, frame = cap.read()
cap.release()
if ret:
cover = Path(str(video_path) + ".cover.jpg")
cv2.imwrite(str(cover), frame)
return str(cover)
except Exception:
pass
return None
def post_video_tencent(title, files, tags, account_file, category=TencentZoneTypes.LIFESTYLE.value, enableTimer=False, videos_per_day=1, daily_times=None, start_days=0, is_draft=False):
account_file = _resolve_cookies(account_file)
files = _resolve_files(files)
if enableTimer: if enableTimer:
publish_datetimes = generate_schedule_time_next_day(len(files), videos_per_day, daily_times,start_days) publish_datetimes = generate_schedule_time_next_day(len(files), videos_per_day, daily_times, start_days)
else: else:
publish_datetimes = [0 for i in range(len(files))] publish_datetimes = [0 for i in range(len(files))]
for index, file in enumerate(files): for index, file in enumerate(files):
for cookie in account_file: for cookie in account_file:
print(f"文件路径{str(file)}")
# 打印视频文件名、标题和 hashtag
print(f"视频文件名:{file}") print(f"视频文件名:{file}")
print(f"标题:{title}") print(f"标题:{title}")
print(f"Hashtag:{tags}") app = TencentVideo(title, str(file), tags, publish_datetimes[index], cookie, category, is_draft, headless=_publish_headless())
app = TencentVideo(title, str(file), tags, publish_datetimes[index], cookie, category, is_draft)
asyncio.run(app.main(), debug=False) asyncio.run(app.main(), debug=False)
def post_video_DouYin(title,files,tags,account_file,category=TencentZoneTypes.LIFESTYLE.value,enableTimer=False,videos_per_day = 1, daily_times=None,start_days = 0, def post_video_DouYin(title, files, tags, account_file, category=TencentZoneTypes.LIFESTYLE.value, enableTimer=False, videos_per_day=1, daily_times=None, start_days=0,
thumbnail_path = '', thumbnail_path='',
productLink = '', productTitle = ''): productLink='', productTitle=''):
# 生成文件的完整路径 account_file = _resolve_cookies(account_file)
account_file = [Path(BASE_DIR / "cookiesFile" / file) for file in account_file] files = _resolve_files(files)
files = [Path(BASE_DIR / "videoFile" / file) for file in files]
if enableTimer: if enableTimer:
publish_datetimes = generate_schedule_time_next_day(len(files), videos_per_day, daily_times,start_days) publish_datetimes = generate_schedule_time_next_day(len(files), videos_per_day, daily_times, start_days)
else: else:
publish_datetimes = [0 for i in range(len(files))] publish_datetimes = [0 for i in range(len(files))]
for index, file in enumerate(files): for index, file in enumerate(files):
for cookie in account_file: for cookie in account_file:
print(f"文件路径{str(file)}")
# 打印视频文件名、标题和 hashtag
print(f"视频文件名:{file}") print(f"视频文件名:{file}")
print(f"标题:{title}") print(f"标题:{title}")
print(f"Hashtag:{tags}") app = DouYinVideo(title, str(file), tags, publish_datetimes[index], cookie, thumbnail_path, productLink, productTitle, headless=_publish_headless())
app = DouYinVideo(title, str(file), tags, publish_datetimes[index], cookie, thumbnail_path, productLink, productTitle)
asyncio.run(app.douyin_upload_video(), debug=False) asyncio.run(app.douyin_upload_video(), debug=False)
def post_video_ks(title,files,tags,account_file,category=TencentZoneTypes.LIFESTYLE.value,enableTimer=False,videos_per_day = 1, daily_times=None,start_days = 0): def post_video_ks(title, files, tags, account_file, category=TencentZoneTypes.LIFESTYLE.value, enableTimer=False, videos_per_day=1, daily_times=None, start_days=0):
# 生成文件的完整路径 account_file = _resolve_cookies(account_file)
account_file = [Path(BASE_DIR / "cookiesFile" / file) for file in account_file] files = _resolve_files(files)
files = [Path(BASE_DIR / "videoFile" / file) for file in files]
if enableTimer: if enableTimer:
publish_datetimes = generate_schedule_time_next_day(len(files), videos_per_day, daily_times,start_days) publish_datetimes = generate_schedule_time_next_day(len(files), videos_per_day, daily_times, start_days)
else: else:
publish_datetimes = [0 for i in range(len(files))] publish_datetimes = [0 for i in range(len(files))]
for index, file in enumerate(files): for index, file in enumerate(files):
for cookie in account_file: for cookie in account_file:
print(f"文件路径{str(file)}")
# 打印视频文件名、标题和 hashtag
print(f"视频文件名:{file}") print(f"视频文件名:{file}")
print(f"标题:{title}") print(f"标题:{title}")
print(f"Hashtag:{tags}") app = KSVideo(title, str(file), tags, publish_datetimes[index], cookie, headless=_publish_headless())
app = KSVideo(title, str(file), tags, publish_datetimes[index], cookie)
asyncio.run(app.main(), debug=False) asyncio.run(app.main(), debug=False)
def post_video_xhs(title,files,tags,account_file,category=TencentZoneTypes.LIFESTYLE.value,enableTimer=False,videos_per_day = 1, daily_times=None,start_days = 0):
# 生成文件的完整路径 def post_video_xhs(title, files, tags, account_file, category=TencentZoneTypes.LIFESTYLE.value, enableTimer=False, videos_per_day=1, daily_times=None, start_days=0):
account_file = [Path(BASE_DIR / "cookiesFile" / file) for file in account_file] account_file = _resolve_cookies(account_file)
files = [Path(BASE_DIR / "videoFile" / file) for file in files] files = _resolve_files(files)
file_num = len(files) file_num = len(files)
if enableTimer: if enableTimer:
publish_datetimes = generate_schedule_time_next_day(file_num, videos_per_day, daily_times,start_days) publish_datetimes = generate_schedule_time_next_day(file_num, videos_per_day, daily_times, start_days)
else: else:
publish_datetimes = 0 publish_datetimes = 0
for index, file in enumerate(files): for index, file in enumerate(files):
for cookie in account_file: for cookie in account_file:
# 打印视频文件名、标题和 hashtag
print(f"视频文件名:{file}") print(f"视频文件名:{file}")
print(f"标题:{title}") print(f"标题:{title}")
print(f"Hashtag:{tags}") app = XiaoHongShuVideo(title, file, tags, publish_datetimes, cookie, headless=_publish_headless())
app = XiaoHongShuVideo(title, file, tags, publish_datetimes, cookie)
asyncio.run(app.main(), debug=False) asyncio.run(app.main(), debug=False)
# ===== 新增:CLI 主线平台接入 Web 后端 =====
# post_video("333",["demo.mp4"],"d","d") def post_video_bilibili(title, files, tags, account_file, category=None, enableTimer=False, videos_per_day=1, daily_times=None, start_days=0, desc='', tid=249):
# post_video_DouYin("333",["demo.mp4"],"d","d") """B站视频发布(封装 biliup)。Web 端暂不支持定时(立即发布),定时请用 CLI。"""
account_file = _resolve_cookies(account_file)
files = _resolve_files(files)
for file in files:
for cookie in account_file:
args = ["-u", str(cookie), "upload", str(file), "--title", title, "--desc", desc or "", "--tid", str(tid)]
if tags:
args.extend(["--tag", ",".join(tags)])
print(f"biliup 命令: {' '.join(args)}")
result = run_biliup_command(args)
if result.returncode != 0:
raise RuntimeError((result.stderr or result.stdout or "").strip() or "B站上传失败")
def post_video_baijiahao(title, files, tags, account_file, category=None, enableTimer=False, videos_per_day=1, daily_times=None, start_days=0, thumbnail_path='', desc=''):
account_file = _resolve_cookies(account_file)
files = _resolve_files(files)
for file in files:
cover = thumbnail_path or _extract_cover(file)
for cookie in account_file:
app = BaiJiaHaoVideo(
title=title, file_path=str(file), tags=tags, account_file=str(cookie),
desc=desc or "", thumbnail_path=cover, collection_name=None, debug=False, headless=_publish_headless(),
)
asyncio.run(app.main(), debug=False)
def post_video_alipay(title, files, tags, account_file, category=None, enableTimer=False, videos_per_day=1, daily_times=None, start_days=0, thumbnail_path='', desc=''):
account_file = _resolve_cookies(account_file)
files = _resolve_files(files)
for file in files:
for cookie in account_file:
app = AlipayVideo(
title=title, file_path=str(file), tags=tags, account_file=str(cookie),
desc=desc or "", thumbnail_path=thumbnail_path or None, collection_name=None, debug=False, headless=_publish_headless(),
)
asyncio.run(app.main(), debug=False)
def post_video_weibo(title, files, tags, account_file, category=None, enableTimer=False, videos_per_day=1, daily_times=None, start_days=0, thumbnail_path='', desc=''):
account_file = _resolve_cookies(account_file)
files = _resolve_files(files)
for file in files:
for cookie in account_file:
app = WeiBoVideo(
title=title, file_path=str(file), tags=tags, account_file=str(cookie),
desc=desc or "", thumbnail_path=thumbnail_path or None, collection_name=None, debug=False, headless=_publish_headless(),
)
asyncio.run(app.main(), debug=False)
def post_video_hupu(title, files, tags, account_file, category=None, enableTimer=False, videos_per_day=1, daily_times=None, start_days=0, thumbnail_path='', desc=''):
account_file = _resolve_cookies(account_file)
files = _resolve_files(files)
for file in files:
for cookie in account_file:
app = HuPuVideo(
title=title, file_path=str(file), tags=tags, account_file=str(cookie),
desc=desc or "", thumbnail_path=thumbnail_path or None, debug=False, headless=_publish_headless(),
)
asyncio.run(app.main(), debug=False)
def post_video_youtube(title, files, tags, account_file, category=None, enableTimer=False, videos_per_day=1, daily_times=None, start_days=0, thumbnail_path='', desc=''):
account_file = _resolve_cookies(account_file)
files = _resolve_files(files)
for file in files:
for cookie in account_file:
app = YouTubeVideo(
title, str(file), tags, str(cookie),
description=desc or "", thumbnail_path=thumbnail_path or None,
playlist=None, visibility="public", debug=False, headless=_publish_headless(),
)
asyncio.run(app.main(), debug=False)
def post_video_tiktok(title, files, tags, account_file, category=None, enableTimer=False, videos_per_day=1, daily_times=None, start_days=0, thumbnail_path="", desc=""):
"""TikTok 视频发布(Chrome 版实现)。支持定时发布与封面(thumbnail)。"""
account_file = _resolve_cookies(account_file)
files = _resolve_files(files)
if enableTimer:
publish_datetimes = generate_schedule_time_next_day(len(files), videos_per_day, daily_times, start_days)
else:
publish_datetimes = [0 for i in range(len(files))]
for index, file in enumerate(files):
for cookie in account_file:
app = TiktokVideo(
title, str(file), tags, publish_datetimes[index], str(cookie),
thumbnail_path if thumbnail_path else None,
)
app.headless = _publish_headless()
asyncio.run(app.main(), debug=False)
# ===== 图文(note)发布 —— 小红书/抖音/快手 =====
def post_note_xiaohongshu(title, files, tags, account_file, desc=""):
account_files = _resolve_cookies(account_file)
image_files = _resolve_files(files)
for cookie in account_files:
app = XiaoHongShuNote(
image_paths=[str(f) for f in image_files],
note=desc or "",
tags=tags,
publish_date=0,
account_file=str(cookie),
title=title,
desc=desc or "",
debug=False,
headless=_publish_headless(),
)
asyncio.run(app.main(), debug=False)
def post_note_douyin(title, files, tags, account_file, desc=""):
account_files = _resolve_cookies(account_file)
image_files = _resolve_files(files)
for cookie in account_files:
app = DouYinNote(
image_paths=[str(f) for f in image_files],
note=desc or "",
tags=tags,
publish_date=0,
account_file=str(cookie),
title=title,
debug=False,
headless=_publish_headless(),
)
asyncio.run(app.douyin_upload_note(), debug=False)
def post_note_kuaishou(title, files, tags, account_file, desc=""):
account_files = _resolve_cookies(account_file)
image_files = _resolve_files(files)
for cookie in account_files:
app = KSNote(
image_paths=[str(f) for f in image_files],
note=desc or "",
tags=tags,
publish_date=0,
account_file=str(cookie),
title=title,
debug=False,
headless=_publish_headless(),
)
asyncio.run(app.main(), debug=False)
def post_note_tencent(title, files, tags, account_file, desc="", is_draft=False):
account_files = _resolve_cookies(account_file)
image_files = _resolve_files(files)
for cookie in account_files:
app = TencentNote(
image_paths=[str(f) for f in image_files],
note=desc or "",
tags=tags,
publish_date=0,
account_file=str(cookie),
title=title,
debug=False,
headless=_publish_headless(),
is_draft=is_draft,
)
asyncio.run(app.main(), debug=False)
+725 -146
View File
@@ -1,9 +1,13 @@
import asyncio import asyncio
import base64
import os import os
import sqlite3 import sqlite3
import subprocess
import threading import threading
import time import time
import uuid import uuid
from datetime import datetime
from functools import wraps
from pathlib import Path from pathlib import Path
from queue import Queue from queue import Queue
from flask_cors import CORS from flask_cors import CORS
@@ -12,7 +16,205 @@ from flask import Flask, request, jsonify, Response, render_template, send_from_
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from conf import BASE_DIR from conf import BASE_DIR
from myUtils.login import get_tencent_cookie, douyin_cookie_gen, get_ks_cookie, xiaohongshu_cookie_gen from myUtils.login import get_tencent_cookie, douyin_cookie_gen, get_ks_cookie, xiaohongshu_cookie_gen
from myUtils.postVideo import post_video_tencent, post_video_DouYin, post_video_ks, post_video_xhs from myUtils.postVideo import (
post_video_tencent,
post_video_DouYin,
post_video_ks,
post_video_xhs,
post_video_bilibili,
post_video_baijiahao,
post_video_alipay,
post_video_weibo,
post_video_hupu,
post_video_youtube,
post_video_tiktok,
post_note_xiaohongshu,
post_note_douyin,
post_note_kuaishou,
post_note_tencent,
)
from uploader.baijiahao_uploader.main import baijiahao_setup
from uploader.alipay_uploader.main import alipay_setup
from uploader.weibo_uploader.main import weibo_setup
from uploader.hupu_uploader.main import hupu_setup
from uploader.youtube_uploader.main import youtube_setup
from uploader.bilibili_uploader.runtime import ensure_biliup_binary
from loguru import logger
from utils.settings import get_setting, set_setting, get_bool
# ===== 发布任务监控(异步发布 + 进度 + 日志) =====
PUBLISH_TASKS = {}
CHECK_TASKS = {}
_publish_thread_local = threading.local()
_publish_lock = threading.Lock()
SUCCESS_KEYWORDS = ["发布成功", "发布完成", "published success", "video published", "已发布", "发布完毕", "上传成功"]
def _logs_indicate_success(logs):
return any(any(k in log for k in SUCCESS_KEYWORDS) for log in logs)
def _update_task_progress(task, text, level):
ts = datetime.now().strftime("%H:%M:%S")
task["logs"].append(ts + " " + text)
if len(task["logs"]) > 800:
task["logs"] = task["logs"][-800:]
if any(k in text for k in ["发布成功", "published success", "发布完成", "已发布", "video published"]):
task["step"] = "发布成功"
task["progress"] = 100
elif any(k in text for k in ["发布", "publish", "点击发布", "提交发布"]):
task["step"] = "发布中"
task["progress"] = max(task["progress"], 85)
elif any(k in text for k in ["封面", "cover"]):
task["step"] = "处理封面"
task["progress"] = max(task["progress"], 70)
elif any(k in text for k in ["标题", "话题", "标签", "title", "tag"]):
task["step"] = "填写信息"
task["progress"] = max(task["progress"], 60)
elif any(k in text for k in ["上传", "upload", "搬运", "选择文件"]):
task["step"] = "上传中"
task["progress"] = max(task["progress"], 30)
elif any(k in text for k in ["失败", "错误", "error", "超时", "timeout"]):
task["step"] = "出现异常"
def _publish_log_sink(message):
try:
task = getattr(_publish_thread_local, "publish_task", None)
if not task:
return
record = message.record
text = str(record.get("message", ""))
level = record.get("level", {})
level_name = getattr(level, "name", "INFO") if level else "INFO"
_update_task_progress(task, text, level_name)
except Exception:
pass
logger.add(_publish_log_sink, level="INFO", format="{message}")
def _dispatch_publish(type, title, file_list, account_list, tags, category, enableTimer, videos_per_day, daily_times, start_days, thumbnail_path, desc, productLink, productTitle, is_draft, is_image):
match type:
case 1:
if is_image:
post_note_xiaohongshu(title, file_list, tags, account_list, desc)
else:
post_video_xhs(title, file_list, tags, account_list, category, enableTimer, videos_per_day, daily_times, start_days)
case 2:
if is_image:
post_note_tencent(title, file_list, tags, account_list, desc, is_draft)
else:
post_video_tencent(title, file_list, tags, account_list, category, enableTimer, videos_per_day, daily_times, start_days, is_draft)
case 3:
if is_image:
post_note_douyin(title, file_list, tags, account_list, desc)
else:
post_video_DouYin(title, file_list, tags, account_list, category, enableTimer, videos_per_day, daily_times, start_days, thumbnail_path, productLink, productTitle)
case 4:
if is_image:
post_note_kuaishou(title, file_list, tags, account_list, desc)
else:
post_video_ks(title, file_list, tags, account_list, category, enableTimer, videos_per_day, daily_times, start_days)
case 5:
post_video_bilibili(title, file_list, tags, account_list, category, enableTimer, videos_per_day, daily_times, start_days, desc)
case 6:
post_video_baijiahao(title, file_list, tags, account_list, category, enableTimer, videos_per_day, daily_times, start_days, thumbnail_path, desc)
case 7:
post_video_alipay(title, file_list, tags, account_list, category, enableTimer, videos_per_day, daily_times, start_days, thumbnail_path, desc)
case 8:
post_video_weibo(title, file_list, tags, account_list, category, enableTimer, videos_per_day, daily_times, start_days, thumbnail_path, desc)
case 9:
post_video_hupu(title, file_list, tags, account_list, category, enableTimer, videos_per_day, daily_times, start_days, thumbnail_path, desc)
case 10:
post_video_youtube(title, file_list, tags, account_list, category, enableTimer, videos_per_day, daily_times, start_days, thumbnail_path, desc)
case 11:
post_video_tiktok(title, file_list, tags, account_list, category, enableTimer, videos_per_day, daily_times, start_days, thumbnail_path, desc)
case _:
raise ValueError("不支持的平台类型: " + str(type))
def _run_publish_task(task, type, title, file_list, account_list, tags, category, enableTimer, videos_per_day, daily_times, start_days, thumbnail_path, desc, productLink, productTitle, is_draft, is_image):
_publish_thread_local.publish_task = task
task["status"] = "running"
task["step"] = "启动浏览器"
task["progress"] = 5
try:
_dispatch_publish(type, title, file_list, account_list, tags, category, enableTimer, videos_per_day, daily_times, start_days, thumbnail_path, desc, productLink, productTitle, is_draft, is_image)
task["status"] = "success"
task["step"] = "发布完成"
task["progress"] = 100
except Exception as e:
if _logs_indicate_success(task["logs"]):
task["status"] = "success"
task["step"] = "发布成功(平台已确认)"
task["progress"] = 100
else:
task["status"] = "failed"
task["error"] = str(e)
task["step"] = "发布失败"
finally:
_publish_thread_local.publish_task = None
def _create_publish_task(data):
file_list = data.get('fileList', [])
account_list = data.get('accountList', [])
type = data.get('type')
title = data.get('title')
tags = data.get('tags')
category = data.get('category')
if category == 0:
category = None
productLink = data.get('productLink', '')
productTitle = data.get('productTitle', '')
thumbnail_path = data.get('thumbnail', '')
desc = data.get('desc', '')
enableTimer = data.get('enableTimer')
is_draft = data.get('isDraft', False)
videos_per_day = data.get('videosPerDay')
daily_times = data.get('dailyTimes')
start_days = data.get('startDays')
if not file_list:
return None, "文件列表不能为空"
if not account_list:
return None, "账号列表不能为空"
if not type:
return None, "平台类型不能为空"
if not title:
return None, "标题不能为空"
_IMG_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"}
is_image = bool(file_list) and all(Path(f).suffix.lower() in _IMG_EXTS for f in file_list)
if is_image and type not in (1, 2, 3, 4):
return None, "图文发布仅支持小红书/抖音/快手,请选择支持图文的平台"
task_id = str(uuid.uuid4())
task = {
"id": task_id,
"status": "pending",
"step": "排队中",
"progress": 0,
"logs": [],
"error": None,
"type": type,
"title": title,
"created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}
with _publish_lock:
PUBLISH_TASKS[task_id] = task
thread = threading.Thread(
target=_run_publish_task,
args=(task, type, title, file_list, account_list, tags, category, enableTimer, videos_per_day, daily_times, start_days, thumbnail_path, desc, productLink, productTitle, is_draft, is_image),
daemon=True,
)
thread.start()
return task_id, None
active_queues = {} active_queues = {}
app = Flask(__name__) app = Flask(__name__)
@@ -407,164 +609,30 @@ def login():
@app.route('/postVideo', methods=['POST']) @app.route('/postVideo', methods=['POST'])
def postVideo(): def postVideo():
# 获取JSON数据
data = request.get_json() data = request.get_json()
if not data: if not data:
return jsonify({"code": 400, "msg": "请求数据不能为空", "data": None}), 400 return jsonify({"code": 400, "msg": "请求数据不能为空", "data": None}), 400
task_id, err = _create_publish_task(data)
if err:
return jsonify({"code": 400, "msg": err, "data": None}), 400
return jsonify({"code": 200, "msg": "发布任务已提交", "data": {"taskId": task_id}}), 200
# 从JSON数据中提取fileList和accountList
file_list = data.get('fileList', [])
account_list = data.get('accountList', [])
type = data.get('type')
title = data.get('title')
tags = data.get('tags')
category = data.get('category')
enableTimer = data.get('enableTimer')
if category == 0:
category = None
productLink = data.get('productLink', '')
productTitle = data.get('productTitle', '')
thumbnail_path = data.get('thumbnail', '')
is_draft = data.get('isDraft', False) # 新增参数:是否保存为草稿
videos_per_day = data.get('videosPerDay')
daily_times = data.get('dailyTimes')
start_days = data.get('startDays')
# 参数校验
if not file_list:
return jsonify({"code": 400, "msg": "文件列表不能为空", "data": None}), 400
if not account_list:
return jsonify({"code": 400, "msg": "账号列表不能为空", "data": None}), 400
if not type:
return jsonify({"code": 400, "msg": "平台类型不能为空", "data": None}), 400
if not title:
return jsonify({"code": 400, "msg": "标题不能为空", "data": None}), 400
# 打印获取到的数据(仅作为示例)
print("File List:", file_list)
print("Account List:", account_list)
try:
match type:
case 1:
post_video_xhs(title, file_list, tags, account_list, category, enableTimer, videos_per_day, daily_times,
start_days)
case 2:
post_video_tencent(title, file_list, tags, account_list, category, enableTimer, videos_per_day, daily_times,
start_days, is_draft)
case 3:
post_video_DouYin(title, file_list, tags, account_list, category, enableTimer, videos_per_day, daily_times,
start_days, thumbnail_path, productLink, productTitle)
case 4:
post_video_ks(title, file_list, tags, account_list, category, enableTimer, videos_per_day, daily_times,
start_days)
case _:
return jsonify({"code": 400, "msg": f"不支持的平台类型: {type}", "data": None}), 400
# 返回响应给客户端
return jsonify(
{
"code": 200,
"msg": "发布任务已提交",
"data": None
}), 200
except Exception as e:
print(f"发布视频时出错: {str(e)}")
return jsonify({
"code": 500,
"msg": f"发布失败: {str(e)}",
"data": None
}), 500
@app.route('/updateUserinfo', methods=['POST'])
def updateUserinfo():
# 获取JSON数据
data = request.get_json()
# 从JSON数据中提取 type 和 userName
user_id = data.get('id')
type = data.get('type')
userName = data.get('userName')
try:
# 获取数据库连接
with sqlite3.connect(Path(BASE_DIR / "db" / "database.db")) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# 更新数据库记录
cursor.execute('''
UPDATE user_info
SET type = ?,
userName = ?
WHERE id = ?;
''', (type, userName, user_id))
conn.commit()
return jsonify({
"code": 200,
"msg": "account update successfully",
"data": None
}), 200
except Exception as e:
return jsonify({
"code": 500,
"msg": str("update failed!"),
"data": None
}), 500
@app.route('/postVideoBatch', methods=['POST']) @app.route('/postVideoBatch', methods=['POST'])
def postVideoBatch(): def postVideoBatch():
data_list = request.get_json() data_list = request.get_json()
if not isinstance(data_list, list): if not isinstance(data_list, list):
return jsonify({"code": 400, "msg": "Expected a JSON array", "data": None}), 400 return jsonify({"code": 400, "msg": "Expected a JSON array", "data": None}), 400
task_ids = []
for data in data_list: for data in data_list:
# 从JSON数据中提取fileList和accountList task_id, err = _create_publish_task(data)
file_list = data.get('fileList', []) if task_id:
account_list = data.get('accountList', []) task_ids.append(task_id)
type = data.get('type') if not task_ids:
title = data.get('title') return jsonify({"code": 400, "msg": "没有可提交的发布任务", "data": None}), 400
tags = data.get('tags') return jsonify({"code": 200, "msg": None, "data": {"taskIds": task_ids}}), 200
category = data.get('category')
enableTimer = data.get('enableTimer')
if category == 0:
category = None
productLink = data.get('productLink', '')
productTitle = data.get('productTitle', '')
is_draft = data.get('isDraft', False)
videos_per_day = data.get('videosPerDay')
daily_times = data.get('dailyTimes')
start_days = data.get('startDays')
# 打印获取到的数据(仅作为示例)
print("File List:", file_list)
print("Account List:", account_list)
match type:
case 1:
post_video_xhs(title, file_list, tags, account_list, category, enableTimer, videos_per_day, daily_times,
start_days)
case 2:
post_video_tencent(title, file_list, tags, account_list, category, enableTimer, videos_per_day, daily_times,
start_days, is_draft)
case 3:
post_video_DouYin(title, file_list, tags, account_list, category, enableTimer, videos_per_day, daily_times,
start_days, productLink, productTitle)
case 4:
post_video_ks(title, file_list, tags, account_list, category, enableTimer, videos_per_day, daily_times,
start_days)
# 返回响应给客户端
return jsonify(
{
"code": 200,
"msg": None,
"data": None
}), 200
# Cookie文件上传API
@app.route('/uploadCookie', methods=['POST']) @app.route('/uploadCookie', methods=['POST'])
def upload_cookie(): def upload_cookie():
try: try:
@@ -685,6 +753,163 @@ def download_cookie():
}), 500 }), 500
# ===== 新增:CLI 主线平台登录桥接 =====
def _qrcode_to_queue(status_queue):
def callback(payload):
try:
if isinstance(payload, dict):
data_url = payload.get("image_data_url") or ""
image_path = payload.get("image_path") or ""
if data_url:
status_queue.put(data_url)
elif image_path and os.path.exists(image_path):
with open(image_path, "rb") as fobj:
b64 = base64.b64encode(fobj.read()).decode()
ext = Path(image_path).suffix.lstrip(".").lower() or "png"
status_queue.put("data:image/" + ext + ";base64," + b64)
else:
status_queue.put(str(payload))
elif isinstance(payload, str):
status_queue.put(payload)
else:
status_queue.put(str(payload))
except Exception:
pass
return callback
def _insert_account(platform_type, account_name, cookie_filename):
with sqlite3.connect(Path(BASE_DIR / "db" / "database.db")) as conn:
cursor = conn.cursor()
cursor.execute(
"INSERT INTO user_info (type, filePath, userName, status) VALUES (?, ?, ?, ?)",
(int(platform_type), cookie_filename, account_name, 1),
)
conn.commit()
def _bilibili_login(account_file, status_queue):
try:
binary = ensure_biliup_binary(force_check=False)
except Exception:
return False
workdir = account_file.parent
qrcode_path = workdir / "qrcode.png"
status_queue.put("正在启动 B 站扫码登录,请稍候...")
proc = subprocess.Popen(
[str(binary), "-u", str(account_file), "login"],
cwd=str(workdir),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
last_mtime = 0
for _ in range(600):
if qrcode_path.exists():
try:
mtime = qrcode_path.stat().st_mtime
except Exception:
mtime = 0
if mtime != last_mtime:
last_mtime = mtime
try:
b64 = base64.b64encode(qrcode_path.read_bytes()).decode()
status_queue.put("data:image/png;base64," + b64)
except Exception:
pass
if proc.poll() is not None:
break
time.sleep(1)
try:
proc.wait(timeout=60)
except Exception:
proc.kill()
return proc.returncode == 0 and account_file.exists()
async def _tiktok_login_coro(account_file, status_queue):
"""TikTok 交互式登录:弹浏览器让用户手动登录,轮询 sessionid cookie。"""
from playwright.async_api import async_playwright
try:
async with async_playwright() as p:
browser = await p.chromium.launch(headless=False)
context = await browser.new_context()
page = await context.new_page()
await page.goto("https://www.tiktok.com/login?lang=en", wait_until="domcontentloaded")
for _ in range(600):
try:
cookies = await context.cookies()
except Exception:
cookies = []
if any(c.get("name") == "sessionid" and c.get("value") for c in cookies):
await asyncio.sleep(2)
await context.storage_state(path=str(account_file))
await browser.close()
return {"success": True}
await asyncio.sleep(1)
await browser.close()
return {"success": False}
except Exception:
return {"success": False}
def _login_new_platform(platform_type, account_name, status_queue):
uuid_v1 = str(uuid.uuid1())
account_file = Path(BASE_DIR / "cookiesFile" / (uuid_v1 + ".json"))
account_file.parent.mkdir(exist_ok=True)
cookie_filename = uuid_v1 + ".json"
if int(platform_type) == 5:
ok = _bilibili_login(account_file, status_queue)
if ok:
_insert_account(platform_type, account_name, cookie_filename)
status_queue.put("200")
else:
status_queue.put("500")
return
login_headless = (get_setting("login_mode", "qr") == "qr")
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
cb = _qrcode_to_queue(status_queue)
try:
status_queue.put("正在打开浏览器登录,请稍候...")
t = int(platform_type)
if t == 6:
result = loop.run_until_complete(baijiahao_setup(str(account_file), handle=True, return_detail=True, qrcode_callback=cb, headless=login_headless))
elif t == 7:
result = loop.run_until_complete(alipay_setup(str(account_file), handle=True, return_detail=True, qrcode_callback=cb, headless=login_headless))
elif t == 8:
result = loop.run_until_complete(weibo_setup(str(account_file), handle=True, return_detail=True, qrcode_callback=cb, headless=login_headless))
elif t == 9:
result = loop.run_until_complete(hupu_setup(str(account_file), handle=True, return_detail=True, qrcode_callback=cb, headless=login_headless))
elif t == 10:
status_queue.put("已打开浏览器,请在弹出的 Chrome 窗口中登录 Google / YouTube 账号")
result = loop.run_until_complete(youtube_setup(str(account_file), handle=True, return_detail=True, headless=False))
elif t == 11:
status_queue.put("已打开浏览器,请在 Chrome 窗口中登录 TikTok(手机号/邮箱/Google/扫码等任意方式)")
result = loop.run_until_complete(_tiktok_login_coro(str(account_file), status_queue))
else:
result = {"success": False}
if isinstance(result, dict) and result.get("success"):
_insert_account(platform_type, account_name, cookie_filename)
status_queue.put("200")
else:
status_queue.put("500")
except Exception:
status_queue.put("500")
finally:
try:
loop.close()
except Exception:
pass
# 包装函数:在线程中运行异步函数 # 包装函数:在线程中运行异步函数
def run_async_function(type,id,status_queue): def run_async_function(type,id,status_queue):
match type: match type:
@@ -708,6 +933,20 @@ def run_async_function(type,id,status_queue):
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
loop.run_until_complete(get_ks_cookie(id,status_queue)) loop.run_until_complete(get_ks_cookie(id,status_queue))
loop.close() loop.close()
case '5':
_login_new_platform('5', id, status_queue)
case '6':
_login_new_platform('6', id, status_queue)
case '7':
_login_new_platform('7', id, status_queue)
case '8':
_login_new_platform('8', id, status_queue)
case '9':
_login_new_platform('9', id, status_queue)
case '10':
_login_new_platform('10', id, status_queue)
case '11':
_login_new_platform('11', id, status_queue)
# SSE 流生成器函数 # SSE 流生成器函数
def sse_stream(status_queue): def sse_stream(status_queue):
@@ -719,5 +958,345 @@ def sse_stream(status_queue):
# 避免 CPU 占满 # 避免 CPU 占满
time.sleep(0.1) time.sleep(0.1)
@app.route('/publish/status', methods=['GET'])
def publish_status():
task_id = request.args.get('taskId')
with _publish_lock:
task = PUBLISH_TASKS.get(task_id)
if not task:
return jsonify({"code": 404, "msg": "任务不存在", "data": None}), 404
return jsonify({"code": 200, "msg": None, "data": task}), 200
@app.route('/publish/tasks', methods=['GET'])
def publish_tasks():
with _publish_lock:
tasks = list(PUBLISH_TASKS.values())
tasks = sorted(tasks, key=lambda t: t.get("created_at", ""), reverse=True)
return jsonify({"code": 200, "msg": None, "data": tasks}), 200
# 平台能力矩阵 —— 对齐 CLI 主线(docs/CLI.md / README 能力表)
@app.route('/platforms', methods=['GET'])
def platforms():
data = [
{"key": "douyin", "name": "抖音", "type": 3, "video": True, "note": True, "schedule": True, "cli": True, "web": True, "theme": "danger", "desc": "主线重构最完整,支持图文/定时"},
{"key": "kuaishou", "name": "快手", "type": 4, "video": True, "note": True, "schedule": True, "cli": True, "web": True, "theme": "warning", "desc": "浏览器自动化,支持图文/定时"},
{"key": "xiaohongshu", "name": "小红书", "type": 1, "video": True, "note": True, "schedule": True, "cli": True, "web": True, "theme": "danger", "desc": "浏览器自动化,支持图文/定时"},
{"key": "tencent", "name": "视频号", "type": 2, "video": True, "note": True, "schedule": True, "cli": True, "web": True, "theme": "success", "desc": "浏览器自动化,支持定时"},
{"key": "bilibili", "name": "B站", "type": 5, "video": True, "note": False, "schedule": True, "cli": True, "web": True, "theme": "primary", "desc": "封装 biliup,支持定时"},
{"key": "baijiahao", "name": "百家号", "type": 6, "video": True, "note": False, "schedule": False, "cli": True, "web": True, "theme": "primary", "desc": "浏览器自动化"},
{"key": "alipay", "name": "支付宝生活号", "type": 7, "video": True, "note": False, "schedule": False, "cli": True, "web": True, "theme": "primary", "desc": "浏览器自动化,支持生活号视频"},
{"key": "weibo", "name": "微博", "type": 8, "video": True, "note": False, "schedule": False, "cli": True, "web": True, "theme": "primary", "desc": "浏览器自动化,标题最多 30 字"},
{"key": "hupu", "name": "虎扑", "type": 9, "video": True, "note": False, "schedule": False, "cli": True, "web": True, "theme": "primary", "desc": "浏览器自动化,标题 4–40 字"},
{"key": "youtube", "name": "YouTube", "type": 10, "video": True, "note": False, "schedule": False, "cli": True, "web": True, "theme": "primary", "desc": "Studio 自动化,支持播放列表/可见性"},
{"key": "tiktok", "name": "TikTok", "type": 11, "video": True, "note": False, "schedule": True, "cli": False, "web": True, "theme": "default", "desc": "交互式登录 + Chrome 版发布,已接入 Web 后端"},
]
return jsonify({"code": 200, "msg": None, "data": data})
# ===================== Open API(对外开放) =====================
def _ensure_api_keys_table():
with sqlite3.connect(Path(BASE_DIR / "db" / "database.db")) as conn:
conn.execute("CREATE TABLE IF NOT EXISTS api_keys (id INTEGER PRIMARY KEY AUTOINCREMENT, key TEXT NOT NULL UNIQUE, name TEXT NOT NULL, enabled INTEGER DEFAULT 1, created_at TEXT)")
conn.commit()
def _ensure_default_api_key():
_ensure_api_keys_table()
with sqlite3.connect(Path(BASE_DIR / "db" / "database.db")) as conn:
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM api_keys")
if cur.fetchone()[0] == 0:
default_key = "sk-sau-" + uuid.uuid4().hex[:24]
cur.execute("INSERT INTO api_keys (key, name, enabled, created_at) VALUES (?, ?, 1, ?)", (default_key, "默认密钥", datetime.now().strftime("%Y-%m-%d %H:%M:%S")))
conn.commit()
print("已创建默认 API Key:", default_key)
def _get_valid_api_keys():
_ensure_default_api_key()
_ensure_api_keys_table()
with sqlite3.connect(Path(BASE_DIR / "db" / "database.db")) as conn:
cur = conn.cursor()
cur.execute("SELECT key FROM api_keys WHERE enabled = 1")
return {row[0] for row in cur.fetchall()}
def require_api_key(f):
@wraps(f)
def wrapper(*args, **kwargs):
auth = request.headers.get("X-API-Key") or ""
if not auth:
bearer = request.headers.get("Authorization", "") or ""
if bearer.startswith("Bearer "):
auth = bearer[7:].strip()
if not auth or auth not in _get_valid_api_keys():
return jsonify({"code": 401, "msg": "无效或缺失的 API Key", "data": None}), 401
return f(*args, **kwargs)
return wrapper
@app.route('/open/keys', methods=['GET'])
def list_api_keys():
_ensure_default_api_key()
_ensure_api_keys_table()
with sqlite3.connect(Path(BASE_DIR / "db" / "database.db")) as conn:
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute("SELECT id, key, name, enabled, created_at FROM api_keys ORDER BY id DESC")
rows = [dict(r) for r in cur.fetchall()]
return jsonify({"code": 200, "msg": None, "data": rows}), 200
@app.route('/open/keys', methods=['POST'])
def create_api_key():
data = request.get_json() or {}
name = data.get('name', '新密钥')
key = "sk-sau-" + uuid.uuid4().hex[:24]
_ensure_api_keys_table()
with sqlite3.connect(Path(BASE_DIR / "db" / "database.db")) as conn:
conn.execute("INSERT INTO api_keys (key, name, enabled, created_at) VALUES (?, ?, 1, ?)", (key, name, datetime.now().strftime("%Y-%m-%d %H:%M:%S")))
conn.commit()
return jsonify({"code": 200, "msg": "密钥已创建", "data": {"key": key}}), 200
@app.route('/open/keys/<int:key_id>', methods=['DELETE'])
def delete_api_key(key_id):
_ensure_api_keys_table()
with sqlite3.connect(Path(BASE_DIR / "db" / "database.db")) as conn:
conn.execute("DELETE FROM api_keys WHERE id = ?", (key_id,))
conn.commit()
return jsonify({"code": 200, "msg": "密钥已删除", "data": None}), 200
@app.route('/open/v1/platforms', methods=['GET'])
@require_api_key
def open_platforms():
data = [
{"key": "douyin", "name": "抖音", "type": 3, "video": True, "note": True, "schedule": True},
{"key": "kuaishou", "name": "快手", "type": 4, "video": True, "note": True, "schedule": True},
{"key": "xiaohongshu", "name": "小红书", "type": 1, "video": True, "note": True, "schedule": True},
{"key": "tencent", "name": "视频号", "type": 2, "video": True, "note": False, "schedule": True},
{"key": "bilibili", "name": "B站", "type": 5, "video": True, "note": False, "schedule": True},
{"key": "baijiahao", "name": "百家号", "type": 6, "video": True, "note": False, "schedule": False},
{"key": "alipay", "name": "支付宝生活号", "type": 7, "video": True, "note": False, "schedule": False},
{"key": "weibo", "name": "微博", "type": 8, "video": True, "note": False, "schedule": False},
{"key": "hupu", "name": "虎扑", "type": 9, "video": True, "note": False, "schedule": False},
{"key": "youtube", "name": "YouTube", "type": 10, "video": True, "note": False, "schedule": False},
{"key": "tiktok", "name": "TikTok", "type": 11, "video": True, "note": False, "schedule": True},
]
return jsonify({"code": 200, "msg": None, "data": data}), 200
@app.route('/open/v1/accounts', methods=['GET'])
@require_api_key
def open_accounts():
with sqlite3.connect(Path(BASE_DIR / "db" / "database.db")) as conn:
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute("SELECT id, type, filePath, userName, status FROM user_info ORDER BY id DESC")
rows = [dict(r) for r in cur.fetchall()]
return jsonify({"code": 200, "msg": None, "data": rows}), 200
@app.route('/open/v1/materials', methods=['GET'])
@require_api_key
def open_materials():
with sqlite3.connect(Path(BASE_DIR / "db" / "database.db")) as conn:
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute("SELECT * FROM file_records ORDER BY id DESC")
rows = [dict(r) for r in cur.fetchall()]
return jsonify({"code": 200, "msg": None, "data": rows}), 200
@app.route('/open/v1/materials', methods=['POST'])
@require_api_key
def open_upload_material():
if 'file' not in request.files:
return jsonify({"code": 400, "msg": "缺少文件字段 file", "data": None}), 400
file = request.files['file']
if file.filename == '':
return jsonify({"code": 400, "msg": "文件名为空", "data": None}), 400
custom_filename = request.form.get('filename', None)
if custom_filename:
filename = secure_filename(custom_filename + "." + file.filename.split('.')[-1])
else:
filename = secure_filename(file.filename)
if not filename:
return jsonify({"code": 400, "msg": "非法文件名", "data": None}), 400
try:
uuid_v1 = uuid.uuid1()
final_filename = str(uuid_v1) + "_" + filename
filepath = Path(BASE_DIR / "videoFile" / final_filename)
file.save(filepath)
with sqlite3.connect(Path(BASE_DIR / "db" / "database.db")) as conn:
conn.execute("INSERT INTO file_records (filename, filesize, file_path) VALUES (?, ?, ?)", (filename, round(float(os.path.getsize(filepath)) / (1024 * 1024), 2), final_filename))
conn.commit()
return jsonify({"code": 200, "msg": "上传成功", "data": {"filename": filename, "filePath": final_filename}}), 200
except Exception as e:
return jsonify({"code": 500, "msg": str(e), "data": None}), 500
@app.route('/open/v1/materials/<int:file_id>', methods=['DELETE'])
@require_api_key
def open_delete_material(file_id):
with sqlite3.connect(Path(BASE_DIR / "db" / "database.db")) as conn:
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute("SELECT * FROM file_records WHERE id = ?", (file_id,))
record = cur.fetchone()
if not record:
return jsonify({"code": 404, "msg": "素材不存在", "data": None}), 404
record = dict(record)
file_path = Path(BASE_DIR / "videoFile" / record['file_path'])
if file_path.exists():
try:
file_path.unlink()
except Exception:
pass
cur.execute("DELETE FROM file_records WHERE id = ?", (file_id,))
conn.commit()
return jsonify({"code": 200, "msg": "删除成功", "data": None}), 200
@app.route('/open/v1/publish', methods=['POST'])
@require_api_key
def open_publish():
data = request.get_json()
if not data:
return jsonify({"code": 400, "msg": "请求数据不能为空", "data": None}), 400
task_id, err = _create_publish_task(data)
if err:
return jsonify({"code": 400, "msg": err, "data": None}), 400
return jsonify({"code": 200, "msg": "发布任务已提交", "data": {"taskId": task_id}}), 200
@app.route('/open/v1/publish/<task_id>', methods=['GET'])
@require_api_key
def open_publish_status(task_id):
with _publish_lock:
task = PUBLISH_TASKS.get(task_id)
if not task:
return jsonify({"code": 404, "msg": "任务不存在", "data": None}), 404
return jsonify({"code": 200, "msg": None, "data": task}), 200
# ===================== 系统全局配置 =====================
@app.route('/settings', methods=['GET'])
def get_settings():
data = {
"login_mode": get_setting("login_mode", "qr"),
"publish_headless": get_bool("publish_headless", True),
}
return jsonify({"code": 200, "msg": None, "data": data}), 200
@app.route('/settings', methods=['POST'])
def update_settings():
data = request.get_json() or {}
if "login_mode" in data:
set_setting("login_mode", data["login_mode"])
if "publish_headless" in data:
set_setting("publish_headless", "true" if data["publish_headless"] else "false")
return jsonify({"code": 200, "msg": "配置已保存", "data": None}), 200
# ===================== 账号校验(单账号/批量,异步) =====================
def _run_check_task(task):
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
with sqlite3.connect(Path(BASE_DIR / "db" / "database.db")) as conn:
for aid in task["ids"]:
cur = conn.cursor()
cur.execute("SELECT type, filePath, userName FROM user_info WHERE id = ?", (aid,))
row = cur.fetchone()
if not row:
task["results"][str(aid)] = {"valid": None, "name": str(aid)}
task["done"] += 1
continue
ptype, fp, name = row
try:
valid = loop.run_until_complete(check_cookie(ptype, fp))
except Exception:
valid = False
task["results"][str(aid)] = {"valid": valid, "name": name}
cur.execute("UPDATE user_info SET status = ? WHERE id = ?", (1 if valid else 0, aid))
conn.commit()
task["done"] += 1
task["logs"].append("账号「" + name + "」校验" + ("有效" if valid else "失效"))
task["status"] = "done"
except Exception as e:
task["status"] = "error"
task["error"] = str(e)
finally:
try:
loop.close()
except Exception:
pass
@app.route('/checkAccounts', methods=['POST'])
def check_accounts():
data = request.get_json() or {}
ids = data.get('ids', [])
if not ids:
return jsonify({"code": 400, "msg": "请选择要校验的账号", "data": None}), 400
ids = [int(i) for i in ids]
task_id = str(uuid.uuid4())
task = {"id": task_id, "ids": ids, "total": len(ids), "done": 0, "status": "running", "results": {}, "logs": [], "error": None}
with _publish_lock:
CHECK_TASKS[task_id] = task
threading.Thread(target=_run_check_task, args=(task,), daemon=True).start()
return jsonify({"code": 200, "msg": "校验任务已提交", "data": {"taskId": task_id}}), 200
@app.route('/checkAccounts/status', methods=['GET'])
def check_account_status():
task_id = request.args.get('taskId')
task = CHECK_TASKS.get(task_id)
if not task:
return jsonify({"code": 404, "msg": "任务不存在", "data": None}), 404
return jsonify({"code": 200, "msg": None, "data": task}), 200
@app.route('/deleteAccounts', methods=['POST'])
def delete_accounts():
data = request.get_json() or {}
ids = data.get('ids', [])
if not ids:
return jsonify({"code": 400, "msg": "请选择要删除的账号", "data": None}), 400
ids = [int(i) for i in ids]
deleted = 0
with sqlite3.connect(Path(BASE_DIR / "db" / "database.db")) as conn:
cur = conn.cursor()
for aid in ids:
cur.execute("SELECT filePath FROM user_info WHERE id = ?", (aid,))
row = cur.fetchone()
if not row:
continue
cookie_file = Path(BASE_DIR / "cookiesFile" / row[0])
if cookie_file.exists():
try:
cookie_file.unlink()
except Exception:
pass
cur.execute("DELETE FROM user_info WHERE id = ?", (aid,))
deleted += 1
conn.commit()
return jsonify({"code": 200, "msg": "已删除 " + str(deleted) + " 个账号", "data": {"deleted": deleted}}), 200
if __name__ == '__main__': if __name__ == '__main__':
app.run(host='0.0.0.0' ,port=5409) app.run(host='0.0.0.0' ,port=5409)
+1 -1
View File
@@ -22,7 +22,7 @@ export default defineConfig({
open: true, open: true,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:5409', target: 'http://127.0.0.1:5409',
changeOrigin: true, changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '') rewrite: (path) => path.replace(/^\/api/, '')
} }
+13
View File
@@ -0,0 +1,13 @@
# indicate this is the root of the project
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
+4
View File
@@ -0,0 +1,4 @@
# 打包路径
VITE_BASE_URL= /
NODE_ENV=development
+4
View File
@@ -0,0 +1,4 @@
# 打包路径 根据项目不同按需配置
VITE_BASE_URL=https://static.tdesign.tencent.com/starter/react/
NODE_ENV=production
+5
View File
@@ -0,0 +1,5 @@
build
mock
config
public
scripts
+60
View File
@@ -0,0 +1,60 @@
module.exports = {
root: true,
extends: [
'airbnb-base',
'prettier',
'plugin:react/recommended',
'plugin:import/typescript',
'plugin:@typescript-eslint/recommended',
],
plugins: ['eslint-plugin-prettier'],
parser: '@typescript-eslint/parser',
env: {
jest: true,
},
settings: {
react: {
version: 'detect',
},
},
rules: {
'no-shadow': 'off',
'no-param-reassign': ['error', { props: false }],
'no-console': 'off',
'no-plusplus': [
'error',
{
allowForLoopAfterthoughts: true,
},
],
'react/display-name': 'off',
// jsx 单引号
'jsx-quotes': [2, 'prefer-single'],
'import/no-cycle': 'off', // TODO: remove
'import/extensions': 'off',
'import/no-unresolved': 'off',
'import/order': 'off',
'import/prefer-default-export': 'off',
'import/no-extraneous-dependencies': ['error', { devDependencies: true }],
// 关闭variable必须全部大写规则
'@typescript-eslint/naming-convention': [
'error',
{
selector: 'variable',
modifiers: ['const'],
format: null,
},
],
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/ban-types': 'off',
// 统一eslint prettier配置
'prettier/prettier': [
'warn',
{},
{
usePrettierrc: true,
},
],
},
};
+25
View File
@@ -0,0 +1,25 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/coverage
# production
/dist
# misc
.DS_Store
.idea
.VSCodeCounter
# log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# lock 文件 请根据自身项目或团队需求选择具体的包管理工具 并移除具体的ignore的lock文件
yarn.lock
pnpm-lock.yaml
# code editor setting
/.vscode
+22
View File
@@ -0,0 +1,22 @@
module.exports = {
printWidth: 120,
tabWidth: 2,
useTabs: false,
semi: true,
singleQuote: true,
quoteProps: 'as-needed',
jsxSingleQuote: true,
trailingComma: 'all',
bracketSpacing: true,
jsxBracketSameLine: false,
arrowParens: 'always',
rangeStart: 0,
rangeEnd: null,
requirePragma: false,
insertPragma: false,
proseWrap: 'preserve',
htmlWhitespaceSensitivity: 'css',
vueIndentScriptAndStyle: false,
endOfLine: 'lf',
embeddedLanguageFormatting: 'auto',
};
+9
View File
@@ -0,0 +1,9 @@
MIT License
Copyright (c) 2021-present TDesign
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.
+71
View File
@@ -0,0 +1,71 @@
# sau-web-react · 自媒体自动化发布管理端
基于 **腾讯 TDesign React Starter**(tdesign-react-starter)重构的 social-auto-upload 管理端前端,
替代历史 Vue 版 sau_frontend/,并将平台能力同步至 CLI 主线。
## 技术栈
- React 18 + TypeScript
- TDesign React(腾讯企业级组件库)
- Redux Toolkit(状态管理)
- React Router v6
- Vite(构建/开发服务器)
## 运行
先启动 Flask 后端(在项目根目录 social-auto-upload/ 下):
source .venv/bin/activate
python sau_backend.py # 监听 0.0.0.0:5409
再启动前端:
cd sau_web_react
npm install
npm run dev # 监听 0.0.0.0:5174
打开 http://127.0.0.1:5174/
开发环境通过 Vite 代理将 /api/* 转发到 http://127.0.0.1:5409(并去掉 /api 前缀)。
登录扫码走 SSE,前端 EventSource 直连后端 5409(后端已开启 CORS)。
## 页面与功能
| 页面 | 路由 | 功能 |
| --- | --- | --- |
| 首页 | /dashboard | 账号/平台/素材统计卡片、快捷操作、最近素材 |
| 账号管理 | /account | 平台筛选、搜索、扫码登录、Cookie 导入/导出、Cookie 校验、编辑、删除 |
| 素材管理 | /material | 素材上传(视频/图片多选)、预览、下载、删除 |
| 发布中心 | /publish | 多平台 Tab、选择素材/账号、标题/标签、定时发布、草稿、原创、单发/批量发布 |
| 关于 | /about | 系统信息 + 平台能力矩阵(对齐 CLI 主线) |
## 平台能力矩阵(已同步 CLI 主线)
平台配置集中在 src/configs/platforms.ts,共 11 个平台(10 个 CLI 主线 + TikTok 预留):
- Web 后端已接入(type 1-4):抖音、快手、小红书、视频号 —— 登录/发布走 Flask 后端
- CLI 主线已接入,Web 待接入(type 5-10):B站、百家号、支付宝、微博、虎扑、YouTube
- 未进 CLI 主线(type 11):TikTok
后端新增 GET /platforms 接口返回同一能力矩阵;前端 services/platform.ts 优先读取后端、
失败时回退本地静态配置。
## 目录结构
src/
├── configs/ # 平台能力矩阵、host、颜色
├── layouts/ # TDesign 布局(侧边菜单/Header/Footer/主题设置)
├── modules/ # Redux Toolkit slices(global/account/material)
├── pages/ # 页面(Dashboard/Account/Material/Publish/About)
├── router/ # 路由配置(modules/* 按页拆分)
├── services/ # 接口层(account/material/publish/platform)
├── utils/ # axios 封装等
└── types/ # 全局类型
## 与历史 Vue 前端的关系
- 历史版 sau_frontend/(Vue 3 + Element Plus)保留未删除,作为参考。
- 本 React 版复用同一 Flask 后端 sau_backend.py 的接口契约(/getAccounts、/getFiles、
/uploadSave、/postVideo 等),因此可无缝替换。
- 前端平台能力已扩展到 CLI 主线 10 平台;Web 后端其余平台(B站/百家号/支付宝/微博/虎扑/YouTube)
的发布通道逐步接入中,当前可通过 sau CLI 命令使用。
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EveryPublish</title>
<meta name="description" content="EveryPublish 多平台自媒体自动化发布管理端" />
<meta name="theme-color" content="#0052d9" />
<link rel="icon" href="/favicon.ico" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+67
View File
@@ -0,0 +1,67 @@
{
"name": "sau-web-react",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite --mode development",
"dev:mock": "vite --open --mode mock",
"build:test": "vite build --mode test",
"build": "vite build --mode release",
"preview": "vite preview --mode test",
"lint": "eslint ./src --ext ts,tsx",
"lint:fix": "eslint ./src --ext ts,tsx --fix"
},
"devDependencies": {
"@honkhonk/vite-plugin-svgr": "^1.1.0",
"@types/echarts": "^4.9.13",
"@types/lodash": "^4.14.178",
"@types/mockjs": "^1.0.6",
"@types/node": "^20.2.5",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^1.3.2",
"eslint": "^8.3.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-prettier": "^8.4.0",
"eslint-config-react-app": "^7.0.0",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react": "^7.28.0",
"less": "^4.1.3",
"prettier": "^2.5.1",
"typescript": "^4.8.4",
"vite": "^2.9.15",
"vite-plugin-mock": "^2.9.6"
},
"dependencies": {
"@reduxjs/toolkit": "^1.8.5",
"axios": "^1.2.0",
"axios-jsonp": "^1.0.4",
"classnames": "^2.3.1",
"dayjs": "^1.10.7",
"echarts": "^5.3.0",
"echarts-for-react": "^3.0.2",
"lodash": "^4.17.21",
"mockjs": "^1.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "^7.2.4",
"react-router-dom": "^6.3.0",
"tdesign-icons-react": "^0.6.0",
"tdesign-react": "^1.15.0",
"tvision-color": "^1.5.0"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+3
View File
@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
Binary file not shown.

After

Width:  |  Height:  |  Size: 789 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 972 KiB

@@ -0,0 +1,73 @@
<svg width="184" height="32" viewBox="0 0 188 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M77.7301 8.34426H80.6699L78.3234 21.7922H75.3799L77.7301 8.34426Z" fill="currentcolor"/>
<path d="M78.5992 3.09961H81.6221L81.0742 6.12246H78.0513L78.5992 3.09961Z" fill="currentcolor"/>
<path
d="M32.5765 6.46921H36.937L37.4131 3.82422H25.4615L24.9854 6.46921H29.3723L26.6706 21.7913H29.8559L32.5765 6.46921Z"
fill="currentcolor"/>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M51.4051 11.4275C51.5998 10.4187 51.7035 9.39434 51.7149 8.3669C51.7149 4.97375 50.007 3.8364 45.4765 3.84774H39.2381L36.083 21.8035H43.3908C48.1329 21.8035 49.8181 20.5906 50.8836 14.4504L51.4051 11.4275ZM45.0004 6.47006C47.5623 6.47006 48.5372 6.80258 48.5372 8.83922C48.5177 9.70444 48.4254 10.5665 48.2613 11.4162L47.7399 14.4391C47.0522 18.4481 46.3191 19.1585 42.7597 19.1585H39.7369L41.9473 6.47006H45.0004Z"
fill="currentcolor"/>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M63.863 10.8329C63.8629 11.1826 63.8313 11.5317 63.7685 11.8758L63.436 13.765C63.0582 15.8735 62.276 16.6103 60.3074 16.6103H55.0816L54.9607 17.3471C54.8956 17.6913 54.8564 18.0399 54.8436 18.39C54.8436 19.2175 55.2705 19.4102 56.6459 19.4102H61.4145L60.4396 21.8058H56.0754C52.9958 21.8058 51.9189 21.0199 51.9189 19.1004C51.9308 18.5439 51.9864 17.9893 52.0852 17.4416L53.011 12.1289C53.5777 8.85291 54.9531 8.19166 58.4407 8.19166H60.0542C62.5292 8.17654 63.863 8.83779 63.863 10.8329ZM60.9006 11.2599C60.9006 10.6893 60.5454 10.5457 59.4534 10.5457H57.7682C56.6762 10.5457 56.1547 10.6893 55.8789 12.1138L55.501 14.294H59.5327C59.6593 14.3151 59.7889 14.3081 59.9124 14.2735C60.036 14.239 60.1504 14.1778 60.2477 14.0942C60.345 14.0106 60.4228 13.9066 60.4755 13.7897C60.5283 13.6728 60.5547 13.5457 60.553 13.4174L60.8137 11.8758C60.8561 11.6728 60.8826 11.4669 60.893 11.2599H60.9006Z"
fill="currentcolor"/>
<path
d="M69.644 19.3964H64.0215L64.4485 21.8185H69.644C72.4477 21.8185 73.1543 21.486 73.5813 19.0186L73.842 17.5072C73.9744 16.8585 74.0527 16.2001 74.0762 15.5385C74.0762 14.1896 73.3885 13.7853 71.3028 13.7853H68.4575C67.9096 13.7853 67.7471 13.7361 67.7471 13.4301C67.754 13.2471 67.7768 13.065 67.8152 12.886L68.0079 11.8166C68.1741 10.8909 68.3366 10.7511 69.2624 10.7511H74.269L75.2627 8.35547H69.2624C66.5834 8.35547 65.873 8.6502 65.3969 11.3783L65.208 12.4477C65.073 13.0947 64.9933 13.752 64.9699 14.4125C64.9699 15.7652 65.6576 16.1695 67.7471 16.1695H70.4715C71.0194 16.1695 71.1856 16.2187 71.1856 16.5247C71.1769 16.7078 71.1529 16.8898 71.1139 17.0688L70.8985 18.3271C70.7322 19.2529 70.566 19.3964 69.644 19.3964Z"
fill="currentcolor"/>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M93.2642 13.8225C93.4341 12.9856 93.5377 12.1366 93.5741 11.2833C93.5741 9.00486 92.3158 8.17735 89.3194 8.16602H87.6833C83.7196 8.16602 82.843 9.03509 82.1553 12.8137L81.8001 14.7823C81.6298 15.6192 81.5262 16.4682 81.4902 17.3215C81.4902 19.5962 82.7372 20.4275 85.76 20.4275H89.1607L88.9945 21.3532C88.6393 23.439 88.2841 23.5826 86.4326 23.5826H81.5469L81.9739 26.0008H87.0976C89.8258 26.0008 91.2238 25.3585 91.7944 22.1354L93.2642 13.8225ZM90.3132 13.7961L89.5877 18.0432L86.4931 18.0318C84.9287 18.0318 84.43 17.8429 84.43 16.8227C84.475 16.1366 84.5697 15.4547 84.7133 14.7823L85.0723 12.8137C85.3784 11.0944 85.6618 10.5465 87.4641 10.5465H88.5335C90.0751 10.5465 90.5966 10.7392 90.5966 11.7557C90.5573 12.4423 90.4625 13.1247 90.3132 13.7961Z"
fill="currentcolor"/>
<path
d="M107.112 10.7615C107.059 11.7344 106.933 12.7019 106.735 13.6559L105.31 21.7911H102.37L103.818 13.6559C103.946 13.0334 104.026 12.4016 104.056 11.7666C104.056 10.9353 103.7 10.7691 102.586 10.7691H99.1208L97.2013 21.776H94.2578L96.6081 8.32812H103.13C106.183 8.34324 107.112 9.06872 107.112 10.7615Z"
fill="currentcolor"/>
<path
d="M112.333 21.9715H117.879C121.093 21.9715 122.473 21.5668 123.045 18.3532L123.473 15.9727C123.568 15.4728 123.783 14.0683 123.783 13.4017C123.783 11.6164 122.497 11.3307 120.474 11.3307H117.427C116.76 11.3307 116.427 11.2117 116.427 10.807C116.427 10.5928 116.498 10.1167 116.617 9.49773L116.903 8.02182C117.141 6.80777 117.332 6.56972 118.427 6.56972H124.211L125.306 3.95117H118.26C115.284 3.95117 114.356 4.61771 113.856 7.47431L113.404 9.99763C113.285 10.688 113.19 11.3069 113.19 11.8306C113.19 13.3303 113.832 14.0683 115.76 14.0683H119.307C120.164 14.0683 120.498 14.1159 120.498 14.6396C120.498 14.9015 120.45 15.4252 120.283 16.2583L120.045 17.4962C119.736 19.1625 119.498 19.3292 117.927 19.3292H111.856L112.333 21.9715Z"
fill="currentcolor"/>
<path
d="M130.124 10.8784H133.909L134.337 8.47411H130.552L131.266 4.47488H128.314L127.6 8.47411H125.815L125.41 10.8784H127.172L126.101 16.9487C125.958 17.7104 125.839 18.5436 125.839 19.1625C125.839 21.2574 127.172 21.9715 129.838 21.9715H131.576L132.599 19.5672H130.981C129.433 19.5672 128.838 19.5434 128.838 18.5912C128.838 18.2103 128.933 17.5914 129.052 16.9487L130.124 10.8784Z"
fill="currentcolor"/>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M136.155 8.47411H142.344C145.034 8.47411 146.081 9.21207 146.081 10.926C146.081 11.4973 145.915 12.4495 145.772 13.2589L144.248 21.9715H137.107C134.798 21.9715 134.084 21.1384 134.084 19.5672C134.084 18.9959 134.179 18.377 134.298 17.6628L134.441 16.8535C135.012 13.6636 136.059 13.4732 139.297 13.4732H142.868L142.939 13.0447C143.034 12.5448 143.106 12.1639 143.106 11.8306C143.106 11.1165 142.701 10.8784 141.344 10.8784H136.559L136.155 8.47411ZM137.059 18.8531C137.059 19.4244 137.297 19.5672 137.94 19.5672H141.796L142.463 15.7584H138.749C137.75 15.7584 137.512 15.9727 137.345 16.9487L137.154 18.0199C137.083 18.4008 137.059 18.6626 137.059 18.8531Z"
fill="currentcolor"/>
<path
d="M146.66 21.9715H149.636L151.231 12.9971C151.54 11.2117 151.897 10.926 153.611 10.926H155.778L156.801 8.47411H153.516C149.779 8.47411 148.922 9.14065 148.279 12.8304L146.66 21.9715Z"
fill="currentcolor"/>
<path
d="M165.098 10.8784H161.313L160.242 16.9487C160.123 17.5914 160.028 18.2103 160.028 18.5912C160.028 19.5434 160.623 19.5672 162.17 19.5672H163.789L162.765 21.9715H161.027C158.361 21.9715 157.028 21.2574 157.028 19.1625C157.028 18.5436 157.147 17.7104 157.29 16.9487L158.361 10.8784H156.6L157.004 8.47411H158.79L159.504 4.47488H162.456L161.742 8.47411H165.527L165.098 10.8784Z"
fill="currentcolor"/>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M173.343 8.30748H171.724C168.225 8.30748 166.844 8.97402 166.273 12.2591L165.345 17.5914C165.249 18.1627 165.178 18.7579 165.178 19.2578C165.178 21.186 166.273 21.9715 169.368 21.9715H173.748L174.724 19.5672H169.939C168.558 19.5672 168.13 19.3768 168.13 18.5436C168.13 18.2818 168.177 17.9009 168.249 17.4962L168.368 16.7582H173.605C175.581 16.7582 176.366 16.0203 176.747 13.9016L177.08 12.0211C177.152 11.6402 177.176 11.2593 177.176 10.9736C177.176 8.97402 175.843 8.30748 173.343 8.30748ZM174.129 12.0211L173.867 13.5684C173.772 14.1873 173.51 14.4492 172.843 14.4492H168.796L169.177 12.2591C169.439 10.8308 169.963 10.688 171.058 10.688H172.748C173.843 10.688 174.2 10.8308 174.2 11.4021C174.2 11.5688 174.176 11.7354 174.129 12.0211Z"
fill="currentcolor"/>
<path
d="M179.919 21.9715H176.943L178.562 12.8304C179.205 9.14065 180.062 8.47411 183.799 8.47411H187.084L186.06 10.926H183.894C182.18 10.926 181.823 11.2117 181.514 12.9971L179.919 21.9715Z"
fill="currentcolor"/>
<path
d="M5.21231 5.28999H0.481548C0.412498 5.29078 0.344103 5.27654 0.281107 5.24826C0.21811 5.21998 0.162019 5.17833 0.116725 5.12621C0.0714314 5.07408 0.0380182 5.01273 0.0188032 4.9464C-0.000411771 4.88008 -0.00496858 4.81036 0.00544886 4.7421L0.783833 0.377856C0.80328 0.271783 0.8593 0.175885 0.942146 0.106846C1.02499 0.0378082 1.12942 0 1.23726 0L6.14939 0L5.21231 5.28999Z"
fill="#0064FF"/>
<path d="M8.52984 16.5117H3.22852L5.21226 5.29688H10.5136L8.52984 16.5117Z" fill="url(#paint0_linear_18110_34031)"/>
<path
d="M7.20647 21.8093H2.85735C2.78839 21.8085 2.72041 21.7929 2.65803 21.7635C2.59565 21.7341 2.54035 21.6915 2.49589 21.6388C2.45142 21.5861 2.41884 21.5244 2.40036 21.458C2.38188 21.3916 2.37794 21.3219 2.38881 21.2538L3.23142 16.5117H8.52142L7.65235 21.4239C7.63472 21.53 7.58048 21.6267 7.49905 21.6971C7.41761 21.7675 7.3141 21.8072 7.20647 21.8093V21.8093Z"
fill="#009BFF"/>
<path
d="M20.0919 5.28999H5.21191L6.149 0H20.8476C20.9168 0.00013044 20.9851 0.0153276 21.0478 0.0445343C21.1105 0.0737409 21.166 0.116256 21.2106 0.169121C21.2552 0.221986 21.2878 0.283932 21.306 0.35065C21.3242 0.417368 21.3277 0.487255 21.3162 0.555449L20.5416 4.91213C20.5236 5.01823 20.4685 5.11448 20.3862 5.1837C20.3038 5.25293 20.1995 5.2906 20.0919 5.28999V5.28999Z"
fill="url(#paint1_linear_18110_34031)"/>
<defs>
<linearGradient id="paint0_linear_18110_34031" x1="7.81543" y1="4.88815" x2="7.92589" y2="15.4398"
gradientUnits="userSpaceOnUse">
<stop stop-color="#009BFF"/>
<stop offset="0.35" stop-color="#0081FE"/>
<stop offset="0.75" stop-color="#006AFD"/>
<stop offset="1" stop-color="#0062FD"/>
</linearGradient>
<linearGradient id="paint1_linear_18110_34031" x1="5.68577" y1="2.65136" x2="20.3785" y2="5.41299"
gradientUnits="userSpaceOnUse">
<stop offset="0.03" stop-color="#E9FFFF"/>
<stop offset="0.17" stop-color="#C4FAC9"/>
<stop offset="0.33" stop-color="#A0F694"/>
<stop offset="0.48" stop-color="#82F269"/>
<stop offset="0.63" stop-color="#6AEF47"/>
<stop offset="0.76" stop-color="#5AED2F"/>
<stop offset="0.89" stop-color="#4FEB20"/>
<stop offset="1" stop-color="#4CEB1B"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 9.6 KiB

@@ -0,0 +1,5 @@
<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="49" height="49" rx="24.5" fill="#0052D9" stroke="none"/>
<path d="M23.9219 31H26.0859L22.0781 19.7266H19.8125L15.8125 31H17.8516L18.8203 28.1172H22.9688L23.9219 31ZM20.8359 21.875H20.9688L22.5 26.5234H19.2891L20.8359 21.875ZM30.6328 29.6172C29.7734 29.6172 29.1562 29.1875 29.1562 28.4688C29.1562 27.7734 29.6641 27.3828 30.75 27.3125L32.6797 27.1875V27.8672C32.6797 28.8594 31.8047 29.6172 30.6328 29.6172ZM30.0625 31.1406C31.1797 31.1406 32.1172 30.6562 32.5938 29.8281H32.7266V31H34.5938V25.1641C34.5938 23.3516 33.3594 22.2812 31.1641 22.2812C29.1328 22.2812 27.7188 23.2422 27.5625 24.75H29.3906C29.5703 24.1719 30.1797 23.8594 31.0703 23.8594C32.1172 23.8594 32.6797 24.3281 32.6797 25.1641V25.8828L30.4766 26.0156C28.3984 26.1406 27.2344 27.0312 27.2344 28.5781C27.2344 30.1406 28.4141 31.1406 30.0625 31.1406Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 979 B

@@ -0,0 +1,5 @@
<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="49" height="49" rx="24.5" fill="#0594FA" stroke="none"/>
<path d="M20.6641 31C23.0703 31 24.5391 29.7734 24.5391 27.7969C24.5391 26.3281 23.4922 25.2344 21.9766 25.0938V24.9531C23.1094 24.7734 23.9844 23.7266 23.9844 22.5391C23.9844 20.8047 22.6953 19.7266 20.5547 19.7266H15.8438V31H20.6641ZM17.8594 21.3281H20.0625C21.2812 21.3281 21.9922 21.9062 21.9922 22.8984C21.9922 23.9141 21.2344 24.4688 19.8047 24.4688H17.8594V21.3281ZM17.8594 29.3984V25.9219H20.125C21.6641 25.9219 22.4766 26.5156 22.4766 27.6406C22.4766 28.7891 21.6875 29.3984 20.2031 29.3984H17.8594ZM31.2812 31.1406C33.4375 31.1406 34.7891 29.4453 34.7891 26.7266C34.7891 23.9922 33.4453 22.3125 31.2812 22.3125C30.1094 22.3125 29.125 22.8828 28.6797 23.8125H28.5469V19.1484H26.6094V31H28.4766V29.6484H28.6094C29.0938 30.5859 30.0859 31.1406 31.2812 31.1406ZM30.6719 23.9609C31.9922 23.9609 32.7969 25.0078 32.7969 26.7266C32.7969 28.4453 32 29.4922 30.6719 29.4922C29.3438 29.4922 28.5156 28.4297 28.5156 26.7266C28.5156 25.0234 29.3516 23.9609 30.6719 23.9609Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,5 @@
<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="49" height="49" rx="24.5" fill="#7587DB" stroke="none"/>
<path d="M21.1172 31.2812C23.7188 31.2812 25.625 29.7266 25.8906 27.4219H23.9062C23.6328 28.6875 22.5469 29.4844 21.1172 29.4844C19.1953 29.4844 18 27.9062 18 25.3594C18 22.8203 19.1953 21.2422 21.1094 21.2422C22.5312 21.2422 23.6172 22.1172 23.8984 23.4688H25.8828C25.6484 21.1172 23.6719 19.4453 21.1094 19.4453C17.9141 19.4453 15.9375 21.7031 15.9375 25.3672C15.9375 29.0156 17.9219 31.2812 21.1172 31.2812ZM35.3125 25.3281C35.1094 23.5312 33.7812 22.2812 31.5859 22.2812C29.0156 22.2812 27.5078 23.9297 27.5078 26.7031C27.5078 29.5156 29.0234 31.1719 31.5938 31.1719C33.7578 31.1719 35.1016 29.9688 35.3125 28.1797H33.4688C33.2656 29.0703 32.5938 29.5469 31.5859 29.5469C30.2656 29.5469 29.4688 28.5 29.4688 26.7031C29.4688 24.9297 30.2578 23.9062 31.5859 23.9062C32.6484 23.9062 33.2891 24.5 33.4688 25.3281H35.3125Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,5 @@
<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="49" height="49" rx="24.5" fill="#00A870" stroke="none"/>
<path d="M15.875 19.7266V31H20.1016C23.4766 31 25.4062 28.9297 25.4062 25.3125C25.4062 21.7734 23.4531 19.7266 20.1016 19.7266H15.875ZM17.8906 21.4688H19.8359C22.0469 21.4688 23.3516 22.8828 23.3516 25.3438C23.3516 27.8594 22.0781 29.2578 19.8359 29.2578H17.8906V21.4688ZM30.5938 31.1406C31.7812 31.1406 32.7656 30.5859 33.25 29.6484H33.3828V31H35.2578V19.1484H33.3203V23.8125H33.1875C32.7344 22.875 31.7656 22.3125 30.5938 22.3125C28.4375 22.3125 27.0781 24.0156 27.0781 26.7188C27.0781 29.4375 28.4297 31.1406 30.5938 31.1406ZM31.1953 23.9609C32.5234 23.9609 33.3438 25.0234 33.3438 26.7266C33.3438 28.4453 32.5312 29.4922 31.1953 29.4922C29.8672 29.4922 29.0625 28.4531 29.0625 26.7266C29.0625 25.0078 29.875 23.9609 31.1953 23.9609Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 958 B

@@ -0,0 +1,32 @@
<svg width="200" height="140" viewBox="0 0 200 140" fill="none" xmlns="http://www.w3.org/2000/svg">
<g mask="url(#mask0_17_619)">
<path d="M30 62H118V122H30V62Z" fill="#97A3B7" />
<g filter="url(#filter0_f_17_619)">
<rect x="12" y="84" width="80" height="60" fill="#E3E6EB" />
</g>
<g filter="url(#filter1_f_17_619)">
<rect x="80" y="54" width="80" height="60" fill="#E3E6EB" />
</g>
<rect x="46" y="105" width="32" height="2" fill="white" />
<rect x="46" y="98" width="32" height="2" fill="white" />
<rect x="46" y="88" width="16" height="2" fill="white" />
</g>
<path opacity="0.9" d="M63 20H151V30H63V20Z" fill="currentcolor" />
<mask id="mask1_17_619" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="63" y="30" width="88" height="50">
<path d="M63 30H151V80H63V30Z" fill="currentcolor" />
</mask>
<g mask="url(#mask1_17_619)">
<path d="M63 30H151V80H63V30Z" fill="currentcolor" />
<g opacity="0.3" filter="url(#filter2_f_17_619)">
<path d="M30 62H118V122H30V62Z" fill="#97A3B7" />
</g>
</g>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M95.6863 40.8577L105.964 51.1345C106.295 51.0466 106.642 50.9998 107 50.9998C109.213 50.9998 111 52.7865 111 54.9998C111 55.3574 110.953 55.7038 110.866 56.0333L121.142 66.3135L118.314 69.1419L113.716 64.5448C111.653 65.423 109.384 65.9089 107 65.9089C99.7273 65.9089 93.5164 61.3853 91 54.9998C92.1785 52.0093 94.1673 49.4271 96.6961 47.5268L92.8579 43.6861L95.6863 40.8577ZM99 54.9998C99 59.4158 102.584 62.9998 107 62.9998C108.483 62.9998 109.872 62.5957 111.063 61.8917L108.034 58.8657C107.704 58.9532 107.358 58.9998 107 58.9998C104.787 58.9998 103 57.2131 103 54.9998C103 54.6423 103.047 54.2958 103.134 53.9663L100.107 50.9389C99.4037 52.1295 99 53.5178 99 54.9998ZM107 44.0907C114.273 44.0907 120.484 48.6143 123 54.9998C122.071 57.3574 120.638 59.4612 118.834 61.1773L114.729 57.0717C114.906 56.4108 115 55.7162 115 54.9998C115 50.5838 111.416 46.9998 107 46.9998C106.284 46.9998 105.589 47.0941 104.928 47.2711L102.378 44.7205C103.848 44.3101 105.398 44.0907 107 44.0907Z"
fill="white" />
<rect x="68" y="24" width="2" height="2" fill="white" />
<rect x="74" y="24" width="2" height="2" fill="white" />
<rect x="80" y="24" width="66" height="2" fill="white" />
<path d="M157 53.9998L181.249 95.9998H132.751L157 53.9998Z" fill="white" stroke="black" />
<path d="M157 88.9998L157 70.9998" stroke="black" />
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -0,0 +1,36 @@
<svg width="200" height="140" viewBox="0 0 200 140" fill="none" xmlns="http://www.w3.org/2000/svg">
<g mask="url(#mask0_16559_24301)">
<path d="M30 62H118V122H30V62Z" fill="#97A3B7" />
<g filter="url(#filter0_f_16559_24301)">
<rect x="12" y="84" width="80" height="60" fill="#E3E6EB" />
</g>
<g filter="url(#filter1_f_16559_24301)">
<rect x="80" y="54" width="80" height="60" fill="#E3E6EB" />
</g>
<path d="M49 93L42 100L49 107" stroke="white" stroke-width="2" />
<path d="M69 107L76 100L69 93" stroke="white" stroke-width="2" />
<path d="M62.3647 87.4431L55.6355 112.557" stroke="white" stroke-width="2" />
</g>
<path opacity="0.9" d="M63 20H151V30H63V20Z" fill="currentcolor" />
<mask id="mask1_16559_24301" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="63" y="30" width="88" height="50">
<path d="M63 30H151V80H63V30Z" fill="currentcolor" />
</mask>
<g mask="url(#mask1_16559_24301)">
<path d="M63 30H151V80H63V30Z" fill="currentcolor" />
<g opacity="0.3" filter="url(#filter2_f_16559_24301)">
<path d="M30 62H118V122H30V62Z" fill="#97A3B7" />
</g>
</g>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M105.25 41C112.015 41 117.5 46.4845 117.5 53.25C117.5 55.6827 116.791 57.9498 115.568 59.8558L121 65.2877L117.288 69L111.856 63.5681C109.95 64.7909 107.683 65.5 105.25 65.5C98.4845 65.5 93 60.0155 93 53.25C93 46.4845 98.4845 41 105.25 41ZM105.25 44.5C100.418 44.5 96.5 48.4175 96.5 53.25C96.5 58.0825 100.418 62 105.25 62C110.082 62 114 58.0825 114 53.25C114 48.4175 110.082 44.5 105.25 44.5Z"
fill="white" />
<rect x="68" y="24" width="2" height="2" fill="white" />
<rect x="74" y="24" width="2" height="2" fill="white" />
<rect x="80" y="24" width="66" height="2" fill="white" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M153 56C140.85 56 131 65.8497 131 78C131 82.6039 132.414 86.8776 134.832 90.4102L127 98.5L139.495 95.3681C143.222 98.2709 147.909 100 153 100C165.15 100 175 90.1503 175 78C175 65.8497 165.15 56 153 56Z"
fill="white" />
<path
d="M131 78L131.5 78V78L131 78ZM134.832 90.4102L135.191 90.758L135.475 90.4647L135.245 90.1278L134.832 90.4102ZM127 98.5L126.641 98.1522L125.422 99.411L127.122 98.985L127 98.5ZM139.495 95.3681L139.802 94.9736L139.61 94.8238L139.373 94.8831L139.495 95.3681ZM153 100L153 100.5L153 100.5L153 100ZM175 78L174.5 78L174.5 78L175 78ZM131.5 78C131.5 66.1259 141.126 56.5 153 56.5V55.5C140.574 55.5 130.5 65.5736 130.5 78L131.5 78ZM135.245 90.1278C132.882 86.6757 131.5 82.5 131.5 78H130.5C130.5 82.7079 131.946 87.0794 134.419 90.6926L135.245 90.1278ZM134.473 90.0624L126.641 98.1522L127.359 98.8478L135.191 90.758L134.473 90.0624ZM127.122 98.985L139.616 95.8531L139.373 94.8831L126.878 98.015L127.122 98.985ZM153 99.5C148.024 99.5 143.445 97.8105 139.802 94.9736L139.187 95.7626C143 98.7314 147.794 100.5 153 100.5V99.5ZM174.5 78C174.5 89.8741 164.874 99.5 153 99.5L153 100.5C165.426 100.5 175.5 90.4264 175.5 78L174.5 78ZM153 56.5C164.874 56.5 174.5 66.1259 174.5 78H175.5C175.5 65.5736 165.426 55.5 153 55.5V56.5Z"
fill="black" />
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -0,0 +1,32 @@
<svg width="200" height="140" viewBox="0 0 200 140" fill="none" xmlns="http://www.w3.org/2000/svg">
<g mask="url(#mask0_16559_24251)">
<path d="M68 48L106.105 70V114L68 136L29.8949 114V70L68 48Z" fill="#97A3B7" />
<g filter="url(#filter0_f_16559_24251)">
<rect x="46.3911" y="92" width="80" height="60" fill="#E3E6EB" />
</g>
<g filter="url(#filter1_f_16559_24251)">
<rect y="23" width="80" height="60" fill="#E3E6EB" />
</g>
</g>
<mask id="mask1_16559_24251" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="80" y="9" width="78" height="88">
<path d="M119 9L157.105 31V75L119 97L80.8949 75V31L119 9Z" fill="currentcolor" />
</mask>
<g mask="url(#mask1_16559_24251)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M80.895 31V75L119 97L157.105 75V31L119 53L80.895 31Z"
fill="currentcolor" />
<path opacity="0.9" d="M119 -35L157.105 -13L157.105 31.5L119 53.5L80.8952 31.5L80.895 -13L119 -35Z"
fill="currentcolor" />
<g opacity="0.3" filter="url(#filter2_f_16559_24251)">
<path d="M68 48L106.105 70V114L68 136L29.8949 114V70L68 48Z" fill="#97A3B7" />
</g>
</g>
<path
d="M143 68.822L147.867 85.875L148 86.3405L148.469 86.2228L165.671 81.911L153.336 94.6522L152.999 95L153.336 95.3478L165.671 108.089L148.469 103.777L148 103.659L147.867 104.125L143 121.178L138.133 104.125L138 103.659L137.531 103.777L120.329 108.089L132.664 95.3478L133.001 95L132.664 94.6522L120.329 81.911L137.531 86.2228L138 86.3405L138.133 85.875L143 68.822Z"
fill="white" stroke="black" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M123.243 35.0821L126.071 33.4493L123.243 31.8164L120.414 33.4493L123.243 35.0821ZM119 32.6329L121.828 31L114.757 26.9179L111.929 28.5507L119 32.6329ZM127.485 35.8986C122.806 38.6001 115.194 38.6001 110.515 35.8986C105.835 33.197 105.835 28.803 110.515 26.1014C115.194 23.3999 122.806 23.3999 127.485 26.1014C132.165 28.803 132.165 33.197 127.485 35.8986ZM107.686 24.4686C101.438 28.0756 101.438 33.9244 107.686 37.5314C113.934 41.1384 124.066 41.1384 130.314 37.5314C136.562 33.9244 136.562 28.0756 130.314 24.4686C124.066 20.8616 113.934 20.8616 107.686 24.4686Z"
fill="white" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M41.8989 86.2863L44.7272 87.9193L44.7275 94.4512L41.8992 92.8181L41.8989 86.2863ZM53.9194 93.2269L56.7477 94.86L56.7479 101.392L53.9196 99.7587L53.9194 93.2269ZM44.7281 107.515L41.8999 105.882L41.9 109.148L44.7283 110.781L44.7282 107.515L53.92 112.822L53.9201 116.088L56.7484 117.721L56.7483 114.455L53.9201 112.822L53.92 109.556L44.728 104.249L44.7281 107.515Z"
fill="white" />
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

@@ -0,0 +1,33 @@
<svg width="200" height="140" viewBox="0 0 200 140" fill="none" xmlns="http://www.w3.org/2000/svg">
<g mask="url(#mask0_22_990)">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M144.569 105.61L96.5692 133.322L48.5693 105.61V83.7121L96.569 55.9995L144.569 83.7122V105.61Z"
fill="#97A3B7" />
<g filter="url(#filter0_f_22_990)">
<rect x="-3" y="33.9995" width="80" height="60" fill="#E3E6EB" />
</g>
<g filter="url(#filter1_f_22_990)">
<rect x="97" y="97.9995" width="80" height="60" fill="#E3E6EB" />
</g>
</g>
<mask id="mask1_22_990" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="53" y="16" width="86" height="69">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M113.357 42.715L129.829 33.2059C128.859 32.4995 127.789 31.823 126.643 31.1615C121.268 28.0584 114.723 26.0153 107.758 25.023C103.549 19.4606 97.5775 16.1248 90.4344 16.1945C83.6788 16.2821 74.9482 21.9412 68.9271 30.5602C68.096 30.975 67.2847 31.4099 66.4949 31.8659C52.1168 40.1664 49.5542 52.6155 59.0218 61.9309C57.9871 56.1259 58.712 51.0657 62.1231 45.7161C62.0653 46.3482 61.9127 50.143 61.8906 50.7834C61.2209 69.6969 76.9107 84.8409 88.03 84.7113C96.4806 84.6119 103.595 79.6976 108.349 72.0797C114.563 70.8487 120.438 68.786 125.443 65.8968C138.919 58.1167 142.01 46.7146 134.547 37.629L117.948 47.2113C119.71 50.8655 117.997 55.034 112.87 57.9936C107.744 60.9532 100.523 61.9424 94.1928 60.9252C91.3499 60.4563 88.6706 59.5834 86.4524 58.3029L86.4042 58.275L113.357 42.715ZM78.6546 53.7727C72.5285 49.7965 72.9717 43.5469 79.8498 39.5762C86.7276 35.6056 97.5532 35.3496 104.441 38.8864L78.6546 53.7727ZM93.5561 18.1703C98.1657 18.1302 102.284 20.5752 105.496 24.7401C97.0486 23.8219 88.1122 24.4143 79.9732 26.5054C83.672 21.3809 88.444 18.2301 93.5561 18.1703ZM91.3238 81.6169C85.471 81.685 80.3525 77.691 76.9473 71.2848C85.7921 73.6267 95.8719 74.0599 105.374 72.6022C101.618 78.1222 96.6601 81.5531 91.3238 81.6169Z"
fill="currentcolor" />
</mask>
<g mask="url(#mask1_22_990)">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M113.357 42.715L129.829 33.2059C128.859 32.4995 127.789 31.823 126.643 31.1615C121.268 28.0584 114.723 26.0153 107.758 25.023C103.549 19.4606 97.5775 16.1248 90.4344 16.1945C83.6788 16.2821 74.9482 21.9412 68.9271 30.5602C68.096 30.975 67.2847 31.4099 66.4949 31.8659C52.1168 40.1664 49.5542 52.6155 59.0218 61.9309C57.9871 56.1259 58.712 51.0657 62.1231 45.7161C62.0653 46.3482 61.9127 50.143 61.8906 50.7834C61.2209 69.6969 76.9107 84.8409 88.03 84.7113C96.4806 84.6119 103.595 79.6976 108.349 72.0797C114.563 70.8487 120.438 68.786 125.443 65.8968C138.919 58.1167 142.01 46.7146 134.547 37.629L117.948 47.2113C119.71 50.8655 117.997 55.034 112.87 57.9936C107.744 60.9532 100.523 61.9424 94.1928 60.9252C91.3499 60.4563 88.6706 59.5834 86.4524 58.3029L86.4042 58.275L113.357 42.715ZM78.6546 53.7727C72.5285 49.7965 72.9717 43.5469 79.8498 39.5762C86.7276 35.6056 97.5532 35.3496 104.441 38.8864L78.6546 53.7727ZM93.5561 18.1703C98.1657 18.1302 102.284 20.5752 105.496 24.7401C97.0486 23.8219 88.1122 24.4143 79.9732 26.5054C83.672 21.3809 88.444 18.2301 93.5561 18.1703ZM91.3238 81.6169C85.471 81.685 80.3525 77.691 76.9473 71.2848C85.7921 73.6267 95.8719 74.0599 105.374 72.6022C101.618 78.1222 96.6601 81.5531 91.3238 81.6169Z"
fill="currentcolor" />
<g opacity="0.3" filter="url(#filter2_f_22_990)">
<path d="M96.569 55.9995L144.569 83.7122V139.138L96.569 166.85L48.5692 139.138V83.7122L96.569 55.9995Z"
fill="#97A3B7" />
</g>
</g>
<ellipse cx="155" cy="78" rx="22" ry="22" transform="rotate(180 155 78)" fill="white" stroke="black" />
<path d="M155 83L155 65" stroke="black" />
<rect x="155" y="87" width="0.00390625" height="0.00390625" fill="#C4C4C4" stroke="black" stroke-width="2"
stroke-linejoin="round" />
<path d="M96.5693 112L96.5693 87.9995" stroke="white" stroke-width="2" />
<path d="M86.5693 97.9995L96.5693 87.9995L106.569 97.9995" stroke="white" stroke-width="2" />
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,49 @@
<svg width="200" height="140" viewBox="0 0 200 140" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_216_313" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="80" y="9" width="78" height="88">
<path d="M119 9L157.105 31V75L119 97L80.8949 75V31L119 9Z" fill="currentColor"/>
</mask>
<g mask="url(#mask0_216_313)">
<path d="M119 9L157.105 31V75L119 97L80.8949 75V31L119 9Z" fill="currentColor"/>
<path opacity="0.9" d="M119 -35L157.105 -13V31L119 53L80.8949 31V-13L119 -35Z" fill="currentColor" />
<g opacity="0.3" filter="url(#filter0_f_216_313)">
<path d="M68 48L106.105 70V114L68 136L29.8949 114V70L68 48Z" fill="#97A3B7"/>
</g>
</g>
<mask id="mask1_216_313" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="29" y="48" width="78" height="88">
<path d="M68 48L106.105 70V114L68 136L29.8949 114V70L68 48Z" fill="#97A3B7"/>
</mask>
<g mask="url(#mask1_216_313)">
<path d="M68 48L106.105 70V114L68 136L29.8949 114V70L68 48Z" fill="#97A3B7"/>
<g filter="url(#filter1_f_216_313)">
<rect x="46.3906" y="92" width="80" height="60" fill="#E3E6EB"/>
</g>
<g filter="url(#filter2_f_216_313)">
<rect y="23" width="80" height="60" fill="#E3E6EB"/>
</g>
</g>
<path d="M41.8984 86.2866L44.7267 87.9197L44.727 94.4515L41.8987 92.8185L41.8984 86.2866Z" fill="white"/>
<path d="M53.9189 93.2273L56.7472 94.8603L56.7474 101.392L53.9191 99.7591L53.9189 93.2273Z" fill="white"/>
<path d="M44.7276 107.515L41.8994 105.882L41.8995 109.148L44.7278 110.781L44.7276 107.515L53.9195 112.823L53.9196 116.088L56.7479 117.721L56.7478 114.455L53.9195 112.823L53.9195 109.557L44.7275 104.249L44.7276 107.515Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M108.348 23.4792C106.188 25.9904 106.535 29.3829 109.395 31.5604C112.66 34.0461 117.963 34.007 121.24 31.4731C124.516 28.9392 124.526 24.8699 121.261 22.3841C118.401 20.2066 113.977 19.9666 110.721 21.6439L115.923 25.6047L113.55 27.4399L108.348 23.4792Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M120.865 33.0087L129.83 39.8346L132.203 37.9994L123.238 31.1735C122.917 31.521 122.554 31.8531 122.149 32.1657C121.745 32.4784 121.315 32.7594 120.865 33.0087ZM119.662 32.0932C120.123 31.8557 120.561 31.5798 120.967 31.2655C121.373 30.9513 121.73 30.6134 122.035 30.2579L122.035 30.2579C121.73 30.6134 121.373 30.9513 120.967 31.2655C120.561 31.5798 120.123 31.8557 119.662 32.0931L119.662 32.0932Z" fill="white"/>
<path d="M144 70L168.249 112H119.751L144 70Z" fill="white" stroke="#181818"/>
<path d="M144 100L144 82" stroke="#181818"/>
<path d="M144 105H144.004V105.004H144V105Z" stroke="#181818" stroke-width="2" stroke-linejoin="round"/>
<defs>
<filter id="filter0_f_216_313" x="23.8949" y="42" width="88.2102" height="100" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="3" result="effect1_foregroundBlur_216_313"/>
</filter>
<filter id="filter1_f_216_313" x="-3.60938" y="42" width="180" height="160" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="25" result="effect1_foregroundBlur_216_313"/>
</filter>
<filter id="filter2_f_216_313" x="-50" y="-27" width="180" height="160" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="25" result="effect1_foregroundBlur_216_313"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

@@ -0,0 +1,23 @@
<svg width="200" height="140" viewBox="0 0 200 140" fill="none" xmlns="http://www.w3.org/2000/svg">
<g mask="url(#mask0_21_716)">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M33 46.843L96.3214 119L159.643 46.843C142.742 31.9998 120.583 23 96.3214 23C72.0601 23 49.9009 31.9998 33 46.843Z"
fill="#97A3B7" />
<g filter="url(#filter0_f_21_716)">
<rect x="95" y="21" width="80" height="60" fill="#E3E6EB" />
</g>
<g filter="url(#filter1_f_21_716)">
<rect x="-7" y="43" width="80" height="60" fill="#E3E6EB" />
</g>
</g>
<path
d="M72.8122 63.6882L69.6548 66.8455L75.9009 73.0916C71.2469 75.1648 66.9663 77.925 63.188 81.2433L96.3213 119L108.234 105.425L114.647 111.837L117.804 108.68L80.4504 71.3261C80.4505 71.3261 80.4503 71.3261 80.4504 71.3261L72.8122 63.6882Z"
fill="currentcolor" />
<path
d="M129.455 81.2433L114.137 98.6982L85.3974 69.9585C88.9142 69.1786 92.5697 68.7674 96.3213 68.7674C109.016 68.7674 120.611 73.4766 129.455 81.2433Z"
fill="currentcolor" />
<path
d="M152 21.822L156.867 38.875L157 39.3405L157.469 39.2228L174.671 34.911L162.336 47.6522L161.999 48L162.336 48.3478L174.671 61.089L157.469 56.7772L157 56.6595L156.867 57.125L152 74.178L147.133 57.125L147 56.6595L146.531 56.7772L129.329 61.089L141.664 48.3478L142.001 48L141.664 47.6522L129.329 34.911L146.531 39.2228L147 39.3405L147.133 38.875L152 21.822Z"
fill="white" stroke="black" />
<path d="M101 31L90 42L101 53L93 61" stroke="white" stroke-width="2" />
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,13 @@
<svg width="88" height="48" viewBox="0 0 88 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0H88V48H0V0Z" fill="var(--td-component-border)"/>
<path d="M42.8627 14.0518V16.7601H44.4877V14.0518H42.8627Z" fill="var(--td-text-color-primary)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.3488 23.9824C38.3488 21.0407 40.7335 18.656 43.6752 18.656C46.6168 18.656 49.0015 21.0407 49.0015 23.9824C49.0015 26.9241 46.6168 29.3088 43.6752 29.3088C40.7335 29.3088 38.3488 26.9241 38.3488 23.9824ZM43.6752 20.281C41.6309 20.281 39.9738 21.9382 39.9738 23.9824C39.9738 26.0266 41.6309 27.6838 43.6752 27.6838C45.7194 27.6838 47.3766 26.0266 47.3766 23.9824C47.3766 21.9382 45.7194 20.281 43.6752 20.281Z" fill="var(--td-text-color-primary)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M52.208 26.781H49.5867L47.5262 33.48L49.0794 33.9577L49.5903 32.2968H52.2045L52.7154 33.9577L54.2686 33.48L52.208 26.781ZM51.7047 30.6718L51.0077 28.406H50.787L50.0901 30.6718H51.7047Z" fill="var(--td-text-color-primary)"/>
<path d="M48.2077 18.3009L50.1225 16.3861L51.2715 17.5351L49.3567 19.4499L48.2077 18.3009Z" fill="var(--td-text-color-primary)"/>
<path d="M53.6057 23.1699H50.8974V24.7949H53.6057V23.1699Z" fill="var(--td-text-color-primary)"/>
<path d="M44.4877 31.2045V33.9129H42.8627V31.2045H44.4877Z" fill="var(--td-text-color-primary)"/>
<path d="M37.2279 31.5786L39.1427 29.6638L37.9936 28.5147L36.0788 30.4295L37.2279 31.5786Z" fill="var(--td-text-color-primary)"/>
<path d="M36.453 24.7949H33.7446V23.1699H36.453V24.7949Z" fill="var(--td-text-color-primary)"/>
<path d="M36.0788 17.5351L37.9936 19.4499L39.1427 18.3009L37.2279 16.3861L36.0788 17.5351Z" fill="var(--td-text-color-primary)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,5 @@
<svg width="88" height="48" viewBox="0 0 88 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0H88V48H0V0Z" fill="#13161B"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M52.5327 26.8699C52.0346 26.9554 51.5225 26.9999 51 26.9999C46.0294 26.9999 42 22.9705 42 17.9999C42 16.9964 42.1642 16.0313 42.4673 15.1299C38.2268 15.8574 35 19.5518 35 23.9999C35 28.9705 39.0294 32.9999 44 32.9999C47.9671 32.9999 51.3346 30.4332 52.5327 26.8699Z" fill="#949EAA"/>
</svg>

After

Width:  |  Height:  |  Size: 495 B

@@ -0,0 +1,13 @@
<svg width="88" height="48" viewBox="0 0 88 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="88" height="48" fill="var(--td-component-border)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M43.9999 20.583C42.1129 20.583 40.5832 22.1127 40.5832 23.9997C40.5832 25.8867 42.1129 27.4163 43.9999 27.4163C45.8869 27.4163 47.4166 25.8867 47.4166 23.9997C47.4166 22.1127 45.8869 20.583 43.9999 20.583ZM39.0832 23.9997C39.0832 21.2843 41.2845 19.083 43.9999 19.083C46.7153 19.083 48.9166 21.2843 48.9166 23.9997C48.9166 26.7151 46.7153 28.9163 43.9999 28.9163C41.2845 28.9163 39.0832 26.7151 39.0832 23.9997Z" fill="var(--td-text-color-primary)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M43.2499 17.333V14.833H44.7499V17.333H43.2499Z" fill="var(--td-text-color-primary)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M48.1838 18.7552L49.9513 16.9877L51.0119 18.0483L49.2444 19.8158L48.1838 18.7552Z" fill="var(--td-text-color-primary)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M50.6666 23.2497H53.1666V24.7497H50.6666V23.2497Z" fill="var(--td-text-color-primary)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M49.2444 28.1835L51.0119 29.951L49.9513 31.0117L48.1838 29.2442L49.2444 28.1835Z" fill="var(--td-text-color-primary)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M44.7499 30.6663V33.1663H43.2499V30.6663H44.7499Z" fill="var(--td-text-color-primary)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M39.8162 29.2442L38.0487 31.0117L36.988 29.951L38.7555 28.1835L39.8162 29.2442Z" fill="var(--td-text-color-primary)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M37.3333 24.7497H34.8333V23.2497H37.3333V24.7497Z" fill="var(--td-text-color-primary)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.7555 19.8158L36.988 18.0483L38.0487 16.9877L39.8162 18.7552L38.7555 19.8158Z" fill="var(--td-text-color-primary)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,51 @@
<svg width="28" height="28" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2557_23660)">
<path
d="M9.14038 8.25177H3.53919C3.45719 8.25173 3.37619 8.23372 3.30188 8.19901C3.22758 8.16431 3.16179 8.11375 3.10912 8.05089C3.05646 7.98803 3.01819 7.91439 2.99703 7.83516C2.97587 7.75593 2.97234 7.67303 2.98665 7.59228L3.87785 2.44561C3.90079 2.32052 3.96685 2.20742 4.06455 2.12601C4.16225 2.04459 4.2854 2.00001 4.41258 2.00001H10.2054L9.14038 8.25177Z"
fill="url(#paint0_linear_2557_23660)"/>
<path d="M13.0301 21.4727H6.77832L9.11772 8.2473H15.3695L13.0301 21.4727Z" fill="url(#paint1_linear_2557_23660)"/>
<path
d="M11.4707 27.7245H6.34183C6.26051 27.7236 6.18034 27.7051 6.10678 27.6704C6.03322 27.6358 5.96801 27.5856 5.91557 27.5235C5.86313 27.4613 5.82471 27.3886 5.80292 27.3102C5.78113 27.2319 5.77648 27.1497 5.78929 27.0694L6.78297 21.4861H13.0214L11.9965 27.2789C11.9738 27.4025 11.9091 27.5144 11.8132 27.5956C11.7174 27.6769 11.5963 27.7224 11.4707 27.7245Z"
fill="url(#paint2_linear_2557_23660)"/>
<path
d="M26.666 8.25176H9.14062L10.2457 2.01337H27.5795C27.6613 2.01417 27.7419 2.03268 27.8158 2.06763C27.8898 2.10258 27.9552 2.15313 28.0077 2.21581C28.0603 2.27849 28.0986 2.35179 28.12 2.43069C28.1415 2.50959 28.1456 2.59221 28.1321 2.67285L27.2186 7.81953C27.1941 7.94607 27.1246 8.0595 27.0231 8.13892C26.9216 8.21834 26.7948 8.25841 26.666 8.25176V8.25176Z"
fill="url(#paint3_linear_2557_23660)"/>
</g>
<defs>
<linearGradient id="paint0_linear_2557_23660" x1="2.71742" y1="5.12812" x2="10.0624" y2="4.98122"
gradientUnits="userSpaceOnUse">
<stop stop-color="#0062FF"/>
<stop offset="0.26" stop-color="#006AFF"/>
<stop offset="0.68" stop-color="#0081FF"/>
<stop offset="1" stop-color="#0097FF"/>
</linearGradient>
<linearGradient id="paint1_linear_2557_23660" x1="12.383" y1="7.62346" x2="9.12269" y2="22.0419"
gradientUnits="userSpaceOnUse">
<stop stop-color="#0097FF"/>
<stop offset="0.32" stop-color="#0081FF"/>
<stop offset="0.74" stop-color="#006AFF"/>
<stop offset="1" stop-color="#0062FF"/>
</linearGradient>
<linearGradient id="paint2_linear_2557_23660" x1="5.63057" y1="27.3635" x2="12.8582" y2="21.4727"
gradientUnits="userSpaceOnUse">
<stop stop-color="#009EFF"/>
<stop offset="0.31" stop-color="#00A3FF"/>
<stop offset="0.71" stop-color="#00B3FF"/>
<stop offset="1" stop-color="#00C3FF"/>
</linearGradient>
<linearGradient id="paint3_linear_2557_23660" x1="8.84911" y1="5.12811" x2="27.9399" y2="4.74629"
gradientUnits="userSpaceOnUse">
<stop offset="0.03" stop-color="#ECFFFE"/>
<stop offset="0.19" stop-color="#AFF1D9"/>
<stop offset="0.34" stop-color="#79E5B9"/>
<stop offset="0.49" stop-color="#4EDB9F"/>
<stop offset="0.63" stop-color="#2CD48A"/>
<stop offset="0.77" stop-color="#14CE7C"/>
<stop offset="0.89" stop-color="#05CB73"/>
<stop offset="1" stop-color="#00CA70"/>
</linearGradient>
<clipPath id="clip0_2557_23660">
<rect width="25.1407" height="25.72" fill="white" transform="translate(2.97754 2)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

@@ -0,0 +1,86 @@
.boardPanelDark {
background: var(--td-brand-color) !important;
.boardTitle,
.boardItemLeft,
.boardItemDesc,
.trendColorUp,
.trendColorDown,
.boardItemBottom {
color: var(--td-text-color-anti);
}
.trendIconUp,
.trendIconDown {
background: var(--td-brand-color-5);
}
}
.boardPanel {
:global {
.t-card__body {
padding-top: 0;
}
}
}
.boardTitle {
line-height: 22px;
font-size: 14px;
color: var(--td-text-color-secondary);
}
.boardItem {
display: flex;
justify-content: space-between;
align-items: center;
}
.boardItemLeft {
display: inline-block;
color: var(--td-text-color-primary);
font-size: 27px;
line-height: 44px;
}
.boardItemBottom {
display: flex;
justify-content: space-between;
align-items: center;
}
.boardItemIcon {
opacity: 0.6;
}
.boardItemDesc {
display: flex;
align-items: center;
justify-content: center;
line-height: 22px;
color: var(--td-text-color-placeholder);
}
.trendIcon {
border-radius: 50%;
width: 16px;
height: 16px;
margin-left: 8px;
margin-right: 8px;
}
.trendIconUp {
background: var(--td-error-color-2);
}
.trendIconDown {
background: var(--td-success-color-2);
}
.trendColorUp {
color: #e34d59;
display: flex;
align-items: center;
}
.trendColorDown {
color: #00a870;
display: flex;
align-items: center;
}
@@ -0,0 +1,77 @@
import React from 'react';
import { ChevronRightIcon } from 'tdesign-icons-react';
import { Card } from 'tdesign-react';
import classnames from 'classnames';
import Style from './index.module.less';
export enum ETrend {
up,
down,
}
export interface IBoardProps extends React.HTMLAttributes<HTMLElement> {
title?: string;
count?: string;
Icon?: React.ReactElement;
desc?: string;
trend?: ETrend;
trendNum?: string;
dark?: boolean;
border?: boolean;
}
export const TrendIcon = ({ trend, trendNum }: { trend?: ETrend; trendNum?: string | number }) => (
<div
className={classnames({
[Style.trendColorUp]: trend === ETrend.up,
[Style.trendColorDown]: trend === ETrend.down,
})}
>
<div
className={classnames(Style.trendIcon, {
[Style.trendIconUp]: trend === ETrend.up,
[Style.trendIconDown]: trend === ETrend.down,
})}
>
{trend === ETrend.up ? (
<svg width='16' height='16' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path d='M4.5 8L8 4.5L11.5 8' stroke='currentColor' strokeWidth='1.5' />
<path d='M8 5V12' stroke='currentColor' strokeWidth='1.5' />
</svg>
) : (
<svg width='16' height='16' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path d='M11.5 8L8 11.5L4.5 8' stroke='currentColor' strokeWidth='1.5' />
<path d='M8 11L8 4' stroke='currentColor' strokeWidth='1.5' />
</svg>
)}
</div>
{trendNum}
</div>
);
const Board = ({ title, count, desc, trend, trendNum, Icon, dark, border = false }: IBoardProps) => (
<Card
title={<span className={Style.boardTitle}>{title}</span>}
className={classnames({
[Style.boardPanelDark]: dark,
[Style.boardPanel]: true,
})}
bordered={border}
footer={
<div className={Style.boardItemBottom}>
<div className={Style.boardItemDesc}>
{desc}
<TrendIcon trend={trend} trendNum={trendNum} />
</div>
<ChevronRightIcon className={Style.boardItemIcon} />
</div>
}
>
<div className={Style.boardItem}>
<div className={Style.boardItemLeft}>{count}</div>
<div className={Style.boardItemRight}>{Icon}</div>
</div>
</Card>
);
export default React.memo(Board);
@@ -0,0 +1,20 @@
import React from 'react';
import { DateRangePicker, DateRangeValue } from 'tdesign-react';
import dayjs from 'dayjs';
const RECENT_7_DAYS: DateRangeValue = [
dayjs().subtract(7, 'day').format('YYYY-MM-DD'),
dayjs().subtract(1, 'day').format('YYYY-MM-DD'),
];
const LastWeekDatePicker = (onChange: (value: DateRangeValue) => void) => (
<DateRangePicker
mode='date'
placeholder={['开始时间', '结束时间']}
value={RECENT_7_DAYS}
format='YYYY-MM-DD'
onChange={(value) => onChange(value)}
/>
);
export default LastWeekDatePicker;
@@ -0,0 +1,33 @@
.errorBox {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 75vh;
padding: 24px;
min-height: 400px;
color: var(--td-brand-color);
img {
width: 200px;
height: 140px;
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;
}
}
@@ -0,0 +1,51 @@
import React, { memo } from 'react';
import { Button } from 'tdesign-react';
import Light403Icon from 'assets/svg/assets-result-403.svg?component';
import Light404Icon from 'assets/svg/assets-result-404.svg?component';
import Light500Icon from 'assets/svg/assets-result-500.svg?component';
import style from './index.module.less';
enum ECode {
forbidden = 403,
notFount = 404,
error = 500,
}
interface IErrorPageProps {
code: ECode;
title?: string;
desc?: string;
}
const errorInfo = {
[ECode.forbidden]: {
title: '403 Forbidden',
desc: '抱歉,您无权限访问此页面',
icon: <Light403Icon />,
},
[ECode.notFount]: {
title: '404 Not Found',
desc: '抱歉,您访问的页面不存在。',
icon: <Light404Icon />,
},
[ECode.error]: {
title: '500 Internal Server Error',
desc: '抱歉,服务器出错啦!',
icon: <Light500Icon />,
},
};
const ErrorPage: React.FC<IErrorPageProps> = (props) => {
const info = errorInfo[props.code];
return (
<div className={style.errorBox}>
{info?.icon}
<div className={style.title}>{info?.title}</div>
<div className={style.description}>{info?.desc}</div>
<Button theme='primary'>返回首页</Button>
</div>
);
};
export default memo(ErrorPage);
+20
View File
@@ -0,0 +1,20 @@
import { ETheme } from 'types/index.d';
export const defaultColor = ['#0052d9', '#0594fa', '#00a870', '#ebb105', '#ed7b2f', '#e34d59', '#ed49b4', '#834ec2'];
export const darkColor = ['#4582e6', '#29a4fb', '#03a56f', '#ca8d03', '#ed7b2f', '#ea7b84', '#f172c5', '#ab87d5'];
export const CHART_COLORS = {
[ETheme.light]: {
textColor: 'rgba(0, 0, 0, 0.9)',
placeholderColor: 'rgba(0, 0, 0, 0.35)',
borderColor: '#dcdcdc',
containerColor: '#fff',
},
[ETheme.dark]: {
textColor: 'rgba(255, 255, 255, 0.9)',
placeholderColor: 'rgba(255, 255, 255, 0.35)',
borderColor: '#5e5e5e',
containerColor: '#242424',
},
};
+19
View File
@@ -0,0 +1,19 @@
export default {
mock: {
// 本地mock数据
API: '',
},
development: {
// 开发环境:走 vite proxy 的 /api,转发到 Flask 后端 127.0.0.1:5409
API: '',
},
test: {
API: '',
},
release: {
API: '',
},
site: {
API: '',
},
};
+48
View File
@@ -0,0 +1,48 @@
export interface IPlatform {
key: string;
name: string;
/** 后端 user_info.type 字段值 */
type: number;
video: boolean;
/** 图文 */
note: boolean;
/** 定时发布 */
schedule: boolean;
/** CLI 主线是否已接入 */
cli: boolean;
/** Web 后端是否已接入 */
web: boolean;
desc: string;
theme: 'default' | 'primary' | 'warning' | 'danger' | 'success';
}
/**
* 平台能力矩阵 —— 对齐当前 CLI 主线(docs/CLI.md / README 能力表)。
* 抖音、快手、小红书、视频号为 Web 后端已接入平台(type 1-4);
* 其余平台为 CLI 主线已接入、Web 后端待接入(type 5-10)。
*/
export const PLATFORMS: IPlatform[] = [
{ key: 'douyin', name: '抖音', type: 3, video: true, note: true, schedule: true, cli: true, web: true, theme: 'danger', desc: '主线重构最完整,支持图文/定时' },
{ key: 'kuaishou', name: '快手', type: 4, video: true, note: true, schedule: true, cli: true, web: true, theme: 'warning', desc: '浏览器自动化,支持图文/定时' },
{ key: 'xiaohongshu', name: '小红书', type: 1, video: true, note: true, schedule: true, cli: true, web: true, theme: 'danger', desc: '浏览器自动化,支持图文/定时' },
{ key: 'tencent', name: '视频号', type: 2, video: true, note: true, schedule: true, cli: true, web: true, theme: 'success', desc: '浏览器自动化,支持定时' },
{ key: 'bilibili', name: 'B站', type: 5, video: true, note: false, schedule: true, cli: true, web: true, theme: 'primary', desc: '封装 biliup,支持定时' },
{ key: 'baijiahao', name: '百家号', type: 6, video: true, note: false, schedule: false, cli: true, web: true, theme: 'primary', desc: '浏览器自动化' },
{ key: 'alipay', name: '支付宝生活号', type: 7, video: true, note: false, schedule: false, cli: true, web: true, theme: 'primary', desc: '浏览器自动化,支持生活号视频' },
{ key: 'weibo', name: '微博', type: 8, video: true, note: false, schedule: false, cli: true, web: true, theme: 'primary', desc: '浏览器自动化,标题最多 30 字' },
{ key: 'hupu', name: '虎扑', type: 9, video: true, note: false, schedule: false, cli: true, web: true, theme: 'primary', desc: '浏览器自动化,标题 4–40 字' },
{ key: 'youtube', name: 'YouTube', type: 10, video: true, note: false, schedule: false, cli: true, web: true, theme: 'primary', desc: 'Studio 自动化,支持播放列表/可见性' },
{ key: 'tiktok', name: 'TikTok', type: 11, video: true, note: false, schedule: true, cli: false, web: true, theme: 'default', desc: '交互式登录 + Chrome 版发布,已接入 Web 后端' },
];
export const getPlatformByType = (type: number): IPlatform | undefined =>
PLATFORMS.find((p) => p.type === type);
export const getPlatformByKey = (key: string): IPlatform | undefined =>
PLATFORMS.find((p) => p.key === key);
/** Web 后端已接入的平台(登录/上传/发布走 Flask) */
export const WEB_PLATFORMS = PLATFORMS.filter((p) => p.web);
/** CLI 主线已接入的平台 */
export const CLI_PLATFORMS = PLATFORMS.filter((p) => p.cli);
+53
View File
@@ -0,0 +1,53 @@
declare module '*.avif' {
export default src as string;
}
declare module '*.bmp' {
export default src as string;
}
declare module '*.gif' {
export default src as string;
}
declare module '*.jpg' {
export default src as string;
}
declare module '*.jpeg' {
export default src as string;
}
declare module '*.png' {
export default src as string;
}
declare module '*.webp' {
export default src as string;
}
declare module '*.svg' {
export default src as string;
}
declare module '*.svg?component' {
export default src as string;
}
declare module '*.module.css' {
export default classes as { readonly [key: string]: string };
}
declare module '*.module.less' {
export default classes as { readonly [key: string]: string };
}
declare module '*.less' {
export default classes as { readonly [key: string]: string };
}
declare module 'tvision-color';
declare interface ImportMeta {
env: {
MODE: 'development' | 'test' | 'release' | 'mock' | 'site';
};
}
@@ -0,0 +1,36 @@
import { useMemo } from 'react';
import { useAppSelector } from 'modules/store';
import { selectGlobal } from 'modules/global';
import { getChartColor } from 'utils/color';
import { CHART_COLORS } from 'configs/color';
import lodashSet from 'lodash/set';
import lodashMap from 'lodash/map';
import { ETheme } from '../types';
export type TChartColorKey = keyof (typeof CHART_COLORS)[ETheme.light];
/**
* 根据当前主题色返回动态的图表颜色列表
* @param options 图表的固定配置
* @param configs 需要动态变换颜色的字段
* @returns string[]
*/
export default function useDynamicChart(
options: Record<string, any>,
configs?: Partial<Record<TChartColorKey, Array<string>>>,
) {
const { theme, color } = useAppSelector(selectGlobal);
return useMemo(() => {
const dynamicColor = getChartColor(theme, color);
const newOptions = {
...options,
};
// 设置动态的图表颜色
lodashSet(newOptions, 'color', dynamicColor.colorList);
if (configs) {
lodashMap(configs, (config, configKey: TChartColorKey) => {
config?.map((val) => lodashSet(newOptions, val, dynamicColor[configKey]));
});
}
return newOptions;
}, [theme, color, options]);
}
@@ -0,0 +1,29 @@
.sidePanel {
height: 100%;
display: flex;
flex-direction: row!important;
}
.sideContainer {
flex: 1;
min-width: 760px;
overflow: auto;
}
.topPanel {
min-width: 1150px;
}
.mixPanel {
height: 100%;
}
.mixMain {
flex-direction: row!important;
flex: 1;
overflow: auto;
}
.mixContent {
overflow: auto;
}
@@ -0,0 +1,51 @@
import React from 'react';
import { Layout } from 'tdesign-react';
import { ELayout } from 'modules/global';
import Header from './Header';
import Footer from './Footer';
import Menu from './Menu';
import classnames from 'classnames';
import Content from './AppRouter';
import Style from './AppLayout.module.less';
const SideLayout = React.memo(() => (
<Layout className={classnames(Style.sidePanel, 'narrow-scrollbar')}>
<Menu showLogo showOperation />
<Layout className={Style.sideContainer}>
<Header />
<Content />
<Footer />
</Layout>
</Layout>
));
const TopLayout = React.memo(() => (
<Layout className={Style.topPanel}>
<Header showMenu />
<Content />
<Footer />
</Layout>
));
const MixLayout = React.memo(() => (
<Layout className={Style.mixPanel}>
<Header />
<Layout className={Style.mixMain}>
<Menu />
<Layout className={Style.mixContent}>
<Content />
<Footer />
</Layout>
</Layout>
</Layout>
));
const FullPageLayout = React.memo(() => <Content />);
export default {
[ELayout.side]: SideLayout,
[ELayout.top]: TopLayout,
[ELayout.mix]: MixLayout,
[ELayout.fullPage]: FullPageLayout,
};
@@ -0,0 +1,7 @@
.loading {
height: 100%;
width: 100%;
display: flex;
justify-content: center;
align-content: center;
}
@@ -0,0 +1,65 @@
import React, { Suspense, memo } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { Layout, Loading } from 'tdesign-react';
import routers, { IRouter } from 'router';
import { resolve } from 'utils/path';
import Page from './Page';
import Style from './AppRouter.module.less';
const { Content } = Layout;
type TRenderRoutes = (routes: IRouter[], parentPath?: string, breadcrumbs?: string[]) => React.ReactNode[];
/**
* 渲染应用路由
* @param routes
* @param parentPath
* @param breadcrumb
*/
const renderRoutes: TRenderRoutes = (routes, parentPath = '', breadcrumb = []) =>
routes.map((route, index: number) => {
const { Component, children, redirect, meta } = route;
const currentPath = resolve(parentPath, route.path);
let currentBreadcrumb = breadcrumb;
if (meta?.title) {
currentBreadcrumb = currentBreadcrumb.concat([meta?.title]);
}
if (redirect) {
// 重定向
return <Route key={index} path={currentPath} element={<Navigate to={redirect} replace />} />;
}
if (Component) {
// 有路由菜单
return (
<Route
key={index}
path={currentPath}
element={
<Page isFullPage={route.isFullPage} breadcrumbs={currentBreadcrumb}>
<Component />
</Page>
}
/>
);
}
// 无路由菜单
return children ? renderRoutes(children, currentPath, currentBreadcrumb) : null;
});
const AppRouter = () => (
<Content>
<Suspense
fallback={
<div className={Style.loading}>
<Loading />
</div>
}
>
<Routes>{renderRoutes(routers)}</Routes>
</Suspense>
</Content>
);
export default memo(AppRouter);
@@ -0,0 +1,21 @@
import React from 'react';
import { Layout, Row } from 'tdesign-react';
import { useAppSelector } from 'modules/store';
import { selectGlobal } from 'modules/global';
const { Footer: TFooter } = Layout;
const Footer = () => {
const globalState = useAppSelector(selectGlobal);
if (!globalState.showFooter) {
return null;
}
return (
<TFooter>
<Row justify='center'>EveryPublish · 多平台自媒体自动化发布系统 © {new Date().getFullYear()}</Row>
</TFooter>
);
};
export default React.memo(Footer);
@@ -0,0 +1,54 @@
.header {
display: flex;
align-items: center;
justify-content: center;
}
.badge {
:global {
.t-badge--circle {
top: 5px;
right: 6px;
}
}
}
.menuIcon {
width: 30px !important;
height: 30px !important;
}
.dropItem {
display: flex;
align-items: center;
span {
margin-left: 8px;
}
}
.dropdown {
padding-left: 0;
padding-right: 0;
.icon {
font-size: 20px;
display: inline-block;
margin: 0 5px;
}
:global {
.t-button--variant-text {
padding-left: 9px;
padding-right: 9px;
}
.t-button__text {
display: flex;
align-items: center;
}
.t-dropdown__item {
max-width: none !important;
width: 117px;
&-text {
display: flex;
align-items: center;
}
.t-icon {
margin-right: 8px;
}
}
}
}
@@ -0,0 +1,25 @@
import React, { memo } from 'react';
import { Button, Popup, Space } from 'tdesign-react';
import { SettingIcon } from 'tdesign-icons-react';
import { useAppDispatch } from 'modules/store';
import { toggleSetting } from 'modules/global';
import Style from './HeaderIcon.module.less';
export default memo(() => {
const dispatch = useAppDispatch();
return (
<Space align='center'>
<Popup content='页面设置' placement='bottom' showArrow destroyOnClose>
<Button
className={Style.menuIcon}
shape='square'
size='large'
variant='text'
onClick={() => dispatch(toggleSetting())}
icon={<SettingIcon />}
/>
</Popup>
</Space>
);
});
@@ -0,0 +1,14 @@
.panel {
:global {
.t-input {
border: none;
}
.t-input:hover {
background: var(--td-bg-color-secondarycontainer);
transition: background var(--td-anim-duration-base) linear
}
.t-input--focused {
box-shadow: none;
}
}
}
@@ -0,0 +1,8 @@
import React from 'react';
import { Input } from 'tdesign-react';
import { SearchIcon } from 'tdesign-icons-react';
import Style from './Search.module.less';
const Search = () => <Input className={Style.panel} prefixIcon={<SearchIcon />} placeholder='请输入搜索内容' />;
export default React.memo(Search);
@@ -0,0 +1,11 @@
.panel {
flex-shrink: 0;
padding-left: 20px;
padding-right: 20px;
position: sticky;
top: 0;
z-index: 101;
display: flex;
justify-content: space-between;
border-bottom: 1px solid var(--td-component-stroke);
}
@@ -0,0 +1,49 @@
import React, { memo } from 'react';
import { Layout, Button, Space } from 'tdesign-react';
import { ViewListIcon } from 'tdesign-icons-react';
import { useAppDispatch, useAppSelector } from 'modules/store';
import { selectGlobal, toggleMenu } from 'modules/global';
import HeaderIcon from './HeaderIcon';
import { HeaderMenu } from '../Menu';
import Search from './Search';
import Style from './index.module.less';
const { Header } = Layout;
export default memo((props: { showMenu?: boolean }) => {
const globalState = useAppSelector(selectGlobal);
const dispatch = useAppDispatch();
if (!globalState.showHeader) {
return null;
}
let HeaderLeft;
if (props.showMenu) {
HeaderLeft = (
<div>
<HeaderMenu />
</div>
);
} else {
HeaderLeft = (
<Space align='center'>
<Button
shape='square'
size='large'
variant='text'
onClick={() => dispatch(toggleMenu(null))}
icon={<ViewListIcon />}
/>
<Search />
</Space>
);
}
return (
<Header className={Style.panel}>
{HeaderLeft}
<HeaderIcon />
</Header>
);
});
@@ -0,0 +1,47 @@
.menuPanel {
display: flex;
flex-direction: column;
height: 100%;
}
.menuTip {
color: var(--td-text-color-primary);
opacity: 0.4;
height: 38px;
line-height: 38px;
text-align: center;
}
.menuLogo {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
color: var(--td-text-color-primary);
}
.menuMiniLogo {
width: 28px;
height: 28px;
}
.logoFull {
color: var(--td-text-color-primary);
width: 184px;
height: 32px;
}
.logoText {
font-size: 16px;
font-weight: 600;
color: var(--td-brand-color);
white-space: nowrap;
}
.logoMiniText {
font-size: 16px;
font-weight: 600;
color: var(--td-brand-color);
}
@@ -0,0 +1,115 @@
import React, { memo, useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Menu, MenuValue } from 'tdesign-react';
import router, { IRouter } from 'router';
import { resolve } from 'utils/path';
import { useAppSelector } from 'modules/store';
import { selectGlobal } from 'modules/global';
import MenuLogo from './MenuLogo';
import Style from './Menu.module.less';
const { SubMenu, MenuItem, HeadMenu } = Menu;
interface IMenuProps {
showLogo?: boolean;
showOperation?: boolean;
}
const renderMenuItems = (menu: IRouter[], parentPath = '') => {
const navigate = useNavigate();
return menu.map((item) => {
const { children, meta, path } = item;
if (!meta || meta?.hidden === true) {
// 无meta信息 或 hidden == true,路由不显示为菜单
return null;
}
const { Icon, title, single } = meta;
const routerPath = resolve(parentPath, path);
if (!children || children.length === 0) {
return (
<MenuItem
key={routerPath}
value={routerPath}
icon={Icon ? <Icon /> : undefined}
onClick={() => navigate(routerPath)}
>
{title}
</MenuItem>
);
}
if (single && children?.length > 0) {
const firstChild = children[0];
if (firstChild?.meta && !firstChild?.meta?.hidden) {
const { Icon, title } = meta;
const singlePath = resolve(resolve(parentPath, path), firstChild.path);
return (
<MenuItem
key={singlePath}
value={singlePath}
icon={Icon ? <Icon /> : undefined}
onClick={() => navigate(singlePath)}
>
{title}
</MenuItem>
);
}
}
return (
<SubMenu key={routerPath} value={routerPath} title={title} icon={Icon ? <Icon /> : undefined}>
{renderMenuItems(children, routerPath)}
</SubMenu>
);
});
};
/**
* 顶部菜单
*/
export const HeaderMenu = memo(() => {
const globalState = useAppSelector(selectGlobal);
const location = useLocation();
const [active, setActive] = useState<MenuValue>(location.pathname); // todo
return (
<HeadMenu
expandType='popup'
style={{ marginBottom: 20 }}
value={active}
theme={globalState.theme}
onChange={(v) => setActive(v)}
>
{renderMenuItems(router)}
</HeadMenu>
);
});
/**
* 左侧菜单
*/
export default memo((props: IMenuProps) => {
const location = useLocation();
const globalState = useAppSelector(selectGlobal);
const { version } = globalState;
const bottomText = globalState.collapsed ? version : 'EveryPublish';
return (
<Menu
width='232px'
style={{ flexShrink: 0, height: '100%' }}
className={Style.menuPanel2}
value={location.pathname}
theme={globalState.theme}
collapsed={globalState.collapsed}
operations={props.showOperation ? <div className={Style.menuTip}>{bottomText}</div> : undefined}
logo={props.showLogo ? <MenuLogo collapsed={globalState.collapsed} /> : undefined}
>
{renderMenuItems(router)}
</Menu>
);
});
@@ -0,0 +1,21 @@
import React, { memo } from 'react';
import Style from './Menu.module.less';
import { useNavigate } from 'react-router-dom';
interface IProps {
collapsed?: boolean;
}
export default memo((props: IProps) => {
const navigate = useNavigate();
const handleClick = () => {
navigate('/');
};
return (
<div className={Style.menuLogo} onClick={handleClick}>
{props.collapsed ? <span className={Style.logoMiniText}>E</span> : <span className={Style.logoText}>EveryPublish</span>}
</div>
);
});
@@ -0,0 +1,9 @@
.panel {
margin: 24px;
padding: 0;
overflow: auto;
}
.breadcrumb {
margin-bottom: 8px;
}
@@ -0,0 +1,39 @@
import React, { useEffect } from 'react';
import { useAppDispatch, useAppSelector } from '../../modules/store';
import { selectGlobal, switchFullPage } from '../../modules/global';
import { Layout, Breadcrumb } from 'tdesign-react';
import Style from './Page.module.less';
const { Content } = Layout;
const { BreadcrumbItem } = Breadcrumb;
const Page = ({
children,
isFullPage,
breadcrumbs,
}: React.PropsWithChildren<{ isFullPage?: boolean; breadcrumbs?: string[] }>) => {
const globalState = useAppSelector(selectGlobal);
const dispatch = useAppDispatch();
useEffect(() => {
dispatch(switchFullPage(isFullPage));
}, [isFullPage]);
if (isFullPage) {
return <>{children}</>;
}
return (
<Content className={Style.panel}>
{globalState.showBreadcrumbs && (
<Breadcrumb className={Style.breadcrumb}>
{breadcrumbs?.map((item, index) => (
<BreadcrumbItem key={index}>{item}</BreadcrumbItem>
))}
</Breadcrumb>
)}
{children}
</Content>
);
};
export default React.memo(Page);
@@ -0,0 +1,17 @@
.panel {
display: flex;
justify-content: space-between;
}
.box {
cursor: pointer;
border: 2px solid;
padding: 4px;
border-radius: 50%;
}
.item {
width: 24px;
height: 24px;
border-radius: 50%;
}
@@ -0,0 +1,67 @@
import React, { useState } from 'react';
import { defaultColor } from 'configs/color';
import { Popup, ColorPickerPanel } from 'tdesign-react';
import Style from './RadioColor.module.less';
interface IProps {
defaultValue?: number | string;
onChange: (color: string) => void;
}
const RadioColor = (props: IProps) => {
const [isColoPickerDisplay, onPopupVisibleChange] = useState(false);
return (
<div className={Style.panel}>
{defaultColor.map((color, index) => (
<div
key={index}
onClick={() => props?.onChange(color)}
className={Style.box}
style={{ borderColor: props.defaultValue === color && !isColoPickerDisplay ? color : 'transparent' }}
>
<div className={Style.item} style={{ backgroundColor: color }} />
</div>
))}
<Popup
trigger='click'
placement='bottom-right'
expandAnimation
visible={isColoPickerDisplay}
onVisibleChange={(v) => onPopupVisibleChange(v)}
overlayInnerStyle={{ padding: 0 }}
content={
<ColorPickerPanel
onChange={(v) => props?.onChange(v)}
colorModes={['monochrome']}
format='HEX'
swatchColors={[]}
defaultValue={props.defaultValue as string}
/>
}
>
<div
key='dynamic'
className={Style.box}
style={{
borderColor:
isColoPickerDisplay || defaultColor.indexOf(props.defaultValue as string) === -1
? (props.defaultValue as string)
: 'transparent',
}}
>
<div
className={Style.item}
style={{
background:
'conic-gradient(from 90deg at 50% 50%, #FF0000 -19.41deg, #FF0000 18.76deg, #FF8A00 59.32deg, #FFE600 99.87deg, #14FF00 141.65deg, #00A3FF 177.72deg, #0500FF 220.23deg, #AD00FF 260.13deg, #FF00C7 300.69deg, #FF0000 340.59deg, #FF0000 378.76deg)',
}}
/>
</div>
</Popup>
</div>
);
};
export default React.memo(RadioColor);
@@ -0,0 +1,28 @@
.radioRectPanel {
display: flex;
justify-content: space-between;
}
.rectItem {
border: 2px solid #e3e6eb;
padding: 8px;
border-radius: 3px;
cursor: pointer;
}
.rectItemSelected {
border-color: var(--td-brand-color);
}
.rectText {
text-align: center;
font-size: 14px;
margin-top: 8px;
line-height: 21px;
}
.rectImg {
width: 88px;
height: 48px;
background-size: 100% 100%;
}
@@ -0,0 +1,47 @@
import React, { memo, useState } from 'react';
import classname from 'classnames';
import Style from './RadioRect.module.less';
interface IOption {
value?: any;
image: JSX.Element | string;
name?: string;
}
interface IProps {
defaultValue?: number | string;
onChange: (value?: any) => void;
options: IOption[];
}
export default memo((props: IProps) => {
const [selectValue, setSelectValue] = useState(props.defaultValue);
const handleClick = (option: IOption) => {
setSelectValue(option.value);
props?.onChange(option.value);
};
return (
<div className={Style.radioRectPanel}>
{props.options.map((item, index) => {
let ImageItem = item.image;
if (typeof item.image === 'string') {
ImageItem = <div className={Style.rectImg} style={{ backgroundImage: `url(${item.image})` }} />;
}
return (
<div key={index}>
<div
className={classname(Style.rectItem, { [Style.rectItemSelected]: selectValue === item.value })}
onClick={() => handleClick(item)}
>
{ImageItem}
</div>
{item.name && <div className={Style.rectText}>{item.name}</div>}
</div>
);
})}
</div>
);
});
@@ -0,0 +1,13 @@
.settingTitle {
margin: 32px 0 24px 0;
color: var(--td-text-color-primary);
font-size: 14px;
line-height: 22px;
font-weight: 500;
}
.settingSubTitle {
font-size: 14px;
line-height: 21px;
margin-bottom: 24px;
}
@@ -0,0 +1,123 @@
import React, { memo } from 'react';
import { Row, Col, Switch } from 'tdesign-react';
import { useAppDispatch, useAppSelector } from 'modules/store';
import {
selectGlobal,
toggleShowHeader,
toggleShowBreadcrumbs,
toggleShowFooter,
switchTheme,
switchColor,
switchLayout,
openSystemTheme,
ELayout,
} from 'modules/global';
import { ETheme } from 'types/index.d';
import RadioColor from './RadioColor';
import RadioRect from './RadioRect';
import Light from 'assets/svg/assets-setting-light.svg?component';
import Dark from 'assets/svg/assets-setting-dark.svg?component';
import System from 'assets/svg/assets-setting-auto.svg?component';
import Style from './index.module.less';
enum ESettingTheme {
system,
}
const themeList = [
{
value: ETheme.light,
image: <Light />,
name: '明亮',
},
{
value: ETheme.dark,
image: <Dark />,
name: '黑暗',
},
{
value: ESettingTheme.system,
image: <System />,
name: '跟随系统',
},
];
const layoutList = [
{
value: ELayout.side,
image: 'https://tdesign.gtimg.com/starter/setting/side.png',
},
{
value: ELayout.top,
image: 'https://tdesign.gtimg.com/starter/setting/top.png',
},
{
value: ELayout.mix,
image: 'https://tdesign.gtimg.com/starter/setting/mix.png',
},
];
export default memo(() => {
const dispatch = useAppDispatch();
const globalState = useAppSelector(selectGlobal);
const handleThemeSwitch = (value: any) => {
if (value === ESettingTheme.system) {
dispatch(openSystemTheme());
} else {
dispatch(switchTheme(value));
dispatch(switchColor(globalState.color));
}
};
return (
<div>
<div className={Style.settingTitle}>主题模式</div>
<RadioRect
defaultValue={globalState.systemTheme ? ESettingTheme.system : globalState.theme}
onChange={handleThemeSwitch}
options={themeList}
/>
<div className={Style.settingTitle}>主题色</div>
<RadioColor defaultValue={globalState.color} onChange={(value) => dispatch(switchColor(value))} />
<div className={Style.settingTitle}>导航布局</div>
<RadioRect
defaultValue={globalState.layout}
onChange={(value) => dispatch(switchLayout(value))}
options={layoutList}
/>
<div className={Style.settingTitle}>元素开关</div>
<Row justify='space-between'>
<Col>
<div className={Style.settingSubTitle}>显示 Header</div>
</Col>
<Col>
<Switch size='large' value={globalState.showHeader} onChange={() => dispatch(toggleShowHeader())} />
</Col>
</Row>
<Row justify='space-between'>
<Col>
<div className={Style.settingSubTitle}>显示 Breadcrumbs</div>
</Col>
<Col>
<Switch size='large' value={globalState.showBreadcrumbs} onChange={() => dispatch(toggleShowBreadcrumbs())} />
</Col>
</Row>
<Row justify='space-between'>
<Col>
<div className={Style.settingSubTitle}>显示 Footer</div>
</Col>
<Col>
<Switch size='large' value={globalState.showFooter} onChange={() => dispatch(toggleShowFooter())} />
</Col>
</Row>
</div>
);
});
@@ -0,0 +1,27 @@
.panel {
height: 100%;
:global {
.t-menu--scroll::-webkit-scrollbar {
width: 8px;
background: transparent;
}
.t-menu--scroll::-webkit-scrollbar-thumb {
border-radius: 8px;
border: 2px solid transparent;
background-clip: content-box;
background-color: #eeeeee;
}
.t-default-menu.t-menu--dark {
background: var(--td-gray-color-13);
}
.t-default-menu:not(.t-menu--dark) .t-menu__item.t-is-active:not(.t-is-opened) {
background-color: var(--td-brand-color-1);
color: var(--td-brand-color);
.t-icon {
color: var(--td-brand-color);
}
}
}
}
+46
View File
@@ -0,0 +1,46 @@
import React, { memo, useEffect } from 'react';
import { Drawer, Layout } from 'tdesign-react';
import throttle from 'lodash/throttle';
import { useAppSelector, useAppDispatch } from 'modules/store';
import { selectGlobal, toggleSetting, toggleMenu, ELayout, switchTheme } from 'modules/global';
import Setting from './components/Setting';
import AppLayout from './components/AppLayout';
import Style from './index.module.less';
export default memo(() => {
const globalState = useAppSelector(selectGlobal);
const dispatch = useAppDispatch();
const AppContainer = AppLayout[globalState.isFullPage ? ELayout.fullPage : globalState.layout];
useEffect(() => {
dispatch(switchTheme(globalState.theme));
const handleResize = throttle(() => {
if (window.innerWidth < 900) {
dispatch(toggleMenu(true));
} else if (window.innerWidth > 1000) {
dispatch(toggleMenu(false));
}
}, 100);
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
return (
<Layout className={Style.panel}>
<AppContainer />
<Drawer
destroyOnClose
visible={globalState.setting}
size='458px'
footer={false}
header='页面配置'
onClose={() => dispatch(toggleSetting())}
>
<Setting />
</Drawer>
</Layout>
);
});
+28
View File
@@ -0,0 +1,28 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom';
import store from 'modules/store';
import App from 'layouts/index';
import 'tdesign-react/es/style/index.css';
import './styles/index.less';
const env = import.meta.env.MODE || 'development';
const baseRouterName = env === 'site' ? '/starter/react/' : '';
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const root = document.getElementById('app')!;
const renderApp = () => {
ReactDOM.createRoot(root).render(
<Provider store={store}>
<BrowserRouter basename={baseRouterName}>
<App />
</BrowserRouter>
</Provider>,
);
};
renderApp();
@@ -0,0 +1,47 @@
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import { RootState } from '../store';
import { getAccounts as fetchAccounts, IAccount } from 'services/account';
const namespace = 'account';
interface IAccountState {
loading: boolean;
accounts: IAccount[];
}
const initialState: IAccountState = {
loading: false,
accounts: [],
};
export const loadAccounts = createAsyncThunk(namespace + '/loadAccounts', async () => {
return await fetchAccounts();
});
const accountSlice = createSlice({
name: namespace,
initialState,
reducers: {
setAccounts: (state, action: PayloadAction<IAccount[]>) => {
state.accounts = action.payload;
},
},
extraReducers: (builder) => {
builder
.addCase(loadAccounts.pending, (state) => {
state.loading = true;
})
.addCase(loadAccounts.fulfilled, (state, action) => {
state.loading = false;
state.accounts = action.payload;
})
.addCase(loadAccounts.rejected, (state) => {
state.loading = false;
});
},
});
export const { setAccounts } = accountSlice.actions;
export const selectAccounts = (state: RootState) => state.account.accounts;
export const selectAccountLoading = (state: RootState) => state.account.loading;
export default accountSlice.reducer;
+151
View File
@@ -0,0 +1,151 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { ETheme } from 'types/index.d';
import { Color } from 'tvision-color';
import { CHART_COLORS, defaultColor } from 'configs/color';
import { generateColorMap, insertThemeStylesheet } from 'utils/color';
import { RootState } from '../store';
import { version } from '../../../package.json';
const namespace = 'global';
export enum ELayout {
side = 1,
top,
mix,
fullPage,
}
export interface IGlobalState {
loading: boolean;
collapsed: boolean;
/**
* 是否显示面包屑导航
*/
setting: boolean;
version: string;
color: string;
/**
* 主题:深色 浅色
*/
theme: ETheme;
/**
* 是否开启跟随系统主题
*/
systemTheme: boolean;
layout: ELayout;
isFullPage: boolean;
showHeader: boolean;
showBreadcrumbs: boolean;
showFooter: boolean;
chartColors: Record<string, string>;
}
const defaultTheme = ETheme.light;
const initialState: IGlobalState = {
loading: true,
collapsed: window.innerWidth < 1000, // 宽度小于1000 菜单闭合
setting: false,
version,
theme: defaultTheme,
systemTheme: false,
layout: ELayout.side,
isFullPage: false,
color: defaultColor?.[0],
showHeader: true,
showBreadcrumbs: true,
showFooter: true,
chartColors: CHART_COLORS[defaultTheme],
};
// 创建带有命名空间的reducer
const globalSlice = createSlice({
name: namespace,
initialState,
reducers: {
toggleMenu: (state, action) => {
if (action.payload === null) {
state.collapsed = !state.collapsed;
} else {
state.collapsed = !!action.payload;
}
},
toggleSetting: (state) => {
state.setting = !state.setting;
},
toggleShowHeader: (state) => {
state.showHeader = !state.showHeader;
},
toggleShowBreadcrumbs: (state) => {
state.showBreadcrumbs = !state.showBreadcrumbs;
},
toggleShowFooter: (state) => {
state.showFooter = !state.showFooter;
},
switchTheme: (state, action: PayloadAction<ETheme>) => {
const finalTheme = action?.payload;
// 切换 chart 颜色
state.chartColors = CHART_COLORS[finalTheme];
// 切换主题颜色
state.theme = finalTheme;
// 关闭跟随系统
state.systemTheme = false;
document.documentElement.setAttribute('theme-mode', finalTheme);
},
openSystemTheme: (state) => {
const media = window.matchMedia('(prefers-color-scheme:dark)');
if (media.matches) {
const finalTheme = media.matches ? ETheme.dark : ETheme.light;
state.chartColors = CHART_COLORS[finalTheme];
// 切换主题颜色
state.theme = finalTheme;
state.systemTheme = true;
document.documentElement.setAttribute('theme-mode', finalTheme);
}
},
switchColor: (state, action) => {
if (action?.payload) {
state.color = action?.payload;
const mode = state.theme;
const hex = action?.payload;
const { colors: newPalette, primary: brandColorIndex } = Color.getColorGradations({
colors: [hex],
step: 10,
remainInput: false, // 是否保留输入 不保留会矫正不合适的主题色
})[0];
const newColorMap = generateColorMap(hex, newPalette, mode, brandColorIndex);
insertThemeStylesheet(hex, newColorMap, mode);
document.documentElement.setAttribute('theme-color', hex || '');
}
},
switchLayout: (state, action) => {
if (action?.payload) {
state.layout = action?.payload;
}
},
switchFullPage: (state, action) => {
state.isFullPage = !!action?.payload;
},
},
extraReducers: () => {},
});
export const selectGlobal = (state: RootState) => state.global;
export const {
toggleMenu,
toggleSetting,
toggleShowHeader,
toggleShowBreadcrumbs,
toggleShowFooter,
switchTheme,
switchColor,
switchLayout,
switchFullPage,
openSystemTheme,
} = globalSlice.actions;
export default globalSlice.reducer;
@@ -0,0 +1,47 @@
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import { RootState } from '../store';
import { getMaterials as fetchMaterials, IMaterial } from 'services/material';
const namespace = 'material';
interface IMaterialState {
loading: boolean;
materials: IMaterial[];
}
const initialState: IMaterialState = {
loading: false,
materials: [],
};
export const loadMaterials = createAsyncThunk(namespace + '/loadMaterials', async () => {
return await fetchMaterials();
});
const materialSlice = createSlice({
name: namespace,
initialState,
reducers: {
setMaterials: (state, action: PayloadAction<IMaterial[]>) => {
state.materials = action.payload;
},
},
extraReducers: (builder) => {
builder
.addCase(loadMaterials.pending, (state) => {
state.loading = true;
})
.addCase(loadMaterials.fulfilled, (state, action) => {
state.loading = false;
state.materials = action.payload;
})
.addCase(loadMaterials.rejected, (state) => {
state.loading = false;
});
},
});
export const { setMaterials } = materialSlice.actions;
export const selectMaterials = (state: RootState) => state.material.materials;
export const selectMaterialLoading = (state: RootState) => state.material.loading;
export default materialSlice.reducer;
+24
View File
@@ -0,0 +1,24 @@
import { configureStore, combineReducers } from '@reduxjs/toolkit';
import { TypedUseSelectorHook, useSelector, useDispatch } from 'react-redux';
import global from './global';
import account from './account';
import material from './material';
const reducer = combineReducers({
global,
account,
material,
});
export const store = configureStore({
reducer,
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
export default store;
@@ -0,0 +1,43 @@
.header {
margin-bottom: 20px;
text-align: center;
h1 {
font-size: 22px;
margin: 0 0 6px;
}
.version {
color: var(--td-text-color-secondary);
margin: 0;
}
}
.card {
margin-bottom: 16px;
}
.paragraph {
color: var(--td-text-color-secondary);
line-height: 1.8;
margin: 0;
}
.featureList {
margin: 0;
padding-left: 20px;
color: var(--td-text-color-secondary);
line-height: 2;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.tip {
margin: 14px 0 0;
font-size: 12px;
color: var(--td-text-color-placeholder);
}
+70
View File
@@ -0,0 +1,70 @@
import React, { memo } from 'react';
import { Card, Table, Tag } from 'tdesign-react';
import { PLATFORMS, IPlatform } from 'configs/platforms';
import CommonStyle from 'styles/common.module.less';
import style from './index.module.less';
const boolTag = (v: boolean) =>
v ? <Tag theme='success' variant='light'>支持</Tag> : <Tag theme='default' variant='light'>—</Tag>;
const About = () => {
const columns = [
{ colKey: 'name', title: '平台', width: 140, ellipsis: true },
{ colKey: 'video', title: '视频', width: 90, cell: ({ row }: { row: IPlatform }) => boolTag(row.video) },
{ colKey: 'note', title: '图文', width: 90, cell: ({ row }: { row: IPlatform }) => boolTag(row.note) },
{ colKey: 'schedule', title: '定时发布', width: 100, cell: ({ row }: { row: IPlatform }) => boolTag(row.schedule) },
{ colKey: 'cli', title: 'CLI 主线', width: 100, cell: ({ row }: { row: IPlatform }) => boolTag(row.cli) },
{ colKey: 'web', title: 'Web 后端', width: 100, cell: ({ row }: { row: IPlatform }) => boolTag(row.web) },
{ colKey: 'desc', title: '说明', ellipsis: true },
];
return (
<div className={CommonStyle.pageWithPadding}>
<div className={style.header}>
<h1>自媒体自动化运营系统</h1>
<p className={style.version}>EveryPublish · Web 管理端(React + TDesign)</p>
</div>
<Card title='系统简介' bordered className={style.card}>
<p className={style.paragraph}>
本系统是一款强大的自动化工具,帮助内容创作者和运营人员一键将视频/图文内容高效发布到多个国内外主流社交媒体平台,
支持视频上传、图文发布、定时发布、多账号管理等能力。
</p>
</Card>
<Card title='平台能力矩阵(已同步 CLI 主线)' bordered className={style.card}>
<Table
rowKey='key'
data={PLATFORMS}
columns={columns}
stripe
hover
/>
<p className={style.tip}>
10 个 CLI 主线平台(抖音、快手、小红书、视频号、B站、百家号、支付宝、微博、虎扑、YouTube)均已接入 Web 后端,
支持扫码登录与视频发布;TikTok 未进 CLI 主线,暂未接入。
</p>
</Card>
<Card title='核心功能' bordered className={style.card}>
<ul className={style.featureList}>
<li>多平台账号管理与登录状态维护(扫码登录 / Cookie 导入导出)</li>
<li>视频/图文素材上传与管理</li>
<li>一键多平台发布与批量发布</li>
<li>定时发布与草稿</li>
<li>平台能力矩阵对齐 CLI 主线</li>
</ul>
</Card>
<Card title='技术栈' bordered className={style.card}>
<div className={style.tags}>
{['React 18', 'TypeScript', 'TDesign React', 'Redux Toolkit', 'Vite', 'Flask', 'Playwright', 'SQLite'].map((t) => (
<Tag key={t} variant='outline'>{t}</Tag>
))}
</div>
</Card>
</div>
);
};
export default memo(About);
@@ -0,0 +1,111 @@
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.toolbarLeft {
display: flex;
gap: 10px;
}
.toolbarRight {
display: flex;
gap: 10px;
}
.tableWrap {
background: var(--td-bg-color-container);
border-radius: 8px;
}
.opCell {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.loginForm {
display: flex;
flex-direction: column;
gap: 16px;
padding: 8px 0;
}
.formItem {
display: flex;
flex-direction: column;
gap: 6px;
label {
font-size: 13px;
color: var(--td-text-color-secondary);
}
}
.qrBox {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.qrImg {
width: 220px;
height: 220px;
border: 1px solid var(--td-component-stroke);
border-radius: 8px;
}
.qrLoading {
width: 220px;
height: 220px;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed var(--td-component-stroke);
border-radius: 8px;
}
.qrTip {
font-size: 13px;
color: var(--td-text-color-secondary);
margin: 0;
}
.warnText {
font-size: 13px;
color: var(--td-warning-color);
margin: 0;
}
.checkBox {
display: flex;
flex-direction: column;
gap: 12px;
}
.checkHead {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.checkLogs {
max-height: 260px;
overflow-y: auto;
padding: 8px 10px;
background: var(--td-bg-color-component);
border-radius: 8px;
}
.checkLogLine {
font-size: 12px;
color: var(--td-text-color-secondary);
line-height: 1.8;
word-break: break-all;
}
+447
View File
@@ -0,0 +1,447 @@
import React, { memo, useEffect, useMemo, useRef, useState } from 'react';
import { Table, Tag, Button, Input, Select, Dialog, MessagePlugin, DialogPlugin, Loading, Progress } from 'tdesign-react';
import { SearchIcon, AddIcon, RefreshIcon } from 'tdesign-icons-react';
import { useAppDispatch, useAppSelector } from 'modules/store';
import { loadAccounts, selectAccounts, selectAccountLoading } from 'modules/account';
import {
deleteAccount,
updateAccount,
downloadCookie,
uploadCookie,
getLoginSSEUrl,
checkAccounts,
getCheckStatus,
deleteAccounts,
IAccount,
ICheckTask,
} from 'services/account';
import { PLATFORMS, WEB_PLATFORMS, IPlatform } from 'configs/platforms';
import CommonStyle from 'styles/common.module.less';
import style from './index.module.less';
const platformOptions = [
{ label: '全部平台', value: 'all' },
...PLATFORMS.map((p) => ({ label: p.name, value: String(p.type) })),
];
const statusTag = (status: number) => {
if (status === 1) return <Tag theme='success' variant='light'>正常</Tag>;
if (status === -1) return <Tag theme='warning' variant='light'>验证中</Tag>;
return <Tag theme='danger' variant='light'>异常</Tag>;
};
const Account = () => {
const dispatch = useAppDispatch();
const accounts = useAppSelector(selectAccounts);
const loading = useAppSelector(selectAccountLoading);
const [filterPlatform, setFilterPlatform] = useState<string>('all');
const [searchKeyword, setSearchKeyword] = useState('');
const [addVisible, setAddVisible] = useState(false);
const [editVisible, setEditVisible] = useState(false);
const [editTarget, setEditTarget] = useState<IAccount | null>(null);
const [editPlatform, setEditPlatform] = useState<number>(0);
const [editName, setEditName] = useState('');
// 登录态
const [loginPlatform, setLoginPlatform] = useState<number>(WEB_PLATFORMS[0]?.type || 1);
const [loginName, setLoginName] = useState('');
const [qrSrc, setQrSrc] = useState('');
const [loginStatus, setLoginStatus] = useState<'idle' | 'pending' | 'success' | 'fail'>('idle');
const [loginHint, setLoginHint] = useState('');
const esRef = useRef<EventSource | null>(null);
// 多选 + 校验任务
const [selectedRowKeys, setSelectedRowKeys] = useState<(string | number)[]>([]);
const [checkDialogVisible, setCheckDialogVisible] = useState(false);
const [checkTask, setCheckTask] = useState<ICheckTask | null>(null);
const checkPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
dispatch(loadAccounts());
}, [dispatch]);
useEffect(() => {
return () => {
if (esRef.current) {
esRef.current.close();
esRef.current = null;
}
};
}, []);
const filtered = useMemo(() => {
return accounts.filter((a) => {
const matchPlatform = filterPlatform === 'all' || String(a.type) === filterPlatform;
const kw = searchKeyword.trim().toLowerCase();
const matchKw = !kw || a.name.toLowerCase().includes(kw) || a.platform.includes(kw);
return matchPlatform && matchKw;
});
}, [accounts, filterPlatform, searchKeyword]);
const refresh = () => dispatch(loadAccounts());
const startCheck = async (ids: number[]) => {
if (!ids.length) {
MessagePlugin.warning('请先选择要校验的账号');
return;
}
try {
const res = await checkAccounts(ids);
if (!res.taskId) {
MessagePlugin.error('未返回校验任务 ID');
return;
}
setCheckDialogVisible(true);
pollCheck(res.taskId);
} catch {
MessagePlugin.error('提交校验任务失败');
}
};
const pollCheck = (taskId: string) => {
if (checkPollRef.current) clearInterval(checkPollRef.current);
const poll = async () => {
try {
const task = await getCheckStatus(taskId);
setCheckTask(task);
if (task && (task.status === 'done' || task.status === 'error')) {
if (checkPollRef.current) {
clearInterval(checkPollRef.current);
checkPollRef.current = null;
}
MessagePlugin.success('校验完成');
refresh();
}
} catch {
// 轮询失败继续
}
};
poll();
checkPollRef.current = setInterval(poll, 2000);
};
const batchDelete = () => {
if (!selectedRowKeys.length) {
MessagePlugin.warning('请先选择账号');
return;
}
DialogPlugin.confirm({
header: '批量删除',
body: '确定删除选中的 ' + selectedRowKeys.length + ' 个账号?对应 Cookie 文件也会被删除。',
confirmBtn: { content: '删除', theme: 'danger' },
onConfirm: async () => {
try {
await deleteAccounts(selectedRowKeys as number[]);
MessagePlugin.success('删除成功');
setSelectedRowKeys([]);
refresh();
} catch {
MessagePlugin.error('删除失败');
}
},
});
};
const onDelete = (row: IAccount) => {
DialogPlugin.confirm({
header: '删除账号',
body: '确定删除账号「' + row.name + '」?对应 Cookie 文件也会被删除。',
confirmBtn: { content: '删除', theme: 'danger' },
onConfirm: async () => {
try {
await deleteAccount(row.id);
MessagePlugin.success('删除成功');
refresh();
} catch {
MessagePlugin.error('删除失败');
}
},
});
};
const openEdit = (row: IAccount) => {
setEditTarget(row);
setEditPlatform(row.type);
setEditName(row.name);
setEditVisible(true);
};
const submitEdit = async () => {
if (!editTarget) return;
try {
await updateAccount({ id: editTarget.id, type: editPlatform, userName: editName });
MessagePlugin.success('更新成功');
setEditVisible(false);
refresh();
} catch {
MessagePlugin.error('更新失败');
}
};
const onUploadCookie = (row: IAccount) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async () => {
const file = input.files && input.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
formData.append('id', String(row.id));
formData.append('platform', String(row.type));
try {
await uploadCookie(formData);
MessagePlugin.success('Cookie 上传成功');
refresh();
} catch {
MessagePlugin.error('Cookie 上传失败');
}
};
input.click();
};
const startLogin = () => {
if (!loginName.trim()) {
MessagePlugin.warning('请输入账号名称');
return;
}
if (esRef.current) esRef.current.close();
setQrSrc('');
setLoginHint('');
setLoginStatus('pending');
const url = getLoginSSEUrl(loginPlatform, loginName.trim());
const es = new EventSource(url);
esRef.current = es;
es.onmessage = (e) => {
const msg = e.data;
if (msg === '200') {
setLoginStatus('success');
es.close();
esRef.current = null;
MessagePlugin.success('登录成功');
setAddVisible(false);
setLoginStatus('idle');
refresh();
} else if (msg === '500') {
setLoginStatus('fail');
es.close();
esRef.current = null;
MessagePlugin.error('登录失败');
} else if (msg && msg.startsWith('data:image')) {
setQrSrc(msg);
setLoginHint('');
} else if (msg && msg !== '') {
setLoginHint(msg);
}
};
es.onerror = () => {
if (esRef.current && loginStatus !== 'pending') {
es.close();
esRef.current = null;
}
};
};
const columns = [
{ colKey: 'row-select', type: 'multiple' as const, width: 50 },
{ colKey: 'name', title: '账号名称', width: 170, ellipsis: true },
{
colKey: 'platform',
title: '平台',
width: 140,
cell: ({ row }: { row: IAccount }) => {
const p = PLATFORMS.find((x) => x.type === row.type);
return <Tag theme={p?.theme || 'default'} variant='light'>{row.platform}</Tag>;
},
},
{
colKey: 'status',
title: '状态',
width: 120,
cell: ({ row }: { row: IAccount }) => (
<span style={{ cursor: 'pointer' }} onClick={() => startCheck([row.id])} title='点击校验该账号 Cookie'>
{statusTag(row.status)}
</span>
),
},
{
colKey: 'op',
title: '操作',
width: 320,
cell: ({ row }: { row: IAccount }) => (
<div className={style.opCell}>
<Button variant='text' theme='primary' onClick={() => startCheck([row.id])}>校验</Button>
<Button variant='text' theme='primary' onClick={() => openEdit(row)}>编辑</Button>
<Button variant='text' theme='primary' onClick={() => downloadCookie(row.filePath)}>下载Cookie</Button>
<Button variant='text' theme='primary' onClick={() => onUploadCookie(row)}>上传Cookie</Button>
<Button variant='text' theme='danger' onClick={() => onDelete(row)}>删除</Button>
</div>
),
},
];
const selectedPlatform = PLATFORMS.find((p) => p.type === loginPlatform) as IPlatform | undefined;
const loginSupported = selectedPlatform?.web === true;
return (
<div className={CommonStyle.pageWithPadding}>
<div className={style.toolbar}>
<div className={style.toolbarLeft}>
<Button icon={<AddIcon />} onClick={() => setAddVisible(true)}>添加账号</Button>
<Button icon={<RefreshIcon />} variant='outline' onClick={refresh} loading={loading}>刷新</Button>
<Button variant='outline' onClick={() => startCheck(selectedRowKeys as number[])}>校验所选</Button>
<Button variant='outline' theme='danger' onClick={batchDelete}>批量删除</Button>
</div>
<div className={style.toolbarRight}>
<Select
value={filterPlatform}
onChange={(v) => setFilterPlatform(String(v))}
options={platformOptions}
style={{ width: 160 }}
/>
<Input
value={searchKeyword}
onChange={setSearchKeyword}
placeholder='搜索账号名称'
prefixIcon={<SearchIcon />}
clearable
style={{ width: 240 }}
/>
</div>
</div>
<div className={style.tableWrap}>
<Table rowKey='id' data={filtered} columns={columns} loading={loading} hover empty='暂无账号数据' selectedRowKeys={selectedRowKeys} onSelectChange={(v) => setSelectedRowKeys(v)} />
</div>
{/* 添加账号(扫码登录) */}
<Dialog
header='添加账号'
visible={addVisible}
onClose={() => {
if (esRef.current) {
esRef.current.close();
esRef.current = null;
}
setLoginStatus('idle');
setQrSrc('');
setLoginHint('');
setAddVisible(false);
}}
footer={false}
width={460}
>
<div className={style.loginForm}>
<div className={style.formItem}>
<label>平台</label>
<Select
value={loginPlatform}
onChange={(v) => {
setLoginPlatform(Number(v));
setQrSrc('');
setLoginHint('');
setLoginStatus('idle');
}}
options={PLATFORMS.map((p) => ({ label: p.name + (p.web ? '' : '(待接入)'), value: p.type }))}
style={{ width: '100%' }}
/>
</div>
<div className={style.formItem}>
<label>账号名称</label>
<Input value={loginName} onChange={setLoginName} placeholder='用于区分账号,如 creator1' />
</div>
{!loginSupported ? (
<p className={style.warnText}>
该平台登录暂未接入 Web 后端(已接入 CLI 主线)。可通过「上传 Cookie」方式导入账号。
</p>
) : (
<>
{loginStatus === 'idle' && (
<Button block theme='primary' onClick={startLogin} disabled={!loginName.trim()}>生成登录二维码</Button>
)}
{loginStatus === 'pending' && (
<div className={style.qrBox}>
{qrSrc ? (
<img src={qrSrc} alt='登录二维码' className={style.qrImg} />
) : loginHint ? (
<p className={style.warnText}>{loginHint}</p>
) : (
<div className={style.qrLoading}><Loading text='正在生成二维码...' /></div>
)}
{qrSrc && <p className={style.qrTip}>请使用对应平台 App 扫码登录</p>}
</div>
)}
{loginStatus === 'fail' && (
<div className={style.qrBox}>
<p className={style.warnText}>登录失败,请重试。</p>
<Button block theme='primary' onClick={startLogin}>重新生成二维码</Button>
</div>
)}
</>
)}
</div>
</Dialog>
{/* 编辑账号 */}
<Dialog
header='编辑账号'
visible={editVisible}
onClose={() => setEditVisible(false)}
onConfirm={submitEdit}
confirmBtn={{ content: '保存' }}
cancelBtn={{ content: '取消' }}
width={420}
>
<div className={style.loginForm}>
<div className={style.formItem}>
<label>平台</label>
<Select
value={editPlatform}
onChange={(v) => setEditPlatform(Number(v))}
options={PLATFORMS.map((p) => ({ label: p.name, value: p.type }))}
style={{ width: '100%' }}
/>
</div>
<div className={style.formItem}>
<label>账号名称</label>
<Input value={editName} onChange={setEditName} />
</div>
</div>
</Dialog>
{/* Cookie 校验进度 */}
<Dialog
header='Cookie 校验进度'
visible={checkDialogVisible}
onClose={() => setCheckDialogVisible(false)}
footer={false}
width={520}
>
{checkTask ? (
<div className={style.checkBox}>
<div className={style.checkHead}>
<span>进度:{checkTask.done} / {checkTask.total}</span>
{checkTask.status === 'done' && <Tag theme='success' variant='light'>完成</Tag>}
{checkTask.status === 'error' && <Tag theme='danger' variant='light'>出错</Tag>}
</div>
<Progress percentage={checkTask.total ? Math.round((checkTask.done / checkTask.total) * 100) : 0} theme='line' />
{checkTask.error && <p className={style.warnText}>{checkTask.error}</p>}
<div className={style.checkLogs}>
{checkTask.logs.length === 0 ? (
<p className={style.warnText}>校验中...</p>
) : (
checkTask.logs.map((log, i) => <div key={i} className={style.checkLogLine}>{log}</div>)
)}
</div>
</div>
) : (
<p className={style.warnText}>正在提交校验任务...</p>
)}
</Dialog>
</div>
);
};
export default memo(Account);
@@ -0,0 +1,157 @@
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
h1 {
font-size: 20px;
margin: 0;
}
}
.card {
margin-bottom: 16px;
}
.tip {
margin: 12px 0 0;
font-size: 13px;
color: var(--td-text-color-secondary);
code {
color: var(--td-brand-color);
background: var(--td-bg-color-component);
padding: 2px 6px;
border-radius: 4px;
}
}
.keyCell {
display: flex;
align-items: center;
gap: 8px;
}
.keyText {
font-family: monospace;
color: var(--td-brand-color);
}
.layout {
display: grid;
grid-template-columns: 260px 1fr;
gap: 16px;
align-items: start;
}
.listCard {
position: sticky;
top: 16px;
}
.detailCard {
min-width: 0;
}
.apiNav {
display: flex;
flex-direction: column;
gap: 4px;
}
.apiNavItem {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: var(--td-bg-color-component);
}
}
.apiNavActive {
background: var(--td-brand-color-light);
}
.apiNavName {
font-size: 13px;
}
.detailHeader {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.detailPath {
font-family: monospace;
font-size: 15px;
font-weight: 600;
color: var(--td-text-color-primary);
}
.detailDesc {
font-size: 13px;
color: var(--td-text-color-secondary);
margin: 0 0 16px;
}
.sectionTitle {
font-size: 14px;
font-weight: 600;
margin: 20px 0 10px;
}
.empty {
font-size: 13px;
color: var(--td-text-color-placeholder);
margin: 0;
}
.codeBlock {
background: var(--td-bg-color-component);
padding: 12px 14px;
border-radius: 8px;
font-family: monospace;
font-size: 12px;
line-height: 1.7;
overflow-x: auto;
margin: 0;
white-space: pre-wrap;
word-break: break-all;
}
.testArea {
display: flex;
flex-direction: column;
gap: 12px;
}
.testRow {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.testResult {
margin-top: 16px;
}
.testStatusRow {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.testStatusLabel {
font-size: 13px;
color: var(--td-text-color-secondary);
}
+346
View File
@@ -0,0 +1,346 @@
import React, { memo, useEffect, useState } from 'react';
import { Card, Table, Tag, Button, Input, Textarea, MessagePlugin, DialogPlugin, Select } from 'tdesign-react';
import { RefreshIcon, AddIcon } from 'tdesign-icons-react';
import { getApiKeys, createApiKey, deleteApiKey, IApiKey } from 'services/openApi';
import CommonStyle from 'styles/common.module.less';
import style from './index.module.less';
interface IApiParam {
name: string;
type: string;
required: boolean;
desc: string;
}
interface IApiDoc {
id: string;
name: string;
method: 'GET' | 'POST' | 'DELETE';
path: string;
desc: string;
params: IApiParam[];
curlExample: string;
testKind: 'plain' | 'taskId' | 'fileId' | 'publish' | 'upload';
}
const BASE = 'http://127.0.0.1:5409';
const APIS: IApiDoc[] = [
{
id: 'platforms',
name: '查询平台能力',
method: 'GET',
path: '/open/v1/platforms',
desc: '获取所有支持的平台及能力(视频/图文/定时)',
params: [],
curlExample: 'curl -X GET ' + BASE + '/open/v1/platforms -H "X-API-Key: YOUR_API_KEY"',
testKind: 'plain',
},
{
id: 'accounts',
name: '查询账号列表',
method: 'GET',
path: '/open/v1/accounts',
desc: '获取已登录的发布账号列表(含平台类型、状态)',
params: [],
curlExample: 'curl -X GET ' + BASE + '/open/v1/accounts -H "X-API-Key: YOUR_API_KEY"',
testKind: 'plain',
},
{
id: 'materials',
name: '查询素材列表',
method: 'GET',
path: '/open/v1/materials',
desc: '获取已上传的素材(视频/图片)列表',
params: [],
curlExample: 'curl -X GET ' + BASE + '/open/v1/materials -H "X-API-Key: YOUR_API_KEY"',
testKind: 'plain',
},
{
id: 'upload-material',
name: '上传素材',
method: 'POST',
path: '/open/v1/materials',
desc: '上传视频或图片素材(multipart/form-data)',
params: [{ name: 'file', type: 'file', required: true, desc: '素材文件(视频或图片)' }, { name: 'filename', type: 'string', required: false, desc: '自定义文件名(可选)' }],
curlExample: 'curl -X POST ' + BASE + '/open/v1/materials -H "X-API-Key: YOUR_API_KEY" -F "file=@/path/to/video.mp4"',
testKind: 'upload',
},
{
id: 'delete-material',
name: '删除素材',
method: 'DELETE',
path: '/open/v1/materials/{id}',
desc: '删除指定素材(同时删除文件与记录)',
params: [{ name: 'id', type: 'integer', required: true, desc: '素材 ID(路径参数)' }],
curlExample: 'curl -X DELETE ' + BASE + '/open/v1/materials/1 -H "X-API-Key: YOUR_API_KEY"',
testKind: 'fileId',
},
{
id: 'publish',
name: '提交发布任务',
method: 'POST',
path: '/open/v1/publish',
desc: '提交视频/图文发布任务(异步,返回任务 ID)',
params: [
{ name: 'type', type: 'integer', required: true, desc: '平台类型:1小红书 2视频号 3抖音 4快手 5B站 6百家号 7支付宝 8微博 9虎扑 10YouTube 11TikTok' },
{ name: 'title', type: 'string', required: true, desc: '标题' },
{ name: 'fileList', type: 'string[]', required: true, desc: '素材 filePath 列表(视频一个,图片多个)' },
{ name: 'accountList', type: 'string[]', required: true, desc: '账号 filePath 列表' },
{ name: 'tags', type: 'string[]', required: false, desc: '标签列表' },
{ name: 'desc', type: 'string', required: false, desc: '描述/正文' },
{ name: 'isDraft', type: 'boolean', required: false, desc: '是否草稿' },
],
curlExample: 'curl -X POST ' + BASE + '/open/v1/publish -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" -d \'{"type":3,"title":"标题","fileList":["xxx.mp4"],"accountList":["yyy.json"]}\'',
testKind: 'publish',
},
{
id: 'publish-status',
name: '查询发布状态',
method: 'GET',
path: '/open/v1/publish/{taskId}',
desc: '查询发布任务的状态、步骤、进度与日志',
params: [{ name: 'taskId', type: 'string', required: true, desc: '任务 ID(路径参数)' }],
curlExample: 'curl -X GET ' + BASE + '/open/v1/publish/xxx-task-id -H "X-API-Key: YOUR_API_KEY"',
testKind: 'taskId',
},
];
const methodTheme: Record<string, 'success' | 'primary' | 'danger'> = {
GET: 'success',
POST: 'primary',
DELETE: 'danger',
};
const ApiDocs = () => {
const [keys, setKeys] = useState<IApiKey[]>([]);
const [selectedKey, setSelectedKey] = useState('');
const [selectedApi, setSelectedApi] = useState<IApiDoc>(APIS[0]);
const [paramValue, setParamValue] = useState('');
const [testStatus, setTestStatus] = useState<number | null>(null);
const [testResult, setTestResult] = useState('');
const [testing, setTesting] = useState(false);
const [fileRef, setFileRef] = useState<File | null>(null);
const loadKeys = async () => {
try {
const list = await getApiKeys();
setKeys(list);
if (!selectedKey && list.length) setSelectedKey(list[0].key);
} catch {
MessagePlugin.error('获取 API Key 失败');
}
};
useEffect(() => {
loadKeys();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onCopyKey = (key: string) => {
navigator.clipboard.writeText(key).then(() => MessagePlugin.success('已复制'));
};
const onCreateKey = async () => {
try {
const res = await createApiKey('外部系统密钥');
MessagePlugin.success('密钥已创建');
loadKeys();
if (res.key) setSelectedKey(res.key);
} catch {
MessagePlugin.error('创建失败');
}
};
const onDeleteKey = (row: IApiKey) => {
DialogPlugin.confirm({
header: '删除密钥',
body: '删除后使用该密钥的外部系统将无法调用 API,确定删除?',
confirmBtn: { content: '删除', theme: 'danger' },
onConfirm: async () => {
try {
await deleteApiKey(row.id);
MessagePlugin.success('已删除');
loadKeys();
} catch {
MessagePlugin.error('删除失败');
}
},
});
};
const doTest = async () => {
if (!selectedKey) {
MessagePlugin.warning('请先选择或生成 API Key');
return;
}
setTesting(true);
setTestResult('');
setTestStatus(null);
try {
let url = '/api' + selectedApi.path;
let method = selectedApi.method;
let body: BodyInit | undefined;
let headers: Record<string, string> = { 'X-API-Key': selectedKey };
if (selectedApi.testKind === 'taskId') {
url = '/api' + selectedApi.path.replace('{taskId}', encodeURIComponent(paramValue || ''));
} else if (selectedApi.testKind === 'fileId') {
url = '/api' + selectedApi.path.replace('{id}', encodeURIComponent(paramValue || ''));
} else if (selectedApi.testKind === 'publish') {
body = paramValue || '{}';
headers['Content-Type'] = 'application/json';
} else if (selectedApi.testKind === 'upload') {
if (!fileRef) {
MessagePlugin.warning('请选择要上传的文件');
return;
}
const fd = new FormData();
fd.append('file', fileRef);
body = fd;
delete headers['Content-Type'];
}
const resp = await fetch(url, { method, headers, body });
const text = await resp.text();
setTestStatus(resp.status);
try {
setTestResult(JSON.stringify(JSON.parse(text), null, 2));
} catch {
setTestResult(text);
}
} catch (e: any) {
setTestStatus(0);
setTestResult('请求失败:' + (e?.message || String(e)));
} finally {
setTesting(false);
}
};
return (
<div className={CommonStyle.pageWithPadding}>
<div className={style.header}>
<h1>API 文档</h1>
<Button icon={<AddIcon />} onClick={onCreateKey}>生成 API Key</Button>
</div>
{/* API Key 管理 */}
<Card title='API Key(对外开放鉴权)' bordered className={style.card}>
<Table
rowKey='id'
data={keys}
columns={[
{ colKey: 'name', title: '名称', width: 160 },
{
colKey: 'key',
title: 'Key',
ellipsis: true,
cell: ({ row }: { row: IApiKey }) => (
<span className={style.keyCell}>
<code className={style.keyText}>{row.key}</code>
<Button variant='text' theme='primary' size='small' onClick={() => onCopyKey(row.key)}>复制</Button>
</span>
),
},
{ colKey: 'created_at', title: '创建时间', width: 180 },
{
colKey: 'op',
title: '操作',
width: 100,
cell: ({ row }: { row: IApiKey }) => (
<Button variant='text' theme='danger' onClick={() => onDeleteKey(row)}>删除</Button>
),
},
]}
empty='暂无密钥,点击右上角生成'
/>
<p className={style.tip}>调用 Open API 时,在请求头携带 <code>X-API-Key: 你的key</code> 或 <code>Authorization: Bearer 你的key</code></p>
</Card>
<div className={style.layout}>
{/* 左侧 API 列表 */}
<Card title='接口列表' bordered className={style.listCard}>
<div className={style.apiNav}>
{APIS.map((api) => (
<div
key={api.id}
className={style.apiNavItem + (selectedApi.id === api.id ? ' ' + style.apiNavActive : '')}
onClick={() => setSelectedApi(api)}
>
<Tag theme={methodTheme[api.method]} variant='light' size='small'>{api.method}</Tag>
<span className={style.apiNavName}>{api.name}</span>
</div>
))}
</div>
</Card>
{/* 右侧 API 详情 */}
<Card bordered className={style.detailCard}>
<div className={style.detailHeader}>
<Tag theme={methodTheme[selectedApi.method]} variant='light'>{selectedApi.method}</Tag>
<code className={style.detailPath}>{selectedApi.path}</code>
</div>
<p className={style.detailDesc}>{selectedApi.desc}</p>
<div className={style.sectionTitle}>请求参数</div>
{selectedApi.params.length === 0 ? (
<p className={style.empty}>无参数</p>
) : (
<Table
rowKey='name'
data={selectedApi.params}
columns={[
{ colKey: 'name', title: '参数名', width: 140 },
{ colKey: 'type', title: '类型', width: 100 },
{ colKey: 'required', title: '必填', width: 70, cell: ({ row }: { row: IApiParam }) => (row.required ? <Tag theme='danger' variant='light' size='small'>是</Tag> : <Tag theme='default' variant='light' size='small'>否</Tag>) },
{ colKey: 'desc', title: '说明' },
]}
/>
)}
<div className={style.sectionTitle}>curl 示例</div>
<pre className={style.codeBlock}>{selectedApi.curlExample}</pre>
<div className={style.sectionTitle}>点击测试</div>
<div className={style.testArea}>
<div className={style.testRow}>
<Select
value={selectedKey}
onChange={(v) => setSelectedKey(String(v))}
options={keys.map((k) => ({ label: k.name + '(' + k.key.slice(0, 12) + '...)', value: k.key }))}
placeholder='选择测试用的 API Key'
style={{ width: 300 }}
/>
</div>
{(selectedApi.testKind === 'taskId' || selectedApi.testKind === 'fileId') && (
<Input value={paramValue} onChange={setParamValue} placeholder={selectedApi.testKind === 'taskId' ? '输入 taskId' : '输入素材 id'} style={{ width: 300 }} />
)}
{selectedApi.testKind === 'publish' && (
<Textarea value={paramValue} onChange={setParamValue} placeholder={'{"type":3,"title":"测试","fileList":["xxx.mp4"],"accountList":["yyy.json"]}'} autosize={{ minRows: 3, maxRows: 6 }} />
)}
{selectedApi.testKind === 'upload' && (
<input type='file' onChange={(e) => setFileRef(e.target.files ? e.target.files[0] : null)} />
)}
<Button theme='primary' onClick={doTest} loading={testing}>发送请求</Button>
</div>
{testStatus !== null && (
<div className={style.testResult}>
<div className={style.testStatusRow}>
<span className={style.testStatusLabel}>响应状态:</span>
<Tag theme={testStatus >= 200 && testStatus < 300 ? 'success' : 'danger'} variant='light'>{testStatus || '网络错误'}</Tag>
</div>
<pre className={style.codeBlock}>{testResult}</pre>
</div>
)}
</Card>
</div>
</div>
);
};
export default memo(ApiDocs);
@@ -0,0 +1,99 @@
.pageHeader {
margin-bottom: 20px;
h1 {
font-size: 22px;
font-weight: 600;
margin: 0 0 6px;
}
p {
color: var(--td-text-color-secondary);
margin: 0;
}
}
.statCard {
.statBody {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 12px;
}
.statIcon {
width: 52px;
height: 52px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 26px;
flex-shrink: 0;
}
.statInfo {
.statValue {
font-size: 26px;
font-weight: 700;
line-height: 1.1;
}
.statLabel {
font-size: 13px;
color: var(--td-text-color-secondary);
}
}
.statFooter {
border-top: 1px solid var(--td-component-stroke);
padding-top: 10px;
font-size: 13px;
color: var(--td-text-color-secondary);
}
}
.section {
margin-top: 16px;
}
.actionCard {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 22px 12px;
border: 1px solid var(--td-component-stroke);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--td-brand-color);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
transform: translateY(-3px);
}
.actionIcon {
width: 46px;
height: 46px;
border-radius: 50%;
background: rgba(0, 82, 217, 0.1);
color: var(--td-brand-color);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
.actionTitle {
font-size: 15px;
font-weight: 600;
}
.actionDesc {
font-size: 12px;
color: var(--td-text-color-secondary);
}
}
+183
View File
@@ -0,0 +1,183 @@
import React, { memo, useEffect, useMemo } from 'react';
import { Row, Col, Card, Table, Tag, Button } from 'tdesign-react';
import { useNavigate } from 'react-router-dom';
import { UserIcon, FileIcon, QueueIcon, HelpCircleIcon, UsergroupIcon } from 'tdesign-icons-react';
import { useAppDispatch, useAppSelector } from 'modules/store';
import { loadAccounts, selectAccounts } from 'modules/account';
import { loadMaterials, selectMaterials } from 'modules/material';
import { PLATFORMS } from 'configs/platforms';
import CommonStyle from 'styles/common.module.less';
import style from './index.module.less';
const VIDEO_EXT = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv', '.m4v', '.webm'];
const IMAGE_EXT = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'];
const fileType = (filename: string) => {
const lower = filename.toLowerCase();
if (VIDEO_EXT.some((e) => lower.endsWith(e))) return '视频';
if (IMAGE_EXT.some((e) => lower.endsWith(e))) return '图片';
return '其他';
};
const typeTheme: Record<string, 'success' | 'warning' | 'default'> = {
视频: 'success',
图片: 'warning',
其他: 'default',
};
interface IStatCardProps {
title: string;
value: number;
Icon: React.ReactElement;
footer: React.ReactNode;
color: string;
}
const StatCard = ({ title, value, Icon, footer, color }: IStatCardProps) => (
<Card bordered className={style.statCard}>
<div className={style.statBody}>
<div className={style.statIcon} style={{ background: color + '1a', color }}>
{Icon}
</div>
<div className={style.statInfo}>
<div className={style.statValue}>{value}</div>
<div className={style.statLabel}>{title}</div>
</div>
</div>
<div className={style.statFooter}>{footer}</div>
</Card>
);
const Dashboard = () => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const accounts = useAppSelector(selectAccounts);
const materials = useAppSelector(selectMaterials);
useEffect(() => {
dispatch(loadAccounts());
dispatch(loadMaterials());
}, [dispatch]);
const accountStats = useMemo(() => {
const normal = accounts.filter((a) => a.status === 1).length;
const abnormal = accounts.filter((a) => a.status === 0).length;
return { total: accounts.length, normal, abnormal };
}, [accounts]);
const platformMap = useMemo(() => {
const map: Record<string, number> = {};
accounts.forEach((a) => {
map[a.platform] = (map[a.platform] || 0) + 1;
});
return map;
}, [accounts]);
const materialStats = useMemo(() => {
const videos = materials.filter((m) => fileType(m.filename) === '视频').length;
const images = materials.filter((m) => fileType(m.filename) === '图片').length;
return { total: materials.length, videos, images, others: materials.length - videos - images };
}, [materials]);
const recentMaterials = useMemo(
() =>
[...materials]
.sort((a, b) => new Date(b.upload_time).getTime() - new Date(a.upload_time).getTime())
.slice(0, 5),
[materials],
);
const quickActions = [
{ title: '账号管理', desc: '管理所有平台账号', Icon: <UsergroupIcon />, path: '/account' },
{ title: '素材管理', desc: '上传和管理视频素材', Icon: <FileIcon />, path: '/material' },
{ title: '发布中心', desc: '发布内容到各平台', Icon: <QueueIcon />, path: '/publish' },
{ title: '关于系统', desc: '查看系统信息', Icon: <HelpCircleIcon />, path: '/about' },
];
const usedPlatforms = Object.keys(platformMap).filter((k) => platformMap[k] > 0);
return (
<div className={CommonStyle.pageWithPadding}>
<div className={style.pageHeader}>
<h1>概览</h1>
</div>
<Row gutter={[16, 16]}>
<Col span={4}>
<StatCard
title='账号总数'
value={accountStats.total}
color='#0052d9'
Icon={<UserIcon />}
footer={
<span>
正常 <b>{accountStats.normal}</b> · 异常 <b>{accountStats.abnormal}</b>
</span>
}
/>
</Col>
<Col span={4}>
<StatCard
title='已接入平台'
value={usedPlatforms.length}
color='#00a870'
Icon={<QueueIcon />}
footer={
<span>
共支持 <b>{PLATFORMS.length}</b> 个平台
</span>
}
/>
</Col>
<Col span={4}>
<StatCard
title='素材总数'
value={materialStats.total}
color='#ed7b2f'
Icon={<FileIcon />}
footer={
<span>
视频 <b>{materialStats.videos}</b> · 图片 <b>{materialStats.images}</b>
</span>
}
/>
</Col>
</Row>
<Card title='快捷操作' bordered className={style.section}>
<Row gutter={[16, 16]}>
{quickActions.map((action) => (
<Col span={3} key={action.path}>
<div className={style.actionCard} onClick={() => navigate(action.path)}>
<div className={style.actionIcon}>{action.Icon}</div>
<div className={style.actionTitle}>{action.title}</div>
<div className={style.actionDesc}>{action.desc}</div>
</div>
</Col>
))}
</Row>
</Card>
<Card title='最近上传素材' bordered className={style.section} actions={<Button variant='text' onClick={() => navigate('/material')}>查看全部</Button>}>
<Table
rowKey='id'
data={recentMaterials}
columns={[
{ colKey: 'filename', title: '文件名', ellipsis: true },
{ colKey: 'filesize', title: '大小(MB)', width: 120 },
{
colKey: 'type',
title: '类型',
width: 100,
cell: ({ row }) => <Tag theme={typeTheme[fileType(row.filename)]} variant='light'>{fileType(row.filename)}</Tag>,
},
{ colKey: 'upload_time', title: '上传时间', width: 200 },
]}
empty='暂无素材数据'
/>
</Card>
</div>
);
};
export default memo(Dashboard);
@@ -0,0 +1,99 @@
.toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.hint {
font-size: 12px;
color: var(--td-text-color-placeholder);
}
.tableWrap {
background: var(--td-bg-color-container);
border-radius: 8px;
}
.opCell {
display: flex;
gap: 4px;
}
.nameCell {
display: flex;
align-items: center;
gap: 8px;
}
.thumb {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 8px;
flex-shrink: 0;
}
.thumbVideo {
width: 40px;
height: 40px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.playIcon {
width: 0;
height: 0;
border-left: 10px solid #fff;
border-top: 6px solid transparent;
border-bottom: 6px solid transparent;
}
.thumbOther {
width: 40px;
height: 40px;
border-radius: 8px;
background: var(--td-bg-color-component);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: var(--td-text-color-secondary);
flex-shrink: 0;
}
.fileName {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.previewBox {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
.previewImg {
max-width: 100%;
max-height: 480px;
border-radius: 8px;
}
.previewVideo {
width: 100%;
max-height: 480px;
border-radius: 8px;
}
.previewName {
font-size: 13px;
color: var(--td-text-color-secondary);
margin: 0;
word-break: break-all;
}
}
+161
View File
@@ -0,0 +1,161 @@
import React, { memo, useEffect, useMemo, useRef, useState } from 'react';
import { Table, Tag, Button, MessagePlugin, DialogPlugin, Dialog } from 'tdesign-react';
import { AddIcon, RefreshIcon } from 'tdesign-icons-react';
import { useAppDispatch, useAppSelector } from 'modules/store';
import { loadMaterials, selectMaterials, selectMaterialLoading } from 'modules/material';
import { uploadMaterial, deleteMaterial, getFileUrl, IMaterial } from 'services/material';
import CommonStyle from 'styles/common.module.less';
import style from './index.module.less';
const VIDEO_EXT = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv', '.m4v', '.webm'];
const IMAGE_EXT = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'];
const fileType = (filename: string) => {
const lower = filename.toLowerCase();
if (VIDEO_EXT.some((e) => lower.endsWith(e))) return '视频';
if (IMAGE_EXT.some((e) => lower.endsWith(e))) return '图片';
return '其他';
};
const typeTheme: Record<string, 'success' | 'warning' | 'default'> = {
视频: 'success',
图片: 'warning',
其他: 'default',
};
const Material = () => {
const dispatch = useAppDispatch();
const materials = useAppSelector(selectMaterials);
const loading = useAppSelector(selectMaterialLoading);
const fileInputRef = useRef<HTMLInputElement>(null);
const [preview, setPreview] = useState<IMaterial | null>(null);
const [uploading, setUploading] = useState(false);
useEffect(() => {
dispatch(loadMaterials());
}, [dispatch]);
const refresh = () => dispatch(loadMaterials());
const onPickFiles = () => {
fileInputRef.current?.click();
};
const onFilesChosen = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
setUploading(true);
try {
for (let i = 0; i < files.length; i++) {
const formData = new FormData();
formData.append('file', files[i]);
await uploadMaterial(formData);
}
MessagePlugin.success('上传成功');
refresh();
} catch {
MessagePlugin.error('上传失败');
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const onDelete = (row: IMaterial) => {
DialogPlugin.confirm({
header: '删除素材',
body: '确定删除素材「' + row.filename + '」?',
confirmBtn: { content: '删除', theme: 'danger' },
onConfirm: async () => {
try {
await deleteMaterial(row.id);
MessagePlugin.success('删除成功');
refresh();
} catch {
MessagePlugin.error('删除失败');
}
},
});
};
const isImage = (row: IMaterial) => fileType(row.filename) === '图片';
const isVideo = (row: IMaterial) => fileType(row.filename) === '视频';
const columns = [
{
colKey: 'filename',
title: '文件名',
ellipsis: true,
cell: ({ row }: { row: IMaterial }) => (
<div className={style.nameCell}>
{isImage(row) ? (
<img src={getFileUrl(row.file_path)} alt='' className={style.thumb} />
) : isVideo(row) ? (
<span className={style.thumbVideo}><span className={style.playIcon} /></span>
) : (
<span className={style.thumbOther}>文件</span>
)}
<span className={style.fileName} title={row.filename}>{row.filename}</span>
</div>
),
},
{ colKey: 'filesize', title: '大小(MB)', width: 110 },
{
colKey: 'type',
title: '类型',
width: 100,
cell: ({ row }: { row: IMaterial }) => <Tag theme={typeTheme[fileType(row.filename)]} variant='light'>{fileType(row.filename)}</Tag>,
},
{ colKey: 'upload_time', title: '上传时间', width: 200 },
{
colKey: 'op',
title: '操作',
width: 180,
cell: ({ row }: { row: IMaterial }) => (
<div className={style.opCell}>
{(isImage(row) || isVideo(row)) && (
<Button variant='text' theme='primary' onClick={() => setPreview(row)}>预览</Button>
)}
<Button variant='text' theme='primary' onClick={() => window.open(getFileUrl(row.file_path), '_blank')}>下载</Button>
<Button variant='text' theme='danger' onClick={() => onDelete(row)}>删除</Button>
</div>
),
},
];
return (
<div className={CommonStyle.pageWithPadding}>
<div className={style.toolbar}>
<Button icon={<AddIcon />} onClick={onPickFiles} loading={uploading}>上传素材</Button>
<Button icon={<RefreshIcon />} variant='outline' onClick={refresh} loading={loading}>刷新</Button>
<span className={style.hint}>支持视频(mp4/mov/avi/mkv/webm 等)与图片(jpg/png/webp 等),可多选</span>
<input ref={fileInputRef} type='file' multiple hidden onChange={onFilesChosen} />
</div>
<div className={style.tableWrap}>
<Table rowKey='id' data={materials} columns={columns} loading={loading} hover empty='暂无素材数据' />
</div>
<Dialog
header='预览'
visible={!!preview}
onClose={() => setPreview(null)}
footer={false}
width={760}
>
{preview && (
<div className={style.previewBox}>
{isImage(preview) && <img src={getFileUrl(preview.file_path)} alt={preview.filename} className={style.previewImg} />}
{isVideo(preview) && (
<video src={getFileUrl(preview.file_path)} controls className={style.previewVideo} />
)}
<p className={style.previewName}>{preview.filename}</p>
</div>
)}
</Dialog>
</div>
);
};
export default memo(Material);
@@ -0,0 +1,193 @@
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
h1 {
font-size: 20px;
margin: 0;
}
}
.card {
margin-bottom: 16px;
}
.sectionLabel {
font-size: 13px;
font-weight: 500;
color: var(--td-text-color-secondary);
margin: 12px 0 8px;
&:first-child {
margin-top: 0;
}
}
.materialGrid {
display: flex;
flex-wrap: wrap;
gap: 8px 20px;
}
.materialItem {
margin-right: 0;
max-width: 100%;
}
.materialName {
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
vertical-align: middle;
}
.emptyText {
color: var(--td-text-color-placeholder);
font-size: 13px;
margin: 8px 0 0;
}
.typeBadge {
margin-top: 16px;
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--td-text-color-secondary);
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
margin-bottom: 14px;
label {
font-size: 13px;
color: var(--td-text-color-secondary);
}
}
.switchRow {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid var(--td-component-stroke);
max-width: 360px;
&:last-child {
border-bottom: none;
}
span {
font-size: 13px;
color: var(--td-text-color-secondary);
}
}
.inlineRow {
display: flex;
gap: 16px;
margin-top: 14px;
.field {
margin-bottom: 0;
}
}
.channelList {
display: flex;
flex-direction: column;
gap: 16px;
}
.channelItem {
padding: 12px 14px;
border: 1px solid var(--td-component-stroke);
border-radius: 8px;
}
.channelHead {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.channelName {
font-size: 15px;
font-weight: 600;
}
.noAccount {
font-size: 12px;
color: var(--td-text-color-placeholder);
}
.taskList {
display: flex;
flex-direction: column;
gap: 16px;
}
.taskItem {
padding: 12px 14px;
border: 1px solid var(--td-component-stroke);
border-radius: 8px;
}
.taskHead {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.taskTitle {
font-size: 14px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.taskProgressRow {
display: flex;
align-items: center;
gap: 12px;
}
.taskStep {
font-size: 13px;
color: var(--td-text-color-secondary);
white-space: nowrap;
}
.taskError {
font-size: 13px;
color: var(--td-error-color);
margin: 8px 0 0;
word-break: break-all;
}
.taskLogs {
margin-top: 10px;
padding: 8px 10px;
background: var(--td-bg-color-component);
border-radius: 8px;
max-height: 160px;
overflow-y: auto;
}
.taskLogLine {
font-size: 12px;
color: var(--td-text-color-secondary);
line-height: 1.7;
word-break: break-all;
}
+336
View File
@@ -0,0 +1,336 @@
import React, { memo, useEffect, useMemo, useRef, useState } from 'react';
import { Card, Checkbox, Input, Textarea, Switch, Button, Tag, MessagePlugin, Select, Radio, Dialog, Progress } from 'tdesign-react';
import { useAppDispatch, useAppSelector } from 'modules/store';
import { loadAccounts, selectAccounts } from 'modules/account';
import { loadMaterials, selectMaterials } from 'modules/material';
import { postVideo, postVideoBatch, getPublishStatus, IPublishPayload, IPublishTask } from 'services/publish';
import { PLATFORMS } from 'configs/platforms';
import CommonStyle from 'styles/common.module.less';
import style from './index.module.less';
const VIDEO_EXT = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv', '.m4v', '.webm'];
const IMAGE_EXT = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'];
const isVideo = (name: string) => VIDEO_EXT.some((e) => name.toLowerCase().endsWith(e));
const isImage = (name: string) => IMAGE_EXT.some((e) => name.toLowerCase().endsWith(e));
const Publish = () => {
const dispatch = useAppDispatch();
const accounts = useAppSelector(selectAccounts);
const materials = useAppSelector(selectMaterials);
// 素材:视频单选 + 图片多选(互斥)
const [videoFile, setVideoFile] = useState('');
const [imageFiles, setImageFiles] = useState<string[]>([]);
// 内容信息
const [title, setTitle] = useState('');
const [tags, setTags] = useState('');
const [desc, setDesc] = useState('');
const [category, setCategory] = useState(1);
const [enableTimer, setEnableTimer] = useState(false);
const [dailyTimes, setDailyTimes] = useState('12');
const [startDays, setStartDays] = useState(0);
const [isDraft, setIsDraft] = useState(false);
// 每个渠道选择的账号(platformType -> account filePath[])
const [accountMap, setAccountMap] = useState<Record<number, string[]>>({});
const [submitting, setSubmitting] = useState(false);
const [taskDialogVisible, setTaskDialogVisible] = useState(false);
const [activeTasks, setActiveTasks] = useState<IPublishTask[]>([]);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
dispatch(loadAccounts());
dispatch(loadMaterials());
}, [dispatch]);
const videoMaterials = useMemo(() => materials.filter((m) => isVideo(m.filename)), [materials]);
const imageMaterials = useMemo(() => materials.filter((m) => isImage(m.filename)), [materials]);
// 素材类型与最终 fileList
const materialType = videoFile ? 'video' : imageFiles.length ? 'image' : '';
const fileList = videoFile ? [videoFile] : imageFiles;
// 按素材类型展示可发布渠道
const availablePlatforms = useMemo(() => {
if (materialType === 'image') return PLATFORMS.filter((p) => p.note);
if (materialType === 'video') return PLATFORMS.filter((p) => p.video);
return [];
}, [materialType]);
const updateAccountMap = (type: number, files: string[]) => {
setAccountMap((prev) => ({ ...prev, [type]: files }));
};
const buildPayload = (type: number, accs: string[]): IPublishPayload => {
const dailyTimesArr = enableTimer
? dailyTimes.split(',').map((x) => parseInt(x.trim(), 10)).filter((n) => !Number.isNaN(n))
: undefined;
return {
type,
title: title.trim(),
fileList,
accountList: accs,
tags: tags ? tags.split(/[,,]/).map((x) => x.trim()).filter(Boolean) : undefined,
desc: desc ? desc.trim() : undefined,
category: category === 1 ? 1 : 0,
enableTimer: materialType === 'video' ? enableTimer : false,
videosPerDay: enableTimer ? dailyTimesArr?.length : undefined,
dailyTimes: dailyTimesArr,
startDays: enableTimer ? startDays : undefined,
isDraft,
};
};
const submit = async () => {
if (!materialType || !fileList.length) {
MessagePlugin.warning('请先选择素材');
return;
}
if (!title.trim()) {
MessagePlugin.warning('请输入标题');
return;
}
const payloads: IPublishPayload[] = [];
for (const p of availablePlatforms) {
const accs = accountMap[p.type] || [];
if (accs.length) payloads.push(buildPayload(p.type, accs));
}
if (!payloads.length) {
MessagePlugin.warning('请至少为一个渠道选择发布账号');
return;
}
setSubmitting(true);
try {
const res = payloads.length === 1 ? await postVideo(payloads[0]) : await postVideoBatch(payloads);
const taskIds: string[] = (res as any).taskIds || ((res as any).taskId ? [(res as any).taskId] : []);
if (!taskIds.length) {
MessagePlugin.error('未返回任务 ID');
return;
}
const initTasks: IPublishTask[] = taskIds.map((id) => ({
id,
status: 'pending',
step: '排队中',
progress: 0,
logs: [],
error: null,
type: 0,
title: title.trim(),
created_at: '',
}));
setActiveTasks(initTasks);
setTaskDialogVisible(true);
startPolling(taskIds);
} catch {
MessagePlugin.error('提交发布任务失败');
} finally {
setSubmitting(false);
}
};
const stopPolling = () => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
};
const startPolling = (taskIds: string[]) => {
stopPolling();
const poll = async () => {
try {
const results = await Promise.all(taskIds.map((id) => getPublishStatus(id)));
const valid = results.filter((t): t is IPublishTask => !!t);
setActiveTasks(valid);
const allDone = valid.length > 0 && valid.every((t) => t.status === 'success' || t.status === 'failed');
if (allDone) {
stopPolling();
const failed = valid.filter((t) => t.status === 'failed').length;
if (failed > 0) {
MessagePlugin.warning('发布结束:' + (valid.length - failed) + ' 成功,' + failed + ' 失败');
} else {
MessagePlugin.success('全部发布成功');
}
}
} catch {
// 轮询失败,继续下一次
}
};
poll();
pollRef.current = setInterval(poll, 2000);
};
useEffect(() => {
return () => stopPolling();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className={CommonStyle.pageWithPadding}>
<div className={style.header}>
<h1>发布中心</h1>
<Button theme='primary' onClick={submit} loading={submitting}>发布</Button>
</div>
{/* 素材选择 */}
<Card title='1. 选择素材' bordered className={style.card}>
<div className={style.sectionLabel}>视频(单选)</div>
{videoMaterials.length === 0 ? (
<p className={style.emptyText}>暂无视频素材,请先到「素材管理」上传</p>
) : (
<Radio.Group value={videoFile} onChange={(v) => { setVideoFile(String(v)); setImageFiles([]); }}>
<div className={style.materialGrid}>
{videoMaterials.map((m) => (
<Radio key={m.id} value={m.file_path} className={style.materialItem}>
<span className={style.materialName} title={m.filename}>{m.filename}</span>
</Radio>
))}
</div>
</Radio.Group>
)}
<div className={style.sectionLabel}>图片(多选,用于图文发布)</div>
{imageMaterials.length === 0 ? (
<p className={style.emptyText}>暂无图片素材</p>
) : (
<Checkbox.Group value={imageFiles} onChange={(v) => { setImageFiles(v as string[]); setVideoFile(''); }}>
<div className={style.materialGrid}>
{imageMaterials.map((m) => (
<Checkbox key={m.id} value={m.file_path} className={style.materialItem}>
<span className={style.materialName} title={m.filename}>{m.filename}</span>
</Checkbox>
))}
</div>
</Checkbox.Group>
)}
{materialType && (
<div className={style.typeBadge}>
当前素材类型:
<Tag theme={materialType === 'video' ? 'success' : 'warning'} variant='light'>
{materialType === 'video' ? '视频' : '图文'}
</Tag>
</div>
)}
</Card>
{/* 内容信息 */}
<Card title='2. 内容信息' bordered className={style.card}>
<div className={style.field}>
<label>标题</label>
<Input value={title} onChange={setTitle} placeholder='标题' maxlength={100} />
</div>
<div className={style.field}>
<label>标签(逗号分隔,不带 #)</label>
<Input value={tags} onChange={setTags} placeholder='例如:科技,教程' />
</div>
<div className={style.field}>
<label>描述 / 正文</label>
<Textarea value={desc} onChange={setDesc} placeholder='描述或图文正文' autosize={{ minRows: 2, maxRows: 5 }} />
</div>
<div className={style.switchRow}>
<span>原创声明</span>
<Switch size='medium' value={category === 1} onChange={(v) => setCategory(v ? 1 : 0)} />
</div>
<div className={style.switchRow}>
<span>保存为草稿</span>
<Switch size='medium' value={isDraft} onChange={setIsDraft} />
</div>
{materialType === 'video' && (
<>
<div className={style.switchRow}>
<span>定时发布</span>
<Switch size='medium' value={enableTimer} onChange={setEnableTimer} />
</div>
{enableTimer && (
<div className={style.inlineRow}>
<div className={style.field}>
<label>每天发布时间(时,逗号分隔)</label>
<Input value={dailyTimes} onChange={setDailyTimes} placeholder='12 或 8,12,18' />
</div>
<div className={style.field}>
<label>开始天数(0=明天)</label>
<Input value={String(startDays)} onChange={(v) => setStartDays(parseInt(v, 10) || 0)} />
</div>
</div>
)}
</>
)}
</Card>
{/* 渠道选择 */}
<Card title={'3. 选择发布渠道(' + (materialType === 'image' ? '图文' : materialType === 'video' ? '视频' : '请先选择素材') + ')'} bordered className={style.card}>
{!materialType ? (
<p className={style.emptyText}>请先在上方选择视频或图片素材</p>
) : (
<div className={style.channelList}>
{availablePlatforms.map((p) => {
const platformAccounts = accounts.filter((a) => a.type === p.type);
return (
<div key={p.type} className={style.channelItem}>
<div className={style.channelHead}>
<span className={style.channelName}>{p.name}</span>
<Tag theme={p.theme || 'default'} variant='light' size='small'>
{materialType === 'image' ? '图文' : '视频'}
</Tag>
{platformAccounts.length === 0 && <span className={style.noAccount}>(暂无账号)</span>}
</div>
<Select
value={accountMap[p.type] || []}
onChange={(v) => updateAccountMap(p.type, v as string[])}
options={platformAccounts.map((a) => ({ label: a.name, value: a.filePath }))}
multiple
clearable
placeholder='选择发布账号(可多选)'
disabled={platformAccounts.length === 0}
style={{ width: '100%' }}
/>
</div>
);
})}
</div>
)}
</Card>
{/* 发布任务进度 */}
<Dialog
header='发布任务进度'
visible={taskDialogVisible}
onClose={() => setTaskDialogVisible(false)}
footer={false}
width={640}
>
<div className={style.taskList}>
{activeTasks.map((task) => {
const p = PLATFORMS.find((x) => x.type === task.type);
const statusTheme = task.status === 'success' ? 'success' : task.status === 'failed' ? 'danger' : 'warning';
const statusText = task.status === 'success' ? '成功' : task.status === 'failed' ? '失败' : '进行中';
return (
<div key={task.id} className={style.taskItem}>
<div className={style.taskHead}>
<span className={style.taskTitle}>{(p ? p.name : '平台') + ' · ' + task.title}</span>
<Tag theme={statusTheme} variant='light'>{statusText}</Tag>
</div>
<div className={style.taskProgressRow}>
<Progress percentage={task.progress} theme='line' />
<span className={style.taskStep}>{task.step}</span>
</div>
{task.error && <p className={style.taskError}>{task.error}</p>}
{task.logs.length > 0 && (
<div className={style.taskLogs}>
{task.logs.slice(-6).map((log, i) => (
<div key={i} className={style.taskLogLine}>{log}</div>
))}
</div>
)}
</div>
);
})}
</div>
</Dialog>
</div>
);
};
export default memo(Publish);
@@ -0,0 +1,61 @@
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
h1 {
font-size: 20px;
margin: 0;
}
}
.tableWrap {
background: var(--td-bg-color-container);
border-radius: 8px;
}
.detail {
display: flex;
flex-direction: column;
gap: 12px;
}
.detailHead {
display: flex;
align-items: center;
justify-content: space-between;
}
.detailTitle {
font-size: 15px;
font-weight: 600;
}
.detailError {
font-size: 13px;
color: var(--td-error-color);
margin: 0;
word-break: break-all;
}
.detailLogs {
padding: 10px 12px;
background: var(--td-bg-color-component);
border-radius: 8px;
max-height: 400px;
overflow-y: auto;
}
.detailLogLine {
font-size: 12px;
color: var(--td-text-color-secondary);
line-height: 1.8;
word-break: break-all;
}
.detailEmpty {
font-size: 13px;
color: var(--td-text-color-placeholder);
margin: 0;
}
@@ -0,0 +1,113 @@
import React, { memo, useEffect, useState } from 'react';
import { Table, Tag, Button, Dialog, Progress, MessagePlugin } from 'tdesign-react';
import { RefreshIcon } from 'tdesign-icons-react';
import { getPublishTasks, IPublishTask } from 'services/publish';
import { PLATFORMS } from 'configs/platforms';
import CommonStyle from 'styles/common.module.less';
import style from './index.module.less';
const statusText = (s: string) => (s === 'success' ? '成功' : s === 'failed' ? '失败' : s === 'running' ? '进行中' : '排队中');
const statusTheme = (s: string): 'success' | 'danger' | 'warning' | 'default' =>
s === 'success' ? 'success' : s === 'failed' ? 'danger' : s === 'running' ? 'warning' : 'default';
const PublishLogs = () => {
const [tasks, setTasks] = useState<IPublishTask[]>([]);
const [loading, setLoading] = useState(false);
const [detail, setDetail] = useState<IPublishTask | null>(null);
const refresh = async (silent = false) => {
if (!silent) setLoading(true);
try {
const list = await getPublishTasks();
setTasks(list);
} catch {
if (!silent) MessagePlugin.error('获取发布日志失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
refresh();
const timer = setInterval(() => refresh(true), 5000);
return () => clearInterval(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const columns = [
{
colKey: 'created_at',
title: '时间',
width: 170,
},
{
colKey: 'type',
title: '平台',
width: 130,
cell: ({ row }: { row: IPublishTask }) => {
const p = PLATFORMS.find((x) => x.type === row.type);
return <Tag theme={p?.theme || 'default'} variant='light'>{p?.name || row.type}</Tag>;
},
},
{ colKey: 'title', title: '标题', ellipsis: true },
{
colKey: 'progress',
title: '进度',
width: 180,
cell: ({ row }: { row: IPublishTask }) => <Progress percentage={row.progress} theme='line' />,
},
{
colKey: 'status',
title: '状态',
width: 100,
cell: ({ row }: { row: IPublishTask }) => <Tag theme={statusTheme(row.status)} variant='light'>{statusText(row.status)}</Tag>,
},
{
colKey: 'op',
title: '操作',
width: 100,
cell: ({ row }: { row: IPublishTask }) => (
<Button variant='text' theme='primary' onClick={() => setDetail(row)}>日志</Button>
),
},
];
return (
<div className={CommonStyle.pageWithPadding}>
<div className={style.header}>
<h1>发布日志</h1>
<Button icon={<RefreshIcon />} variant='outline' onClick={() => refresh()}>刷新</Button>
</div>
<div className={style.tableWrap}>
<Table rowKey='id' data={tasks} columns={columns} loading={loading} hover empty='暂无发布记录' />
</div>
<Dialog header='发布日志详情' visible={!!detail} onClose={() => setDetail(null)} footer={false} width={680}>
{detail && (
<div className={style.detail}>
<div className={style.detailHead}>
<span className={style.detailTitle}>
{(PLATFORMS.find((x) => x.type === detail.type)?.name || detail.type) + ' · ' + detail.title}
</span>
<Tag theme={statusTheme(detail.status)} variant='light'>{statusText(detail.status)}</Tag>
</div>
<Progress percentage={detail.progress} theme='line' />
{detail.error && <p className={style.detailError}>错误:{detail.error}</p>}
<div className={style.detailLogs}>
{detail.logs.length === 0 ? (
<p className={style.detailEmpty}>暂无日志</p>
) : (
detail.logs.map((log, i) => (
<div key={i} className={style.detailLogLine}>{log}</div>
))
)}
</div>
</div>
)}
</Dialog>
</div>
);
};
export default memo(PublishLogs);
@@ -0,0 +1,71 @@
.header {
margin-bottom: 20px;
h1 {
font-size: 20px;
margin: 0 0 4px;
}
.sub {
color: var(--td-text-color-secondary);
margin: 0;
font-size: 13px;
}
}
.card {
margin-bottom: 16px;
max-width: 720px;
}
.radioList {
display: flex;
flex-direction: column;
gap: 14px;
}
.radioItem {
display: flex;
align-items: flex-start;
gap: 8px;
}
.radioTitle {
font-size: 14px;
font-weight: 500;
margin-right: 10px;
}
.radioDesc {
font-size: 13px;
color: var(--td-text-color-secondary);
}
.tip {
margin: 16px 0 0;
font-size: 13px;
color: var(--td-text-color-placeholder);
line-height: 1.7;
}
.switchRow {
display: flex;
align-items: center;
justify-content: space-between;
}
.switchInfo {
display: flex;
flex-direction: column;
gap: 4px;
}
.switchTitle {
font-size: 14px;
font-weight: 500;
}
.switchDesc {
font-size: 13px;
color: var(--td-text-color-secondary);
}
@@ -0,0 +1,79 @@
import React, { memo, useEffect, useState } from 'react';
import { Card, Radio, Switch, Button, MessagePlugin } from 'tdesign-react';
import { getSettings, updateSettings } from 'services/settings';
import CommonStyle from 'styles/common.module.less';
import style from './index.module.less';
const Settings = () => {
const [loginMode, setLoginMode] = useState('qr');
const [publishHeadless, setPublishHeadless] = useState(true);
const [saving, setSaving] = useState(false);
const load = async () => {
try {
const s = await getSettings();
if (s.login_mode) setLoginMode(s.login_mode);
if (typeof s.publish_headless === 'boolean') setPublishHeadless(s.publish_headless);
} catch {
// 忽略加载失败,使用默认值
}
};
useEffect(() => {
load();
}, []);
const save = async () => {
setSaving(true);
try {
await updateSettings({ login_mode: loginMode, publish_headless: publishHeadless });
MessagePlugin.success('配置已保存');
} catch {
MessagePlugin.error('保存失败');
} finally {
setSaving(false);
}
};
return (
<div className={CommonStyle.pageWithPadding}>
<div className={style.header}>
<h1>系统设置</h1>
<p className={style.sub}>全局配置,覆盖所有平台</p>
</div>
<Card title='登录方式' bordered className={style.card}>
<Radio.Group value={loginMode} onChange={(v) => setLoginMode(String(v))}>
<div className={style.radioList}>
<Radio value='qr' className={style.radioItem}>
<span className={style.radioTitle}>扫码登录</span>
<span className={style.radioDesc}>不弹浏览器,抓取二维码展示,用 App 扫码</span>
</Radio>
<Radio value='browser' className={style.radioItem}>
<span className={style.radioTitle}>浏览器登录</span>
<span className={style.radioDesc}>弹出浏览器窗口,手动登录</span>
</Radio>
</div>
</Radio.Group>
<p className={style.tip}>
扫码登录适用于小红书、抖音、快手、B站、百家号、支付宝、微博、虎扑;
YouTube、TikTok 无稳定二维码,无论选择哪种方式都会弹出浏览器交互登录。
</p>
</Card>
<Card title='发布模式' bordered className={style.card}>
<div className={style.switchRow}>
<div className={style.switchInfo}>
<div className={style.switchTitle}>发布静默模式(无感打开浏览器)</div>
<div className={style.switchDesc}>开启后发布时浏览器在后台运行,不弹窗口、不抢焦点、不闪烁;关闭则弹窗可见</div>
</div>
<Switch size='large' value={publishHeadless} onChange={setPublishHeadless} />
</div>
</Card>
<Button theme='primary' onClick={save} loading={saving}>保存配置</Button>
</div>
);
};
export default memo(Settings);
+46
View File
@@ -0,0 +1,46 @@
import React, { lazy } from 'react';
import { BrowserRouterProps } from 'react-router-dom';
import dashboard from './modules/dashboard';
import account from './modules/account';
import material from './modules/material';
import publish from './modules/publish';
import publishLogs from './modules/publishLogs';
import apiDocs from './modules/apiDocs';
import about from './modules/about';
export interface IRouter {
path: string;
redirect?: string;
Component?: React.FC<BrowserRouterProps> | (() => any);
/**
* 当前路由是否全屏显示
*/
isFullPage?: boolean;
/**
* meta未赋值 路由不显示到菜单中
*/
meta?: {
title?: string;
Icon?: React.FC;
/**
* 侧边栏隐藏该路由
*/
hidden?: boolean;
/**
* 单层路由
*/
single?: boolean;
};
children?: IRouter[];
}
const routes: IRouter[] = [
{
path: '/',
redirect: '/dashboard',
},
];
const allRoutes = [...routes, ...dashboard, ...account, ...material, ...publish, ...publishLogs, ...apiDocs, ...about];
export default allRoutes;
+16
View File
@@ -0,0 +1,16 @@
import { lazy } from 'react';
import { HelpCircleIcon } from 'tdesign-icons-react';
import { IRouter } from '../index';
const about: IRouter[] = [
{
path: '/about',
Component: lazy(() => import('pages/About')),
meta: {
title: '关于',
Icon: HelpCircleIcon,
},
},
];
export default about;
@@ -0,0 +1,16 @@
import { lazy } from 'react';
import { UsergroupIcon } from 'tdesign-icons-react';
import { IRouter } from '../index';
const account: IRouter[] = [
{
path: '/account',
Component: lazy(() => import('pages/Account')),
meta: {
title: '账号管理',
Icon: UsergroupIcon,
},
},
];
export default account;
@@ -0,0 +1,16 @@
import { lazy } from 'react';
import { ServiceIcon } from 'tdesign-icons-react';
import { IRouter } from '../index';
const apiDocs: IRouter[] = [
{
path: '/api-docs',
Component: lazy(() => import('pages/ApiDocs')),
meta: {
title: 'API 文档',
Icon: ServiceIcon,
},
},
];
export default apiDocs;
@@ -0,0 +1,16 @@
import { lazy } from 'react';
import { DashboardIcon } from 'tdesign-icons-react';
import { IRouter } from '../index';
const dashboard: IRouter[] = [
{
path: '/dashboard',
Component: lazy(() => import('pages/Dashboard')),
meta: {
title: '首页',
Icon: DashboardIcon,
},
},
];
export default dashboard;
@@ -0,0 +1,16 @@
import { lazy } from 'react';
import { FileIcon } from 'tdesign-icons-react';
import { IRouter } from '../index';
const material: IRouter[] = [
{
path: '/material',
Component: lazy(() => import('pages/Material')),
meta: {
title: '素材管理',
Icon: FileIcon,
},
},
];
export default material;
@@ -0,0 +1,16 @@
import { lazy } from 'react';
import { QueueIcon } from 'tdesign-icons-react';
import { IRouter } from '../index';
const publish: IRouter[] = [
{
path: '/publish',
Component: lazy(() => import('pages/Publish')),
meta: {
title: '发布中心',
Icon: QueueIcon,
},
},
];
export default publish;
@@ -0,0 +1,16 @@
import { lazy } from 'react';
import { ViewListIcon } from 'tdesign-icons-react';
import { IRouter } from '../index';
const publishLogs: IRouter[] = [
{
path: '/publish-logs',
Component: lazy(() => import('pages/PublishLogs')),
meta: {
title: '发布日志',
Icon: ViewListIcon,
},
},
];
export default publishLogs;
@@ -0,0 +1,16 @@
import { lazy } from 'react';
import { SettingIcon } from 'tdesign-icons-react';
import { IRouter } from '../index';
const settings: IRouter[] = [
{
path: '/settings',
Component: lazy(() => import('pages/Settings')),
meta: {
title: '系统设置',
Icon: SettingIcon,
},
},
];
export default settings;
+77
View File
@@ -0,0 +1,77 @@
import request from 'utils/request';
import { getPlatformByType } from 'configs/platforms';
export interface IAccount {
id: number;
type: number;
filePath: string;
name: string;
/** -1 验证中 1 正常 0 异常 */
status: number;
platform: string;
}
/** 登录 SSE 直连地址(EventSource 不走 axios,需完整 URL,后端 CORS 已开) */
export const LOGIN_SSE_BASE = 'http://127.0.0.1:5409';
const normalize = (row: any[]): IAccount => ({
id: row[0],
type: row[1],
filePath: row[2],
name: row[3],
status: row[4],
platform: getPlatformByType(row[1])?.name || '未知',
});
/** 快速获取账号列表(不校验 cookie) */
export const getAccounts = async (): Promise<IAccount[]> => {
const result: any = await request.get('/api/getAccounts');
const rows = result?.data || [];
return rows.map(normalize);
};
/** 获取账号列表并逐个校验 cookie 有效性(较慢) */
export const getValidAccounts = async (): Promise<IAccount[]> => {
const result: any = await request.get('/api/getValidAccounts');
const rows = result?.data || [];
return rows.map(normalize);
};
export const deleteAccount = (id: number) => request.get('/api/deleteAccount?id=' + id);
export const updateAccount = (data: { id: number; type: number; userName: string }) =>
request.post('/api/updateUserinfo', data);
export const downloadCookie = (filePath: string) => {
window.open('/api/downloadCookie?filePath=' + encodeURIComponent(filePath), '_blank');
};
export const uploadCookie = (formData: FormData) =>
request.post('/api/uploadCookie', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
export const getLoginSSEUrl = (type: number, id: string) =>
LOGIN_SSE_BASE + '/login?type=' + type + '&id=' + encodeURIComponent(id);
export interface ICheckTask {
id: string;
ids: number[];
total: number;
done: number;
status: 'running' | 'done' | 'error';
results: Record<string, { valid: boolean | null; name: string }>;
logs: string[];
error: string | null;
}
/** 校验指定账号(单个或多个),返回校验任务 ID */
export const checkAccounts = (ids: number[]): Promise<{ taskId: string }> =>
request.post('/api/checkAccounts', { ids }).then((r: any) => r?.data || {});
/** 查询校验任务进度 */
export const getCheckStatus = (taskId: string): Promise<ICheckTask | null> =>
request.get('/api/checkAccounts/status?taskId=' + taskId).then((r: any) => r?.data || null);
/** 批量删除账号 */
export const deleteAccounts = (ids: number[]) => request.post('/api/deleteAccounts', { ids });
+25
View File
@@ -0,0 +1,25 @@
import request from 'utils/request';
export interface IMaterial {
id: number;
filename: string;
filesize: number;
upload_time: string;
file_path: string;
uuid: string;
}
export const getMaterials = async (): Promise<IMaterial[]> => {
const result: any = await request.get('/api/getFiles');
return result?.data || [];
};
export const uploadMaterial = (formData: FormData) =>
request.post('/api/uploadSave', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 0,
});
export const deleteMaterial = (id: number) => request.get('/api/deleteFile?id=' + id);
export const getFileUrl = (filename: string) => '/api/getFile?filename=' + encodeURIComponent(filename);

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