Merge branch 'main' into main
Browse files- CLAUDE.md +101 -0
- myUtils/postVideo.py +2 -2
- sau_backend.py +188 -10
- sau_frontend/.env.development +1 -1
- sau_frontend/src/api/account.js +9 -4
- sau_frontend/src/api/material.js +2 -2
- sau_frontend/src/utils/request.js +3 -2
- sau_frontend/src/views/AccountManagement.vue +268 -38
- sau_frontend/src/views/MaterialManagement.vue +154 -45
- sau_frontend/src/views/PublishCenter.vue +69 -6
- sau_frontend/vite.config.js +8 -1
- uploader/tencent_uploader/main.py +30 -13
CLAUDE.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## Project Overview
|
| 2 |
+
|
| 3 |
+
This project, `social-auto-upload`, is a powerful automation tool designed to help content creators and operators efficiently publish video content to multiple domestic and international mainstream social media platforms in one click. The project implements video upload, scheduled release and other functions for platforms such as `Douyin`, `Bilibili`, `Xiaohongshu`, `Kuaishou`, `WeChat Channel`, `Baijiahao` and `TikTok`.
|
| 4 |
+
|
| 5 |
+
The project consists of a Python backend and a Vue.js frontend.
|
| 6 |
+
|
| 7 |
+
**Backend:**
|
| 8 |
+
|
| 9 |
+
* Framework: Flask
|
| 10 |
+
* Core Functionality:
|
| 11 |
+
* Handles file uploads and management.
|
| 12 |
+
* Interacts with a SQLite database to store information about files and user accounts.
|
| 13 |
+
* Uses `playwright` for browser automation to interact with social media platforms.
|
| 14 |
+
* Provides a RESTful API for the frontend to consume.
|
| 15 |
+
* Uses Server-Sent Events (SSE) for real-time communication with the frontend during the login process.
|
| 16 |
+
|
| 17 |
+
**Frontend:**
|
| 18 |
+
|
| 19 |
+
* Framework: Vue.js
|
| 20 |
+
* Build Tool: Vite
|
| 21 |
+
* UI Library: Element Plus
|
| 22 |
+
* State Management: Pinia
|
| 23 |
+
* Routing: Vue Router
|
| 24 |
+
* Core Functionality:
|
| 25 |
+
* Provides a web interface for managing social media accounts, video files, and publishing videos.
|
| 26 |
+
* Communicates with the backend via a RESTful API.
|
| 27 |
+
|
| 28 |
+
**Command-line Interface:**
|
| 29 |
+
|
| 30 |
+
The project also provides a command-line interface (CLI) for users who prefer to work from the terminal. The CLI supports two main actions:
|
| 31 |
+
|
| 32 |
+
* `login`: To log in to a social media platform.
|
| 33 |
+
* `upload`: To upload a video to a social media platform, with an option to schedule the upload.
|
| 34 |
+
|
| 35 |
+
## Building and Running
|
| 36 |
+
|
| 37 |
+
### Backend
|
| 38 |
+
|
| 39 |
+
1. **Install dependencies:**
|
| 40 |
+
```bash
|
| 41 |
+
pip install -r requirements.txt
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
2. **Install Playwright browser drivers:**
|
| 45 |
+
```bash
|
| 46 |
+
playwright install chromium
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
3. **Initialize the database:**
|
| 50 |
+
```bash
|
| 51 |
+
python db/createTable.py
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
4. **Run the backend server:**
|
| 55 |
+
```bash
|
| 56 |
+
python sau_backend.py
|
| 57 |
+
```
|
| 58 |
+
The backend server will start on `http://localhost:5409`.
|
| 59 |
+
|
| 60 |
+
### Frontend
|
| 61 |
+
|
| 62 |
+
1. **Navigate to the frontend directory:**
|
| 63 |
+
```bash
|
| 64 |
+
cd sau_frontend
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
2. **Install dependencies:**
|
| 68 |
+
```bash
|
| 69 |
+
npm install
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
3. **Run the development server:**
|
| 73 |
+
```bash
|
| 74 |
+
npm run dev
|
| 75 |
+
```
|
| 76 |
+
The frontend development server will start on `http://localhost:5173`.
|
| 77 |
+
|
| 78 |
+
### Command-line Interface
|
| 79 |
+
|
| 80 |
+
To use the CLI, you can run the `cli_main.py` script with the appropriate arguments.
|
| 81 |
+
|
| 82 |
+
**Login:**
|
| 83 |
+
|
| 84 |
+
```bash
|
| 85 |
+
python cli_main.py <platform> <account_name> login
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
**Upload:**
|
| 89 |
+
|
| 90 |
+
```bash
|
| 91 |
+
python cli_main.py <platform> <account_name> upload <video_file> [-pt {0,1}] [-t YYYY-MM-DD HH:MM]
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
## Development Conventions
|
| 95 |
+
|
| 96 |
+
* The backend code is located in the root directory and the `myUtils` and `uploader` directories.
|
| 97 |
+
* The frontend code is located in the `sau_frontend` directory.
|
| 98 |
+
* The project uses a SQLite database for data storage. The database file is located at `db/database.db`.
|
| 99 |
+
* The `conf.example.py` file should be copied to `conf.py` and configured with the appropriate settings.
|
| 100 |
+
* The `requirements.txt` file lists the Python dependencies.
|
| 101 |
+
* The `package.json` file in the `sau_frontend` directory lists the frontend dependencies.
|
myUtils/postVideo.py
CHANGED
|
@@ -10,7 +10,7 @@ from utils.constant import TencentZoneTypes
|
|
| 10 |
from utils.files_times import generate_schedule_time_next_day
|
| 11 |
|
| 12 |
|
| 13 |
-
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):
|
| 14 |
# 生成文件的完整路径
|
| 15 |
account_file = [Path(BASE_DIR / "cookiesFile" / file) for file in account_file]
|
| 16 |
files = [Path(BASE_DIR / "videoFile" / file) for file in files]
|
|
@@ -25,7 +25,7 @@ def post_video_tencent(title,files,tags,account_file,category=TencentZoneTypes.L
|
|
| 25 |
print(f"视频文件名:{file}")
|
| 26 |
print(f"标题:{title}")
|
| 27 |
print(f"Hashtag:{tags}")
|
| 28 |
-
app = TencentVideo(title, str(file), tags, publish_datetimes[index], cookie, category)
|
| 29 |
asyncio.run(app.main(), debug=False)
|
| 30 |
|
| 31 |
|
|
|
|
| 10 |
from utils.files_times import generate_schedule_time_next_day
|
| 11 |
|
| 12 |
|
| 13 |
+
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):
|
| 14 |
# 生成文件的完整路径
|
| 15 |
account_file = [Path(BASE_DIR / "cookiesFile" / file) for file in account_file]
|
| 16 |
files = [Path(BASE_DIR / "videoFile" / file) for file in files]
|
|
|
|
| 25 |
print(f"视频文件名:{file}")
|
| 26 |
print(f"标题:{title}")
|
| 27 |
print(f"Hashtag:{tags}")
|
| 28 |
+
app = TencentVideo(title, str(file), tags, publish_datetimes[index], cookie, category, is_draft)
|
| 29 |
asyncio.run(app.main(), debug=False)
|
| 30 |
|
| 31 |
|
sau_backend.py
CHANGED
|
@@ -139,9 +139,10 @@ def upload_save():
|
|
| 139 |
}), 200
|
| 140 |
|
| 141 |
except Exception as e:
|
|
|
|
| 142 |
return jsonify({
|
| 143 |
"code": 500,
|
| 144 |
-
"msg":
|
| 145 |
"data": None
|
| 146 |
}), 500
|
| 147 |
|
|
@@ -157,14 +158,26 @@ def get_all_files():
|
|
| 157 |
cursor.execute("SELECT * FROM file_records")
|
| 158 |
rows = cursor.fetchall()
|
| 159 |
|
| 160 |
-
# 将结果转为字典列表
|
| 161 |
-
data = [
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
except Exception as e:
|
| 169 |
return jsonify({
|
| 170 |
"code": 500,
|
|
@@ -173,6 +186,37 @@ def get_all_files():
|
|
| 173 |
}), 500
|
| 174 |
|
| 175 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
@app.route("/getValidAccounts",methods=['GET'])
|
| 177 |
async def getValidAccounts():
|
| 178 |
with sqlite3.connect(Path(BASE_DIR / "db" / "database.db")) as conn:
|
|
@@ -234,6 +278,18 @@ def delete_file():
|
|
| 234 |
|
| 235 |
record = dict(record)
|
| 236 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
# 删除数据库记录
|
| 238 |
cursor.execute("DELETE FROM file_records WHERE id = ?", (file_id,))
|
| 239 |
conn.commit()
|
|
@@ -338,6 +394,7 @@ def postVideo():
|
|
| 338 |
productLink = data.get('productLink', '')
|
| 339 |
productTitle = data.get('productTitle', '')
|
| 340 |
thumbnail_path = data.get('thumbnail', '')
|
|
|
|
| 341 |
|
| 342 |
videos_per_day = data.get('videosPerDay')
|
| 343 |
daily_times = data.get('dailyTimes')
|
|
@@ -351,7 +408,7 @@ def postVideo():
|
|
| 351 |
start_days)
|
| 352 |
case 2:
|
| 353 |
post_video_tencent(title, file_list, tags, account_list, category, enableTimer, videos_per_day, daily_times,
|
| 354 |
-
start_days)
|
| 355 |
case 3:
|
| 356 |
post_video_DouYin(title, file_list, tags, account_list, category, enableTimer, videos_per_day, daily_times,
|
| 357 |
start_days, thumbnail_path, productLink, productTitle)
|
|
@@ -450,6 +507,127 @@ def postVideoBatch():
|
|
| 450 |
"data": None
|
| 451 |
}), 200
|
| 452 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 453 |
# 包装函数:在线程中运行异步函数
|
| 454 |
def run_async_function(type,id,status_queue):
|
| 455 |
match type:
|
|
|
|
| 139 |
}), 200
|
| 140 |
|
| 141 |
except Exception as e:
|
| 142 |
+
print(f"Upload failed: {e}")
|
| 143 |
return jsonify({
|
| 144 |
"code": 500,
|
| 145 |
+
"msg": f"upload failed: {e}",
|
| 146 |
"data": None
|
| 147 |
}), 500
|
| 148 |
|
|
|
|
| 158 |
cursor.execute("SELECT * FROM file_records")
|
| 159 |
rows = cursor.fetchall()
|
| 160 |
|
| 161 |
+
# 将结果转为字典列表,并提取UUID
|
| 162 |
+
data = []
|
| 163 |
+
for row in rows:
|
| 164 |
+
row_dict = dict(row)
|
| 165 |
+
# 从 file_path 中提取 UUID (文件名的第一部分,下划线前)
|
| 166 |
+
if row_dict.get('file_path'):
|
| 167 |
+
file_path_parts = row_dict['file_path'].split('_', 1) # 只分割第一个下划线
|
| 168 |
+
if len(file_path_parts) > 0:
|
| 169 |
+
row_dict['uuid'] = file_path_parts[0] # UUID 部分
|
| 170 |
+
else:
|
| 171 |
+
row_dict['uuid'] = ''
|
| 172 |
+
else:
|
| 173 |
+
row_dict['uuid'] = ''
|
| 174 |
+
data.append(row_dict)
|
| 175 |
+
|
| 176 |
+
return jsonify({
|
| 177 |
+
"code": 200,
|
| 178 |
+
"msg": "success",
|
| 179 |
+
"data": data
|
| 180 |
+
}), 200
|
| 181 |
except Exception as e:
|
| 182 |
return jsonify({
|
| 183 |
"code": 500,
|
|
|
|
| 186 |
}), 500
|
| 187 |
|
| 188 |
|
| 189 |
+
@app.route("/getAccounts", methods=['GET'])
|
| 190 |
+
def getAccounts():
|
| 191 |
+
"""快速获取所有账号信息,不进行cookie验证"""
|
| 192 |
+
try:
|
| 193 |
+
with sqlite3.connect(Path(BASE_DIR / "db" / "database.db")) as conn:
|
| 194 |
+
conn.row_factory = sqlite3.Row
|
| 195 |
+
cursor = conn.cursor()
|
| 196 |
+
cursor.execute('''
|
| 197 |
+
SELECT * FROM user_info''')
|
| 198 |
+
rows = cursor.fetchall()
|
| 199 |
+
rows_list = [list(row) for row in rows]
|
| 200 |
+
|
| 201 |
+
print("\n📋 当前数据表内容(快速获取):")
|
| 202 |
+
for row in rows:
|
| 203 |
+
print(row)
|
| 204 |
+
|
| 205 |
+
return jsonify(
|
| 206 |
+
{
|
| 207 |
+
"code": 200,
|
| 208 |
+
"msg": None,
|
| 209 |
+
"data": rows_list
|
| 210 |
+
}), 200
|
| 211 |
+
except Exception as e:
|
| 212 |
+
print(f"获取账号列表时出错: {str(e)}")
|
| 213 |
+
return jsonify({
|
| 214 |
+
"code": 500,
|
| 215 |
+
"msg": f"获取账号列表失败: {str(e)}",
|
| 216 |
+
"data": None
|
| 217 |
+
}), 500
|
| 218 |
+
|
| 219 |
+
|
| 220 |
@app.route("/getValidAccounts",methods=['GET'])
|
| 221 |
async def getValidAccounts():
|
| 222 |
with sqlite3.connect(Path(BASE_DIR / "db" / "database.db")) as conn:
|
|
|
|
| 278 |
|
| 279 |
record = dict(record)
|
| 280 |
|
| 281 |
+
# 获取文件路径并删除实际文件
|
| 282 |
+
file_path = Path(BASE_DIR / "videoFile" / record['file_path'])
|
| 283 |
+
if file_path.exists():
|
| 284 |
+
try:
|
| 285 |
+
file_path.unlink() # 删除文件
|
| 286 |
+
print(f"✅ 实际文件已删除: {file_path}")
|
| 287 |
+
except Exception as e:
|
| 288 |
+
print(f"⚠️ 删除实际文件失败: {e}")
|
| 289 |
+
# 即使删除文件失败,也要继续删除数据库记录,避免数据不一致
|
| 290 |
+
else:
|
| 291 |
+
print(f"⚠️ 实际文件不存在: {file_path}")
|
| 292 |
+
|
| 293 |
# 删除数据库记录
|
| 294 |
cursor.execute("DELETE FROM file_records WHERE id = ?", (file_id,))
|
| 295 |
conn.commit()
|
|
|
|
| 394 |
productLink = data.get('productLink', '')
|
| 395 |
productTitle = data.get('productTitle', '')
|
| 396 |
thumbnail_path = data.get('thumbnail', '')
|
| 397 |
+
is_draft = data.get('isDraft', False) # 新增参数:是否保存为草稿
|
| 398 |
|
| 399 |
videos_per_day = data.get('videosPerDay')
|
| 400 |
daily_times = data.get('dailyTimes')
|
|
|
|
| 408 |
start_days)
|
| 409 |
case 2:
|
| 410 |
post_video_tencent(title, file_list, tags, account_list, category, enableTimer, videos_per_day, daily_times,
|
| 411 |
+
start_days, is_draft)
|
| 412 |
case 3:
|
| 413 |
post_video_DouYin(title, file_list, tags, account_list, category, enableTimer, videos_per_day, daily_times,
|
| 414 |
start_days, thumbnail_path, productLink, productTitle)
|
|
|
|
| 507 |
"data": None
|
| 508 |
}), 200
|
| 509 |
|
| 510 |
+
# Cookie文件上传API
|
| 511 |
+
@app.route('/uploadCookie', methods=['POST'])
|
| 512 |
+
def upload_cookie():
|
| 513 |
+
try:
|
| 514 |
+
if 'file' not in request.files:
|
| 515 |
+
return jsonify({
|
| 516 |
+
"code": 500,
|
| 517 |
+
"msg": "没有找到Cookie文件",
|
| 518 |
+
"data": None
|
| 519 |
+
}), 400
|
| 520 |
+
|
| 521 |
+
file = request.files['file']
|
| 522 |
+
if file.filename == '':
|
| 523 |
+
return jsonify({
|
| 524 |
+
"code": 500,
|
| 525 |
+
"msg": "Cookie文件名不能为空",
|
| 526 |
+
"data": None
|
| 527 |
+
}), 400
|
| 528 |
+
|
| 529 |
+
if not file.filename.endswith('.json'):
|
| 530 |
+
return jsonify({
|
| 531 |
+
"code": 500,
|
| 532 |
+
"msg": "Cookie文件必须是JSON格式",
|
| 533 |
+
"data": None
|
| 534 |
+
}), 400
|
| 535 |
+
|
| 536 |
+
# 获取账号信息
|
| 537 |
+
account_id = request.form.get('id')
|
| 538 |
+
platform = request.form.get('platform')
|
| 539 |
+
|
| 540 |
+
if not account_id or not platform:
|
| 541 |
+
return jsonify({
|
| 542 |
+
"code": 500,
|
| 543 |
+
"msg": "缺少账号ID或平台信息",
|
| 544 |
+
"data": None
|
| 545 |
+
}), 400
|
| 546 |
+
|
| 547 |
+
# 从数据库获取账号的文件路径
|
| 548 |
+
with sqlite3.connect(Path(BASE_DIR / "db" / "database.db")) as conn:
|
| 549 |
+
conn.row_factory = sqlite3.Row
|
| 550 |
+
cursor = conn.cursor()
|
| 551 |
+
cursor.execute('SELECT filePath FROM user_info WHERE id = ?', (account_id,))
|
| 552 |
+
result = cursor.fetchone()
|
| 553 |
+
|
| 554 |
+
if not result:
|
| 555 |
+
return jsonify({
|
| 556 |
+
"code": 500,
|
| 557 |
+
"msg": "账号不存在",
|
| 558 |
+
"data": None
|
| 559 |
+
}), 404
|
| 560 |
+
|
| 561 |
+
# 保存上传的Cookie文件到对应路径
|
| 562 |
+
cookie_file_path = Path(BASE_DIR / "cookiesFile" / result['filePath'])
|
| 563 |
+
cookie_file_path.parent.mkdir(parents=True, exist_ok=True)
|
| 564 |
+
|
| 565 |
+
file.save(str(cookie_file_path))
|
| 566 |
+
|
| 567 |
+
# 更新数据库中的账号信息(可选,比如更新更新时间)
|
| 568 |
+
# 这里可以根据需要添加额外的处理逻辑
|
| 569 |
+
|
| 570 |
+
return jsonify({
|
| 571 |
+
"code": 200,
|
| 572 |
+
"msg": "Cookie文件上传成功",
|
| 573 |
+
"data": None
|
| 574 |
+
}), 200
|
| 575 |
+
|
| 576 |
+
except Exception as e:
|
| 577 |
+
print(f"上传Cookie文件时出错: {str(e)}")
|
| 578 |
+
return jsonify({
|
| 579 |
+
"code": 500,
|
| 580 |
+
"msg": f"上传Cookie文件失败: {str(e)}",
|
| 581 |
+
"data": None
|
| 582 |
+
}), 500
|
| 583 |
+
|
| 584 |
+
|
| 585 |
+
# Cookie文件下载API
|
| 586 |
+
@app.route('/downloadCookie', methods=['GET'])
|
| 587 |
+
def download_cookie():
|
| 588 |
+
try:
|
| 589 |
+
file_path = request.args.get('filePath')
|
| 590 |
+
if not file_path:
|
| 591 |
+
return jsonify({
|
| 592 |
+
"code": 500,
|
| 593 |
+
"msg": "缺少文件路径参数",
|
| 594 |
+
"data": None
|
| 595 |
+
}), 400
|
| 596 |
+
|
| 597 |
+
# 验证文件路径的安全性,防止路径遍历攻击
|
| 598 |
+
cookie_file_path = Path(BASE_DIR / "cookiesFile" / file_path).resolve()
|
| 599 |
+
base_path = Path(BASE_DIR / "cookiesFile").resolve()
|
| 600 |
+
|
| 601 |
+
if not cookie_file_path.is_relative_to(base_path):
|
| 602 |
+
return jsonify({
|
| 603 |
+
"code": 500,
|
| 604 |
+
"msg": "非法文件路径",
|
| 605 |
+
"data": None
|
| 606 |
+
}), 400
|
| 607 |
+
|
| 608 |
+
if not cookie_file_path.exists():
|
| 609 |
+
return jsonify({
|
| 610 |
+
"code": 500,
|
| 611 |
+
"msg": "Cookie文件不存在",
|
| 612 |
+
"data": None
|
| 613 |
+
}), 404
|
| 614 |
+
|
| 615 |
+
# 返回文件
|
| 616 |
+
return send_from_directory(
|
| 617 |
+
directory=str(cookie_file_path.parent),
|
| 618 |
+
path=cookie_file_path.name,
|
| 619 |
+
as_attachment=True
|
| 620 |
+
)
|
| 621 |
+
|
| 622 |
+
except Exception as e:
|
| 623 |
+
print(f"下载Cookie文件时出错: {str(e)}")
|
| 624 |
+
return jsonify({
|
| 625 |
+
"code": 500,
|
| 626 |
+
"msg": f"下载Cookie文件失败: {str(e)}",
|
| 627 |
+
"data": None
|
| 628 |
+
}), 500
|
| 629 |
+
|
| 630 |
+
|
| 631 |
# 包装函数:在线程中运行异步函数
|
| 632 |
def run_async_function(type,id,status_queue):
|
| 633 |
match type:
|
sau_frontend/.env.development
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
NODE_ENV=development
|
| 3 |
|
| 4 |
# API 基础地址
|
| 5 |
-
VITE_API_BASE_URL=
|
| 6 |
|
| 7 |
# 应用端口
|
| 8 |
VITE_PORT=5173
|
|
|
|
| 2 |
NODE_ENV=development
|
| 3 |
|
| 4 |
# API 基础地址
|
| 5 |
+
VITE_API_BASE_URL=/api
|
| 6 |
|
| 7 |
# 应用端口
|
| 8 |
VITE_PORT=5173
|
sau_frontend/src/api/account.js
CHANGED
|
@@ -2,21 +2,26 @@ import { http } from '@/utils/request'
|
|
| 2 |
|
| 3 |
// 账号管理相关API
|
| 4 |
export const accountApi = {
|
| 5 |
-
// 获取有效账号列表
|
| 6 |
getValidAccounts() {
|
| 7 |
return http.get('/getValidAccounts')
|
| 8 |
},
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
// 添加账号
|
| 11 |
addAccount(data) {
|
| 12 |
return http.post('/account', data)
|
| 13 |
},
|
| 14 |
-
|
| 15 |
// 更新账号
|
| 16 |
updateAccount(data) {
|
| 17 |
return http.post('/updateUserinfo', data)
|
| 18 |
},
|
| 19 |
-
|
| 20 |
// 删除账号
|
| 21 |
deleteAccount(id) {
|
| 22 |
return http.get(`/deleteAccount?id=${id}`)
|
|
|
|
| 2 |
|
| 3 |
// 账号管理相关API
|
| 4 |
export const accountApi = {
|
| 5 |
+
// 获取有效账号列表(带验证)
|
| 6 |
getValidAccounts() {
|
| 7 |
return http.get('/getValidAccounts')
|
| 8 |
},
|
| 9 |
+
|
| 10 |
+
// 获取账号列表(不带验证,快速加载)
|
| 11 |
+
getAccounts() {
|
| 12 |
+
return http.get('/getAccounts')
|
| 13 |
+
},
|
| 14 |
+
|
| 15 |
// 添加账号
|
| 16 |
addAccount(data) {
|
| 17 |
return http.post('/account', data)
|
| 18 |
},
|
| 19 |
+
|
| 20 |
// 更新账号
|
| 21 |
updateAccount(data) {
|
| 22 |
return http.post('/updateUserinfo', data)
|
| 23 |
},
|
| 24 |
+
|
| 25 |
// 删除账号
|
| 26 |
deleteAccount(id) {
|
| 27 |
return http.get(`/deleteAccount?id=${id}`)
|
sau_frontend/src/api/material.js
CHANGED
|
@@ -8,9 +8,9 @@ export const materialApi = {
|
|
| 8 |
},
|
| 9 |
|
| 10 |
// 上传素材
|
| 11 |
-
uploadMaterial: (formData) => {
|
| 12 |
// 使用http.upload方法,它已经配置了正确的Content-Type
|
| 13 |
-
return http.upload('/uploadSave', formData)
|
| 14 |
},
|
| 15 |
|
| 16 |
// 删除素材
|
|
|
|
| 8 |
},
|
| 9 |
|
| 10 |
// 上传素材
|
| 11 |
+
uploadMaterial: (formData, onUploadProgress) => {
|
| 12 |
// 使用http.upload方法,它已经配置了正确的Content-Type
|
| 13 |
+
return http.upload('/uploadSave', formData, onUploadProgress)
|
| 14 |
},
|
| 15 |
|
| 16 |
// 删除素材
|
sau_frontend/src/utils/request.js
CHANGED
|
@@ -87,11 +87,12 @@ export const http = {
|
|
| 87 |
return request.delete(url, { params })
|
| 88 |
},
|
| 89 |
|
| 90 |
-
upload(url, formData) {
|
| 91 |
return request.post(url, formData, {
|
| 92 |
headers: {
|
| 93 |
'Content-Type': 'multipart/form-data'
|
| 94 |
-
}
|
|
|
|
| 95 |
})
|
| 96 |
}
|
| 97 |
}
|
|
|
|
| 87 |
return request.delete(url, { params })
|
| 88 |
},
|
| 89 |
|
| 90 |
+
upload(url, formData, onUploadProgress) {
|
| 91 |
return request.post(url, formData, {
|
| 92 |
headers: {
|
| 93 |
'Content-Type': 'multipart/form-data'
|
| 94 |
+
},
|
| 95 |
+
onUploadProgress
|
| 96 |
})
|
| 97 |
}
|
| 98 |
}
|
sau_frontend/src/views/AccountManagement.vue
CHANGED
|
@@ -30,7 +30,7 @@
|
|
| 30 |
<el-table :data="filteredAccounts" style="width: 100%">
|
| 31 |
<el-table-column label="头像" width="80">
|
| 32 |
<template #default="scope">
|
| 33 |
-
<el-avatar :src="scope.row.
|
| 34 |
</template>
|
| 35 |
</el-table-column>
|
| 36 |
<el-table-column prop="name" label="名称" width="180" />
|
|
@@ -47,9 +47,14 @@
|
|
| 47 |
<el-table-column prop="status" label="状态">
|
| 48 |
<template #default="scope">
|
| 49 |
<el-tag
|
| 50 |
-
:type="scope.row.status
|
| 51 |
effect="plain"
|
|
|
|
|
|
|
| 52 |
>
|
|
|
|
|
|
|
|
|
|
| 53 |
{{ scope.row.status }}
|
| 54 |
</el-tag>
|
| 55 |
</template>
|
|
@@ -57,6 +62,8 @@
|
|
| 57 |
<el-table-column label="操作">
|
| 58 |
<template #default="scope">
|
| 59 |
<el-button size="small" @click="handleEdit(scope.row)">编辑</el-button>
|
|
|
|
|
|
|
| 60 |
<el-button size="small" type="danger" @click="handleDelete(scope.row)">删除</el-button>
|
| 61 |
</template>
|
| 62 |
</el-table-column>
|
|
@@ -93,7 +100,7 @@
|
|
| 93 |
<el-table :data="filteredKuaishouAccounts" style="width: 100%">
|
| 94 |
<el-table-column label="头像" width="80">
|
| 95 |
<template #default="scope">
|
| 96 |
-
<el-avatar :src="scope.row.
|
| 97 |
</template>
|
| 98 |
</el-table-column>
|
| 99 |
<el-table-column prop="name" label="名称" width="180" />
|
|
@@ -110,9 +117,14 @@
|
|
| 110 |
<el-table-column prop="status" label="状态">
|
| 111 |
<template #default="scope">
|
| 112 |
<el-tag
|
| 113 |
-
:type="scope.row.status
|
| 114 |
effect="plain"
|
|
|
|
|
|
|
| 115 |
>
|
|
|
|
|
|
|
|
|
|
| 116 |
{{ scope.row.status }}
|
| 117 |
</el-tag>
|
| 118 |
</template>
|
|
@@ -120,6 +132,8 @@
|
|
| 120 |
<el-table-column label="操作">
|
| 121 |
<template #default="scope">
|
| 122 |
<el-button size="small" @click="handleEdit(scope.row)">编辑</el-button>
|
|
|
|
|
|
|
| 123 |
<el-button size="small" type="danger" @click="handleDelete(scope.row)">删除</el-button>
|
| 124 |
</template>
|
| 125 |
</el-table-column>
|
|
@@ -156,7 +170,7 @@
|
|
| 156 |
<el-table :data="filteredDouyinAccounts" style="width: 100%">
|
| 157 |
<el-table-column label="头像" width="80">
|
| 158 |
<template #default="scope">
|
| 159 |
-
<el-avatar :src="scope.row.
|
| 160 |
</template>
|
| 161 |
</el-table-column>
|
| 162 |
<el-table-column prop="name" label="名称" width="180" />
|
|
@@ -173,9 +187,14 @@
|
|
| 173 |
<el-table-column prop="status" label="状态">
|
| 174 |
<template #default="scope">
|
| 175 |
<el-tag
|
| 176 |
-
:type="scope.row.status
|
| 177 |
effect="plain"
|
|
|
|
|
|
|
| 178 |
>
|
|
|
|
|
|
|
|
|
|
| 179 |
{{ scope.row.status }}
|
| 180 |
</el-tag>
|
| 181 |
</template>
|
|
@@ -183,6 +202,8 @@
|
|
| 183 |
<el-table-column label="操作">
|
| 184 |
<template #default="scope">
|
| 185 |
<el-button size="small" @click="handleEdit(scope.row)">编辑</el-button>
|
|
|
|
|
|
|
| 186 |
<el-button size="small" type="danger" @click="handleDelete(scope.row)">删除</el-button>
|
| 187 |
</template>
|
| 188 |
</el-table-column>
|
|
@@ -219,7 +240,7 @@
|
|
| 219 |
<el-table :data="filteredChannelsAccounts" style="width: 100%">
|
| 220 |
<el-table-column label="头像" width="80">
|
| 221 |
<template #default="scope">
|
| 222 |
-
<el-avatar :src="scope.row.
|
| 223 |
</template>
|
| 224 |
</el-table-column>
|
| 225 |
<el-table-column prop="name" label="名称" width="180" />
|
|
@@ -236,9 +257,14 @@
|
|
| 236 |
<el-table-column prop="status" label="状态">
|
| 237 |
<template #default="scope">
|
| 238 |
<el-tag
|
| 239 |
-
:type="scope.row.status
|
| 240 |
effect="plain"
|
|
|
|
|
|
|
| 241 |
>
|
|
|
|
|
|
|
|
|
|
| 242 |
{{ scope.row.status }}
|
| 243 |
</el-tag>
|
| 244 |
</template>
|
|
@@ -246,6 +272,8 @@
|
|
| 246 |
<el-table-column label="操作">
|
| 247 |
<template #default="scope">
|
| 248 |
<el-button size="small" @click="handleEdit(scope.row)">编辑</el-button>
|
|
|
|
|
|
|
| 249 |
<el-button size="small" type="danger" @click="handleDelete(scope.row)">删除</el-button>
|
| 250 |
</template>
|
| 251 |
</el-table-column>
|
|
@@ -282,7 +310,7 @@
|
|
| 282 |
<el-table :data="filteredXiaohongshuAccounts" style="width: 100%">
|
| 283 |
<el-table-column label="头像" width="80">
|
| 284 |
<template #default="scope">
|
| 285 |
-
<el-avatar :src="scope.row.
|
| 286 |
</template>
|
| 287 |
</el-table-column>
|
| 288 |
<el-table-column prop="name" label="名称" width="180" />
|
|
@@ -299,9 +327,14 @@
|
|
| 299 |
<el-table-column prop="status" label="状态">
|
| 300 |
<template #default="scope">
|
| 301 |
<el-tag
|
| 302 |
-
:type="scope.row.status
|
| 303 |
effect="plain"
|
|
|
|
|
|
|
| 304 |
>
|
|
|
|
|
|
|
|
|
|
| 305 |
{{ scope.row.status }}
|
| 306 |
</el-tag>
|
| 307 |
</template>
|
|
@@ -309,6 +342,8 @@
|
|
| 309 |
<el-table-column label="操作">
|
| 310 |
<template #default="scope">
|
| 311 |
<el-button size="small" @click="handleEdit(scope.row)">编辑</el-button>
|
|
|
|
|
|
|
| 312 |
<el-button size="small" type="danger" @click="handleDelete(scope.row)">删除</el-button>
|
| 313 |
</template>
|
| 314 |
</el-table-column>
|
|
@@ -393,7 +428,7 @@
|
|
| 393 |
|
| 394 |
<script setup>
|
| 395 |
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
| 396 |
-
import { Refresh, CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue'
|
| 397 |
import { ElMessage, ElMessageBox } from 'element-plus'
|
| 398 |
import { accountApi } from '@/api/account'
|
| 399 |
import { useAccountStore } from '@/stores/account'
|
|
@@ -410,12 +445,31 @@ const activeTab = ref('all')
|
|
| 410 |
// 搜索关键词
|
| 411 |
const searchKeyword = ref('')
|
| 412 |
|
| 413 |
-
// 获取账号数据
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
const fetchAccounts = async () => {
|
| 415 |
if (appStore.isAccountRefreshing) return
|
| 416 |
-
|
| 417 |
appStore.setAccountRefreshing(true)
|
| 418 |
-
|
| 419 |
try {
|
| 420 |
const res = await accountApi.getValidAccounts()
|
| 421 |
if (res.code === 200 && res.data) {
|
|
@@ -436,12 +490,30 @@ const fetchAccounts = async () => {
|
|
| 436 |
}
|
| 437 |
}
|
| 438 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 439 |
// 页面加载时获取账号数据
|
| 440 |
onMounted(() => {
|
| 441 |
-
//
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
|
|
|
|
|
|
|
|
|
| 445 |
})
|
| 446 |
|
| 447 |
// 获取平台标签类型
|
|
@@ -455,10 +527,34 @@ const getPlatformTagType = (platform) => {
|
|
| 455 |
return typeMap[platform] || 'info'
|
| 456 |
}
|
| 457 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 458 |
// 过滤后的账号列表
|
| 459 |
const filteredAccounts = computed(() => {
|
| 460 |
if (!searchKeyword.value) return accountStore.accounts
|
| 461 |
-
return accountStore.accounts.filter(account =>
|
| 462 |
account.name.includes(searchKeyword.value)
|
| 463 |
)
|
| 464 |
})
|
|
@@ -528,7 +624,12 @@ const handleAddAccount = () => {
|
|
| 528 |
// 编辑账号
|
| 529 |
const handleEdit = (row) => {
|
| 530 |
dialogType.value = 'edit'
|
| 531 |
-
Object.assign(accountForm, {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 532 |
dialogVisible.value = true
|
| 533 |
}
|
| 534 |
|
|
@@ -547,7 +648,7 @@ const handleDelete = (row) => {
|
|
| 547 |
try {
|
| 548 |
// 调用API删除账号
|
| 549 |
const response = await accountApi.deleteAccount(row.id)
|
| 550 |
-
|
| 551 |
if (response.code === 200) {
|
| 552 |
// 从状态管理中删除账号
|
| 553 |
accountStore.deleteAccount(row.id)
|
|
@@ -568,6 +669,108 @@ const handleDelete = (row) => {
|
|
| 568 |
})
|
| 569 |
}
|
| 570 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 571 |
// SSE事件源对象
|
| 572 |
let eventSource = null
|
| 573 |
|
|
@@ -583,12 +786,12 @@ const closeSSEConnection = () => {
|
|
| 583 |
const connectSSE = (platform, name) => {
|
| 584 |
// 关闭可能存在的连接
|
| 585 |
closeSSEConnection()
|
| 586 |
-
|
| 587 |
// 设置连接状态
|
| 588 |
sseConnecting.value = true
|
| 589 |
qrCodeData.value = ''
|
| 590 |
loginStatus.value = ''
|
| 591 |
-
|
| 592 |
// 获取平台类型编号
|
| 593 |
const platformTypeMap = {
|
| 594 |
'小红书': '1',
|
|
@@ -596,20 +799,20 @@ const connectSSE = (platform, name) => {
|
|
| 596 |
'抖音': '3',
|
| 597 |
'快手': '4'
|
| 598 |
}
|
| 599 |
-
|
| 600 |
const type = platformTypeMap[platform] || '1'
|
| 601 |
-
|
| 602 |
// 创建SSE连接
|
| 603 |
const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5409'
|
| 604 |
const url = `${baseUrl}/login?type=${type}&id=${encodeURIComponent(name)}`
|
| 605 |
-
|
| 606 |
eventSource = new EventSource(url)
|
| 607 |
-
|
| 608 |
// 监听消息
|
| 609 |
eventSource.onmessage = (event) => {
|
| 610 |
const data = event.data
|
| 611 |
console.log('SSE消息:', data)
|
| 612 |
-
|
| 613 |
// 如果还没有二维码数据,且数据长度较长,认为是二维码
|
| 614 |
if (!qrCodeData.value && data.length > 100) {
|
| 615 |
try {
|
|
@@ -625,30 +828,32 @@ const connectSSE = (platform, name) => {
|
|
| 625 |
} catch (error) {
|
| 626 |
console.error('处理二维码数据出错:', error)
|
| 627 |
}
|
| 628 |
-
}
|
| 629 |
// 如果收到状态码
|
| 630 |
else if (data === '200' || data === '500') {
|
| 631 |
loginStatus.value = data
|
| 632 |
-
|
| 633 |
// 如果登录成功
|
| 634 |
if (data === '200') {
|
| 635 |
setTimeout(() => {
|
| 636 |
// 关闭连接
|
| 637 |
closeSSEConnection()
|
| 638 |
-
|
| 639 |
// 1秒后关闭对话框并开始刷新
|
| 640 |
setTimeout(() => {
|
| 641 |
dialogVisible.value = false
|
| 642 |
sseConnecting.value = false
|
| 643 |
-
|
| 644 |
-
|
|
|
|
|
|
|
| 645 |
// 显示更新账号信息提示
|
| 646 |
ElMessage({
|
| 647 |
type: 'info',
|
| 648 |
message: '正在同步账号信息...',
|
| 649 |
duration: 0
|
| 650 |
})
|
| 651 |
-
|
| 652 |
// 触发刷新操作
|
| 653 |
fetchAccounts().then(() => {
|
| 654 |
// 刷新完成后关闭提示
|
|
@@ -660,7 +865,7 @@ const connectSSE = (platform, name) => {
|
|
| 660 |
} else {
|
| 661 |
// 登录失败,关闭连接
|
| 662 |
closeSSEConnection()
|
| 663 |
-
|
| 664 |
// 2秒后重置状态,允许重试
|
| 665 |
setTimeout(() => {
|
| 666 |
sseConnecting.value = false
|
|
@@ -670,7 +875,7 @@ const connectSSE = (platform, name) => {
|
|
| 670 |
}
|
| 671 |
}
|
| 672 |
}
|
| 673 |
-
|
| 674 |
// 监听错误
|
| 675 |
eventSource.onerror = (error) => {
|
| 676 |
console.error('SSE连接错误:', error)
|
|
@@ -690,14 +895,29 @@ const submitAccountForm = () => {
|
|
| 690 |
} else {
|
| 691 |
// 编辑账号逻辑
|
| 692 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 693 |
const res = await accountApi.updateAccount({
|
| 694 |
id: accountForm.id,
|
| 695 |
-
type:
|
| 696 |
userName: accountForm.name
|
| 697 |
})
|
| 698 |
if (res.code === 200) {
|
| 699 |
// 更新状态管理中的账号
|
| 700 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 701 |
ElMessage.success('更新成功')
|
| 702 |
dialogVisible.value = false
|
| 703 |
// 刷新账号列表
|
|
@@ -785,6 +1005,16 @@ onBeforeUnmount(() => {
|
|
| 785 |
}
|
| 786 |
|
| 787 |
// 二维码容器样式
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 788 |
.qrcode-container {
|
| 789 |
margin-top: 20px;
|
| 790 |
display: flex;
|
|
|
|
| 30 |
<el-table :data="filteredAccounts" style="width: 100%">
|
| 31 |
<el-table-column label="头像" width="80">
|
| 32 |
<template #default="scope">
|
| 33 |
+
<el-avatar :src="getDefaultAvatar(scope.row.name)" :size="40" />
|
| 34 |
</template>
|
| 35 |
</el-table-column>
|
| 36 |
<el-table-column prop="name" label="名称" width="180" />
|
|
|
|
| 47 |
<el-table-column prop="status" label="状态">
|
| 48 |
<template #default="scope">
|
| 49 |
<el-tag
|
| 50 |
+
:type="getStatusTagType(scope.row.status)"
|
| 51 |
effect="plain"
|
| 52 |
+
:class="{'clickable-status': isStatusClickable(scope.row.status)}"
|
| 53 |
+
@click="handleStatusClick(scope.row)"
|
| 54 |
>
|
| 55 |
+
<el-icon :class="scope.row.status === '验证中' ? 'is-loading' : ''" v-if="scope.row.status === '验证中'">
|
| 56 |
+
<Loading />
|
| 57 |
+
</el-icon>
|
| 58 |
{{ scope.row.status }}
|
| 59 |
</el-tag>
|
| 60 |
</template>
|
|
|
|
| 62 |
<el-table-column label="操作">
|
| 63 |
<template #default="scope">
|
| 64 |
<el-button size="small" @click="handleEdit(scope.row)">编辑</el-button>
|
| 65 |
+
<el-button size="small" type="primary" :icon="Download" @click="handleDownloadCookie(scope.row)">下载Cookie</el-button>
|
| 66 |
+
<el-button size="small" type="info" :icon="Upload" @click="handleUploadCookie(scope.row)">上传Cookie</el-button>
|
| 67 |
<el-button size="small" type="danger" @click="handleDelete(scope.row)">删除</el-button>
|
| 68 |
</template>
|
| 69 |
</el-table-column>
|
|
|
|
| 100 |
<el-table :data="filteredKuaishouAccounts" style="width: 100%">
|
| 101 |
<el-table-column label="头像" width="80">
|
| 102 |
<template #default="scope">
|
| 103 |
+
<el-avatar :src="getDefaultAvatar(scope.row.name)" :size="40" />
|
| 104 |
</template>
|
| 105 |
</el-table-column>
|
| 106 |
<el-table-column prop="name" label="名称" width="180" />
|
|
|
|
| 117 |
<el-table-column prop="status" label="状态">
|
| 118 |
<template #default="scope">
|
| 119 |
<el-tag
|
| 120 |
+
:type="getStatusTagType(scope.row.status)"
|
| 121 |
effect="plain"
|
| 122 |
+
:class="{'clickable-status': isStatusClickable(scope.row.status)}"
|
| 123 |
+
@click="handleStatusClick(scope.row)"
|
| 124 |
>
|
| 125 |
+
<el-icon :class="scope.row.status === '验证中' ? 'is-loading' : ''" v-if="scope.row.status === '验证中'">
|
| 126 |
+
<Loading />
|
| 127 |
+
</el-icon>
|
| 128 |
{{ scope.row.status }}
|
| 129 |
</el-tag>
|
| 130 |
</template>
|
|
|
|
| 132 |
<el-table-column label="操作">
|
| 133 |
<template #default="scope">
|
| 134 |
<el-button size="small" @click="handleEdit(scope.row)">编辑</el-button>
|
| 135 |
+
<el-button size="small" type="primary" :icon="Download" @click="handleDownloadCookie(scope.row)">下载Cookie</el-button>
|
| 136 |
+
<el-button size="small" type="info" :icon="Upload" @click="handleUploadCookie(scope.row)">上传Cookie</el-button>
|
| 137 |
<el-button size="small" type="danger" @click="handleDelete(scope.row)">删除</el-button>
|
| 138 |
</template>
|
| 139 |
</el-table-column>
|
|
|
|
| 170 |
<el-table :data="filteredDouyinAccounts" style="width: 100%">
|
| 171 |
<el-table-column label="头像" width="80">
|
| 172 |
<template #default="scope">
|
| 173 |
+
<el-avatar :src="getDefaultAvatar(scope.row.name)" :size="40" />
|
| 174 |
</template>
|
| 175 |
</el-table-column>
|
| 176 |
<el-table-column prop="name" label="名称" width="180" />
|
|
|
|
| 187 |
<el-table-column prop="status" label="状态">
|
| 188 |
<template #default="scope">
|
| 189 |
<el-tag
|
| 190 |
+
:type="getStatusTagType(scope.row.status)"
|
| 191 |
effect="plain"
|
| 192 |
+
:class="{'clickable-status': isStatusClickable(scope.row.status)}"
|
| 193 |
+
@click="handleStatusClick(scope.row)"
|
| 194 |
>
|
| 195 |
+
<el-icon :class="scope.row.status === '验证中' ? 'is-loading' : ''" v-if="scope.row.status === '验证中'">
|
| 196 |
+
<Loading />
|
| 197 |
+
</el-icon>
|
| 198 |
{{ scope.row.status }}
|
| 199 |
</el-tag>
|
| 200 |
</template>
|
|
|
|
| 202 |
<el-table-column label="操作">
|
| 203 |
<template #default="scope">
|
| 204 |
<el-button size="small" @click="handleEdit(scope.row)">编辑</el-button>
|
| 205 |
+
<el-button size="small" type="primary" :icon="Download" @click="handleDownloadCookie(scope.row)">下载Cookie</el-button>
|
| 206 |
+
<el-button size="small" type="info" :icon="Upload" @click="handleUploadCookie(scope.row)">上传Cookie</el-button>
|
| 207 |
<el-button size="small" type="danger" @click="handleDelete(scope.row)">删除</el-button>
|
| 208 |
</template>
|
| 209 |
</el-table-column>
|
|
|
|
| 240 |
<el-table :data="filteredChannelsAccounts" style="width: 100%">
|
| 241 |
<el-table-column label="头像" width="80">
|
| 242 |
<template #default="scope">
|
| 243 |
+
<el-avatar :src="getDefaultAvatar(scope.row.name)" :size="40" />
|
| 244 |
</template>
|
| 245 |
</el-table-column>
|
| 246 |
<el-table-column prop="name" label="名称" width="180" />
|
|
|
|
| 257 |
<el-table-column prop="status" label="状态">
|
| 258 |
<template #default="scope">
|
| 259 |
<el-tag
|
| 260 |
+
:type="getStatusTagType(scope.row.status)"
|
| 261 |
effect="plain"
|
| 262 |
+
:class="{'clickable-status': isStatusClickable(scope.row.status)}"
|
| 263 |
+
@click="handleStatusClick(scope.row)"
|
| 264 |
>
|
| 265 |
+
<el-icon :class="scope.row.status === '验证中' ? 'is-loading' : ''" v-if="scope.row.status === '验证中'">
|
| 266 |
+
<Loading />
|
| 267 |
+
</el-icon>
|
| 268 |
{{ scope.row.status }}
|
| 269 |
</el-tag>
|
| 270 |
</template>
|
|
|
|
| 272 |
<el-table-column label="操作">
|
| 273 |
<template #default="scope">
|
| 274 |
<el-button size="small" @click="handleEdit(scope.row)">编辑</el-button>
|
| 275 |
+
<el-button size="small" type="primary" :icon="Download" @click="handleDownloadCookie(scope.row)">下载Cookie</el-button>
|
| 276 |
+
<el-button size="small" type="info" :icon="Upload" @click="handleUploadCookie(scope.row)">上传Cookie</el-button>
|
| 277 |
<el-button size="small" type="danger" @click="handleDelete(scope.row)">删除</el-button>
|
| 278 |
</template>
|
| 279 |
</el-table-column>
|
|
|
|
| 310 |
<el-table :data="filteredXiaohongshuAccounts" style="width: 100%">
|
| 311 |
<el-table-column label="头像" width="80">
|
| 312 |
<template #default="scope">
|
| 313 |
+
<el-avatar :src="getDefaultAvatar(scope.row.name)" :size="40" />
|
| 314 |
</template>
|
| 315 |
</el-table-column>
|
| 316 |
<el-table-column prop="name" label="名称" width="180" />
|
|
|
|
| 327 |
<el-table-column prop="status" label="状态">
|
| 328 |
<template #default="scope">
|
| 329 |
<el-tag
|
| 330 |
+
:type="getStatusTagType(scope.row.status)"
|
| 331 |
effect="plain"
|
| 332 |
+
:class="{'clickable-status': isStatusClickable(scope.row.status)}"
|
| 333 |
+
@click="handleStatusClick(scope.row)"
|
| 334 |
>
|
| 335 |
+
<el-icon :class="scope.row.status === '验证中' ? 'is-loading' : ''" v-if="scope.row.status === '验证中'">
|
| 336 |
+
<Loading />
|
| 337 |
+
</el-icon>
|
| 338 |
{{ scope.row.status }}
|
| 339 |
</el-tag>
|
| 340 |
</template>
|
|
|
|
| 342 |
<el-table-column label="操作">
|
| 343 |
<template #default="scope">
|
| 344 |
<el-button size="small" @click="handleEdit(scope.row)">编辑</el-button>
|
| 345 |
+
<el-button size="small" type="primary" :icon="Download" @click="handleDownloadCookie(scope.row)">下载Cookie</el-button>
|
| 346 |
+
<el-button size="small" type="info" :icon="Upload" @click="handleUploadCookie(scope.row)">上传Cookie</el-button>
|
| 347 |
<el-button size="small" type="danger" @click="handleDelete(scope.row)">删除</el-button>
|
| 348 |
</template>
|
| 349 |
</el-table-column>
|
|
|
|
| 428 |
|
| 429 |
<script setup>
|
| 430 |
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
| 431 |
+
import { Refresh, CircleCheckFilled, CircleCloseFilled, Download, Upload, Loading } from '@element-plus/icons-vue'
|
| 432 |
import { ElMessage, ElMessageBox } from 'element-plus'
|
| 433 |
import { accountApi } from '@/api/account'
|
| 434 |
import { useAccountStore } from '@/stores/account'
|
|
|
|
| 445 |
// 搜索关键词
|
| 446 |
const searchKeyword = ref('')
|
| 447 |
|
| 448 |
+
// 获取账号数据(快速,不验证)
|
| 449 |
+
const fetchAccountsQuick = async () => {
|
| 450 |
+
try {
|
| 451 |
+
const res = await accountApi.getAccounts()
|
| 452 |
+
if (res.code === 200 && res.data) {
|
| 453 |
+
// 将所有账号的状态暂时设为"验证中"
|
| 454 |
+
const accountsWithPendingStatus = res.data.map(account => {
|
| 455 |
+
// account[4] 是状态字段,暂时设为"验证中"
|
| 456 |
+
const updatedAccount = [...account];
|
| 457 |
+
updatedAccount[4] = '验证中'; // 临时状态
|
| 458 |
+
return updatedAccount;
|
| 459 |
+
});
|
| 460 |
+
accountStore.setAccounts(accountsWithPendingStatus);
|
| 461 |
+
}
|
| 462 |
+
} catch (error) {
|
| 463 |
+
console.error('快速获取账号数据失败:', error)
|
| 464 |
+
}
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
// 获取账号数据(带验证)
|
| 468 |
const fetchAccounts = async () => {
|
| 469 |
if (appStore.isAccountRefreshing) return
|
| 470 |
+
|
| 471 |
appStore.setAccountRefreshing(true)
|
| 472 |
+
|
| 473 |
try {
|
| 474 |
const res = await accountApi.getValidAccounts()
|
| 475 |
if (res.code === 200 && res.data) {
|
|
|
|
| 490 |
}
|
| 491 |
}
|
| 492 |
|
| 493 |
+
// 后台验证所有账号(优化版本,使用setTimeout避免阻塞UI)
|
| 494 |
+
const validateAllAccountsInBackground = async () => {
|
| 495 |
+
// 使用setTimeout将验证过程放在下一个事件循环,避免阻塞UI
|
| 496 |
+
setTimeout(async () => {
|
| 497 |
+
try {
|
| 498 |
+
const res = await accountApi.getValidAccounts()
|
| 499 |
+
if (res.code === 200 && res.data) {
|
| 500 |
+
accountStore.setAccounts(res.data)
|
| 501 |
+
}
|
| 502 |
+
} catch (error) {
|
| 503 |
+
console.error('后台验证账号失败:', error)
|
| 504 |
+
}
|
| 505 |
+
}, 0)
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
// 页面加载时获取账号数据
|
| 509 |
onMounted(() => {
|
| 510 |
+
// 快速获取账号列表(不验证),立即显示
|
| 511 |
+
fetchAccountsQuick()
|
| 512 |
+
|
| 513 |
+
// 在后台验证所有账号
|
| 514 |
+
setTimeout(() => {
|
| 515 |
+
validateAllAccountsInBackground()
|
| 516 |
+
}, 100) // 稍微延迟一下,让用户看到快速加载的效果
|
| 517 |
})
|
| 518 |
|
| 519 |
// 获取平台标签类型
|
|
|
|
| 527 |
return typeMap[platform] || 'info'
|
| 528 |
}
|
| 529 |
|
| 530 |
+
// 判断状态是否可点击(异常状态可点击)
|
| 531 |
+
const isStatusClickable = (status) => {
|
| 532 |
+
return status === '异常'; // 只有异常状态可点击,验证中不可点击
|
| 533 |
+
}
|
| 534 |
+
|
| 535 |
+
// 获取状态标签类型
|
| 536 |
+
const getStatusTagType = (status) => {
|
| 537 |
+
if (status === '验证中') {
|
| 538 |
+
return 'info'; // 验证中使用灰色
|
| 539 |
+
} else if (status === '正常') {
|
| 540 |
+
return 'success'; // 正常使用绿色
|
| 541 |
+
} else {
|
| 542 |
+
return 'danger'; // 无效使用红色
|
| 543 |
+
}
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
// 处理状态点击事件
|
| 547 |
+
const handleStatusClick = (row) => {
|
| 548 |
+
if (isStatusClickable(row.status)) {
|
| 549 |
+
// 触发重新登录流程
|
| 550 |
+
handleReLogin(row)
|
| 551 |
+
}
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
// 过滤后的账号列表
|
| 555 |
const filteredAccounts = computed(() => {
|
| 556 |
if (!searchKeyword.value) return accountStore.accounts
|
| 557 |
+
return accountStore.accounts.filter(account =>
|
| 558 |
account.name.includes(searchKeyword.value)
|
| 559 |
)
|
| 560 |
})
|
|
|
|
| 624 |
// 编辑账号
|
| 625 |
const handleEdit = (row) => {
|
| 626 |
dialogType.value = 'edit'
|
| 627 |
+
Object.assign(accountForm, {
|
| 628 |
+
id: row.id,
|
| 629 |
+
name: row.name,
|
| 630 |
+
platform: row.platform,
|
| 631 |
+
status: row.status
|
| 632 |
+
})
|
| 633 |
dialogVisible.value = true
|
| 634 |
}
|
| 635 |
|
|
|
|
| 648 |
try {
|
| 649 |
// 调用API删除账号
|
| 650 |
const response = await accountApi.deleteAccount(row.id)
|
| 651 |
+
|
| 652 |
if (response.code === 200) {
|
| 653 |
// 从状态管理中删除账号
|
| 654 |
accountStore.deleteAccount(row.id)
|
|
|
|
| 669 |
})
|
| 670 |
}
|
| 671 |
|
| 672 |
+
// 下载Cookie文件
|
| 673 |
+
const handleDownloadCookie = (row) => {
|
| 674 |
+
// 从后端获取Cookie文件
|
| 675 |
+
const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5409'
|
| 676 |
+
const downloadUrl = `${baseUrl}/downloadCookie?filePath=${encodeURIComponent(row.filePath)}`
|
| 677 |
+
|
| 678 |
+
// 创建一个隐藏的链接来触发下载
|
| 679 |
+
const link = document.createElement('a')
|
| 680 |
+
link.href = downloadUrl
|
| 681 |
+
link.download = `${row.name}_cookie.json`
|
| 682 |
+
link.target = '_blank'
|
| 683 |
+
link.style.display = 'none'
|
| 684 |
+
document.body.appendChild(link)
|
| 685 |
+
link.click()
|
| 686 |
+
document.body.removeChild(link)
|
| 687 |
+
}
|
| 688 |
+
|
| 689 |
+
// 上传Cookie文件
|
| 690 |
+
const handleUploadCookie = (row) => {
|
| 691 |
+
// 创建一个隐藏的文件输入框
|
| 692 |
+
const input = document.createElement('input')
|
| 693 |
+
input.type = 'file'
|
| 694 |
+
input.accept = '.json'
|
| 695 |
+
input.style.display = 'none'
|
| 696 |
+
document.body.appendChild(input)
|
| 697 |
+
|
| 698 |
+
input.onchange = async (event) => {
|
| 699 |
+
const file = event.target.files[0]
|
| 700 |
+
if (!file) return
|
| 701 |
+
|
| 702 |
+
// 检查文件类型
|
| 703 |
+
if (!file.name.endsWith('.json')) {
|
| 704 |
+
ElMessage.error('请选择JSON格式的Cookie文件')
|
| 705 |
+
document.body.removeChild(input)
|
| 706 |
+
return
|
| 707 |
+
}
|
| 708 |
+
|
| 709 |
+
try {
|
| 710 |
+
// 创建FormData对象
|
| 711 |
+
const formData = new FormData()
|
| 712 |
+
formData.append('file', file)
|
| 713 |
+
formData.append('id', row.id)
|
| 714 |
+
formData.append('platform', row.platform)
|
| 715 |
+
|
| 716 |
+
// 发送上传请求
|
| 717 |
+
const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5409'
|
| 718 |
+
const response = await fetch(`${baseUrl}/uploadCookie`, {
|
| 719 |
+
method: 'POST',
|
| 720 |
+
body: formData
|
| 721 |
+
})
|
| 722 |
+
|
| 723 |
+
const result = await response.json()
|
| 724 |
+
|
| 725 |
+
if (result.code === 200) {
|
| 726 |
+
ElMessage.success('Cookie文件上传成功')
|
| 727 |
+
// 刷新账号列表以显示更新
|
| 728 |
+
fetchAccounts()
|
| 729 |
+
} else {
|
| 730 |
+
ElMessage.error(result.msg || 'Cookie文件上传失败')
|
| 731 |
+
}
|
| 732 |
+
} catch (error) {
|
| 733 |
+
console.error('上传Cookie文件失败:', error)
|
| 734 |
+
ElMessage.error('Cookie文件上传失败')
|
| 735 |
+
} finally {
|
| 736 |
+
document.body.removeChild(input)
|
| 737 |
+
}
|
| 738 |
+
}
|
| 739 |
+
|
| 740 |
+
input.click()
|
| 741 |
+
}
|
| 742 |
+
|
| 743 |
+
// 重新登录账号
|
| 744 |
+
const handleReLogin = (row) => {
|
| 745 |
+
// 设置表单信息
|
| 746 |
+
dialogType.value = 'edit'
|
| 747 |
+
Object.assign(accountForm, {
|
| 748 |
+
id: row.id,
|
| 749 |
+
name: row.name,
|
| 750 |
+
platform: row.platform,
|
| 751 |
+
status: row.status
|
| 752 |
+
})
|
| 753 |
+
|
| 754 |
+
// 重置SSE状态
|
| 755 |
+
sseConnecting.value = false
|
| 756 |
+
qrCodeData.value = ''
|
| 757 |
+
loginStatus.value = ''
|
| 758 |
+
|
| 759 |
+
// 显示对话框
|
| 760 |
+
dialogVisible.value = true
|
| 761 |
+
|
| 762 |
+
// 立即开始登录流程
|
| 763 |
+
setTimeout(() => {
|
| 764 |
+
connectSSE(row.platform, row.name)
|
| 765 |
+
}, 300)
|
| 766 |
+
}
|
| 767 |
+
|
| 768 |
+
// 获取默认头像
|
| 769 |
+
const getDefaultAvatar = (name) => {
|
| 770 |
+
// 使用简单的默认头像,可以基于用户名生成不同的颜色
|
| 771 |
+
return `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random`
|
| 772 |
+
}
|
| 773 |
+
|
| 774 |
// SSE事件源对象
|
| 775 |
let eventSource = null
|
| 776 |
|
|
|
|
| 786 |
const connectSSE = (platform, name) => {
|
| 787 |
// 关闭可能存在的连接
|
| 788 |
closeSSEConnection()
|
| 789 |
+
|
| 790 |
// 设置连接状态
|
| 791 |
sseConnecting.value = true
|
| 792 |
qrCodeData.value = ''
|
| 793 |
loginStatus.value = ''
|
| 794 |
+
|
| 795 |
// 获取平台类型编号
|
| 796 |
const platformTypeMap = {
|
| 797 |
'小红书': '1',
|
|
|
|
| 799 |
'抖音': '3',
|
| 800 |
'快手': '4'
|
| 801 |
}
|
| 802 |
+
|
| 803 |
const type = platformTypeMap[platform] || '1'
|
| 804 |
+
|
| 805 |
// 创建SSE连接
|
| 806 |
const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5409'
|
| 807 |
const url = `${baseUrl}/login?type=${type}&id=${encodeURIComponent(name)}`
|
| 808 |
+
|
| 809 |
eventSource = new EventSource(url)
|
| 810 |
+
|
| 811 |
// 监听消息
|
| 812 |
eventSource.onmessage = (event) => {
|
| 813 |
const data = event.data
|
| 814 |
console.log('SSE消息:', data)
|
| 815 |
+
|
| 816 |
// 如果还没有二维码数据,且数据长度较长,认为是二维码
|
| 817 |
if (!qrCodeData.value && data.length > 100) {
|
| 818 |
try {
|
|
|
|
| 828 |
} catch (error) {
|
| 829 |
console.error('处理二维码数据出错:', error)
|
| 830 |
}
|
| 831 |
+
}
|
| 832 |
// 如果收到状态码
|
| 833 |
else if (data === '200' || data === '500') {
|
| 834 |
loginStatus.value = data
|
| 835 |
+
|
| 836 |
// 如果登录成功
|
| 837 |
if (data === '200') {
|
| 838 |
setTimeout(() => {
|
| 839 |
// 关闭连接
|
| 840 |
closeSSEConnection()
|
| 841 |
+
|
| 842 |
// 1秒后关闭对话框并开始刷新
|
| 843 |
setTimeout(() => {
|
| 844 |
dialogVisible.value = false
|
| 845 |
sseConnecting.value = false
|
| 846 |
+
|
| 847 |
+
// 根据是否是重新登录显示不同提示
|
| 848 |
+
ElMessage.success(dialogType.value === 'edit' ? '重新登录成功' : '账号添加成功')
|
| 849 |
+
|
| 850 |
// 显示更新账号信息提示
|
| 851 |
ElMessage({
|
| 852 |
type: 'info',
|
| 853 |
message: '正在同步账号信息...',
|
| 854 |
duration: 0
|
| 855 |
})
|
| 856 |
+
|
| 857 |
// 触发刷新操作
|
| 858 |
fetchAccounts().then(() => {
|
| 859 |
// 刷新完成后关闭提示
|
|
|
|
| 865 |
} else {
|
| 866 |
// 登录失败,关闭连接
|
| 867 |
closeSSEConnection()
|
| 868 |
+
|
| 869 |
// 2秒后重置状态,允许重试
|
| 870 |
setTimeout(() => {
|
| 871 |
sseConnecting.value = false
|
|
|
|
| 875 |
}
|
| 876 |
}
|
| 877 |
}
|
| 878 |
+
|
| 879 |
// 监听错误
|
| 880 |
eventSource.onerror = (error) => {
|
| 881 |
console.error('SSE连接错误:', error)
|
|
|
|
| 895 |
} else {
|
| 896 |
// 编辑账号逻辑
|
| 897 |
try {
|
| 898 |
+
// 将平台名称转换为类型数字
|
| 899 |
+
const platformTypeMap = {
|
| 900 |
+
'快手': 1,
|
| 901 |
+
'抖音': 2,
|
| 902 |
+
'视频号': 3,
|
| 903 |
+
'小红书': 4
|
| 904 |
+
};
|
| 905 |
+
const type = platformTypeMap[accountForm.platform] || 1;
|
| 906 |
+
|
| 907 |
const res = await accountApi.updateAccount({
|
| 908 |
id: accountForm.id,
|
| 909 |
+
type: type,
|
| 910 |
userName: accountForm.name
|
| 911 |
})
|
| 912 |
if (res.code === 200) {
|
| 913 |
// 更新状态管理中的账号
|
| 914 |
+
const updatedAccount = {
|
| 915 |
+
id: accountForm.id,
|
| 916 |
+
name: accountForm.name,
|
| 917 |
+
platform: accountForm.platform,
|
| 918 |
+
status: accountForm.status // Keep the existing status
|
| 919 |
+
};
|
| 920 |
+
accountStore.updateAccount(accountForm.id, updatedAccount)
|
| 921 |
ElMessage.success('更新成功')
|
| 922 |
dialogVisible.value = false
|
| 923 |
// 刷新账号列表
|
|
|
|
| 1005 |
}
|
| 1006 |
|
| 1007 |
// 二维码容器样式
|
| 1008 |
+
.clickable-status {
|
| 1009 |
+
cursor: pointer;
|
| 1010 |
+
transition: all 0.3s;
|
| 1011 |
+
|
| 1012 |
+
&:hover {
|
| 1013 |
+
transform: scale(1.05);
|
| 1014 |
+
box-shadow: 0 0 8px rgba(0, 0, 0, 0.15);
|
| 1015 |
+
}
|
| 1016 |
+
}
|
| 1017 |
+
|
| 1018 |
.qrcode-container {
|
| 1019 |
margin-top: 20px;
|
| 1020 |
display: flex;
|
sau_frontend/src/views/MaterialManagement.vue
CHANGED
|
@@ -25,6 +25,7 @@
|
|
| 25 |
|
| 26 |
<div v-if="filteredMaterials.length > 0" class="material-list">
|
| 27 |
<el-table :data="filteredMaterials" style="width: 100%">
|
|
|
|
| 28 |
<el-table-column prop="filename" label="文件名" width="300" />
|
| 29 |
<el-table-column prop="filesize" label="文件大小" width="120">
|
| 30 |
<template #default="scope">
|
|
@@ -58,7 +59,8 @@
|
|
| 58 |
<el-form-item label="文件名称:">
|
| 59 |
<el-input
|
| 60 |
v-model="customFilename"
|
| 61 |
-
placeholder="选填"
|
|
|
|
| 62 |
clearable
|
| 63 |
/>
|
| 64 |
</el-form-item>
|
|
@@ -66,10 +68,11 @@
|
|
| 66 |
<el-upload
|
| 67 |
class="upload-demo"
|
| 68 |
drag
|
|
|
|
| 69 |
:auto-upload="false"
|
| 70 |
:on-change="handleFileChange"
|
|
|
|
| 71 |
:file-list="fileList"
|
| 72 |
-
:limit="1"
|
| 73 |
>
|
| 74 |
<el-icon class="el-icon--upload"><Upload /></el-icon>
|
| 75 |
<div class="el-upload__text">
|
|
@@ -77,11 +80,26 @@
|
|
| 77 |
</div>
|
| 78 |
<template #tip>
|
| 79 |
<div class="el-upload__tip">
|
| 80 |
-
支持视频、图片等格式文件,
|
| 81 |
</div>
|
| 82 |
</template>
|
| 83 |
</el-upload>
|
| 84 |
</el-form-item>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
</el-form>
|
| 86 |
</div>
|
| 87 |
<template #footer>
|
|
@@ -123,7 +141,7 @@
|
|
| 123 |
</template>
|
| 124 |
|
| 125 |
<script setup>
|
| 126 |
-
import { ref,
|
| 127 |
import { Refresh, Upload } from '@element-plus/icons-vue'
|
| 128 |
import { ElMessage, ElMessageBox } from 'element-plus'
|
| 129 |
import { materialApi } from '@/api/material'
|
|
@@ -145,6 +163,17 @@ const currentMaterial = ref(null)
|
|
| 145 |
// 文件上传
|
| 146 |
const fileList = ref([])
|
| 147 |
const customFilename = ref('')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
// 获取素材列表
|
| 150 |
const fetchMaterials = async () => {
|
|
@@ -186,6 +215,7 @@ const handleUploadMaterial = () => {
|
|
| 186 |
// 清空变量
|
| 187 |
fileList.value = []
|
| 188 |
customFilename.value = ''
|
|
|
|
| 189 |
uploadDialogVisible.value = true
|
| 190 |
}
|
| 191 |
|
|
@@ -193,16 +223,24 @@ const handleUploadMaterial = () => {
|
|
| 193 |
const handleUploadDialogClose = () => {
|
| 194 |
fileList.value = []
|
| 195 |
customFilename.value = ''
|
|
|
|
| 196 |
}
|
| 197 |
|
| 198 |
// 文件选择变更
|
| 199 |
const handleFileChange = (file, uploadFileList) => {
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
fileList.value = [file]
|
| 205 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
}
|
| 207 |
|
| 208 |
// 提交上传
|
|
@@ -212,45 +250,67 @@ const submitUpload = async () => {
|
|
| 212 |
return
|
| 213 |
}
|
| 214 |
|
| 215 |
-
// 确保文件对象存在
|
| 216 |
-
const fileObj = fileList.value[0]
|
| 217 |
-
if (!fileObj || !fileObj.raw) {
|
| 218 |
-
ElMessage.warning('文件对象无效,请重新选择文件')
|
| 219 |
-
return
|
| 220 |
-
}
|
| 221 |
-
|
| 222 |
isUploading.value = true
|
| 223 |
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
}
|
| 248 |
-
} catch (error) {
|
| 249 |
-
console.error('上传素材出错:', error)
|
| 250 |
-
ElMessage.error('上传失败: ' + (error.message || '未知错误'))
|
| 251 |
-
} finally {
|
| 252 |
-
isUploading.value = false
|
| 253 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
}
|
| 255 |
|
| 256 |
// 预览素材
|
|
@@ -430,6 +490,24 @@ onMounted(() => {
|
|
| 430 |
justify-content: flex-end;
|
| 431 |
}
|
| 432 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 433 |
/* 覆盖Element Plus对话框样式 */
|
| 434 |
:deep(.el-dialog__body) {
|
| 435 |
padding: 20px 0;
|
|
@@ -445,4 +523,35 @@ onMounted(() => {
|
|
| 445 |
padding-top: 10px;
|
| 446 |
padding-bottom: 15px;
|
| 447 |
}
|
| 448 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
<div v-if="filteredMaterials.length > 0" class="material-list">
|
| 27 |
<el-table :data="filteredMaterials" style="width: 100%">
|
| 28 |
+
<el-table-column prop="uuid" label="UUID" width="180" />
|
| 29 |
<el-table-column prop="filename" label="文件名" width="300" />
|
| 30 |
<el-table-column prop="filesize" label="文件大小" width="120">
|
| 31 |
<template #default="scope">
|
|
|
|
| 59 |
<el-form-item label="文件名称:">
|
| 60 |
<el-input
|
| 61 |
v-model="customFilename"
|
| 62 |
+
placeholder="选填 (仅单个文件时生效)"
|
| 63 |
+
:disabled="customFilenameDisabled"
|
| 64 |
clearable
|
| 65 |
/>
|
| 66 |
</el-form-item>
|
|
|
|
| 68 |
<el-upload
|
| 69 |
class="upload-demo"
|
| 70 |
drag
|
| 71 |
+
multiple
|
| 72 |
:auto-upload="false"
|
| 73 |
:on-change="handleFileChange"
|
| 74 |
+
:on-remove="handleFileRemove"
|
| 75 |
:file-list="fileList"
|
|
|
|
| 76 |
>
|
| 77 |
<el-icon class="el-icon--upload"><Upload /></el-icon>
|
| 78 |
<div class="el-upload__text">
|
|
|
|
| 80 |
</div>
|
| 81 |
<template #tip>
|
| 82 |
<div class="el-upload__tip">
|
| 83 |
+
支持视频、图片等格式文件,可一次选择多个文件
|
| 84 |
</div>
|
| 85 |
</template>
|
| 86 |
</el-upload>
|
| 87 |
</el-form-item>
|
| 88 |
+
<el-form-item label="上传列表" v-if="fileList.length > 0">
|
| 89 |
+
<div class="upload-file-list">
|
| 90 |
+
<div v-for="file in fileList" :key="file.uid" class="upload-file-item">
|
| 91 |
+
<span class="file-name">{{ file.name }}</span>
|
| 92 |
+
<el-progress
|
| 93 |
+
:percentage="uploadProgress[file.uid]?.percentage || 0"
|
| 94 |
+
:text-inside="true"
|
| 95 |
+
:stroke-width="20"
|
| 96 |
+
style="width: 100%; margin-top: 5px;"
|
| 97 |
+
>
|
| 98 |
+
<span>{{ uploadProgress[file.uid]?.speed || '' }}</span>
|
| 99 |
+
</el-progress>
|
| 100 |
+
</div>
|
| 101 |
+
</div>
|
| 102 |
+
</el-form-item>
|
| 103 |
</el-form>
|
| 104 |
</div>
|
| 105 |
<template #footer>
|
|
|
|
| 141 |
</template>
|
| 142 |
|
| 143 |
<script setup>
|
| 144 |
+
import { ref, computed, onMounted, watch } from 'vue'
|
| 145 |
import { Refresh, Upload } from '@element-plus/icons-vue'
|
| 146 |
import { ElMessage, ElMessageBox } from 'element-plus'
|
| 147 |
import { materialApi } from '@/api/material'
|
|
|
|
| 163 |
// 文件上传
|
| 164 |
const fileList = ref([])
|
| 165 |
const customFilename = ref('')
|
| 166 |
+
const customFilenameDisabled = computed(() => fileList.value.length > 1)
|
| 167 |
+
const uploadProgress = ref({}); // { [uid]: { percentage: 0, speed: '' } }
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
watch(fileList, (newList) => {
|
| 171 |
+
if (newList.length <= 1) {
|
| 172 |
+
// If you want to clear the custom name when going back to single file, uncomment below
|
| 173 |
+
// customFilename.value = ''
|
| 174 |
+
}
|
| 175 |
+
});
|
| 176 |
+
|
| 177 |
|
| 178 |
// 获取素材列表
|
| 179 |
const fetchMaterials = async () => {
|
|
|
|
| 215 |
// 清空变量
|
| 216 |
fileList.value = []
|
| 217 |
customFilename.value = ''
|
| 218 |
+
uploadProgress.value = {};
|
| 219 |
uploadDialogVisible.value = true
|
| 220 |
}
|
| 221 |
|
|
|
|
| 223 |
const handleUploadDialogClose = () => {
|
| 224 |
fileList.value = []
|
| 225 |
customFilename.value = ''
|
| 226 |
+
uploadProgress.value = {};
|
| 227 |
}
|
| 228 |
|
| 229 |
// 文件选择变更
|
| 230 |
const handleFileChange = (file, uploadFileList) => {
|
| 231 |
+
fileList.value = uploadFileList;
|
| 232 |
+
const newProgress = {};
|
| 233 |
+
for (const f of uploadFileList) {
|
| 234 |
+
newProgress[f.uid] = { percentage: 0, speed: '' };
|
|
|
|
| 235 |
}
|
| 236 |
+
uploadProgress.value = newProgress;
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
const handleFileRemove = (file, uploadFileList) => {
|
| 240 |
+
fileList.value = uploadFileList;
|
| 241 |
+
const newProgress = { ...uploadProgress.value };
|
| 242 |
+
delete newProgress[file.uid];
|
| 243 |
+
uploadProgress.value = newProgress;
|
| 244 |
}
|
| 245 |
|
| 246 |
// 提交上传
|
|
|
|
| 250 |
return
|
| 251 |
}
|
| 252 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
isUploading.value = true
|
| 254 |
|
| 255 |
+
for (const file of fileList.value) {
|
| 256 |
+
try {
|
| 257 |
+
// 确保文件对象存在
|
| 258 |
+
if (!file || !file.raw) {
|
| 259 |
+
ElMessage.warning(`文件 ${file.name} 对象无效,已跳过`)
|
| 260 |
+
continue
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
const formData = new FormData()
|
| 264 |
+
formData.append('file', file.raw)
|
| 265 |
+
|
| 266 |
+
// 只有当只有一个文件时,自定义文件名才生效
|
| 267 |
+
if (fileList.value.length === 1 && customFilename.value.trim()) {
|
| 268 |
+
formData.append('filename', customFilename.value.trim())
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
let lastLoaded = 0;
|
| 272 |
+
let lastTime = Date.now();
|
| 273 |
+
|
| 274 |
+
const response = await materialApi.uploadMaterial(formData, (progressEvent) => {
|
| 275 |
+
const progressData = uploadProgress.value[file.uid];
|
| 276 |
+
if (!progressData) return;
|
| 277 |
+
|
| 278 |
+
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
|
| 279 |
+
progressData.percentage = progress;
|
| 280 |
+
|
| 281 |
+
const currentTime = Date.now();
|
| 282 |
+
const timeDiff = (currentTime - lastTime) / 1000; // in seconds
|
| 283 |
+
const loadedDiff = progressEvent.loaded - lastLoaded;
|
| 284 |
+
|
| 285 |
+
if (timeDiff > 0.5) { // Update speed every 0.5 seconds
|
| 286 |
+
const speed = loadedDiff / timeDiff; // bytes per second
|
| 287 |
+
if (speed > 1024 * 1024) {
|
| 288 |
+
progressData.speed = (speed / (1024 * 1024)).toFixed(2) + ' MB/s';
|
| 289 |
+
} else {
|
| 290 |
+
progressData.speed = (speed / 1024).toFixed(2) + ' KB/s';
|
| 291 |
+
}
|
| 292 |
+
lastLoaded = progressEvent.loaded;
|
| 293 |
+
lastTime = currentTime;
|
| 294 |
+
}
|
| 295 |
+
})
|
| 296 |
+
|
| 297 |
+
if (response.code === 200) {
|
| 298 |
+
ElMessage.success(`文件 ${file.name} 上传成功`)
|
| 299 |
+
const progressData = uploadProgress.value[file.uid];
|
| 300 |
+
if(progressData) progressData.speed = '完成';
|
| 301 |
+
} else {
|
| 302 |
+
ElMessage.error(`文件 ${file.name} 上传失败: ${response.msg || '未知错误'}`)
|
| 303 |
+
}
|
| 304 |
+
} catch (error) {
|
| 305 |
+
console.error(`上传文件 ${file.name} 出错:`, error)
|
| 306 |
+
ElMessage.error(`文件 ${file.name} 上传失败: ${error.message || '未知错误'}`)
|
| 307 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
}
|
| 309 |
+
|
| 310 |
+
isUploading.value = false
|
| 311 |
+
// Keep dialog open to show results
|
| 312 |
+
// uploadDialogVisible.value = false
|
| 313 |
+
await fetchMaterials()
|
| 314 |
}
|
| 315 |
|
| 316 |
// 预览素材
|
|
|
|
| 490 |
justify-content: flex-end;
|
| 491 |
}
|
| 492 |
|
| 493 |
+
.upload-file-list {
|
| 494 |
+
width: 100%;
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
.upload-file-item {
|
| 498 |
+
border: 1px solid #dcdfe6;
|
| 499 |
+
border-radius: 4px;
|
| 500 |
+
padding: 10px;
|
| 501 |
+
margin-bottom: 10px;
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
.upload-file-item .file-name {
|
| 505 |
+
font-size: 14px;
|
| 506 |
+
color: #606266;
|
| 507 |
+
margin-bottom: 5px;
|
| 508 |
+
display: block;
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
/* 覆盖Element Plus对话框样式 */
|
| 512 |
:deep(.el-dialog__body) {
|
| 513 |
padding: 20px 0;
|
|
|
|
| 523 |
padding-top: 10px;
|
| 524 |
padding-bottom: 15px;
|
| 525 |
}
|
| 526 |
+
|
| 527 |
+
/* 修改上传进度条样式 */
|
| 528 |
+
:deep(.el-progress__text) {
|
| 529 |
+
color: #303133 !important; /* 深灰色字体,确保在各种背景上都可见 */
|
| 530 |
+
font-size: 12px;
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
:deep(.el-progress--line) {
|
| 534 |
+
margin-bottom: 10px;
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
.upload-file-item {
|
| 538 |
+
border: 1px solid #dcdfe6;
|
| 539 |
+
border-radius: 6px; /* 增加圆角 */
|
| 540 |
+
padding: 12px; /* 增加内边距 */
|
| 541 |
+
margin-bottom: 12px; /* 增加外边距 */
|
| 542 |
+
background-color: #fafafa; /* 轻微背景色 */
|
| 543 |
+
transition: box-shadow 0.3s; /* 添加过渡效果 */
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
.upload-file-item:hover {
|
| 547 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); /* 悬停效果 */
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
.upload-file-item .file-name {
|
| 551 |
+
font-size: 14px;
|
| 552 |
+
color: #303133; /* 深灰色字体 */
|
| 553 |
+
margin-bottom: 8px; /* 增加底部间距 */
|
| 554 |
+
display: block;
|
| 555 |
+
font-weight: 500;
|
| 556 |
+
}
|
| 557 |
+
</style>
|
sau_frontend/src/views/PublishCenter.vue
CHANGED
|
@@ -296,6 +296,36 @@
|
|
| 296 |
</el-radio-group>
|
| 297 |
</div>
|
| 298 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
<!-- 标题输入 -->
|
| 300 |
<div class="title-section">
|
| 301 |
<h3>标题</h3>
|
|
@@ -457,7 +487,14 @@
|
|
| 457 |
<!-- 操作按钮 -->
|
| 458 |
<div class="action-buttons">
|
| 459 |
<el-button size="small" @click="cancelPublish(tab)">取消</el-button>
|
| 460 |
-
<el-button
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 461 |
</div>
|
| 462 |
</div>
|
| 463 |
</div>
|
|
@@ -526,7 +563,9 @@ const defaultTabInit = {
|
|
| 526 |
videosPerDay: 1, // 每天发布视频数量
|
| 527 |
dailyTimes: ['10:00'], // 每天发布时间点列表
|
| 528 |
startDays: 0, // 从今天开始计算的发布天数,0表示明天,1表示后天
|
| 529 |
-
publishStatus: null // 发布状态,包含message和type
|
|
|
|
|
|
|
| 530 |
}
|
| 531 |
|
| 532 |
// helper to create a fresh deep-copied tab from defaultTabInit
|
|
@@ -733,29 +772,40 @@ const cancelPublish = (tab) => {
|
|
| 733 |
|
| 734 |
// 确认发布
|
| 735 |
const confirmPublish = async (tab) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 736 |
return new Promise((resolve, reject) => {
|
| 737 |
// 数据验证
|
| 738 |
if (tab.fileList.length === 0) {
|
| 739 |
ElMessage.error('请先上传视频文件')
|
|
|
|
| 740 |
reject(new Error('请先上传视频文件'))
|
| 741 |
return
|
| 742 |
}
|
| 743 |
if (!tab.title.trim()) {
|
| 744 |
ElMessage.error('请输入标题')
|
|
|
|
| 745 |
reject(new Error('请输入标题'))
|
| 746 |
return
|
| 747 |
}
|
| 748 |
if (!tab.selectedPlatform) {
|
| 749 |
ElMessage.error('请选择发布平台')
|
|
|
|
| 750 |
reject(new Error('请选择发布平台'))
|
| 751 |
return
|
| 752 |
}
|
| 753 |
if (tab.selectedAccounts.length === 0) {
|
| 754 |
ElMessage.error('请选择发布账号')
|
|
|
|
| 755 |
reject(new Error('请选择发布账号'))
|
| 756 |
return
|
| 757 |
}
|
| 758 |
-
|
| 759 |
// 构造发布数据,符合后端API格式
|
| 760 |
const publishData = {
|
| 761 |
type: tab.selectedPlatform,
|
|
@@ -772,9 +822,10 @@ const confirmPublish = async (tab) => {
|
|
| 772 |
startDays: tab.scheduleEnabled ? tab.startDays || 0 : 0, // 从今天开始计算的发布天数,0表示明天,1表示后天
|
| 773 |
category: 0, //表示非原创
|
| 774 |
productLink: tab.productLink.trim() || '', // 商品链接
|
| 775 |
-
productTitle: tab.productTitle.trim() || '' // 商品名称
|
|
|
|
| 776 |
}
|
| 777 |
-
|
| 778 |
// 调用后端发布API
|
| 779 |
fetch(`${apiBaseUrl}/postVideo`, {
|
| 780 |
method: 'POST',
|
|
@@ -815,6 +866,9 @@ const confirmPublish = async (tab) => {
|
|
| 815 |
}
|
| 816 |
reject(error)
|
| 817 |
})
|
|
|
|
|
|
|
|
|
|
| 818 |
})
|
| 819 |
}
|
| 820 |
|
|
@@ -1261,10 +1315,19 @@ const batchPublish = async () => {
|
|
| 1261 |
padding-top: 20px;
|
| 1262 |
border-top: 1px solid #ebeef5;
|
| 1263 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1264 |
}
|
| 1265 |
}
|
| 1266 |
}
|
| 1267 |
-
|
| 1268 |
// 已上传文件列表样式
|
| 1269 |
.uploaded-files {
|
| 1270 |
margin-top: 20px;
|
|
|
|
| 296 |
</el-radio-group>
|
| 297 |
</div>
|
| 298 |
|
| 299 |
+
<!-- 草稿选项 (仅在视频号可见) -->
|
| 300 |
+
<div v-if="tab.selectedPlatform === 2" class="draft-section">
|
| 301 |
+
<el-checkbox
|
| 302 |
+
v-model="tab.isDraft"
|
| 303 |
+
label="视频号仅保存草稿(用手机发布)"
|
| 304 |
+
class="draft-checkbox"
|
| 305 |
+
/>
|
| 306 |
+
</div>
|
| 307 |
+
|
| 308 |
+
<!-- 标签 (仅在抖音可见) -->
|
| 309 |
+
<div v-if="tab.selectedPlatform === 3" class="product-section">
|
| 310 |
+
<h3>商品链接</h3>
|
| 311 |
+
<el-input
|
| 312 |
+
v-model="tab.productTitle"
|
| 313 |
+
type="text"
|
| 314 |
+
:rows="1"
|
| 315 |
+
placeholder="请输入商品名称"
|
| 316 |
+
maxlength="200"
|
| 317 |
+
class="product-name-input"
|
| 318 |
+
/>
|
| 319 |
+
<el-input
|
| 320 |
+
v-model="tab.productLink"
|
| 321 |
+
type="text"
|
| 322 |
+
:rows="1"
|
| 323 |
+
placeholder="请输入商品链接"
|
| 324 |
+
maxlength="200"
|
| 325 |
+
class="product-link-input"
|
| 326 |
+
/>
|
| 327 |
+
</div>
|
| 328 |
+
|
| 329 |
<!-- 标题输入 -->
|
| 330 |
<div class="title-section">
|
| 331 |
<h3>标题</h3>
|
|
|
|
| 487 |
<!-- 操作按钮 -->
|
| 488 |
<div class="action-buttons">
|
| 489 |
<el-button size="small" @click="cancelPublish(tab)">取消</el-button>
|
| 490 |
+
<el-button
|
| 491 |
+
size="small"
|
| 492 |
+
type="primary"
|
| 493 |
+
@click="confirmPublish(tab)"
|
| 494 |
+
:loading="tab.publishing || false"
|
| 495 |
+
>
|
| 496 |
+
{{ tab.publishing ? '发布中...' : '发布' }}
|
| 497 |
+
</el-button>
|
| 498 |
</div>
|
| 499 |
</div>
|
| 500 |
</div>
|
|
|
|
| 563 |
videosPerDay: 1, // 每天发布视频数量
|
| 564 |
dailyTimes: ['10:00'], // 每天发布时间点列表
|
| 565 |
startDays: 0, // 从今天开始计算的发布天数,0表示明天,1表示后天
|
| 566 |
+
publishStatus: null, // 发布状态,包含message和type
|
| 567 |
+
publishing: false, // 发布状态,用于控制按钮loading效果
|
| 568 |
+
isDraft: false // 是否保存为草稿,仅视频号平台可见
|
| 569 |
}
|
| 570 |
|
| 571 |
// helper to create a fresh deep-copied tab from defaultTabInit
|
|
|
|
| 772 |
|
| 773 |
// 确认发布
|
| 774 |
const confirmPublish = async (tab) => {
|
| 775 |
+
// 防止重复点击
|
| 776 |
+
if (tab.publishing) {
|
| 777 |
+
return Promise.reject(new Error('正在发布中,请稍候...'))
|
| 778 |
+
}
|
| 779 |
+
|
| 780 |
+
tab.publishing = true // 设置发布状态为进行中
|
| 781 |
+
|
| 782 |
return new Promise((resolve, reject) => {
|
| 783 |
// 数据验证
|
| 784 |
if (tab.fileList.length === 0) {
|
| 785 |
ElMessage.error('请先上传视频文件')
|
| 786 |
+
tab.publishing = false // 重置发布状态
|
| 787 |
reject(new Error('请先上传视频文件'))
|
| 788 |
return
|
| 789 |
}
|
| 790 |
if (!tab.title.trim()) {
|
| 791 |
ElMessage.error('请输入标题')
|
| 792 |
+
tab.publishing = false // 重置发布状态
|
| 793 |
reject(new Error('请输入标题'))
|
| 794 |
return
|
| 795 |
}
|
| 796 |
if (!tab.selectedPlatform) {
|
| 797 |
ElMessage.error('请选择发布平台')
|
| 798 |
+
tab.publishing = false // 重置发布状态
|
| 799 |
reject(new Error('请选择发布平台'))
|
| 800 |
return
|
| 801 |
}
|
| 802 |
if (tab.selectedAccounts.length === 0) {
|
| 803 |
ElMessage.error('请选择发布账号')
|
| 804 |
+
tab.publishing = false // 重置发布状态
|
| 805 |
reject(new Error('请选择发布账号'))
|
| 806 |
return
|
| 807 |
}
|
| 808 |
+
|
| 809 |
// 构造发布数据,符合后端API格式
|
| 810 |
const publishData = {
|
| 811 |
type: tab.selectedPlatform,
|
|
|
|
| 822 |
startDays: tab.scheduleEnabled ? tab.startDays || 0 : 0, // 从今天开始计算的发布天数,0表示明天,1表示后天
|
| 823 |
category: 0, //表示非原创
|
| 824 |
productLink: tab.productLink.trim() || '', // 商品链接
|
| 825 |
+
productTitle: tab.productTitle.trim() || '', // 商品名称
|
| 826 |
+
isDraft: tab.isDraft // 是否保存为草稿,仅视频号平台使用
|
| 827 |
}
|
| 828 |
+
|
| 829 |
// 调用后端发布API
|
| 830 |
fetch(`${apiBaseUrl}/postVideo`, {
|
| 831 |
method: 'POST',
|
|
|
|
| 866 |
}
|
| 867 |
reject(error)
|
| 868 |
})
|
| 869 |
+
.finally(() => {
|
| 870 |
+
tab.publishing = false // 重置发布状态
|
| 871 |
+
})
|
| 872 |
})
|
| 873 |
}
|
| 874 |
|
|
|
|
| 1315 |
padding-top: 20px;
|
| 1316 |
border-top: 1px solid #ebeef5;
|
| 1317 |
}
|
| 1318 |
+
|
| 1319 |
+
.draft-section {
|
| 1320 |
+
margin: 20px 0;
|
| 1321 |
+
|
| 1322 |
+
.draft-checkbox {
|
| 1323 |
+
display: block;
|
| 1324 |
+
margin: 10px 0;
|
| 1325 |
+
}
|
| 1326 |
+
}
|
| 1327 |
}
|
| 1328 |
}
|
| 1329 |
}
|
| 1330 |
+
|
| 1331 |
// 已上传文件列表样式
|
| 1332 |
.uploaded-files {
|
| 1333 |
margin-top: 20px;
|
sau_frontend/vite.config.js
CHANGED
|
@@ -19,7 +19,14 @@ export default defineConfig({
|
|
| 19 |
},
|
| 20 |
server: {
|
| 21 |
port: 5173,
|
| 22 |
-
open: true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
},
|
| 24 |
build: {
|
| 25 |
outDir: 'dist',
|
|
|
|
| 19 |
},
|
| 20 |
server: {
|
| 21 |
port: 5173,
|
| 22 |
+
open: true,
|
| 23 |
+
proxy: {
|
| 24 |
+
'/api': {
|
| 25 |
+
target: 'http://localhost:5409',
|
| 26 |
+
changeOrigin: true,
|
| 27 |
+
rewrite: (path) => path.replace(/^\/api/, '')
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
},
|
| 31 |
build: {
|
| 32 |
outDir: 'dist',
|
uploader/tencent_uploader/main.py
CHANGED
|
@@ -82,15 +82,16 @@ async def weixin_setup(account_file, handle=False):
|
|
| 82 |
|
| 83 |
|
| 84 |
class TencentVideo(object):
|
| 85 |
-
def __init__(self, title, file_path, tags, publish_date: datetime, account_file, category=None):
|
| 86 |
self.title = title # 视频标题
|
| 87 |
self.file_path = file_path
|
| 88 |
self.tags = tags
|
| 89 |
self.publish_date = publish_date
|
| 90 |
self.account_file = account_file
|
| 91 |
self.category = category
|
| 92 |
-
self.local_executable_path = LOCAL_CHROME_PATH
|
| 93 |
self.headless = LOCAL_CHROME_HEADLESS
|
|
|
|
|
|
|
| 94 |
|
| 95 |
async def set_schedule_time_tencent(self, page, publish_date):
|
| 96 |
label_element = page.locator("label").filter(has_text="定时").nth(1)
|
|
@@ -186,21 +187,37 @@ class TencentVideo(object):
|
|
| 186 |
async def click_publish(self, page):
|
| 187 |
while True:
|
| 188 |
try:
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
break
|
| 195 |
except Exception as e:
|
| 196 |
current_url = page.url
|
| 197 |
-
if
|
| 198 |
-
|
| 199 |
-
|
|
|
|
|
|
|
| 200 |
else:
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
|
| 205 |
async def detect_upload_status(self, page):
|
| 206 |
while True:
|
|
|
|
| 82 |
|
| 83 |
|
| 84 |
class TencentVideo(object):
|
| 85 |
+
def __init__(self, title, file_path, tags, publish_date: datetime, account_file, category=None, is_draft=False):
|
| 86 |
self.title = title # 视频标题
|
| 87 |
self.file_path = file_path
|
| 88 |
self.tags = tags
|
| 89 |
self.publish_date = publish_date
|
| 90 |
self.account_file = account_file
|
| 91 |
self.category = category
|
|
|
|
| 92 |
self.headless = LOCAL_CHROME_HEADLESS
|
| 93 |
+
self.is_draft = is_draft # 是否保存为草稿
|
| 94 |
+
self.local_executable_path = LOCAL_CHROME_PATH or None
|
| 95 |
|
| 96 |
async def set_schedule_time_tencent(self, page, publish_date):
|
| 97 |
label_element = page.locator("label").filter(has_text="定时").nth(1)
|
|
|
|
| 187 |
async def click_publish(self, page):
|
| 188 |
while True:
|
| 189 |
try:
|
| 190 |
+
if self.is_draft:
|
| 191 |
+
# 点击"保存草稿"按钮
|
| 192 |
+
draft_button = page.locator('div.form-btns button:has-text("保存草稿")')
|
| 193 |
+
if await draft_button.count():
|
| 194 |
+
await draft_button.click()
|
| 195 |
+
# 等待跳转到草稿箱页面或确认保存成功
|
| 196 |
+
await page.wait_for_url("**/post/list**", timeout=5000) # 使用通配符匹配包含post/list的URL
|
| 197 |
+
tencent_logger.success(" [-]视频草稿保存成功")
|
| 198 |
+
else:
|
| 199 |
+
# 点击"发表"按钮
|
| 200 |
+
publish_button = page.locator('div.form-btns button:has-text("发表")')
|
| 201 |
+
if await publish_button.count():
|
| 202 |
+
await publish_button.click()
|
| 203 |
+
await page.wait_for_url("https://channels.weixin.qq.com/platform/post/list", timeout=5000)
|
| 204 |
+
tencent_logger.success(" [-]视频发布成功")
|
| 205 |
break
|
| 206 |
except Exception as e:
|
| 207 |
current_url = page.url
|
| 208 |
+
if self.is_draft:
|
| 209 |
+
# 检查是否在草稿相关的页面
|
| 210 |
+
if "post/list" in current_url or "draft" in current_url:
|
| 211 |
+
tencent_logger.success(" [-]视频草稿保存成功")
|
| 212 |
+
break
|
| 213 |
else:
|
| 214 |
+
# 检查是否在发布列表页面
|
| 215 |
+
if "https://channels.weixin.qq.com/platform/post/list" in current_url:
|
| 216 |
+
tencent_logger.success(" [-]视频发布成功")
|
| 217 |
+
break
|
| 218 |
+
tencent_logger.exception(f" [-] Exception: {e}")
|
| 219 |
+
tencent_logger.info(" [-] 视频正在发布中...")
|
| 220 |
+
await asyncio.sleep(0.5)
|
| 221 |
|
| 222 |
async def detect_upload_status(self, page):
|
| 223 |
while True:
|