diff --git a/myUtils/auth.py b/myUtils/auth.py index d78b696..03a364c 100644 --- a/myUtils/auth.py +++ b/myUtils/auth.py @@ -116,6 +116,31 @@ async def check_cookie(type, file_path): # 快手 case 4: 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 _: return False diff --git a/myUtils/postVideo.py b/myUtils/postVideo.py index 9f7b16a..484c9d5 100644 --- a/myUtils/postVideo.py +++ b/myUtils/postVideo.py @@ -2,91 +2,276 @@ import asyncio from pathlib import Path from conf import BASE_DIR -from uploader.douyin_uploader.main import DouYinVideo -from uploader.ks_uploader.main import KSVideo -from uploader.tencent_uploader.main import TencentVideo -from uploader.xiaohongshu_uploader.main import XiaoHongShuVideo +from uploader.douyin_uploader.main import DouYinVideo, DouYinNote +from uploader.ks_uploader.main import KSVideo, KSNote +from uploader.tencent_uploader.main import TencentVideo, TencentNote +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.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): - # 生成文件的完整路径 - account_file = [Path(BASE_DIR / "cookiesFile" / file) for file in account_file] - files = [Path(BASE_DIR / "videoFile" / file) for file in files] +def _publish_headless(): + return get_bool("publish_headless", True) + + +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: - 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: publish_datetimes = [0 for i in range(len(files))] for index, file in enumerate(files): for cookie in account_file: - print(f"文件路径{str(file)}") - # 打印视频文件名、标题和 hashtag print(f"视频文件名:{file}") print(f"标题:{title}") - print(f"Hashtag:{tags}") - app = TencentVideo(title, str(file), tags, publish_datetimes[index], cookie, category, is_draft) + app = TencentVideo(title, str(file), tags, publish_datetimes[index], cookie, category, is_draft, headless=_publish_headless()) 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, - thumbnail_path = '', - productLink = '', productTitle = ''): - # 生成文件的完整路径 - account_file = [Path(BASE_DIR / "cookiesFile" / file) for file in account_file] - files = [Path(BASE_DIR / "videoFile" / file) for file in files] +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='', + productLink='', productTitle=''): + 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) + 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: - print(f"文件路径{str(file)}") - # 打印视频文件名、标题和 hashtag print(f"视频文件名:{file}") print(f"标题:{title}") - print(f"Hashtag:{tags}") - app = DouYinVideo(title, str(file), tags, publish_datetimes[index], cookie, thumbnail_path, productLink, productTitle) + app = DouYinVideo(title, str(file), tags, publish_datetimes[index], cookie, thumbnail_path, productLink, productTitle, headless=_publish_headless()) 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): - # 生成文件的完整路径 - account_file = [Path(BASE_DIR / "cookiesFile" / file) for file in account_file] - files = [Path(BASE_DIR / "videoFile" / file) for file in files] +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) + files = _resolve_files(files) 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: publish_datetimes = [0 for i in range(len(files))] for index, file in enumerate(files): for cookie in account_file: - print(f"文件路径{str(file)}") - # 打印视频文件名、标题和 hashtag print(f"视频文件名:{file}") print(f"标题:{title}") - print(f"Hashtag:{tags}") - app = KSVideo(title, str(file), tags, publish_datetimes[index], cookie) + app = KSVideo(title, str(file), tags, publish_datetimes[index], cookie, headless=_publish_headless()) 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): - # 生成文件的完整路径 - account_file = [Path(BASE_DIR / "cookiesFile" / file) for file in account_file] - files = [Path(BASE_DIR / "videoFile" / file) for file in files] + +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 = _resolve_cookies(account_file) + files = _resolve_files(files) file_num = len(files) 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: publish_datetimes = 0 for index, file in enumerate(files): for cookie in account_file: - # 打印视频文件名、标题和 hashtag print(f"视频文件名:{file}") print(f"标题:{title}") - print(f"Hashtag:{tags}") - app = XiaoHongShuVideo(title, file, tags, publish_datetimes, cookie) + app = XiaoHongShuVideo(title, file, tags, publish_datetimes, cookie, headless=_publish_headless()) asyncio.run(app.main(), debug=False) +# ===== 新增:CLI 主线平台接入 Web 后端 ===== -# post_video("333",["demo.mp4"],"d","d") -# post_video_DouYin("333",["demo.mp4"],"d","d") \ No newline at end of file +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): + """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) diff --git a/sau_backend.py b/sau_backend.py index b19ec95..aff4186 100644 --- a/sau_backend.py +++ b/sau_backend.py @@ -1,9 +1,13 @@ import asyncio +import base64 import os import sqlite3 +import subprocess import threading import time import uuid +from datetime import datetime +from functools import wraps from pathlib import Path from queue import Queue 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 conf import BASE_DIR 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 = {} app = Flask(__name__) @@ -407,164 +609,30 @@ def login(): @app.route('/postVideo', methods=['POST']) def postVideo(): - # 获取JSON数据 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 - # 从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']) def postVideoBatch(): data_list = request.get_json() - if not isinstance(data_list, list): return jsonify({"code": 400, "msg": "Expected a JSON array", "data": None}), 400 + task_ids = [] for data in data_list: - # 从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', '') - is_draft = data.get('isDraft', False) + task_id, err = _create_publish_task(data) + if task_id: + task_ids.append(task_id) + if not task_ids: + return jsonify({"code": 400, "msg": "没有可提交的发布任务", "data": None}), 400 + return jsonify({"code": 200, "msg": None, "data": {"taskIds": task_ids}}), 200 - 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']) def upload_cookie(): try: @@ -685,6 +753,163 @@ def download_cookie(): }), 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): match type: @@ -708,6 +933,20 @@ def run_async_function(type,id,status_queue): asyncio.set_event_loop(loop) loop.run_until_complete(get_ks_cookie(id,status_queue)) 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 流生成器函数 def sse_stream(status_queue): @@ -719,5 +958,345 @@ def sse_stream(status_queue): # 避免 CPU 占满 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/', 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/', 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/', 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__': app.run(host='0.0.0.0' ,port=5409) diff --git a/sau_frontend/vite.config.js b/sau_frontend/vite.config.js index c951d49..cf0abd2 100644 --- a/sau_frontend/vite.config.js +++ b/sau_frontend/vite.config.js @@ -22,7 +22,7 @@ export default defineConfig({ open: true, proxy: { '/api': { - target: 'http://localhost:5409', + target: 'http://127.0.0.1:5409', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } diff --git a/sau_web_react/.editorconfig b/sau_web_react/.editorconfig new file mode 100644 index 0000000..3638cec --- /dev/null +++ b/sau_web_react/.editorconfig @@ -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 diff --git a/sau_web_react/.env.development b/sau_web_react/.env.development new file mode 100644 index 0000000..7ea0e4d --- /dev/null +++ b/sau_web_react/.env.development @@ -0,0 +1,4 @@ +# 打包路径 +VITE_BASE_URL= / + +NODE_ENV=development diff --git a/sau_web_react/.env.site b/sau_web_react/.env.site new file mode 100644 index 0000000..7e88aa2 --- /dev/null +++ b/sau_web_react/.env.site @@ -0,0 +1,4 @@ +# 打包路径 根据项目不同按需配置 +VITE_BASE_URL=https://static.tdesign.tencent.com/starter/react/ + +NODE_ENV=production diff --git a/sau_web_react/.eslintignore b/sau_web_react/.eslintignore new file mode 100644 index 0000000..98aae96 --- /dev/null +++ b/sau_web_react/.eslintignore @@ -0,0 +1,5 @@ +build +mock +config +public +scripts diff --git a/sau_web_react/.eslintrc.js b/sau_web_react/.eslintrc.js new file mode 100644 index 0000000..53cc55c --- /dev/null +++ b/sau_web_react/.eslintrc.js @@ -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, + }, + ], + }, +}; diff --git a/sau_web_react/.gitignore b/sau_web_react/.gitignore new file mode 100644 index 0000000..f4293fa --- /dev/null +++ b/sau_web_react/.gitignore @@ -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 diff --git a/sau_web_react/.prettierrc.js b/sau_web_react/.prettierrc.js new file mode 100644 index 0000000..8ae8226 --- /dev/null +++ b/sau_web_react/.prettierrc.js @@ -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', +}; diff --git a/sau_web_react/LICENSE b/sau_web_react/LICENSE new file mode 100644 index 0000000..be586af --- /dev/null +++ b/sau_web_react/LICENSE @@ -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. \ No newline at end of file diff --git a/sau_web_react/README.md b/sau_web_react/README.md new file mode 100644 index 0000000..ef67796 --- /dev/null +++ b/sau_web_react/README.md @@ -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 命令使用。 diff --git a/sau_web_react/index.html b/sau_web_react/index.html new file mode 100644 index 0000000..990a353 --- /dev/null +++ b/sau_web_react/index.html @@ -0,0 +1,15 @@ + + + + + + EveryPublish + + + + + +
+ + + diff --git a/sau_web_react/package.json b/sau_web_react/package.json new file mode 100644 index 0000000..93253f8 --- /dev/null +++ b/sau_web_react/package.json @@ -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" + ] + } +} diff --git a/sau_web_react/public/favicon.ico b/sau_web_react/public/favicon.ico new file mode 100644 index 0000000..086ac80 Binary files /dev/null and b/sau_web_react/public/favicon.ico differ diff --git a/sau_web_react/public/robots.txt b/sau_web_react/public/robots.txt new file mode 100644 index 0000000..e9e57dc --- /dev/null +++ b/sau_web_react/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/sau_web_react/src/assets/image/assets-login-bg-black.png b/sau_web_react/src/assets/image/assets-login-bg-black.png new file mode 100644 index 0000000..b4dde4b Binary files /dev/null and b/sau_web_react/src/assets/image/assets-login-bg-black.png differ diff --git a/sau_web_react/src/assets/image/assets-login-bg-white.png b/sau_web_react/src/assets/image/assets-login-bg-white.png new file mode 100644 index 0000000..4275549 Binary files /dev/null and b/sau_web_react/src/assets/image/assets-login-bg-white.png differ diff --git a/sau_web_react/src/assets/svg/assets-logo-full.svg b/sau_web_react/src/assets/svg/assets-logo-full.svg new file mode 100644 index 0000000..335359b --- /dev/null +++ b/sau_web_react/src/assets/svg/assets-logo-full.svg @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sau_web_react/src/assets/svg/assets-product-1.svg b/sau_web_react/src/assets/svg/assets-product-1.svg new file mode 100644 index 0000000..29e97f0 --- /dev/null +++ b/sau_web_react/src/assets/svg/assets-product-1.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/sau_web_react/src/assets/svg/assets-product-2.svg b/sau_web_react/src/assets/svg/assets-product-2.svg new file mode 100644 index 0000000..827c8bf --- /dev/null +++ b/sau_web_react/src/assets/svg/assets-product-2.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/sau_web_react/src/assets/svg/assets-product-3.svg b/sau_web_react/src/assets/svg/assets-product-3.svg new file mode 100644 index 0000000..a3d461b --- /dev/null +++ b/sau_web_react/src/assets/svg/assets-product-3.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/sau_web_react/src/assets/svg/assets-product-4.svg b/sau_web_react/src/assets/svg/assets-product-4.svg new file mode 100644 index 0000000..7ac1abc --- /dev/null +++ b/sau_web_react/src/assets/svg/assets-product-4.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/sau_web_react/src/assets/svg/assets-result-403.svg b/sau_web_react/src/assets/svg/assets-result-403.svg new file mode 100644 index 0000000..343a25e --- /dev/null +++ b/sau_web_react/src/assets/svg/assets-result-403.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sau_web_react/src/assets/svg/assets-result-404.svg b/sau_web_react/src/assets/svg/assets-result-404.svg new file mode 100644 index 0000000..db1cb36 --- /dev/null +++ b/sau_web_react/src/assets/svg/assets-result-404.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sau_web_react/src/assets/svg/assets-result-500.svg b/sau_web_react/src/assets/svg/assets-result-500.svg new file mode 100644 index 0000000..e820fe2 --- /dev/null +++ b/sau_web_react/src/assets/svg/assets-result-500.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sau_web_react/src/assets/svg/assets-result-browser-incompatible.svg b/sau_web_react/src/assets/svg/assets-result-browser-incompatible.svg new file mode 100644 index 0000000..ebaf5ba --- /dev/null +++ b/sau_web_react/src/assets/svg/assets-result-browser-incompatible.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sau_web_react/src/assets/svg/assets-result-maintenance.svg b/sau_web_react/src/assets/svg/assets-result-maintenance.svg new file mode 100644 index 0000000..f0cc27e --- /dev/null +++ b/sau_web_react/src/assets/svg/assets-result-maintenance.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sau_web_react/src/assets/svg/assets-result-network-error.svg b/sau_web_react/src/assets/svg/assets-result-network-error.svg new file mode 100644 index 0000000..eeee8b5 --- /dev/null +++ b/sau_web_react/src/assets/svg/assets-result-network-error.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sau_web_react/src/assets/svg/assets-setting-auto.svg b/sau_web_react/src/assets/svg/assets-setting-auto.svg new file mode 100644 index 0000000..66f57de --- /dev/null +++ b/sau_web_react/src/assets/svg/assets-setting-auto.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/sau_web_react/src/assets/svg/assets-setting-dark.svg b/sau_web_react/src/assets/svg/assets-setting-dark.svg new file mode 100644 index 0000000..0f15649 --- /dev/null +++ b/sau_web_react/src/assets/svg/assets-setting-dark.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/sau_web_react/src/assets/svg/assets-setting-light.svg b/sau_web_react/src/assets/svg/assets-setting-light.svg new file mode 100644 index 0000000..ffa8f34 --- /dev/null +++ b/sau_web_react/src/assets/svg/assets-setting-light.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/sau_web_react/src/assets/svg/assets-t-logo.svg b/sau_web_react/src/assets/svg/assets-t-logo.svg new file mode 100644 index 0000000..ed0464d --- /dev/null +++ b/sau_web_react/src/assets/svg/assets-t-logo.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sau_web_react/src/components/Board/index.module.less b/sau_web_react/src/components/Board/index.module.less new file mode 100644 index 0000000..44205ad --- /dev/null +++ b/sau_web_react/src/components/Board/index.module.less @@ -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; +} diff --git a/sau_web_react/src/components/Board/index.tsx b/sau_web_react/src/components/Board/index.tsx new file mode 100644 index 0000000..feb6ccf --- /dev/null +++ b/sau_web_react/src/components/Board/index.tsx @@ -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 { + 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 }) => ( +
+
+ {trend === ETrend.up ? ( + + + + + ) : ( + + + + + )} +
+ {trendNum} +
+); + +const Board = ({ title, count, desc, trend, trendNum, Icon, dark, border = false }: IBoardProps) => ( + {title}} + className={classnames({ + [Style.boardPanelDark]: dark, + [Style.boardPanel]: true, + })} + bordered={border} + footer={ +
+
+ {desc} + +
+ +
+ } + > +
+
{count}
+
{Icon}
+
+
+); + +export default React.memo(Board); diff --git a/sau_web_react/src/components/DatePicker/index.tsx b/sau_web_react/src/components/DatePicker/index.tsx new file mode 100644 index 0000000..8d1d6fc --- /dev/null +++ b/sau_web_react/src/components/DatePicker/index.tsx @@ -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) => ( + onChange(value)} + /> +); + +export default LastWeekDatePicker; diff --git a/sau_web_react/src/components/ErrorPage/index.module.less b/sau_web_react/src/components/ErrorPage/index.module.less new file mode 100644 index 0000000..75937c8 --- /dev/null +++ b/sau_web_react/src/components/ErrorPage/index.module.less @@ -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; + } +} diff --git a/sau_web_react/src/components/ErrorPage/index.tsx b/sau_web_react/src/components/ErrorPage/index.tsx new file mode 100644 index 0000000..7a71403 --- /dev/null +++ b/sau_web_react/src/components/ErrorPage/index.tsx @@ -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: , + }, + [ECode.notFount]: { + title: '404 Not Found', + desc: '抱歉,您访问的页面不存在。', + icon: , + }, + [ECode.error]: { + title: '500 Internal Server Error', + desc: '抱歉,服务器出错啦!', + icon: , + }, +}; + +const ErrorPage: React.FC = (props) => { + const info = errorInfo[props.code]; + return ( +
+ {info?.icon} +
{info?.title}
+
{info?.desc}
+ +
+ ); +}; + +export default memo(ErrorPage); diff --git a/sau_web_react/src/configs/color.ts b/sau_web_react/src/configs/color.ts new file mode 100644 index 0000000..46b4fff --- /dev/null +++ b/sau_web_react/src/configs/color.ts @@ -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', + }, +}; diff --git a/sau_web_react/src/configs/host.ts b/sau_web_react/src/configs/host.ts new file mode 100644 index 0000000..fa52b57 --- /dev/null +++ b/sau_web_react/src/configs/host.ts @@ -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: '', + }, +}; diff --git a/sau_web_react/src/configs/platforms.ts b/sau_web_react/src/configs/platforms.ts new file mode 100644 index 0000000..1aaa688 --- /dev/null +++ b/sau_web_react/src/configs/platforms.ts @@ -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); diff --git a/sau_web_react/src/global.d.ts b/sau_web_react/src/global.d.ts new file mode 100644 index 0000000..bad0def --- /dev/null +++ b/sau_web_react/src/global.d.ts @@ -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'; + }; +} diff --git a/sau_web_react/src/hooks/useDynamicChart.ts b/sau_web_react/src/hooks/useDynamicChart.ts new file mode 100644 index 0000000..9afff8b --- /dev/null +++ b/sau_web_react/src/hooks/useDynamicChart.ts @@ -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, + configs?: Partial>>, +) { + 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]); +} diff --git a/sau_web_react/src/layouts/components/AppLayout.module.less b/sau_web_react/src/layouts/components/AppLayout.module.less new file mode 100644 index 0000000..052c362 --- /dev/null +++ b/sau_web_react/src/layouts/components/AppLayout.module.less @@ -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; +} diff --git a/sau_web_react/src/layouts/components/AppLayout.tsx b/sau_web_react/src/layouts/components/AppLayout.tsx new file mode 100644 index 0000000..911b593 --- /dev/null +++ b/sau_web_react/src/layouts/components/AppLayout.tsx @@ -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(() => ( + + + +
+ +