modified: blueprints/__pycache__/__init__.cpython-39.pyc
modified: blueprints/__pycache__/admin.cpython-39.pyc modified: blueprints/__pycache__/auth.cpython-39.pyc modified: blueprints/__pycache__/brand.cpython-311.pyc modified: blueprints/__pycache__/brand.cpython-39.pyc modified: blueprints/brand.py new file: brand_spider/__pycache__/main.cpython-311.pyc modified: brand_spider/__pycache__/main.cpython-39.pyc new file: brand_spider/__pycache__/web_dec.cpython-311.pyc modified: brand_spider/main.py modified: config.py modified: web_source/admin.html modified: web_source/brand.html modified: web_source/home.html modified: web_source/index.html modified: web_source/login.html new file: web_source/templates_backup/admin.html new file: web_source/templates_backup/brand.html new file: web_source/templates_backup/home.html new file: web_source/templates_backup/index.html new file: web_source/templates_backup/login.html
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -4,12 +4,10 @@
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import threading
|
import threading
|
||||||
import datetime
|
|
||||||
import traceback
|
import traceback
|
||||||
import zipfile
|
import zipfile
|
||||||
import io
|
import io
|
||||||
import time
|
import time
|
||||||
import tempfile
|
|
||||||
import requests
|
import requests
|
||||||
from queue import Queue, Empty
|
from queue import Queue, Empty
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
@@ -18,7 +16,7 @@ from urllib.parse import urlparse
|
|||||||
from flask import Blueprint, request, jsonify, session, send_file, redirect, Response
|
from flask import Blueprint, request, jsonify, session, send_file, redirect, Response
|
||||||
|
|
||||||
from app_common import get_db, login_required, BASE_DIR
|
from app_common import get_db, login_required, BASE_DIR
|
||||||
from config import bucket_path
|
from config import bucket_path,JAVA_API_BASE
|
||||||
from brand_spider.main import single_file_handle, TaskCancelledError
|
from brand_spider.main import single_file_handle, TaskCancelledError
|
||||||
|
|
||||||
brand_bp = Blueprint('brand', __name__)
|
brand_bp = Blueprint('brand', __name__)
|
||||||
@@ -39,6 +37,64 @@ _task_event_lock = threading.Lock()
|
|||||||
# }
|
# }
|
||||||
_task_line_progress = {}
|
_task_line_progress = {}
|
||||||
_task_line_progress_lock = threading.Lock()
|
_task_line_progress_lock = threading.Lock()
|
||||||
|
_cancelled_brand_tasks = set()
|
||||||
|
_cancelled_brand_tasks_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_task_cancelled(task_id):
|
||||||
|
with _cancelled_brand_tasks_lock:
|
||||||
|
_cancelled_brand_tasks.add(task_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_task_cancelled(task_id):
|
||||||
|
with _cancelled_brand_tasks_lock:
|
||||||
|
return task_id in _cancelled_brand_tasks
|
||||||
|
|
||||||
|
|
||||||
|
def _get_uid_from_request_headers():
|
||||||
|
"""
|
||||||
|
从请求头读取 uid。
|
||||||
|
前端会在 headers 里携带 uid,用于确定本次请求的归属用户。
|
||||||
|
"""
|
||||||
|
uid = request.headers.get('uid')
|
||||||
|
if uid is None:
|
||||||
|
return None
|
||||||
|
uid = str(uid).strip()
|
||||||
|
if not uid:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(uid)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_user_id():
|
||||||
|
"""
|
||||||
|
优先使用请求头 uid;没有请求头 uid 时回退到 session['user_id']。
|
||||||
|
若二者同时存在但不一致,则视为无效请求并返回 None。
|
||||||
|
"""
|
||||||
|
req_uid = _get_uid_from_request_headers()
|
||||||
|
if req_uid is not None:
|
||||||
|
sess_uid = session.get('user_id')
|
||||||
|
if sess_uid is not None:
|
||||||
|
try:
|
||||||
|
if int(sess_uid) != int(req_uid):
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
if str(sess_uid) != str(req_uid):
|
||||||
|
return None
|
||||||
|
return req_uid
|
||||||
|
return session.get('user_id')
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user_id_or_error():
|
||||||
|
user_id = _resolve_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return None, (jsonify({'success': False, 'error': '未登录或缺少uid'}), 401)
|
||||||
|
try:
|
||||||
|
return int(user_id), None
|
||||||
|
except Exception:
|
||||||
|
return None, (jsonify({'success': False, 'error': 'uid无效'}), 400)
|
||||||
|
|
||||||
|
|
||||||
def _push_task_event(task_id, event):
|
def _push_task_event(task_id, event):
|
||||||
@@ -54,10 +110,14 @@ def _push_task_event(task_id, event):
|
|||||||
# 任务进入终态时,顺便清理行级进度缓存
|
# 任务进入终态时,顺便清理行级进度缓存
|
||||||
with _task_line_progress_lock:
|
with _task_line_progress_lock:
|
||||||
_task_line_progress.pop(task_id, None)
|
_task_line_progress.pop(task_id, None)
|
||||||
|
status = (event or {}).get('status') if isinstance(event, dict) else None
|
||||||
|
if status in ('success', 'failed', 'cancelled'):
|
||||||
|
with _cancelled_brand_tasks_lock:
|
||||||
|
_cancelled_brand_tasks.discard(task_id)
|
||||||
|
|
||||||
|
|
||||||
def _expand_folder_xlsx(folder_path):
|
def _expand_folder_xlsx(folder_path):
|
||||||
"""返回文件夹下第一层 .xlsx 文件的绝对路径列表(保留旧逻辑,不再用于品牌工具文件夹上传)"""
|
"""返回文件夹下所有 .xlsx 文件的绝对路径列表"""
|
||||||
if not folder_path or not os.path.isdir(folder_path):
|
if not folder_path or not os.path.isdir(folder_path):
|
||||||
return []
|
return []
|
||||||
paths = []
|
paths = []
|
||||||
@@ -67,26 +127,6 @@ def _expand_folder_xlsx(folder_path):
|
|||||||
return paths
|
return paths
|
||||||
|
|
||||||
|
|
||||||
def _expand_folder_xlsx_recursive_items(folder_path):
|
|
||||||
"""返回文件夹下所有 .xlsx 文件的绝对路径和相对路径列表(递归)"""
|
|
||||||
if not folder_path or not os.path.isdir(folder_path):
|
|
||||||
return []
|
|
||||||
items = []
|
|
||||||
root = os.path.normpath(folder_path)
|
|
||||||
for current_root, _, filenames in os.walk(root):
|
|
||||||
for name in filenames:
|
|
||||||
if not (name.endswith('.xlsx') or name.endswith('.XLSX')):
|
|
||||||
continue
|
|
||||||
absolute_path = os.path.normpath(os.path.join(current_root, name))
|
|
||||||
relative_path = os.path.relpath(absolute_path, root).replace('\\', '/')
|
|
||||||
items.append({
|
|
||||||
'absolutePath': absolute_path,
|
|
||||||
'relativePath': relative_path,
|
|
||||||
})
|
|
||||||
items.sort(key=lambda item: item['relativePath'])
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
def _upload_local_xlsx_to_oss(local_path, user_id):
|
def _upload_local_xlsx_to_oss(local_path, user_id):
|
||||||
"""将本地 xlsx 文件上传到 OSS,返回 (url, None) 或 (None, error_message)。"""
|
"""将本地 xlsx 文件上传到 OSS,返回 (url, None) 或 (None, error_message)。"""
|
||||||
if not local_path or not os.path.isfile(local_path):
|
if not local_path or not os.path.isfile(local_path):
|
||||||
@@ -99,7 +139,7 @@ def _upload_local_xlsx_to_oss(local_path, user_id):
|
|||||||
safe_base = "".join(c if c.isalnum() or c in '-_.' else '_' for c in base)
|
safe_base = "".join(c if c.isalnum() or c in '-_.' else '_' for c in base)
|
||||||
if not safe_base.endswith('.xlsx'):
|
if not safe_base.endswith('.xlsx'):
|
||||||
safe_base = safe_base + '.xlsx'
|
safe_base = safe_base + '.xlsx'
|
||||||
key = f"{bucket_path}brand_input/{int(time.time() * 1000)}/{user_id}_{safe_base}"
|
key = f"{bucket_path}brand_input/{user_id}/{int(time.time() * 1000)}/{safe_base}"
|
||||||
try:
|
try:
|
||||||
with open(local_path, "rb") as f:
|
with open(local_path, "rb") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
@@ -109,347 +149,125 @@ def _upload_local_xlsx_to_oss(local_path, user_id):
|
|||||||
return None, str(e)
|
return None, str(e)
|
||||||
|
|
||||||
|
|
||||||
def _run_brand_files(file_paths, task_id=None, check_cancelled=None, strategy="Terms"):
|
def _run_brand_single(taskid,brand_ls, fileUrl,strategy,totalLines,chunkTotal,chunkIndex):
|
||||||
"""对多个 xlsx 执行 single_file_handle(线程池多线程),返回 (result_paths, error_message)。
|
""""""
|
||||||
check_cancelled: 可调用对象,返回 True 时中止并返回 (result_paths, 'cancelled')。
|
invalidBrands = [] # 不符合品牌的数据
|
||||||
task_id 存在时会在每处理完一个文件后更新 progress_current。
|
queryFailedBrands = [] # 查询失败的数据
|
||||||
strategy: 品牌匹配方式,默认 Terms,可选 Simple。"""
|
keptRows = []
|
||||||
os.makedirs(BRAND_OUTPUT_DIR, exist_ok=True)
|
for brand in brand_ls:
|
||||||
subdir = str(task_id) if task_id else ('immediate_' + datetime.datetime.now().strftime('%Y%m%d_%H%M%S'))
|
if _is_task_cancelled(taskid):
|
||||||
out_dir = os.path.join(BRAND_OUTPUT_DIR, subdir)
|
raise TaskCancelledError("任务已取消")
|
||||||
os.makedirs(out_dir, exist_ok=True)
|
faild_data,query_faild_data = single_file_handle(brand,strategy)
|
||||||
|
invalidBrands.extend(faild_data)
|
||||||
|
queryFailedBrands.extend(query_faild_data)
|
||||||
|
if len(faild_data) == 0 and len(query_faild_data) == 0:
|
||||||
|
keptRows.append(brand)
|
||||||
|
|
||||||
# 先筛出有效路径并生成 (fp, out_path) 列表
|
data = { "strategy": strategy ,
|
||||||
tasks = []
|
"files": [
|
||||||
for fp in file_paths:
|
{
|
||||||
if not fp or not isinstance(fp, str) or not os.path.isfile(fp):
|
"fileUrl": fileUrl,
|
||||||
continue
|
"originalFilename": "",
|
||||||
base = os.path.splitext(os.path.basename(fp))[0]
|
"relativePath": "",
|
||||||
safe_base = "".join(c if c.isalnum() or c in '-_' else '_' for c in base)
|
"mainSheetName": "",
|
||||||
out_path = os.path.join(out_dir, safe_base + '_result.xlsx')
|
"chunkIndex": chunkIndex,
|
||||||
tasks.append((fp, out_path))
|
"chunkTotal": chunkTotal,
|
||||||
|
"totalLines": totalLines,
|
||||||
if not tasks:
|
"keptRows": keptRows,
|
||||||
return [], None
|
"invalidBrands": invalidBrands,
|
||||||
|
"queryFailedBrands": queryFailedBrands
|
||||||
result_paths = []
|
}
|
||||||
result_lock = threading.Lock()
|
]
|
||||||
current = [0] # 用列表以便在闭包中修改
|
}
|
||||||
|
resp = requests.post(f"{JAVA_API_BASE}/api/brand/tasks/{taskid}/result",headers={"accept":"application/json"},
|
||||||
def process_one(fp, out_path, file_index, files_total):
|
# json={ "strategy": strategy ,
|
||||||
# 行级进度回调:由 single_file_handle 在循环中调用,直接写入内存字典,不落库
|
# "files": [
|
||||||
def progress_callback(current_line, total_lines, extra=None):
|
# { "fileUrl": fileUrl,"originalFilename": "","relativePath": "",
|
||||||
if not task_id:
|
# "mainSheetName": "","columns": [],"keptRows": [],
|
||||||
return
|
# "invalidBrands": invalidBrands,"queryFailedBrands": queryFailedBrands
|
||||||
info = {
|
# }]})
|
||||||
'file_index': file_index,
|
json=data)
|
||||||
'file_total': files_total,
|
print(data)
|
||||||
'file_path': fp,
|
print(taskid,brand_ls, fileUrl)
|
||||||
'current_line': int(current_line or 0),
|
print("提交结果",data,"\n-->",resp.text)
|
||||||
'total_lines': int(total_lines or 0),
|
return True
|
||||||
}
|
|
||||||
if isinstance(extra, dict):
|
|
||||||
info.update(extra)
|
|
||||||
with _task_line_progress_lock:
|
|
||||||
_task_line_progress[task_id] = info
|
|
||||||
|
|
||||||
single_file_handle(fp, out_path, strategy, check_cancelled=check_cancelled, progress_callback=progress_callback)
|
|
||||||
return out_path
|
|
||||||
|
|
||||||
max_workers = min(8, len(tasks))
|
|
||||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
||||||
future_to_out = {
|
|
||||||
executor.submit(process_one, fp, out_path, idx + 1, len(tasks)): out_path
|
|
||||||
for idx, (fp, out_path) in enumerate(tasks)
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
for future in as_completed(future_to_out):
|
|
||||||
if check_cancelled and check_cancelled():
|
|
||||||
for f in future_to_out:
|
|
||||||
f.cancel()
|
|
||||||
return result_paths, 'cancelled'
|
|
||||||
try:
|
|
||||||
out_path = future.result()
|
|
||||||
with result_lock:
|
|
||||||
result_paths.append(out_path)
|
|
||||||
current[0] += 1
|
|
||||||
if task_id:
|
|
||||||
try:
|
|
||||||
conn = get_db()
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute(
|
|
||||||
"UPDATE brand_crawl_tasks SET progress_current = %s WHERE id = %s",
|
|
||||||
(current[0], task_id)
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
except Exception:
|
|
||||||
print(traceback.format_exc())
|
|
||||||
except TaskCancelledError:
|
|
||||||
for f in future_to_out:
|
|
||||||
f.cancel()
|
|
||||||
return result_paths, 'cancelled'
|
|
||||||
except Exception as e:
|
|
||||||
print(traceback.format_exc())
|
|
||||||
print(e)
|
|
||||||
for f in future_to_out:
|
|
||||||
f.cancel()
|
|
||||||
return result_paths, str(e)
|
|
||||||
except Exception as e:
|
|
||||||
print(traceback.format_exc())
|
|
||||||
return result_paths, str(e)
|
|
||||||
return result_paths, None
|
|
||||||
|
|
||||||
|
|
||||||
def _upload_results_to_oss(source_paths, local_result_paths, task_id):
|
|
||||||
|
def _background_brand_task(task_id,data):
|
||||||
"""
|
"""
|
||||||
将源文件和结果文件上传到 OSS,并打包成 zip(分两个文件夹:源文件、结果文件)上传。
|
后台执行爬虫任务
|
||||||
返回 (result_data, error_message),result_data 为 {"urls": [url1, ...], "zip_url": "..."},出错时为 (None, err)。
|
参数示例:
|
||||||
|
data : {
|
||||||
|
"taskId": 348,
|
||||||
|
"strategy": "Terms",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"fileIndex": 1,
|
||||||
|
"fileUrl": "ab8cce4878754bfd89b4cd3ed20e1395",
|
||||||
|
"originalFilename": "品牌样例.xlsx",
|
||||||
|
"relativePath": "店铺A/品牌样例.xlsx",
|
||||||
|
"sheetName": "Sheet1",
|
||||||
|
"columns": [
|
||||||
|
"品牌",
|
||||||
|
"ASIH",
|
||||||
|
"状态",
|
||||||
|
"时间"
|
||||||
|
],
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"品牌": "YQAUTEC",
|
||||||
|
"ASIH": "B0F28NZ752",
|
||||||
|
"状态": "",
|
||||||
|
"时间": "",
|
||||||
|
"__rowIndex": 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"uniqueBrands": [
|
||||||
|
"YQAUTEC"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
"""
|
"""
|
||||||
if not local_result_paths or not task_id:
|
|
||||||
return None, "无结果文件"
|
|
||||||
try:
|
try:
|
||||||
from ali_oss import upload_file as oss_upload_file
|
file_ls = data.get("files")
|
||||||
except ImportError:
|
strategy = data.get("strategy")
|
||||||
return None, "OSS 模块未配置"
|
if not isinstance(file_ls, list):
|
||||||
urls = []
|
file_ls = []
|
||||||
subdir = f"{OSS_PREFIX}/{task_id}"
|
tasks = []
|
||||||
for fp in local_result_paths:
|
for file_data in file_ls:
|
||||||
if not fp or not os.path.isfile(fp):
|
rows = file_data.get("rows") or []
|
||||||
continue
|
brand_ls = [i.get("品牌") for i in rows if i.get("品牌")]
|
||||||
name = os.path.basename(fp)
|
fileUrl = file_data.get("fileUrl")
|
||||||
key = f"{bucket_path}{subdir}/{name}"
|
if not brand_ls:
|
||||||
try:
|
|
||||||
with open(fp, "rb") as f:
|
|
||||||
content = f.read()
|
|
||||||
url = oss_upload_file(content, key)
|
|
||||||
urls.append(url)
|
|
||||||
except Exception as e:
|
|
||||||
return None, f"上传结果文件失败: {e}"
|
|
||||||
if not urls:
|
|
||||||
return None, "没有可上传的结果文件"
|
|
||||||
# 打包成 zip:源文件 -> 源文件/,结果文件 -> 结果文件/
|
|
||||||
buf = io.BytesIO()
|
|
||||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
||||||
for fp in (source_paths or []):
|
|
||||||
if fp and os.path.isfile(fp):
|
|
||||||
zf.write(fp, os.path.join("源文件", os.path.basename(fp)))
|
|
||||||
for fp in local_result_paths:
|
|
||||||
if fp and os.path.isfile(fp):
|
|
||||||
zf.write(fp, os.path.join("结果文件", os.path.basename(fp)))
|
|
||||||
buf.seek(0)
|
|
||||||
zip_key = f"{bucket_path}{subdir}/result.zip"
|
|
||||||
try:
|
|
||||||
zip_url = oss_upload_file(buf.getvalue(), zip_key)
|
|
||||||
except Exception as e:
|
|
||||||
return {"urls": urls, "zip_url": None}, None # 单文件链接已保存,zip 失败不报错
|
|
||||||
return {"urls": urls, "zip_url": zip_url}, None
|
|
||||||
|
|
||||||
|
|
||||||
def _background_brand_task(task_id):
|
|
||||||
"""后台执行单个品牌爬虫任务并更新数据库"""
|
|
||||||
try:
|
|
||||||
conn = get_db()
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute(
|
|
||||||
"SELECT id, user_id, file_paths, strategy, `desc` FROM brand_crawl_tasks WHERE id = %s AND status = 'pending'",
|
|
||||||
(task_id,)
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
conn.close()
|
|
||||||
if not row:
|
|
||||||
return
|
|
||||||
file_paths = row.get('file_paths')
|
|
||||||
if isinstance(file_paths, str):
|
|
||||||
file_paths = json.loads(file_paths) if file_paths else []
|
|
||||||
# 优先使用表里存的 strategy 字段,兼容旧数据则从 desc 解析
|
|
||||||
strategy = (row.get('strategy') or '').strip() or None
|
|
||||||
if not strategy:
|
|
||||||
desc_val = (row.get('desc') or '').strip()
|
|
||||||
strategy = "Simple" if desc_val.startswith('[Simple]') else "Terms"
|
|
||||||
if strategy not in ('Terms', 'Simple'):
|
|
||||||
strategy = 'Terms'
|
|
||||||
if not file_paths:
|
|
||||||
try:
|
|
||||||
conn = get_db()
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute(
|
|
||||||
"UPDATE brand_crawl_tasks SET status = 'failed', error_message = %s WHERE id = %s",
|
|
||||||
('没有有效文件路径', task_id)
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
_push_task_event(task_id, {'status': 'failed', 'error_message': '没有有效文件路径'})
|
|
||||||
return
|
|
||||||
# 将 file_paths 解析为本地路径:URL 下载到 BRAND_OUTPUT_DIR 对应任务文件夹下(兼容旧数据中的本地路径)
|
|
||||||
os.makedirs(BRAND_OUTPUT_DIR, exist_ok=True)
|
|
||||||
task_subdir = str(task_id) if task_id else ('immediate_' + datetime.datetime.now().strftime('%Y%m%d_%H%M%S'))
|
|
||||||
task_dir = os.path.join(BRAND_OUTPUT_DIR, task_subdir)
|
|
||||||
os.makedirs(task_dir, exist_ok=True)
|
|
||||||
|
|
||||||
local_paths = []
|
|
||||||
downloaded_files_to_cleanup_on_fail = []
|
|
||||||
for idx, p in enumerate(file_paths):
|
|
||||||
if not p or not isinstance(p, str):
|
|
||||||
continue
|
continue
|
||||||
p = p.strip()
|
for i in range(0, len(brand_ls), 5):
|
||||||
if _is_url(p):
|
chunk = brand_ls[i:i + 5]
|
||||||
try:
|
tasks.append((chunk, fileUrl))
|
||||||
parsed = urlparse(p)
|
|
||||||
url_name = os.path.basename(parsed.path or '')
|
|
||||||
if not url_name:
|
|
||||||
url_name = f"input_{idx + 1}.xlsx"
|
|
||||||
safe_name = "".join(c if c.isalnum() or c in '-_.' else '_' for c in url_name)
|
|
||||||
if not safe_name.lower().endswith('.xlsx'):
|
|
||||||
safe_name = safe_name + '.xlsx'
|
|
||||||
|
|
||||||
base_no_ext, ext = os.path.splitext(safe_name)
|
if tasks:
|
||||||
dest = os.path.join(task_dir, safe_name)
|
max_workers = min(8, len(tasks))
|
||||||
n = 1
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||||
while os.path.exists(dest):
|
if _is_task_cancelled(task_id):
|
||||||
dest = os.path.join(task_dir, f"{base_no_ext}_{n}{ext}")
|
_push_task_event(task_id, {'status': 'cancelled'})
|
||||||
n += 1
|
|
||||||
|
|
||||||
r = requests.get(p, timeout=120, stream=True)
|
|
||||||
r.raise_for_status()
|
|
||||||
with open(dest, "wb") as f:
|
|
||||||
for chunk in r.iter_content(chunk_size=1024 * 1024):
|
|
||||||
if chunk:
|
|
||||||
f.write(chunk)
|
|
||||||
local_paths.append(dest)
|
|
||||||
downloaded_files_to_cleanup_on_fail.append(dest)
|
|
||||||
except Exception as e:
|
|
||||||
for tf in downloaded_files_to_cleanup_on_fail:
|
|
||||||
try:
|
|
||||||
os.unlink(tf)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
conn = get_db()
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute(
|
|
||||||
"UPDATE brand_crawl_tasks SET status = 'failed', error_message = %s WHERE id = %s",
|
|
||||||
(f'下载文件失败: {str(e)}', task_id)
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
_push_task_event(task_id, {'status': 'failed', 'error_message': f'下载文件失败: {str(e)}'})
|
|
||||||
return
|
return
|
||||||
elif os.path.isfile(p):
|
futures = [
|
||||||
local_paths.append(p)
|
executor.submit(_run_brand_single, task_id, chunk, file_url, strategy,len(brand_ls),len(tasks),chunkIndex+1)
|
||||||
if not local_paths:
|
for chunkIndex,(chunk, file_url) in enumerate(tasks)
|
||||||
try:
|
]
|
||||||
conn = get_db()
|
for future in as_completed(futures):
|
||||||
with conn.cursor() as cur:
|
if _is_task_cancelled(task_id):
|
||||||
cur.execute(
|
for f in futures:
|
||||||
"UPDATE brand_crawl_tasks SET status = 'failed', error_message = %s WHERE id = %s",
|
f.cancel()
|
||||||
('没有有效的本地或可下载文件', task_id)
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
_push_task_event(task_id, {'status': 'failed', 'error_message': '没有有效的本地或可下载文件'})
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
# 原子地将 pending 改为 running,避免用户先点取消时仍被覆盖为 running
|
|
||||||
try:
|
|
||||||
conn = get_db()
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute(
|
|
||||||
"UPDATE brand_crawl_tasks SET status = 'running', progress_total = %s, progress_current = 0 WHERE id = %s AND status = 'pending'",
|
|
||||||
(len(local_paths), task_id)
|
|
||||||
)
|
|
||||||
n = cur.rowcount
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
if n == 0:
|
|
||||||
conn = get_db()
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute("SELECT status FROM brand_crawl_tasks WHERE id = %s", (task_id,))
|
|
||||||
r = cur.fetchone()
|
|
||||||
conn.close()
|
|
||||||
if r and (r.get('status') or '') == 'cancelled':
|
|
||||||
_push_task_event(task_id, {'status': 'cancelled'})
|
_push_task_event(task_id, {'status': 'cancelled'})
|
||||||
return
|
return
|
||||||
except Exception:
|
future.result()
|
||||||
pass
|
_push_task_event(task_id, {'status': 'success'})
|
||||||
|
|
||||||
def check_cancelled():
|
|
||||||
try:
|
|
||||||
conn = get_db()
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute("SELECT status FROM brand_crawl_tasks WHERE id = %s", (task_id,))
|
|
||||||
row = cur.fetchone()
|
|
||||||
conn.close()
|
|
||||||
return row and (row.get('status') or '') == 'cancelled'
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
result_paths, err = _run_brand_files(local_paths, task_id=task_id, check_cancelled=check_cancelled, strategy=strategy)
|
|
||||||
if err:
|
|
||||||
try:
|
|
||||||
conn = get_db()
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
st = 'cancelled' if err == 'cancelled' else 'failed'
|
|
||||||
cur.execute(
|
|
||||||
"UPDATE brand_crawl_tasks SET status = %s, error_message = %s WHERE id = %s",
|
|
||||||
(st, err if st == 'failed' else None, task_id)
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
except Exception as e:
|
|
||||||
traceback.print_exc()
|
|
||||||
print(e)
|
|
||||||
pass
|
|
||||||
_push_task_event(task_id, {'status': st, 'error_message': err if st == 'failed' else None})
|
|
||||||
return
|
|
||||||
result_data, upload_err = _upload_results_to_oss(local_paths, result_paths, task_id)
|
|
||||||
if upload_err:
|
|
||||||
try:
|
|
||||||
conn = get_db()
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute(
|
|
||||||
"UPDATE brand_crawl_tasks SET status = 'failed', error_message = %s WHERE id = %s",
|
|
||||||
(upload_err, task_id)
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
_push_task_event(task_id, {'status': 'failed', 'error_message': upload_err})
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
conn = get_db()
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute(
|
|
||||||
"UPDATE brand_crawl_tasks SET status = 'success', result_paths = %s WHERE id = %s",
|
|
||||||
(json.dumps(result_data), task_id)
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
_push_task_event(task_id, {'status': 'success'})
|
|
||||||
finally:
|
|
||||||
pass
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
try:
|
print("执行出错",traceback.format_exc())
|
||||||
conn = get_db()
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute(
|
|
||||||
"UPDATE brand_crawl_tasks SET status = 'failed', error_message = %s WHERE id = %s",
|
|
||||||
(str(e), task_id)
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
_push_task_event(task_id, {'status': 'failed', 'error_message': str(e)})
|
_push_task_event(task_id, {'status': 'failed', 'error_message': str(e)})
|
||||||
|
print("执行完成")
|
||||||
|
|
||||||
|
|
||||||
def _parse_json(val, default=None):
|
def _parse_json(val, default=None):
|
||||||
@@ -494,6 +312,9 @@ def _normalize_result_paths(raw):
|
|||||||
def api_brand_expand_folder():
|
def api_brand_expand_folder():
|
||||||
"""展开文件夹,返回其下所有 .xlsx 文件路径列表"""
|
"""展开文件夹,返回其下所有 .xlsx 文件路径列表"""
|
||||||
try:
|
try:
|
||||||
|
_, err = _get_user_id_or_error()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
folder = (data.get('folder') or '').strip()
|
folder = (data.get('folder') or '').strip()
|
||||||
if not folder:
|
if not folder:
|
||||||
@@ -503,12 +324,33 @@ def api_brand_expand_folder():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
def _expand_folder_xlsx_recursive_items(folder_path):
|
||||||
|
"""返回文件夹下所有 .xlsx 文件的绝对路径和相对路径列表(递归)"""
|
||||||
|
if not folder_path or not os.path.isdir(folder_path):
|
||||||
|
return []
|
||||||
|
items = []
|
||||||
|
root = os.path.normpath(folder_path)
|
||||||
|
for current_root, _, filenames in os.walk(root):
|
||||||
|
for name in filenames:
|
||||||
|
if not (name.endswith('.xlsx') or name.endswith('.XLSX')):
|
||||||
|
continue
|
||||||
|
absolute_path = os.path.normpath(os.path.join(current_root, name))
|
||||||
|
relative_path = os.path.relpath(absolute_path, root).replace('\\', '/')
|
||||||
|
items.append({
|
||||||
|
'absolutePath': absolute_path,
|
||||||
|
'relativePath': relative_path,
|
||||||
|
})
|
||||||
|
items.sort(key=lambda item: item['relativePath'])
|
||||||
|
return items
|
||||||
|
|
||||||
@brand_bp.route('/api/brand/expand-folder-recursive', methods=['POST'])
|
@brand_bp.route('/api/brand/expand-folder-recursive', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def api_brand_expand_folder_recursive():
|
def api_brand_expand_folder_recursive():
|
||||||
"""递归展开文件夹,返回 .xlsx 文件绝对路径及相对路径列表"""
|
"""递归展开文件夹,返回 .xlsx 文件绝对路径及相对路径列表"""
|
||||||
try:
|
try:
|
||||||
|
_, err = _get_user_id_or_error()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
folder = (data.get('folder') or '').strip()
|
folder = (data.get('folder') or '').strip()
|
||||||
if not folder:
|
if not folder:
|
||||||
@@ -542,65 +384,58 @@ def api_brand_run():
|
|||||||
if not paths:
|
if not paths:
|
||||||
return jsonify({'success': False, 'error': '没有有效的 xlsx 文件路径'}), 400
|
return jsonify({'success': False, 'error': '没有有效的 xlsx 文件路径'}), 400
|
||||||
# 先上传到 OSS,获取链接再入库
|
# 先上传到 OSS,获取链接再入库
|
||||||
user_id = session['user_id']
|
user_id, err = _get_user_id_or_error()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
urls = []
|
urls = []
|
||||||
for p in paths:
|
for p in paths:
|
||||||
url, err = _upload_local_xlsx_to_oss(p, user_id)
|
url, err = _upload_local_xlsx_to_oss(p, user_id)
|
||||||
if err:
|
if err:
|
||||||
return jsonify({'success': False, 'error': f'上传文件失败: {os.path.basename(p)} - {err}'}), 500
|
return jsonify({'success': False, 'error': f'上传文件失败: {os.path.basename(p)} - {err}'}), 500
|
||||||
urls.append(url)
|
base_name = os.path.basename(p)
|
||||||
desc_core = ', '.join(os.path.basename(p) for p in paths)[:500] if paths else ''
|
urls.append({"fileUrl": url,"originalFilename": base_name,"relativePath": p })
|
||||||
desc = f'[{strategy}] ' + (desc_core or '')
|
|
||||||
conn = get_db()
|
# 请求提交
|
||||||
with conn.cursor() as cur:
|
resp = requests.post(f"{JAVA_API_BASE}/api/brand/tasks?userId={user_id}",headers={
|
||||||
cur.execute(
|
"content-type":"application/json",
|
||||||
"INSERT INTO brand_crawl_tasks (user_id, file_paths, `desc`, strategy, status, task_type) VALUES (%s, %s, %s, %s, 'pending', %s)",
|
},data=json.dumps({ "files": urls, "strategy": strategy,"taskType": 1,"archiveName": ""}))
|
||||||
(user_id, json.dumps(urls), desc or None, strategy, task_type)
|
print({ "files": urls, "strategy": strategy,"taskType": 1,"archiveName": ""})
|
||||||
)
|
resp_data = resp.json()
|
||||||
task_id = cur.lastrowid
|
# print(f"{JAVA_API_BASE}/api/brand/tasks?userId={user_id} 返回:",resp_data,"状态:",resp.status_code)
|
||||||
conn.commit()
|
if resp_data.get("success"):
|
||||||
conn.close()
|
task_id = resp_data.get("data").get("taskId")
|
||||||
threading.Thread(target=_background_brand_task, args=(task_id,), daemon=True).start()
|
data = resp_data.get("data")
|
||||||
return jsonify({'success': True, 'task_id': task_id})
|
threading.Thread(target=_background_brand_task, args=(task_id,data), daemon=True).start()
|
||||||
|
return jsonify({'success': True, 'task_id': task_id})
|
||||||
|
else:
|
||||||
|
return jsonify({'success': False, 'error': "请求接口失败"}), 500
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
traceback.print_exc()
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@brand_bp.route('/api/brand/tasks', methods=['GET', 'POST'])
|
@brand_bp.route('/api/brand/tasks', methods=['GET', 'POST'])
|
||||||
@login_required
|
# @login_required
|
||||||
def api_brand_tasks():
|
def api_brand_tasks():
|
||||||
"""GET: 获取当前用户的任务列表;POST: 添加任务(后台执行)"""
|
"""GET: 获取当前用户的任务列表;POST: 添加任务(后台执行)"""
|
||||||
if request.method == 'GET':
|
if request.method == 'GET':
|
||||||
try:
|
try:
|
||||||
conn = get_db()
|
user_id, err = _get_user_id_or_error()
|
||||||
with conn.cursor() as cur:
|
if err:
|
||||||
cur.execute(
|
return err
|
||||||
"""SELECT id, file_paths, `desc`, strategy, status, result_paths, error_message,
|
resp = requests.get(f"{JAVA_API_BASE}/api/brand/tasks", params={
|
||||||
COALESCE(progress_current, 0) AS progress_current,
|
"userId": user_id
|
||||||
COALESCE(progress_total, 0) AS progress_total,
|
})
|
||||||
created_at, updated_at
|
resp_data = resp.json()
|
||||||
FROM brand_crawl_tasks WHERE user_id = %s ORDER BY id DESC LIMIT 100""",
|
print("获取列表",resp.text)
|
||||||
(session['user_id'],)
|
if resp_data.get("success"):
|
||||||
)
|
items = resp_data.get("data").get("items")
|
||||||
rows = cur.fetchall()
|
return jsonify({'success': True, 'items': items})
|
||||||
conn.close()
|
return jsonify({'success': True, 'items': []})
|
||||||
items = []
|
|
||||||
for r in rows:
|
|
||||||
items.append({
|
|
||||||
'id': r['id'],
|
|
||||||
'file_paths': _parse_json(r['file_paths'], []),
|
|
||||||
'desc': (r.get('desc') or '').strip() or None,
|
|
||||||
'strategy': (r.get('strategy') or 'Terms').strip() or 'Terms',
|
|
||||||
'status': r.get('status') or 'pending',
|
|
||||||
'result_paths': _parse_json(r['result_paths']),
|
|
||||||
'error_message': (r.get('error_message') or '').strip() or None,
|
|
||||||
'progress_current': int(r.get('progress_current') or 0),
|
|
||||||
'progress_total': int(r.get('progress_total') or 0),
|
|
||||||
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r.get('created_at') else '',
|
|
||||||
'updated_at': r['updated_at'].strftime('%Y-%m-%d %H:%M') if r.get('updated_at') else '',
|
|
||||||
})
|
|
||||||
return jsonify({'success': True, 'items': items})
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
traceback.print_exc()
|
||||||
|
print("获取列表失败",e)
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
# POST
|
# POST
|
||||||
try:
|
try:
|
||||||
@@ -621,111 +456,95 @@ def api_brand_tasks():
|
|||||||
paths = [p.strip() for p in paths if p and isinstance(p, str)]
|
paths = [p.strip() for p in paths if p and isinstance(p, str)]
|
||||||
if not paths:
|
if not paths:
|
||||||
return jsonify({'success': False, 'error': '请提供至少一个文件路径或链接'}), 400
|
return jsonify({'success': False, 'error': '请提供至少一个文件路径或链接'}), 400
|
||||||
user_id = session['user_id']
|
user_id, err = _get_user_id_or_error()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
urls = []
|
urls = []
|
||||||
for p in paths:
|
for p in paths:
|
||||||
if _is_url(p):
|
url, err = _upload_local_xlsx_to_oss(p, user_id)
|
||||||
urls.append(p)
|
if err:
|
||||||
elif os.path.isfile(p):
|
return jsonify({'success': False, 'error': f'上传文件失败: {os.path.basename(p)} - {err}'}), 500
|
||||||
url, err = _upload_local_xlsx_to_oss(p, user_id)
|
base_name = os.path.basename(p)
|
||||||
if err:
|
urls.append({"fileUrl": url, "originalFilename": base_name, "relativePath": p})
|
||||||
return jsonify({'success': False, 'error': f'上传文件失败: {os.path.basename(p)} - {err}'}), 500
|
params = {
|
||||||
urls.append(url)
|
"userId": user_id
|
||||||
else:
|
}
|
||||||
return jsonify({'success': False, 'error': f'无效路径或链接: {p[:80]}'}), 400
|
# resp = requests.post(f"{JAVA_API_BASE}/api/brand/tasks?userId={user_id}",headers={
|
||||||
desc_core = ', '.join(os.path.basename(urlparse(u).path or u) for u in urls)[:500] if urls else ''
|
# "content-type":"application/json",
|
||||||
desc = f'[{strategy}] ' + (desc_core or '')
|
# },data=json.dumps({ "files": urls, "strategy": strategy,"taskType": 1,"archiveName": ""}))
|
||||||
conn = get_db()
|
req_data = {"files": urls, "strategy": strategy, "taskType": 2, "archiveName": ""}
|
||||||
with conn.cursor() as cur:
|
resp = requests.post(f"{JAVA_API_BASE}/api/brand/tasks?userId={user_id}", headers={"content-type": "application/json"},
|
||||||
cur.execute(
|
data=json.dumps(req_data))
|
||||||
"INSERT INTO brand_crawl_tasks (user_id, file_paths, `desc`, strategy, status, task_type) VALUES (%s, %s, %s, %s, 'pending', %s)",
|
print(req_data)
|
||||||
(user_id, json.dumps(urls), desc or None, strategy, task_type)
|
# print(f"{JAVA_API_BASE}/api/brand/tasks?userId={user_id},返回", resp.text)
|
||||||
)
|
resp_data = resp.json()
|
||||||
task_id = cur.lastrowid
|
if resp_data.get("success"):
|
||||||
conn.commit()
|
task_id = resp_data.get("data").get("taskId")
|
||||||
conn.close()
|
data = resp_data.get("data")
|
||||||
# threading.Thread(target=_background_brand_task, args=(task_id,), daemon=True).start()
|
return jsonify({'success': True, 'task_id': task_id})
|
||||||
return jsonify({'success': True, 'task_id': task_id})
|
return jsonify({'success': False, 'task_id': "请求异常"})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@brand_bp.route('/api/brand/tasks/<int:task_id>')
|
@brand_bp.route('/api/brand/tasks/<int:task_id>')
|
||||||
@login_required
|
# @login_required
|
||||||
def api_brand_task_detail(task_id):
|
def api_brand_task_detail(task_id):
|
||||||
"""获取单个任务详情(用于轮询状态)"""
|
"""获取单个任务详情(用于轮询状态)"""
|
||||||
try:
|
try:
|
||||||
conn = get_db()
|
resp = requests.get(f"{JAVA_API_BASE}/api/brand/tasks/{task_id}")
|
||||||
with conn.cursor() as cur:
|
resp_data = resp.json()
|
||||||
cur.execute(
|
if resp_data.get("success"):
|
||||||
"""SELECT id, file_paths, `desc`, strategy, status, result_paths, error_message,
|
task = resp_data["data"]["task"]
|
||||||
COALESCE(progress_current, 0) AS progress_current,
|
return jsonify({
|
||||||
COALESCE(progress_total, 0) AS progress_total,
|
'success': True,
|
||||||
created_at, updated_at
|
'task': task
|
||||||
FROM brand_crawl_tasks WHERE id = %s AND user_id = %s""",
|
})
|
||||||
(task_id, session['user_id'])
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
conn.close()
|
|
||||||
if not row:
|
|
||||||
return jsonify({'success': False, 'error': '任务不存在'}), 404
|
|
||||||
return jsonify({
|
|
||||||
'success': True,
|
|
||||||
'task': {
|
|
||||||
'id': row['id'],
|
|
||||||
'file_paths': _parse_json(row['file_paths'], []),
|
|
||||||
'desc': (row.get('desc') or '').strip() or None,
|
|
||||||
'strategy': (row.get('strategy') or 'Terms').strip() or 'Terms',
|
|
||||||
'status': row.get('status') or 'pending',
|
|
||||||
'result_paths': _parse_json(row['result_paths']),
|
|
||||||
'error_message': (row.get('error_message') or '').strip() or None,
|
|
||||||
'progress_current': int(row.get('progress_current') or 0),
|
|
||||||
'progress_total': int(row.get('progress_total') or 0),
|
|
||||||
'created_at': row['created_at'].strftime('%Y-%m-%d %H:%M') if row.get('created_at') else '',
|
|
||||||
'updated_at': row['updated_at'].strftime('%Y-%m-%d %H:%M') if row.get('updated_at') else '',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@brand_bp.route('/api/brand/tasks/<int:task_id>/line-progress')
|
@brand_bp.route('/api/brand/tasks/<int:task_id>/line-progress')
|
||||||
@login_required
|
# @login_required
|
||||||
def api_brand_task_line_progress(task_id):
|
def api_brand_task_line_progress(task_id):
|
||||||
"""
|
"""
|
||||||
获取任务的“当前文件行级进度”(当前行数/总行数)。
|
获取任务的“当前文件行级进度”(当前行数/总行数)。
|
||||||
该信息仅存放在内存字典 _task_line_progress 中,不写入数据库。
|
该信息仅存放在内存字典 _task_line_progress 中,不写入数据库。
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
with _task_line_progress_lock:
|
resp = requests.get(f"{JAVA_API_BASE}/api/brand/tasks/{task_id}")
|
||||||
info = _task_line_progress.get(task_id)
|
resp_data = resp.json()
|
||||||
if not info:
|
if resp_data.get("success"):
|
||||||
return jsonify({'success': True, 'has_progress': False})
|
if resp_data["data"]["line_progress"]["has_progress"]:
|
||||||
# 为了避免暴露完整路径,只返回文件名
|
info = resp_data["data"]["line_progress"]["info"]
|
||||||
file_name = os.path.basename(info.get('file_path') or '') if info.get('file_path') else ''
|
resp = {
|
||||||
resp = {
|
'file_index': int(info.get('file_index') or 0),
|
||||||
'file_index': int(info.get('file_index') or 0),
|
'file_total': int(info.get('file_total') or 0),
|
||||||
'file_total': int(info.get('file_total') or 0),
|
'file_name': info.get("file_name",""),
|
||||||
'file_name': file_name,
|
'current_line': int(info.get('current_line') or 0),
|
||||||
'current_line': int(info.get('current_line') or 0),
|
'total_lines': int(info.get('total_lines') or 0),
|
||||||
'total_lines': int(info.get('total_lines') or 0),
|
}
|
||||||
}
|
return jsonify({'success': True, 'has_progress': True, 'info': resp})
|
||||||
return jsonify({'success': True, 'has_progress': True, 'info': resp})
|
raise RuntimeError("获取进度失败")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@brand_bp.route('/api/brand/tasks/<int:task_id>/events')
|
@brand_bp.route('/api/brand/tasks/<int:task_id>/events')
|
||||||
@login_required
|
# @login_required
|
||||||
def api_brand_task_events(task_id):
|
def api_brand_task_events(task_id):
|
||||||
"""SSE:任务完成时后端主动推送事件,前端监听后隐藏进度条与取消按钮"""
|
"""SSE:任务完成时后端主动推送事件,前端监听后隐藏进度条与取消按钮"""
|
||||||
|
user_id, err = _get_user_id_or_error()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
|
||||||
def _task_status_event():
|
def _task_status_event():
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
try:
|
try:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT status, error_message FROM brand_crawl_tasks WHERE id = %s AND user_id = %s",
|
"SELECT status, error_message FROM brand_crawl_tasks WHERE id = %s AND user_id = %s",
|
||||||
(task_id, session['user_id'])
|
(task_id, user_id)
|
||||||
)
|
)
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
finally:
|
finally:
|
||||||
@@ -778,21 +597,28 @@ def api_brand_task_events(task_id):
|
|||||||
|
|
||||||
|
|
||||||
@brand_bp.route('/api/brand/tasks/<int:task_id>/cancel', methods=['POST'])
|
@brand_bp.route('/api/brand/tasks/<int:task_id>/cancel', methods=['POST'])
|
||||||
@login_required
|
# @login_required
|
||||||
def api_brand_task_cancel(task_id):
|
def api_brand_task_cancel(task_id):
|
||||||
"""取消正在执行或等待中的任务(pending/running 均可取消)"""
|
"""取消正在执行或等待中的任务(pending/running 均可取消)"""
|
||||||
try:
|
try:
|
||||||
conn = get_db()
|
user_id, err = _get_user_id_or_error()
|
||||||
with conn.cursor() as cur:
|
if err:
|
||||||
cur.execute(
|
return err
|
||||||
"UPDATE brand_crawl_tasks SET status = 'cancelled' WHERE id = %s AND user_id = %s AND status IN ('pending', 'running')",
|
|
||||||
(task_id, session['user_id'])
|
|
||||||
)
|
resp = requests.post(
|
||||||
n = cur.rowcount
|
f"{JAVA_API_BASE}/api/brand/tasks/{task_id}/cancel",
|
||||||
conn.commit()
|
headers={"accept": "application/json"},
|
||||||
conn.close()
|
timeout=20
|
||||||
if n == 0:
|
)
|
||||||
return jsonify({'success': False, 'error': '任务不存在或无法取消'}), 400
|
try:
|
||||||
|
resp_data = resp.json()
|
||||||
|
except Exception:
|
||||||
|
return jsonify({'success': False, 'error': '取消接口返回非 JSON'}), 500
|
||||||
|
if not resp_data.get('success'):
|
||||||
|
return jsonify({'success': False, 'error': '第三方取消任务失败'}), 400
|
||||||
|
|
||||||
|
_mark_task_cancelled(task_id)
|
||||||
_push_task_event(task_id, {'status': 'cancelled'})
|
_push_task_event(task_id, {'status': 'cancelled'})
|
||||||
return jsonify({'success': True})
|
return jsonify({'success': True})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -804,17 +630,21 @@ def api_brand_task_cancel(task_id):
|
|||||||
def api_brand_task_delete(task_id):
|
def api_brand_task_delete(task_id):
|
||||||
"""删除任务(仅限非 running 状态)"""
|
"""删除任务(仅限非 running 状态)"""
|
||||||
try:
|
try:
|
||||||
conn = get_db()
|
user_id, err = _get_user_id_or_error()
|
||||||
with conn.cursor() as cur:
|
if err:
|
||||||
cur.execute(
|
return err
|
||||||
"DELETE FROM brand_crawl_tasks WHERE id = %s AND user_id = %s AND status != 'running'",
|
|
||||||
(task_id, session['user_id'])
|
resp = requests.delete(
|
||||||
)
|
f"{JAVA_API_BASE}/api/brand/tasks/{task_id}",
|
||||||
n = cur.rowcount
|
headers={"accept": "application/json"},
|
||||||
conn.commit()
|
timeout=20
|
||||||
conn.close()
|
)
|
||||||
if n == 0:
|
try:
|
||||||
return jsonify({'success': False, 'error': '任务不存在或正在执行中无法删除'}), 400
|
resp_data = resp.json()
|
||||||
|
except Exception:
|
||||||
|
return jsonify({'success': False, 'error': '取消接口返回非 JSON'}), 500
|
||||||
|
if not resp_data.get('success'):
|
||||||
|
return jsonify({'success': False, 'error': '第三方取消任务失败'}), 400
|
||||||
return jsonify({'success': True})
|
return jsonify({'success': True})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
@@ -825,11 +655,15 @@ def api_brand_task_delete(task_id):
|
|||||||
def api_brand_download(task_id):
|
def api_brand_download(task_id):
|
||||||
"""下载任务结果:优先使用 OSS zip_url 重定向;否则本地/单链接/多链接按原逻辑处理"""
|
"""下载任务结果:优先使用 OSS zip_url 重定向;否则本地/单链接/多链接按原逻辑处理"""
|
||||||
try:
|
try:
|
||||||
|
user_id, err = _get_user_id_or_error()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT result_paths FROM brand_crawl_tasks WHERE id = %s AND user_id = %s",
|
"SELECT result_paths FROM brand_crawl_tasks WHERE id = %s AND user_id = %s",
|
||||||
(task_id, session['user_id'])
|
(task_id, user_id)
|
||||||
)
|
)
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
BIN
app/brand_spider/__pycache__/main.cpython-311.pyc
Normal file
BIN
app/brand_spider/__pycache__/main.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
app/brand_spider/__pycache__/web_dec.cpython-311.pyc
Normal file
BIN
app/brand_spider/__pycache__/web_dec.cpython-311.pyc
Normal file
Binary file not shown.
@@ -3,10 +3,7 @@ import re
|
|||||||
import random
|
import random
|
||||||
import requests
|
import requests
|
||||||
import time
|
import time
|
||||||
import datetime
|
|
||||||
import openpyxl
|
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from openpyxl import Workbook
|
|
||||||
|
|
||||||
from brand_spider.web_dec import decrypt_via_service, get_guid
|
from brand_spider.web_dec import decrypt_via_service, get_guid
|
||||||
|
|
||||||
@@ -77,7 +74,7 @@ def search(hashsearch, data, proxies=None):
|
|||||||
# print("原始结果", enc_text)
|
# print("原始结果", enc_text)
|
||||||
|
|
||||||
# 若返回 Forbidden,则从 config 的 proxy_url 获取代理 IP 后重试,并开启 3 分钟代理窗口
|
# 若返回 Forbidden,则从 config 的 proxy_url 获取代理 IP 后重试,并开启 3 分钟代理窗口
|
||||||
if enc_text.strip() == '{"message":"Forbidden"}' and CONFIG_PROXY_URL:
|
if (enc_text.strip() == '{"message":"Forbidden"}' or "Too Many Requests" in enc_text) and CONFIG_PROXY_URL:
|
||||||
try:
|
try:
|
||||||
proxy_resp = requests.get(CONFIG_PROXY_URL, timeout=10)
|
proxy_resp = requests.get(CONFIG_PROXY_URL, timeout=10)
|
||||||
print("代理请求结果->:", proxy_resp.text)
|
print("代理请求结果->:", proxy_resp.text)
|
||||||
@@ -125,7 +122,7 @@ class TaskCancelledError(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def single_file_handle(file_path, output_path, strategy="Terms", check_cancelled=None, progress_callback=None):
|
def single_file_handle(brand, strategy="Terms"):
|
||||||
status_info = {
|
status_info = {
|
||||||
"Ended": "已结束",
|
"Ended": "已结束",
|
||||||
"Expired": "已过期的",
|
"Expired": "已过期的",
|
||||||
@@ -471,208 +468,123 @@ def single_file_handle(file_path, output_path, strategy="Terms", check_cancelled
|
|||||||
"欧洲联盟"
|
"欧洲联盟"
|
||||||
]
|
]
|
||||||
check_staus = ["已过期的","已结束"]
|
check_staus = ["已过期的","已结束"]
|
||||||
# 1. 读取 Excel 活动工作表
|
|
||||||
wb = openpyxl.load_workbook(file_path, data_only=True)
|
|
||||||
active_sheet = wb.active
|
|
||||||
active_sheet_name = active_sheet.title
|
|
||||||
print(active_sheet_name)
|
|
||||||
|
|
||||||
# 2. 将数据转换为列表嵌套字典
|
|
||||||
rows = list(active_sheet.iter_rows(values_only=True))
|
|
||||||
if rows:
|
|
||||||
columns = list(rows[0])
|
|
||||||
data_rows = []
|
|
||||||
for row in rows[1:]:
|
|
||||||
row_dict = {}
|
|
||||||
for idx, col in enumerate(columns):
|
|
||||||
row_dict[col] = row[idx] if idx < len(row) else None
|
|
||||||
data_rows.append(row_dict)
|
|
||||||
else:
|
|
||||||
columns = []
|
|
||||||
data_rows = []
|
|
||||||
|
|
||||||
# 3. 确保必要列存在
|
|
||||||
# required_cols = ["状态", "国家", "时间"]
|
|
||||||
# for col in required_cols:
|
|
||||||
# if col not in columns:
|
|
||||||
# columns.append(col)
|
|
||||||
# for row_dict in data_rows:
|
|
||||||
# row_dict[col] = ""
|
|
||||||
|
|
||||||
# 4. 提取唯一品牌列表
|
|
||||||
brand_ls = set()
|
|
||||||
for row in data_rows:
|
|
||||||
brand = row.get("品牌")
|
|
||||||
if brand:
|
|
||||||
brand_ls.add(brand)
|
|
||||||
|
|
||||||
# 5. 初始化不符合品牌的数据列表
|
# 5. 初始化不符合品牌的数据列表
|
||||||
faild_data = []
|
faild_data = []
|
||||||
# 初始化查询失败的品牌的数据列表
|
# 初始化查询失败的品牌的数据列表
|
||||||
query_faild_data = []
|
query_faild_data = []
|
||||||
|
|
||||||
# 6. 对每个品牌进行处理
|
|
||||||
brands = list(brand_ls)
|
|
||||||
total_brands = len(brands)
|
|
||||||
for idx, brand in enumerate(brands, start=1):
|
|
||||||
if check_cancelled and check_cancelled():
|
|
||||||
raise TaskCancelledError()
|
|
||||||
|
|
||||||
# 行级进度回调:当前第 idx 条 / total_brands
|
# Simple 策略下最多翻 3 页(0,30,60),直到命中 554 行判断
|
||||||
if progress_callback and total_brands > 0:
|
max_pages = 3 if strategy == "Simple" else 1
|
||||||
try:
|
page = 0
|
||||||
progress_callback(idx, total_brands, extra={'current_brand': str(brand)})
|
matched = False
|
||||||
except Exception:
|
|
||||||
# 回调失败不影响主流程
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Simple 策略下最多翻 3 页(0,30,60),直到命中 554 行判断
|
while page < max_pages and not matched:
|
||||||
max_pages = 3 if strategy == "Simple" else 1
|
try:
|
||||||
page = 0
|
as_structure_dict = {
|
||||||
matched = False
|
"_id": create_code_generator(),
|
||||||
|
"boolean": "AND",
|
||||||
|
"bricks": [
|
||||||
|
{
|
||||||
|
"_id": create_code_generator(),
|
||||||
|
"key": "brandName",
|
||||||
|
"value": brand,
|
||||||
|
"strategy": strategy
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
# 构造 asStructure 内部 JSON 对象
|
||||||
|
as_structure_dict = as_structure_dict
|
||||||
|
# print(as_structure_dict)
|
||||||
|
|
||||||
while page < max_pages and not matched:
|
# 将内部对象转为 JSON 字符串(作为 asStructure 字段的值)
|
||||||
try:
|
as_structure_str = json.dumps(as_structure_dict, ensure_ascii=False)
|
||||||
as_structure_dict = {
|
data = {
|
||||||
"_id": create_code_generator(),
|
"sort": "score desc",
|
||||||
"boolean": "AND",
|
"rows": "30",
|
||||||
"bricks": [
|
"asStructure": as_structure_str,
|
||||||
{
|
"fg": "_void_"
|
||||||
"_id": create_code_generator(),
|
}
|
||||||
"key": "brandName",
|
|
||||||
"value": brand,
|
|
||||||
"strategy": strategy
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
# 构造 asStructure 内部 JSON 对象
|
|
||||||
as_structure_dict = as_structure_dict
|
|
||||||
# print(as_structure_dict)
|
|
||||||
|
|
||||||
# 将内部对象转为 JSON 字符串(作为 asStructure 字段的值)
|
# 翻页:第二页 start=30,第三页 start=60(仅 Simple 策略)
|
||||||
as_structure_str = json.dumps(as_structure_dict, ensure_ascii=False)
|
if strategy == "Simple" and page > 0:
|
||||||
data = {
|
data["start"] = str(30 * page)
|
||||||
"sort": "score desc",
|
|
||||||
"rows": "30",
|
|
||||||
"asStructure": as_structure_str,
|
|
||||||
"fg": "_void_"
|
|
||||||
}
|
|
||||||
# 翻页:第二页 start=30,第三页 start=60(仅 Simple 策略)
|
|
||||||
if strategy == "Simple" and page > 0:
|
|
||||||
data["start"] = str(30 * page)
|
|
||||||
|
|
||||||
# print("请求参数", as_structure_dict, "页码:", page)
|
# print("请求参数", as_structure_dict, "页码:", page)
|
||||||
hashsearch = get_guid()
|
hashsearch = get_guid()
|
||||||
enc_text = search(hashsearch, data)
|
enc_text = search(hashsearch, data)
|
||||||
print(f"【{hashsearch}】待解密-->", enc_text)
|
print(f"【{hashsearch}】待解密-->", enc_text)
|
||||||
dec_text = decrypt_via_service(hashsearch, enc_text)
|
dec_text = decrypt_via_service(hashsearch, enc_text)
|
||||||
# print(type(dec_text))
|
# print(type(dec_text))
|
||||||
dec_text = str(dec_text)
|
dec_text = str(dec_text)
|
||||||
# print(f"解密之后的结果-->", dec_text)
|
# print(f"解密之后的结果-->", dec_text)
|
||||||
data = json.loads(dec_text)
|
data = json.loads(dec_text)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 仅第一页请求失败时记录为查询失败品牌
|
# 仅第一页请求失败时记录为查询失败品牌
|
||||||
if page == 0:
|
if page == 0:
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
safe_brand = brand.encode('gbk', errors='replace').decode('gbk')
|
safe_brand = brand.encode('gbk', errors='replace').decode('gbk')
|
||||||
print(f"品牌:{safe_brand},处理失败:{e}")
|
print(f"品牌:{safe_brand},处理失败:{e}")
|
||||||
query_faild_data.append({"品牌": brand, "时间": datetime.datetime.now().strftime("%y-%m-%d %H:%M:%S")})
|
# query_faild_data.append({"brand": brand, "time": datetime.datetime.now().strftime("%y-%m-%d %H:%M:%S")})
|
||||||
break
|
query_faild_data.append(brand)
|
||||||
|
break
|
||||||
|
|
||||||
# print(data)
|
# print(data)
|
||||||
|
|
||||||
|
if data.get("response", {}).get("numFound", 0) > 0:
|
||||||
|
if strategy == "Simple":
|
||||||
|
new_brand_name = clean_text(brand)
|
||||||
|
_brand = new_brand_name.lower()
|
||||||
|
else:
|
||||||
|
_brand = brand
|
||||||
|
|
||||||
|
docs = data["response"]["docs"]
|
||||||
|
for d in docs:
|
||||||
|
office = d.get("office")
|
||||||
|
status = d.get("status")
|
||||||
|
registrationDate = d.get("registrationDate")
|
||||||
|
status_name = status_info.get(status, status)
|
||||||
|
brand_name = d.get("brandName")
|
||||||
|
if isinstance(brand_name, list):
|
||||||
|
brand_name = "|".join(brand_name)
|
||||||
|
|
||||||
if data.get("response", {}).get("numFound", 0) > 0:
|
|
||||||
if strategy == "Simple":
|
if strategy == "Simple":
|
||||||
new_brand_name = clean_text(brand)
|
new_brand_name = clean_text(brand_name)
|
||||||
_brand = new_brand_name.lower()
|
new_brand_name = new_brand_name.lower()
|
||||||
else:
|
if _brand != new_brand_name:
|
||||||
_brand = brand
|
continue
|
||||||
|
|
||||||
docs = data["response"]["docs"]
|
check_office = d.get("designation")
|
||||||
for d in docs:
|
|
||||||
office = d.get("office")
|
|
||||||
status = d.get("status")
|
|
||||||
registrationDate = d.get("registrationDate")
|
|
||||||
status_name = status_info.get(status, status)
|
|
||||||
brand_name = d.get("brandName")
|
|
||||||
if isinstance(brand_name, list):
|
|
||||||
brand_name = "|".join(brand_name)
|
|
||||||
|
|
||||||
if strategy == "Simple":
|
check_office.append(office)
|
||||||
new_brand_name = clean_text(brand_name)
|
for i in set(check_office):
|
||||||
new_brand_name = new_brand_name.lower()
|
office_name = country_info.get(i, i)
|
||||||
if _brand != new_brand_name:
|
# print(office_name,office_name in check_value and status_name not in check_staus)
|
||||||
|
if office_name in check_value and status_name not in check_staus:
|
||||||
|
# 命中 554 行判断,记录并结束当前品牌后续翻页
|
||||||
|
# data_rows = [row for row in data_rows if row.get("品牌") != brand]
|
||||||
|
# print(office_name, office in ["EM","QZ"] and i not in ["EM","QZ"])
|
||||||
|
if office in ["EM","QZ"] and i not in ["EM","QZ"]:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
check_office = d.get("designation")
|
if strategy == "Simple":
|
||||||
|
faild_data.append({"brand": brand, "country": office_name, "status": status_name}) # "reason":"|".join(d.get("brandName","")
|
||||||
|
matched = True
|
||||||
|
else:
|
||||||
|
# print("添加",brand,office_name)
|
||||||
|
faild_data.append({"brand": brand, "country": office_name, "status": status_name})
|
||||||
|
# break
|
||||||
|
if matched:
|
||||||
|
break
|
||||||
|
|
||||||
check_office.append(office)
|
# 如果 Simple 策略下本页未命中,则翻下一页;其他策略不翻页
|
||||||
for i in set(check_office):
|
if not matched:
|
||||||
office_name = country_info.get(i, i)
|
page += 1
|
||||||
# print(office_name,office_name in check_value and status_name not in check_staus)
|
|
||||||
if office_name in check_value and status_name not in check_staus:
|
|
||||||
# 命中 554 行判断,记录并结束当前品牌后续翻页
|
|
||||||
data_rows = [row for row in data_rows if row.get("品牌") != brand]
|
|
||||||
# print(office_name, office in ["EM","QZ"] and i not in ["EM","QZ"])
|
|
||||||
if office in ["EM","QZ"] and i not in ["EM","QZ"]:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if strategy == "Simple":
|
|
||||||
faild_data.append({"品牌": brand, "国家": office_name, "状态": status_name, "原因":"|".join(d.get("brandName","")),"时间": datetime.datetime.now().strftime("%y-%m-%d %H:%M:%S")})
|
|
||||||
matched = True
|
|
||||||
else:
|
|
||||||
# print("添加",brand,office_name)
|
|
||||||
faild_data.append({"品牌": brand, "国家": office_name, "状态": status_name, "时间": datetime.datetime.now().strftime("%y-%m-%d %H:%M:%S")})
|
|
||||||
# break
|
|
||||||
if matched:
|
|
||||||
break
|
|
||||||
|
|
||||||
# 如果 Simple 策略下本页未命中,则翻下一页;其他策略不翻页
|
return faild_data,query_faild_data
|
||||||
if not matched:
|
|
||||||
page += 1
|
|
||||||
# else:
|
|
||||||
# # 更新主表数据
|
|
||||||
# for row in data_rows:
|
|
||||||
# if row.get("品牌") == brand:
|
|
||||||
# row["状态"] = status_name
|
|
||||||
# row["国家"] = office_name
|
|
||||||
# row["时间"] = datetime.datetime.now().strftime("%y-%m-%d %H:%M:%S") # 品牌匹配行
|
|
||||||
# else:
|
|
||||||
# # 非匹配行:时间 = 当前国家值(模拟原 mask 逻辑)
|
|
||||||
# row["时间"] = ""
|
|
||||||
|
|
||||||
# 7. 创建输出工作簿
|
|
||||||
out_wb = Workbook()
|
|
||||||
out_wb.remove(out_wb.active) # 删除默认 sheet
|
|
||||||
|
|
||||||
# 主工作表
|
|
||||||
main_ws = out_wb.create_sheet(title=active_sheet_name)
|
|
||||||
main_ws.append(columns)
|
|
||||||
for row_dict in data_rows:
|
|
||||||
main_ws.append([row_dict.get(col, "") for col in columns])
|
|
||||||
|
|
||||||
# 不符合品牌工作表
|
|
||||||
faild_ws = out_wb.create_sheet(title="不符合品牌")
|
|
||||||
if strategy == "Simple":
|
|
||||||
faild_columns = ["品牌", "国家", "状态","原因"]
|
|
||||||
else:
|
|
||||||
faild_columns = ["品牌", "国家", "状态"]
|
|
||||||
faild_ws.append(faild_columns)
|
|
||||||
for row_dict in faild_data:
|
|
||||||
# print("写入",row_dict)
|
|
||||||
faild_ws.append([row_dict.get(col, "") for col in faild_columns])
|
|
||||||
|
|
||||||
# 查询失败品牌工作表
|
|
||||||
query_faild_ws = out_wb.create_sheet(title="查询失败品牌")
|
|
||||||
query_faild_columns = ["品牌", "时间"]
|
|
||||||
query_faild_ws.append(query_faild_columns)
|
|
||||||
for row_dict in query_faild_data:
|
|
||||||
query_faild_ws.append([row_dict.get(col, "") for col in query_faild_columns])
|
|
||||||
|
|
||||||
out_wb.save(output_path)
|
|
||||||
print("生成完成--->", output_path)
|
|
||||||
return output_path
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ STITCH_WORKFLOW_ID = "7608813873483300907"
|
|||||||
import os
|
import os
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
_base_url = os.getenv("base_url","http://159.75.121.33:15124")
|
_base_url = os.getenv("base_url","http://159.75.121.33:15124")
|
||||||
java_api_base = os.getenv("java_api_base", "http://127.0.0.1:18080")
|
|
||||||
JAVA_API_BASE = java_api_base
|
|
||||||
|
|
||||||
# MySQL 配置
|
# MySQL 配置
|
||||||
mysql_host = os.getenv("mysql_host")
|
mysql_host = os.getenv("mysql_host")
|
||||||
@@ -18,8 +16,9 @@ mysql_database = os.getenv("mysql_database","aiimage")
|
|||||||
proxy_url = os.getenv("proxy_url")
|
proxy_url = os.getenv("proxy_url")
|
||||||
proxy_mode = int(os.getenv("proxy_mode",1))
|
proxy_mode = int(os.getenv("proxy_mode",1))
|
||||||
|
|
||||||
|
|
||||||
client_name=os.getenv("client_name") + ".exe"
|
client_name=os.getenv("client_name") + ".exe"
|
||||||
|
JAVA_API_BASE = os.getenv("java_api_base", "http://127.0.0.1:18080")
|
||||||
|
|
||||||
|
|
||||||
cache_path = "./user_data"
|
cache_path = "./user_data"
|
||||||
|
|
||||||
@@ -39,8 +38,8 @@ os.environ['OSS_ACCESS_KEY_SECRET'] = accessKeySecret
|
|||||||
os.environ['SECRET_KEY'] = "ddffc7c1d02121d9554d7b080b2511b6"
|
os.environ['SECRET_KEY'] = "ddffc7c1d02121d9554d7b080b2511b6"
|
||||||
|
|
||||||
|
|
||||||
debug = True
|
debug = False
|
||||||
version = "1.0.3"
|
version = "1.0.13"
|
||||||
APP_UPDATE_URL = f"{_base_url}/api/version/latest" # 检测更新接口地址,返回 { "file_url": "...", "version": "x.x.x" }
|
APP_UPDATE_URL = f"{_base_url}/api/version/latest" # 检测更新接口地址,返回 { "file_url": "...", "version": "x.x.x" }
|
||||||
os.environ['APP_VERSION'] = version
|
os.environ['APP_VERSION'] = version
|
||||||
os.environ['APP_UPDATE_URL'] = APP_UPDATE_URL
|
os.environ['APP_UPDATE_URL'] = APP_UPDATE_URL
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>亚马逊 - 南日AI</title>
|
<title>亚马逊 - 数富AI</title>
|
||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
body {
|
body {
|
||||||
@@ -338,15 +338,15 @@
|
|||||||
<body>
|
<body>
|
||||||
<header class="top-bar">
|
<header class="top-bar">
|
||||||
<div class="logo-area">
|
<div class="logo-area">
|
||||||
<span class="app-name">南日AI-亚马逊</span>
|
<span class="app-name">数富AI-亚马逊</span>
|
||||||
<a href="/home" class="btn-home">返回首页</a>
|
<a href="/home" class="btn-home">返回首页</a>
|
||||||
</div>
|
</div>
|
||||||
<nav class="nav-tabs">
|
<nav class="nav-tabs">
|
||||||
<span class="nav-tab-group">
|
<span class="nav-tab-group">
|
||||||
<button type="button" class="nav-tab active" data-panel="brandCheck">品牌检测</button>
|
<button type="button" class="nav-tab active" data-panel="brandCheck">品牌检测</button>
|
||||||
<button type="button" class="nav-tab" onclick="window.location='/new_web_source/dedupe.html'">总数据去重</button>
|
<button type="button" class="nav-tab" onclick="window.location='/new_web_source/dedupe.html'">总数据去重</button>
|
||||||
<button type="button" class="nav-tab" onclick="window.location='/new_web_source/convert.html'">格式转换</button>
|
|
||||||
<button type="button" class="nav-tab" onclick="window.location='/new_web_source/split.html'">表格拆分</button>
|
<button type="button" class="nav-tab" onclick="window.location='/new_web_source/split.html'">表格拆分</button>
|
||||||
|
<button type="button" class="nav-tab" onclick="window.location='/new_web_source/convert.html'">格式转换</button>
|
||||||
<!-- 后续增加栏目时在此添加,例如:<button type="button" class="nav-tab" data-panel="other">其他栏目</button> -->
|
<!-- 后续增加栏目时在此添加,例如:<button type="button" class="nav-tab" data-panel="other">其他栏目</button> -->
|
||||||
</span>
|
</span>
|
||||||
<!-- 多组栏目时可用分隔符:<span class="nav-tab-sep" aria-hidden="true"></span> -->
|
<!-- 多组栏目时可用分隔符:<span class="nav-tab-sep" aria-hidden="true"></span> -->
|
||||||
@@ -467,6 +467,16 @@
|
|||||||
var cachedTasks = [];
|
var cachedTasks = [];
|
||||||
var api = window.pywebview && window.pywebview.api;
|
var api = window.pywebview && window.pywebview.api;
|
||||||
|
|
||||||
|
function getUid() {
|
||||||
|
return localStorage.getItem('uid') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function withUidHeaders(headers) {
|
||||||
|
headers = headers || {};
|
||||||
|
headers.uid = getUid();
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
// 顶部栏目切换:点击 nav-tab 时显示对应 data-panel 的 tab-panel
|
// 顶部栏目切换:点击 nav-tab 时显示对应 data-panel 的 tab-panel
|
||||||
document.querySelectorAll('.nav-tab').forEach(function(tab) {
|
document.querySelectorAll('.nav-tab').forEach(function(tab) {
|
||||||
tab.addEventListener('click', function() {
|
tab.addEventListener('click', function() {
|
||||||
@@ -572,8 +582,8 @@
|
|||||||
if (!folder) return;
|
if (!folder) return;
|
||||||
fetch('/api/brand/expand-folder', {
|
fetch('/api/brand/expand-folder', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
headers: withUidHeaders({ 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }),
|
||||||
credentials: 'same-origin',
|
credentials: 'include',
|
||||||
body: JSON.stringify({ folder: folder })
|
body: JSON.stringify({ folder: folder })
|
||||||
})
|
})
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
@@ -604,14 +614,14 @@
|
|||||||
|
|
||||||
var pollTaskId = null;
|
var pollTaskId = null;
|
||||||
var pollTimer = null;
|
var pollTimer = null;
|
||||||
var runTaskEventSource = null;
|
var runTaskEventAbortController = null;
|
||||||
|
|
||||||
function stopPollAndResetRunUI() {
|
function stopPollAndResetRunUI() {
|
||||||
pollTaskId = null;
|
pollTaskId = null;
|
||||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
||||||
if (runTaskEventSource) {
|
if (runTaskEventAbortController) {
|
||||||
runTaskEventSource.close();
|
try { runTaskEventAbortController.abort(); } catch (e) {}
|
||||||
runTaskEventSource = null;
|
runTaskEventAbortController = null;
|
||||||
}
|
}
|
||||||
var btn = document.getElementById('btnRun');
|
var btn = document.getElementById('btnRun');
|
||||||
var loadingMsg = document.getElementById('loadingMsg');
|
var loadingMsg = document.getElementById('loadingMsg');
|
||||||
@@ -645,7 +655,7 @@
|
|||||||
runProgressWrap.style.display = 'block';
|
runProgressWrap.style.display = 'block';
|
||||||
btnCancelRun.style.display = 'inline-block';
|
btnCancelRun.style.display = 'inline-block';
|
||||||
btnCancelRun.onclick = function() {
|
btnCancelRun.onclick = function() {
|
||||||
fetch('/api/brand/tasks/' + taskId + '/cancel', { method: 'POST', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
fetch('/api/brand/tasks/' + taskId + '/cancel', { method: 'POST', credentials: 'include', headers: withUidHeaders({ 'X-Requested-With': 'XMLHttpRequest' }) })
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(res) {
|
.then(function(res) {
|
||||||
if (res.success) onRunTaskFinished('cancelled');
|
if (res.success) onRunTaskFinished('cancelled');
|
||||||
@@ -653,28 +663,64 @@
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 通过 SSE 订阅任务完成事件,完成后由后端主动推送,前端收到后立即隐藏进度条和取消按钮
|
// 通过事件流订阅任务完成事件(使用 fetch 以便携带 uid 请求头)
|
||||||
var eventsUrl = '/api/brand/tasks/' + taskId + '/events';
|
var eventsUrl = '/api/brand/tasks/' + taskId + '/events';
|
||||||
runTaskEventSource = new EventSource(eventsUrl);
|
runTaskEventAbortController = new AbortController();
|
||||||
runTaskEventSource.onmessage = function(e) {
|
(async function() {
|
||||||
try {
|
try {
|
||||||
var data = JSON.parse(e.data);
|
var resp = await fetch(eventsUrl, {
|
||||||
if (data && (data.status === 'success' || data.status === 'failed' || data.status === 'cancelled')) {
|
method: 'GET',
|
||||||
runTaskEventSource.close();
|
credentials: 'include',
|
||||||
runTaskEventSource = null;
|
headers: withUidHeaders({ 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'text/event-stream' }),
|
||||||
// tick();
|
signal: runTaskEventAbortController.signal
|
||||||
onRunTaskFinished(data.status);
|
});
|
||||||
|
if (!resp.ok || !resp.body) return;
|
||||||
|
|
||||||
|
var reader = resp.body.getReader();
|
||||||
|
var decoder = new TextDecoder('utf-8');
|
||||||
|
var buffer = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
var r = await reader.read();
|
||||||
|
if (!r || r.done) break;
|
||||||
|
buffer += decoder.decode(r.value, { stream: true });
|
||||||
|
|
||||||
|
var parts = buffer.split(/\n\n/);
|
||||||
|
buffer = parts.pop();
|
||||||
|
|
||||||
|
var terminalStatus = null;
|
||||||
|
for (var i = 0; i < parts.length; i++) {
|
||||||
|
var part = parts[i];
|
||||||
|
if (!part) continue;
|
||||||
|
|
||||||
|
var dataLine = part.split('\n').find(function(l) { return l.indexOf('data:') === 0; });
|
||||||
|
if (!dataLine) continue;
|
||||||
|
|
||||||
|
var dataStr = dataLine.slice(5).trim();
|
||||||
|
if (!dataStr) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
var data = JSON.parse(dataStr);
|
||||||
|
if (data && (data.status === 'success' || data.status === 'failed' || data.status === 'cancelled')) {
|
||||||
|
terminalStatus = data.status;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (err) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (terminalStatus) {
|
||||||
|
try { runTaskEventAbortController.abort(); } catch (e2) {}
|
||||||
|
runTaskEventAbortController = null;
|
||||||
|
onRunTaskFinished(terminalStatus);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {}
|
} catch (err) {}
|
||||||
};
|
})();
|
||||||
runTaskEventSource.onerror = function() {
|
|
||||||
runTaskEventSource.close();
|
|
||||||
runTaskEventSource = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function tick() {
|
function tick() {
|
||||||
if (!pollTaskId) return;
|
if (!pollTaskId) return;
|
||||||
fetch('/api/brand/tasks/' + taskId, { credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
fetch('/api/brand/tasks/' + taskId, { credentials: 'include', headers: withUidHeaders({ 'X-Requested-With': 'XMLHttpRequest' }) })
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(res) {
|
.then(function(res) {
|
||||||
if (!res.success || !res.task) return;
|
if (!res.success || !res.task) return;
|
||||||
@@ -701,8 +747,8 @@
|
|||||||
var st2 = (t.status || '').toLowerCase();
|
var st2 = (t.status || '').toLowerCase();
|
||||||
if (st2 === 'running') {
|
if (st2 === 'running') {
|
||||||
fetch('/api/brand/tasks/' + taskId + '/line-progress', {
|
fetch('/api/brand/tasks/' + taskId + '/line-progress', {
|
||||||
credentials: 'same-origin',
|
credentials: 'include',
|
||||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
headers: withUidHeaders({ 'X-Requested-With': 'XMLHttpRequest' })
|
||||||
})
|
})
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(res2) {
|
.then(function(res2) {
|
||||||
@@ -730,7 +776,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
tick();
|
tick();
|
||||||
pollTimer = setInterval(tick, 5000);
|
pollTimer = setInterval(tick, 10000);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('btnRun').onclick = function() {
|
document.getElementById('btnRun').onclick = function() {
|
||||||
@@ -748,8 +794,8 @@
|
|||||||
loadingMsg.style.display = 'inline';
|
loadingMsg.style.display = 'inline';
|
||||||
fetch('/api/brand/run', {
|
fetch('/api/brand/run', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
headers: withUidHeaders({ 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }),
|
||||||
credentials: 'same-origin',
|
credentials: 'include',
|
||||||
body: JSON.stringify({ paths: selectedPaths, strategy: strategy, task_type: 1 })
|
body: JSON.stringify({ paths: selectedPaths, strategy: strategy, task_type: 1 })
|
||||||
})
|
})
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
@@ -769,8 +815,8 @@
|
|||||||
} else {
|
} else {
|
||||||
fetch('/api/brand/tasks', {
|
fetch('/api/brand/tasks', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
headers: withUidHeaders({ 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }),
|
||||||
credentials: 'same-origin',
|
credentials: 'include',
|
||||||
body: JSON.stringify({ paths: selectedPaths, strategy: strategy, task_type: 2 })
|
body: JSON.stringify({ paths: selectedPaths, strategy: strategy, task_type: 2 })
|
||||||
})
|
})
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
@@ -787,7 +833,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
function loadTasks() {
|
function loadTasks() {
|
||||||
fetch('/api/brand/tasks', { credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
fetch('/api/brand/tasks', { credentials: 'include', headers: withUidHeaders({ 'X-Requested-With': 'XMLHttpRequest' }) })
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(res) {
|
.then(function(res) {
|
||||||
var list = document.getElementById('taskList');
|
var list = document.getElementById('taskList');
|
||||||
@@ -854,7 +900,7 @@
|
|||||||
if (target.classList && (target.classList.contains('js-cancel-task') || target.closest('.js-cancel-task'))) {
|
if (target.classList && (target.classList.contains('js-cancel-task') || target.closest('.js-cancel-task'))) {
|
||||||
var btn = target.classList.contains('js-cancel-task') ? target : target.closest('.js-cancel-task');
|
var btn = target.classList.contains('js-cancel-task') ? target : target.closest('.js-cancel-task');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
fetch('/api/brand/tasks/' + taskId + '/cancel', { method: 'POST', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
fetch('/api/brand/tasks/' + taskId + '/cancel', { method: 'POST', credentials: 'include', headers: withUidHeaders({ 'X-Requested-With': 'XMLHttpRequest' }) })
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(res) {
|
.then(function(res) {
|
||||||
if (res.success) { showToast('已取消'); loadTasks(); } else { showToast(res.error || '取消失败'); }
|
if (res.success) { showToast('已取消'); loadTasks(); } else { showToast(res.error || '取消失败'); }
|
||||||
@@ -864,7 +910,7 @@
|
|||||||
if (target.classList && (target.classList.contains('js-delete-task') || target.closest('.js-delete-task'))) {
|
if (target.classList && (target.classList.contains('js-delete-task') || target.closest('.js-delete-task'))) {
|
||||||
var btn = target.classList.contains('js-delete-task') ? target : target.closest('.js-delete-task');
|
var btn = target.classList.contains('js-delete-task') ? target : target.closest('.js-delete-task');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
fetch('/api/brand/tasks/' + taskId, { method: 'DELETE', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
fetch('/api/brand/tasks/' + taskId, { method: 'DELETE', credentials: 'include', headers: withUidHeaders({ 'X-Requested-With': 'XMLHttpRequest' }) })
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(res) {
|
.then(function(res) {
|
||||||
if (res.success) { showToast('已删除'); loadTasks(); } else { showToast(res.error || '删除失败'); }
|
if (res.success) { showToast('已删除'); loadTasks(); } else { showToast(res.error || '删除失败'); }
|
||||||
@@ -902,7 +948,40 @@
|
|||||||
a.style.pointerEvents = '';
|
a.style.pointerEvents = '';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
window.open('/api/brand/download/' + taskId, '_blank');
|
fetch('/api/brand/download/' + taskId, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: withUidHeaders({ 'X-Requested-With': 'XMLHttpRequest' }),
|
||||||
|
redirect: 'manual'
|
||||||
|
})
|
||||||
|
.then(function(res) {
|
||||||
|
if (res.status === 301 || res.status === 302) {
|
||||||
|
var loc = res.headers.get('Location');
|
||||||
|
if (loc) {
|
||||||
|
window.open(loc, '_blank');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var ct = (res.headers && res.headers.get && res.headers.get('content-type')) ? res.headers.get('content-type') : '';
|
||||||
|
if (ct && ct.indexOf('application/json') >= 0) {
|
||||||
|
return res.json().then(function(errObj) {
|
||||||
|
throw new Error((errObj && (errObj.error || errObj.msg)) || '下载失败');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res.blob().then(function(blob) {
|
||||||
|
var url = URL.createObjectURL(blob);
|
||||||
|
var a2 = document.createElement('a');
|
||||||
|
a2.href = url;
|
||||||
|
a2.download = 'brand_task_' + taskId + '.zip';
|
||||||
|
document.body.appendChild(a2);
|
||||||
|
a2.click();
|
||||||
|
a2.remove();
|
||||||
|
setTimeout(function() { URL.revokeObjectURL(url); }, 1000);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
showToast('下载失败:' + (err && err.message ? err.message : err));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>首页 - 南日AI</title>
|
<title>首页 - 数富AI</title>
|
||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
body {
|
body {
|
||||||
@@ -186,7 +186,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body data-user-id="{{ user_id or '' }}">
|
<body data-user-id="{{ user_id or '' }}">
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<span class="header-title">南日AI</span>
|
<span class="header-title">数富AI</span>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
{% if is_admin %}
|
{% if is_admin %}
|
||||||
<!-- <a href="/admin" class="admin-link">用户管理</a>-->
|
<!-- <a href="/admin" class="admin-link">用户管理</a>-->
|
||||||
@@ -219,6 +219,7 @@
|
|||||||
<script>
|
<script>
|
||||||
// 权限校验:根据 column-permissions 接口按 column_key 显示入口
|
// 权限校验:根据 column-permissions 接口按 column_key 显示入口
|
||||||
(function() {
|
(function() {
|
||||||
|
localStorage.setItem("uid",{{ user_id }})
|
||||||
var uid = document.body.getAttribute('data-user-id');
|
var uid = document.body.getAttribute('data-user-id');
|
||||||
if (!uid) return;
|
if (!uid) return;
|
||||||
var baseUrl = window.location.origin;
|
var baseUrl = window.location.origin;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>南日AI</title>
|
<title>数富AI</title>
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -2033,7 +2033,7 @@
|
|||||||
<header class="top-bar">
|
<header class="top-bar">
|
||||||
<div class="logo-area">
|
<div class="logo-area">
|
||||||
<img class="logo" src="../logo.jpg"></img>
|
<img class="logo" src="../logo.jpg"></img>
|
||||||
<span class="app-name">南日AI</span>
|
<span class="app-name">数富AI</span>
|
||||||
<a href="/home" class="btn-home" title="返回首页">← 返回首页</a>
|
<a href="/home" class="btn-home" title="返回首页">← 返回首页</a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>登录 - 南日AI</title>
|
<title>登录 - 数富AI</title>
|
||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
body {
|
body {
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<span class="header-title">南日AI</span>
|
<span class="header-title">数富AI</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="login-box">
|
<div class="login-box">
|
||||||
|
|||||||
539
app/web_source/templates_backup/admin.html
Normal file
539
app/web_source/templates_backup/admin.html
Normal file
@@ -0,0 +1,539 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>管理后台 - 南日AI</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.nav { margin-bottom: 24px; }
|
||||||
|
.nav a { color: #667eea; text-decoration: none; }
|
||||||
|
.nav a:hover { text-decoration: underline; }
|
||||||
|
h1 { font-size: 20px; margin-bottom: 20px; }
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
.tab {
|
||||||
|
padding: 12px 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #666;
|
||||||
|
font-size: 15px;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
.tab:hover { color: #667eea; }
|
||||||
|
.tab.active { color: #667eea; font-weight: 600; border-bottom-color: #667eea; }
|
||||||
|
.tab-panel { display: none; }
|
||||||
|
.tab-panel.active { display: block; }
|
||||||
|
|
||||||
|
/* Form & Table */
|
||||||
|
.form-box, .panel-box {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.form-group { margin-bottom: 16px; }
|
||||||
|
.form-group label { display: block; font-size: 14px; margin-bottom: 6px; }
|
||||||
|
.form-group input, .form-group select {
|
||||||
|
width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 6px;
|
||||||
|
}
|
||||||
|
.form-group input[type="checkbox"] { width: auto; margin-right: 8px; }
|
||||||
|
.form-row { display: flex; gap: 16px; flex-wrap: wrap; align-items: flex-end; margin-bottom: 16px; }
|
||||||
|
.form-row .form-group { margin-bottom: 0; flex: 1; min-width: 120px; }
|
||||||
|
|
||||||
|
.btn { padding: 10px 20px; background: #667eea; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; }
|
||||||
|
.btn:hover { opacity: 0.9; }
|
||||||
|
.btn-sm { padding: 6px 12px; font-size: 13px; }
|
||||||
|
.btn-danger { background: #e74c3c; }
|
||||||
|
.btn-secondary { background: #95a5a6; }
|
||||||
|
|
||||||
|
.msg { margin-top: 12px; font-size: 14px; }
|
||||||
|
.msg.ok { color: #27ae60; }
|
||||||
|
.msg.err { color: #e74c3c; }
|
||||||
|
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #eee; font-size: 14px; }
|
||||||
|
th { background: #f8f9fa; font-weight: 600; }
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.pagination span { font-size: 14px; color: #666; }
|
||||||
|
.pagination button { padding: 6px 12px; border: 1px solid #ddd; background: #fff; cursor: pointer; border-radius: 4px; }
|
||||||
|
.pagination button:hover:not(:disabled) { background: #f0f0f0; }
|
||||||
|
.pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.thumb { width: 60px; height: 60px; object-fit: cover; border-radius: 4px; margin-right: 8px; vertical-align: middle; }
|
||||||
|
.thumb-wrap { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||||
|
.empty-tip { color: #999; font-size: 14px; padding: 24px; text-align: center; }
|
||||||
|
.modal-mask {
|
||||||
|
display: none;
|
||||||
|
position: fixed; left: 0; top: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0,0,0,0.4); z-index: 1000;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.modal-mask.show { display: flex; }
|
||||||
|
.modal { background: #fff; border-radius: 8px; padding: 24px; min-width: 320px; max-width: 90%; }
|
||||||
|
.modal h3 { margin-bottom: 16px; font-size: 16px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="nav"><a href="/home">← 返回首页</a></div>
|
||||||
|
<h1>管理后台</h1>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<div class="tab active" data-tab="users">用户管理</div>
|
||||||
|
<div class="tab" data-tab="history">查看生成记录</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 用户管理 -->
|
||||||
|
<div id="panel-users" class="tab-panel active">
|
||||||
|
<div class="form-box">
|
||||||
|
<h3 style="margin-bottom:16px;font-size:15px;">创建用户</h3>
|
||||||
|
<p style="margin-bottom:16px;font-size:13px;color:#666;">无注册入口,仅管理员可在此创建用户。层级:超级管理员 → 管理员 → 普通号。</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>用户名</label>
|
||||||
|
<input type="text" id="username" placeholder="用户名(至少2个字符)">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>密码</label>
|
||||||
|
<input type="password" id="password" placeholder="密码(至少6个字符)">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="formGroupRole">
|
||||||
|
<label>角色</label>
|
||||||
|
<select id="createRole">
|
||||||
|
<option value="normal">普通号</option>
|
||||||
|
<option value="admin" id="optAdmin">管理员</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="formGroupCreatedBy" style="display:none;">
|
||||||
|
<label>所属管理员</label>
|
||||||
|
<select id="createCreatedBy">
|
||||||
|
<option value="">请选择管理员</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="btn" id="btnCreate">创建用户</button>
|
||||||
|
<p class="msg" id="msgCreate"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-box">
|
||||||
|
<h3 style="margin-bottom:16px;font-size:15px;">用户列表</h3>
|
||||||
|
<div class="form-row" style="margin-bottom:16px;">
|
||||||
|
<div class="form-group" style="min-width:180px;">
|
||||||
|
<label>用户名(模糊搜索)</label>
|
||||||
|
<input type="text" id="searchUsername" placeholder="输入用户名关键字">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="filterCreatedByGroup" style="min-width:160px;display:none;">
|
||||||
|
<label>所属管理员</label>
|
||||||
|
<select id="filterCreatedBy">
|
||||||
|
<option value="">全部</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="btn" id="btnSearchUsers">查询</button>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>用户名</th>
|
||||||
|
<th>角色</th>
|
||||||
|
<th>所属管理员</th>
|
||||||
|
<th>创建时间</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="userListBody"></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="pagination" id="userPagination"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 查看生成记录 -->
|
||||||
|
<div id="panel-history" class="tab-panel">
|
||||||
|
<div class="form-box">
|
||||||
|
<h3 style="margin-bottom:16px;font-size:15px;">筛选条件</h3>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group" style="min-width:140px;">
|
||||||
|
<label>指定用户</label>
|
||||||
|
<select id="filterUser">
|
||||||
|
<option value="">全部用户</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="min-width:140px;">
|
||||||
|
<label>开始时间</label>
|
||||||
|
<input type="datetime-local" id="filterTimeStart">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="min-width:140px;">
|
||||||
|
<label>结束时间</label>
|
||||||
|
<input type="datetime-local" id="filterTimeEnd">
|
||||||
|
</div>
|
||||||
|
<button class="btn" id="btnFilterHistory">查询</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-box">
|
||||||
|
<h3 style="margin-bottom:16px;font-size:15px;">生成记录</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>用户</th>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>创建时间</th>
|
||||||
|
<th>结果预览</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="historyListBody"></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="pagination" id="historyPagination"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 编辑用户弹窗 -->
|
||||||
|
<div class="modal-mask" id="editUserModal">
|
||||||
|
<div class="modal">
|
||||||
|
<h3>编辑用户</h3>
|
||||||
|
<input type="hidden" id="editUserId">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>用户名</label>
|
||||||
|
<input type="text" id="editUsername" readonly style="background:#f5f5f5;">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>新密码(不修改留空)</label>
|
||||||
|
<input type="password" id="editPassword" placeholder="留空则不修改密码">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="editFormGroupRole" style="display:none;">
|
||||||
|
<label>角色</label>
|
||||||
|
<select id="editRole">
|
||||||
|
<option value="normal">普通号</option>
|
||||||
|
<option value="admin">管理员</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="editFormGroupCreator" style="display:none;">
|
||||||
|
<label>所属管理员</label>
|
||||||
|
<input type="text" id="editCreatorName" readonly style="background:#f5f5f5;">
|
||||||
|
</div>
|
||||||
|
<p class="msg" id="msgEdit"></p>
|
||||||
|
<div style="margin-top:16px;display:flex;gap:8px;">
|
||||||
|
<button class="btn" id="btnSaveUser">保存</button>
|
||||||
|
<button class="btn btn-secondary" id="btnCloseEdit">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
// Tab 切换
|
||||||
|
document.querySelectorAll('.tab').forEach(function(t) {
|
||||||
|
t.onclick = function() {
|
||||||
|
document.querySelectorAll('.tab').forEach(function(x) { x.classList.remove('active'); });
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(function(x) { x.classList.remove('active'); });
|
||||||
|
t.classList.add('active');
|
||||||
|
var id = 'panel-' + t.dataset.tab;
|
||||||
|
document.getElementById(id).classList.add('active');
|
||||||
|
if (t.dataset.tab === 'users') loadUsers(1);
|
||||||
|
else if (t.dataset.tab === 'history') loadHistory(1);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== 用户管理 ==========
|
||||||
|
var userPage = 1, userPageSize = 15;
|
||||||
|
var currentUserRole = 'admin';
|
||||||
|
var adminsList = [];
|
||||||
|
function roleLabel(role) {
|
||||||
|
if (role === 'super_admin') return '超级管理员';
|
||||||
|
if (role === 'admin') return '管理员';
|
||||||
|
return '普通号';
|
||||||
|
}
|
||||||
|
function buildUserListQuery(page) {
|
||||||
|
var q = 'page=' + (page || 1) + '&page_size=' + userPageSize;
|
||||||
|
var kw = (document.getElementById('searchUsername').value || '').trim();
|
||||||
|
if (kw) q += '&username=' + encodeURIComponent(kw);
|
||||||
|
var cby = document.getElementById('filterCreatedBy').value;
|
||||||
|
if (cby) q += '&created_by_id=' + encodeURIComponent(cby);
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
function loadUsers(page) {
|
||||||
|
userPage = page || 1;
|
||||||
|
fetch('/api/admin/users?' + buildUserListQuery(userPage))
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(res) {
|
||||||
|
var tbody = document.getElementById('userListBody');
|
||||||
|
if (!res.success) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" class="empty-tip">加载失败: ' + (res.error || '') + '</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentUserRole = res.current_user_role || 'admin';
|
||||||
|
adminsList = res.admins || [];
|
||||||
|
var items = res.items || [];
|
||||||
|
if (items.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" class="empty-tip">暂无用户</td></tr>';
|
||||||
|
} else {
|
||||||
|
tbody.innerHTML = items.map(function(u) {
|
||||||
|
return '<tr><td>' + u.id + '</td><td>' + (u.username || '') + '</td><td>' +
|
||||||
|
roleLabel(u.role || 'normal') + '</td><td>' + (u.creator_username || '-') + '</td><td>' + (u.created_at || '') + '</td><td>' +
|
||||||
|
'<button class="btn btn-sm" data-edit="' + u.id + '" data-user="' + (JSON.stringify(u).replace(/"/g, '"')) + '">编辑</button> ' +
|
||||||
|
'<button class="btn btn-sm btn-danger" data-delete="' + u.id + '" data-name="' + (u.username || '').replace(/"/g, '"') + '">删除</button>' +
|
||||||
|
'</td></tr>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
renderPagination('userPagination', res.total, res.page, res.page_size, loadUsers);
|
||||||
|
bindUserActions();
|
||||||
|
updateCreateFormByRole();
|
||||||
|
updateUserFilterByRole();
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
document.getElementById('userListBody').innerHTML = '<tr><td colspan="6" class="empty-tip">请求失败</td></tr>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function updateUserFilterByRole() {
|
||||||
|
var grp = document.getElementById('filterCreatedByGroup');
|
||||||
|
var sel = document.getElementById('filterCreatedBy');
|
||||||
|
if (currentUserRole === 'super_admin') {
|
||||||
|
grp.style.display = 'block';
|
||||||
|
var cur = sel.value;
|
||||||
|
sel.innerHTML = '<option value="">全部</option>';
|
||||||
|
adminsList.forEach(function(a) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = a.id;
|
||||||
|
opt.textContent = a.username;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
sel.value = cur || '';
|
||||||
|
} else {
|
||||||
|
grp.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function updateCreateFormByRole() {
|
||||||
|
var roleSel = document.getElementById('createRole');
|
||||||
|
var optAdmin = document.getElementById('optAdmin');
|
||||||
|
var formCreatedBy = document.getElementById('formGroupCreatedBy');
|
||||||
|
var selCreatedBy = document.getElementById('createCreatedBy');
|
||||||
|
if (currentUserRole === 'super_admin') {
|
||||||
|
if (optAdmin) optAdmin.style.display = '';
|
||||||
|
formCreatedBy.style.display = (roleSel.value === 'normal') ? 'block' : 'none';
|
||||||
|
selCreatedBy.innerHTML = '<option value="">请选择管理员</option>';
|
||||||
|
adminsList.forEach(function(a) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = a.id;
|
||||||
|
opt.textContent = a.username;
|
||||||
|
selCreatedBy.appendChild(opt);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (optAdmin) optAdmin.style.display = 'none';
|
||||||
|
roleSel.value = 'normal';
|
||||||
|
formCreatedBy.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function bindUserActions() {
|
||||||
|
document.querySelectorAll('[data-edit]').forEach(function(btn) {
|
||||||
|
btn.onclick = function() {
|
||||||
|
var raw = (btn.getAttribute('data-user') || '{}').replace(/"/g, '"');
|
||||||
|
var u;
|
||||||
|
try { u = JSON.parse(raw); } catch (e) { u = {}; }
|
||||||
|
document.getElementById('editUserId').value = u.id || '';
|
||||||
|
document.getElementById('editUsername').value = u.username || '';
|
||||||
|
document.getElementById('editPassword').value = '';
|
||||||
|
var editRole = document.getElementById('editRole');
|
||||||
|
var editFormGroupRole = document.getElementById('editFormGroupRole');
|
||||||
|
var editFormGroupCreator = document.getElementById('editFormGroupCreator');
|
||||||
|
var editCreatorName = document.getElementById('editCreatorName');
|
||||||
|
editFormGroupRole.style.display = (currentUserRole === 'super_admin' && u.role !== 'super_admin') ? 'block' : 'none';
|
||||||
|
editFormGroupCreator.style.display = (u.role === 'normal' && u.creator_username) ? 'block' : 'none';
|
||||||
|
editCreatorName.value = u.creator_username || '';
|
||||||
|
if (u.role !== 'super_admin') { editRole.value = u.role || 'normal'; }
|
||||||
|
document.getElementById('msgEdit').textContent = '';
|
||||||
|
document.getElementById('editUserModal').classList.add('show');
|
||||||
|
};
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[data-delete]').forEach(function(btn) {
|
||||||
|
btn.onclick = function() {
|
||||||
|
if (!confirm('确定删除用户 "' + (btn.dataset.name || '') + '" 吗?')) return;
|
||||||
|
fetch('/api/admin/user/' + btn.dataset.delete, { method: 'DELETE' })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(res) {
|
||||||
|
if (res.success) { loadUsers(userPage); }
|
||||||
|
else { alert(res.error || '删除失败'); }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btnSearchUsers').onclick = function() { loadUsers(1); };
|
||||||
|
document.getElementById('createRole').onchange = function() { updateCreateFormByRole(); };
|
||||||
|
document.getElementById('btnCreate').onclick = function() {
|
||||||
|
var username = (document.getElementById('username').value || '').trim();
|
||||||
|
var password = document.getElementById('password').value || '';
|
||||||
|
var role = document.getElementById('createRole').value || 'normal';
|
||||||
|
var createdById = document.getElementById('createCreatedBy').value ? parseInt(document.getElementById('createCreatedBy').value, 10) : null;
|
||||||
|
var msgEl = document.getElementById('msgCreate');
|
||||||
|
msgEl.textContent = '';
|
||||||
|
msgEl.className = 'msg';
|
||||||
|
if (!username || username.length < 2) {
|
||||||
|
msgEl.textContent = '用户名至少2个字符';
|
||||||
|
msgEl.classList.add('err');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!password || password.length < 6) {
|
||||||
|
msgEl.textContent = '密码至少6个字符';
|
||||||
|
msgEl.classList.add('err');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var body = { username: username, password: password, role: role };
|
||||||
|
if (role === 'normal' && currentUserRole === 'super_admin' && createdById) body.created_by_id = createdById;
|
||||||
|
fetch('/api/admin/user', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(res) {
|
||||||
|
if (res.success) {
|
||||||
|
msgEl.textContent = res.msg || '创建成功';
|
||||||
|
msgEl.classList.add('ok');
|
||||||
|
document.getElementById('username').value = '';
|
||||||
|
document.getElementById('password').value = '';
|
||||||
|
loadUsers(1);
|
||||||
|
} else {
|
||||||
|
msgEl.textContent = res.error || '创建失败';
|
||||||
|
msgEl.classList.add('err');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
msgEl.textContent = '请求失败';
|
||||||
|
msgEl.classList.add('err');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('btnSaveUser').onclick = function() {
|
||||||
|
var uid = document.getElementById('editUserId').value;
|
||||||
|
var password = document.getElementById('editPassword').value;
|
||||||
|
var editRoleEl = document.getElementById('editRole');
|
||||||
|
var msgEl = document.getElementById('msgEdit');
|
||||||
|
msgEl.textContent = '';
|
||||||
|
msgEl.className = 'msg';
|
||||||
|
var body = {};
|
||||||
|
if (password) body.password = password;
|
||||||
|
if (currentUserRole === 'super_admin' && editRoleEl && editRoleEl.offsetParent !== null)
|
||||||
|
body.role = editRoleEl.value || 'normal';
|
||||||
|
fetch('/api/admin/user/' + uid, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(res) {
|
||||||
|
if (res.success) {
|
||||||
|
msgEl.textContent = res.msg || '保存成功';
|
||||||
|
msgEl.classList.add('ok');
|
||||||
|
document.getElementById('editUserModal').classList.remove('show');
|
||||||
|
loadUsers(userPage);
|
||||||
|
} else {
|
||||||
|
msgEl.textContent = res.error || '保存失败';
|
||||||
|
msgEl.classList.add('err');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
document.getElementById('btnCloseEdit').onclick = function() {
|
||||||
|
document.getElementById('editUserModal').classList.remove('show');
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== 生成记录 ==========
|
||||||
|
var historyPage = 1, historyPageSize = 15;
|
||||||
|
function toSqlDatetime(val) {
|
||||||
|
if (!val) return '';
|
||||||
|
return val.replace('T', ' ');
|
||||||
|
}
|
||||||
|
function buildHistoryQuery(page) {
|
||||||
|
var q = 'page=' + (page || 1) + '&page_size=' + historyPageSize;
|
||||||
|
var uid = document.getElementById('filterUser').value;
|
||||||
|
var start = toSqlDatetime(document.getElementById('filterTimeStart').value);
|
||||||
|
var end = toSqlDatetime(document.getElementById('filterTimeEnd').value);
|
||||||
|
if (uid) q += '&user_id=' + uid;
|
||||||
|
if (start) q += '&time_start=' + encodeURIComponent(start);
|
||||||
|
if (end) q += '&time_end=' + encodeURIComponent(end);
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
function loadHistory(page) {
|
||||||
|
historyPage = page || 1;
|
||||||
|
fetch('/api/admin/history?' + buildHistoryQuery(historyPage))
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(res) {
|
||||||
|
var tbody = document.getElementById('historyListBody');
|
||||||
|
if (!res.success) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" class="empty-tip">加载失败: ' + (res.error || '') + '</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var items = res.items || [];
|
||||||
|
if (items.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" class="empty-tip">暂无记录</td></tr>';
|
||||||
|
} else {
|
||||||
|
tbody.innerHTML = items.map(function(h) {
|
||||||
|
var urls = (h.result_urls || []);
|
||||||
|
var thumbUrls = (h.long_image_url ? [h.long_image_url] : []).concat(urls);
|
||||||
|
var thumbs = thumbUrls.slice(0, 3).map(function(url) {
|
||||||
|
return '<img src="' + (url || '').replace(/"/g, '"') + '" class="thumb" alt="">';
|
||||||
|
}).join('');
|
||||||
|
return '<tr><td>' + h.id + '</td><td>' + (h.username || '-') + '</td><td>' +
|
||||||
|
(h.panel_type || '-') + '</td><td>' + (h.created_at || '') + '</td><td>' +
|
||||||
|
'<div class="thumb-wrap">' + (thumbs || '-') + '</div></td></tr>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
renderPagination('historyPagination', res.total, res.page, res.page_size, loadHistory);
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
document.getElementById('historyListBody').innerHTML = '<tr><td colspan="5" class="empty-tip">请求失败</td></tr>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function loadUserOptions() {
|
||||||
|
fetch('/api/admin/users?page=1&page_size=999')
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(res) {
|
||||||
|
var sel = document.getElementById('filterUser');
|
||||||
|
var cur = sel.value;
|
||||||
|
sel.innerHTML = '<option value="">全部用户</option>';
|
||||||
|
(res.items || []).forEach(function(u) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = u.id;
|
||||||
|
opt.textContent = u.username + ' (' + roleLabel(u.role || 'normal') + ')';
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
sel.value = cur || '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.getElementById('btnFilterHistory').onclick = function() { loadHistory(1); };
|
||||||
|
|
||||||
|
// ========== 分页 ==========
|
||||||
|
function renderPagination(elId, total, page, pageSize, onPage) {
|
||||||
|
var el = document.getElementById(elId);
|
||||||
|
if (!el) return;
|
||||||
|
var totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
el.innerHTML = '<span>共 ' + total + ' 条</span>' +
|
||||||
|
'<button ' + (page <= 1 ? 'disabled' : '') + ' data-p="' + (page - 1) + '">上一页</button>' +
|
||||||
|
'<span>第 ' + page + ' / ' + totalPages + ' 页</span>' +
|
||||||
|
'<button ' + (page >= totalPages ? 'disabled' : '') + ' data-p="' + (page + 1) + '">下一页</button>';
|
||||||
|
el.querySelectorAll('[data-p]').forEach(function(b) {
|
||||||
|
if (!b.disabled) b.onclick = function() { onPage(parseInt(b.dataset.p, 10)); };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
loadUsers(1);
|
||||||
|
loadUserOptions();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
918
app/web_source/templates_backup/brand.html
Normal file
918
app/web_source/templates_backup/brand.html
Normal file
@@ -0,0 +1,918 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>亚马逊 - 南日AI</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: "Microsoft YaHei", "Noto Serif SC", "SimSun", "Songti SC", "Times New Roman", serif;
|
||||||
|
background: rgba(13, 13, 13, 1);
|
||||||
|
color: #e0e0e0;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100vh;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.top-bar {
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-bottom: 1px solid #2a2a2a;
|
||||||
|
}
|
||||||
|
.logo-area {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.top-bar .app-name { font-size: 18px; font-weight: 600; color: #fff; }
|
||||||
|
.btn-home {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #999;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.btn-home:hover { color: #fff; background: #2a2a2a; }
|
||||||
|
|
||||||
|
/* 顶部栏目导航(容器化,便于后续增加栏目) */
|
||||||
|
.nav-tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
.nav-tab-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: rgba(77, 72, 72, 0.99);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.nav-tab-sep {
|
||||||
|
width: 1px;
|
||||||
|
height: 20px;
|
||||||
|
background: linear-gradient(to bottom, transparent, #444 15%, #444 85%, transparent);
|
||||||
|
margin: 0 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.nav-tab {
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #999;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.nav-tab:hover {
|
||||||
|
color: #fff;
|
||||||
|
background: #2a2a2a;
|
||||||
|
}
|
||||||
|
.nav-tab.active {
|
||||||
|
color: #3498db;
|
||||||
|
background: rgba(52, 152, 219, 0.2);
|
||||||
|
}
|
||||||
|
.top-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
display: flex;
|
||||||
|
height: calc(100vh - 56px);
|
||||||
|
}
|
||||||
|
/* 栏目内容容器:每个栏目一个 .tab-panel,通过 data-panel 与导航对应 */
|
||||||
|
.tab-panel {
|
||||||
|
display: none;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.tab-panel.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.left-panel {
|
||||||
|
width: 380px;
|
||||||
|
background: #1e1e1e;
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-right: 1px solid #2a2a2a;
|
||||||
|
}
|
||||||
|
.right-panel {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #bbb;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.upload-zone {
|
||||||
|
border: 1px dashed #3a3a3a;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
background: #252525;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.upload-zone:hover { border-color: #3498db; background: #2a2a2a; }
|
||||||
|
.upload-zone .hint { color: #888; font-size: 13px; margin-bottom: 12px; }
|
||||||
|
.upload-zone .btns { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }
|
||||||
|
.opt-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #ccc;
|
||||||
|
background: #2a2a2a;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.opt-btn:hover { color: #fff; background: #333; border-color: #3498db; color: #3498db; }
|
||||||
|
.selected-files {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
max-height: 72px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.selected-files span { display: block; margin: 4px 0; word-break: break-all; }
|
||||||
|
.option-group { margin-bottom: 20px; }
|
||||||
|
.radio-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #252525;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.radio-item:hover { background: #2a2a2a; }
|
||||||
|
.radio-item input { margin-top: 3px; }
|
||||||
|
.radio-item .label { font-weight: 500; color: #e0e0e0; font-size: 13px; }
|
||||||
|
.radio-item .desc { font-size: 12px; color: #888; margin-top: 2px; font-weight: normal; }
|
||||||
|
.run-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.btn-run {
|
||||||
|
padding: 10px 22px;
|
||||||
|
background: #3498db;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.btn-run:hover { background: #2980b9; }
|
||||||
|
.btn-run:disabled { background: #555; cursor: not-allowed; color: #999; }
|
||||||
|
.loading-msg { color: #3498db; font-size: 13px; }
|
||||||
|
|
||||||
|
.template-download-row {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: linear-gradient(135deg, #252525 0%, #1e2a1e 100%);
|
||||||
|
border: 1px solid #2a3a2a;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.template-download-row:hover {
|
||||||
|
border-color: #2e7d32;
|
||||||
|
background: linear-gradient(135deg, #2a2a2a 0%, #243324 100%);
|
||||||
|
box-shadow: 0 4px 12px rgba(46, 125, 50, 0.15);
|
||||||
|
}
|
||||||
|
.template-download-row:hover .template-download-text .title { color: #2ecc71; }
|
||||||
|
.template-download-row:hover .template-download-icon {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 6px 16px rgba(46, 125, 50, 0.45), inset 0 1px 0 rgba(255,255,255,0.15);
|
||||||
|
}
|
||||||
|
.template-download-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(145deg, #1b5e20 0%, #2e7d32 50%, #388e3c 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4px 12px rgba(46, 125, 50, 0.35), inset 0 1px 0 rgba(255,255,255,0.1);
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
.template-download-icon svg {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
.template-download-text .title { font-size: 14px; color: #e0e0e0; font-weight: 600; display: block; }
|
||||||
|
.template-download-text .hint { font-size: 12px; color: #888; margin-top: 2px; font-weight: normal; }
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid #2a2a2a;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ddd;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.panel-header-title {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.btn-refresh-tasks {
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
background: #2a2a2a;
|
||||||
|
color: #ccc;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn-refresh-tasks:hover {
|
||||||
|
background: #333;
|
||||||
|
border-color: #3498db;
|
||||||
|
color: #3498db;
|
||||||
|
}
|
||||||
|
.task-list-wrap {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.task-list { list-style: none; }
|
||||||
|
.task-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
background: #1e1e1e;
|
||||||
|
}
|
||||||
|
.task-item .left { flex: 1; min-width: 0; }
|
||||||
|
.task-item .id { font-weight: 600; color: #e0e0e0; font-size: 13px; }
|
||||||
|
.task-item .files { font-size: 12px; color: #888; margin-top: 4px; }
|
||||||
|
.task-item .time { font-size: 12px; color: #666; margin-top: 2px; }
|
||||||
|
.task-item .status {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
.task-item .status.pending { background: rgba(255,193,7,0.2); color: #d4a500; }
|
||||||
|
.task-item .status.running { background: rgba(52,152,219,0.2); color: #3498db; }
|
||||||
|
.task-item .status.success { background: rgba(46,204,113,0.2); color: #2ecc71; }
|
||||||
|
.task-item .status.failed { background: rgba(231,76,60,0.2); color: #e74c3c; }
|
||||||
|
.task-item .status.cancelled { background: rgba(149,165,166,0.2); color: #95a5a6; }
|
||||||
|
.task-item .progress-wrap { margin-top: 8px; margin-right: 8px; flex-shrink: 0; width: 120px; }
|
||||||
|
.task-item .progress-bar-bg { height: 6px; background: #2a2a2a; border-radius: 3px; overflow: hidden; }
|
||||||
|
.task-item .progress-bar-fill { height: 100%; background: #3498db; border-radius: 3px; transition: width 0.2s; }
|
||||||
|
.task-item .btn-cancel, .task-item .btn-delete {
|
||||||
|
padding: 6px 12px; font-size: 12px; border-radius: 6px; cursor: pointer; border: none; margin-left: 6px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.task-item .btn-cancel { background: #e67e22; color: #fff; }
|
||||||
|
.task-item .btn-cancel:hover { background: #d35400; }
|
||||||
|
.task-item .btn-delete { background: #444; color: #ccc; }
|
||||||
|
.task-item .btn-delete:hover { background: #e74c3c; color: #fff; }
|
||||||
|
.task-item .task-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.run-row .progress-wrap { margin-top: 10px; width: 100%; max-width: 280px; }
|
||||||
|
.run-row .progress-bar-bg { height: 8px; background: #2a2a2a; border-radius: 4px; overflow: hidden; }
|
||||||
|
.run-row .progress-bar-fill { height: 100%; background: #3498db; border-radius: 4px; transition: width 0.2s; }
|
||||||
|
.run-row .btn-cancel-run { margin-left: 12px; padding: 8px 16px; background: #e67e22; color: #fff; border: none; border-radius: 6px; font-size: 13px; cursor: pointer; }
|
||||||
|
.run-row .btn-cancel-run:hover { background: #d35400; }
|
||||||
|
.task-item .download {
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: #3498db;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.task-item .download:hover { background: #2980b9; color: #fff; }
|
||||||
|
.task-item .download.hide { display: none; }
|
||||||
|
.empty-tasks { text-align: center; color: #666; padding: 32px; font-size: 13px; }
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 32px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: rgba(0,0,0,0.85);
|
||||||
|
color: #fff;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
z-index: 1000;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
border: 1px solid #333;
|
||||||
|
}
|
||||||
|
.toast.show { opacity: 1; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="top-bar">
|
||||||
|
<div class="logo-area">
|
||||||
|
<span class="app-name">南日AI-亚马逊</span>
|
||||||
|
<a href="/home" class="btn-home">返回首页</a>
|
||||||
|
</div>
|
||||||
|
<nav class="nav-tabs">
|
||||||
|
<span class="nav-tab-group">
|
||||||
|
<button type="button" class="nav-tab active" data-panel="brandCheck">品牌检测</button>
|
||||||
|
<!-- 后续增加栏目时在此添加,例如:<button type="button" class="nav-tab" data-panel="other">其他栏目</button> -->
|
||||||
|
</span>
|
||||||
|
<!-- 多组栏目时可用分隔符:<span class="nav-tab-sep" aria-hidden="true"></span> -->
|
||||||
|
</nav>
|
||||||
|
<div class="top-right"></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="main-content">
|
||||||
|
<!-- 品牌检测栏目容器;后续增加栏目时复制此结构,改 data-panel 与 id,并去掉 .active -->
|
||||||
|
<div class="tab-panel active" data-panel="brandCheck" id="panel-brandCheck">
|
||||||
|
<aside class="left-panel">
|
||||||
|
<div class="section-title">选择文件</div>
|
||||||
|
<div class="upload-zone" id="uploadZone">
|
||||||
|
<div class="hint">选择 .xlsx 文件或文件夹(将读取该文件夹下所有 xlsx)</div>
|
||||||
|
<div class="btns">
|
||||||
|
<button type="button" class="opt-btn" id="btnSelectFiles">选择 Excel 文件</button>
|
||||||
|
<button type="button" class="opt-btn" id="btnSelectFolder">选择文件夹</button>
|
||||||
|
</div>
|
||||||
|
<div class="selected-files" id="selectedFiles"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-title">运行方式</div>
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="radio-item">
|
||||||
|
<input type="radio" name="runMode" value="immediate" checked>
|
||||||
|
<div>
|
||||||
|
<span class="label">立即运行</span>
|
||||||
|
<div class="desc">立刻执行,执行完成后可下载结果</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="radio-item">
|
||||||
|
<input type="radio" name="runMode" value="task">
|
||||||
|
<div>
|
||||||
|
<span class="label">添加任务</span>
|
||||||
|
<div class="desc">数量较多时建议添加任务,后台执行,可在右侧任务队列查看状态与下载结果</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="section-title">匹配方式</div>
|
||||||
|
<div class="option-group">
|
||||||
|
<label class="radio-item">
|
||||||
|
<input type="radio" name="matchStrategy" value="Terms" checked>
|
||||||
|
<div>
|
||||||
|
<span class="label">精确匹配表达式</span>
|
||||||
|
<div class="desc">使用精确匹配,适合严格匹配品牌名称</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="radio-item">
|
||||||
|
<input type="radio" name="matchStrategy" value="Simple">
|
||||||
|
<div>
|
||||||
|
<span class="label">嵌入(结果包含输入的词语)</span>
|
||||||
|
<div class="desc">使用嵌入策略,结果中包含输入的品牌词语</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="run-row" id="runRow">
|
||||||
|
<div style="display:flex;align-items:center;flex-wrap:wrap;gap:10px;">
|
||||||
|
<button type="button" class="btn-run" id="btnRun">立即运行</button>
|
||||||
|
<span class="loading-msg" id="loadingMsg" style="display:none;">生成中,请稍候…</span>
|
||||||
|
<div class="progress-wrap" id="runProgressWrap" style="display:none;">
|
||||||
|
<div class="progress-bar-bg"><div class="progress-bar-fill" id="runProgressFill" style="width:0%;"></div></div>
|
||||||
|
<span class="hint" style="font-size:12px;color:#888;" id="runProgressText">0 / 0</span>
|
||||||
|
</div>
|
||||||
|
<!-- 当前文件行级进度(不经数据库中转,只读内存接口) -->
|
||||||
|
<div class="progress-wrap" id="runLineProgressWrap" style="display:none;">
|
||||||
|
<div class="progress-bar-bg"><div class="progress-bar-fill" id="runLineProgressFill" style="width:0%;"></div></div>
|
||||||
|
<span class="hint" style="font-size:12px;color:#888;" id="runLineProgressText">0 / 0</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-cancel-run" id="btnCancelRun" style="display:none;">取消生成</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="template-download-row" id="templateDownloadRow" role="button" tabindex="0" title="下载品牌文档格式模板(选择保存位置)">
|
||||||
|
<span class="template-download-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z" stroke="rgba(255,255,255,0.9)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M14 2v6h6" stroke="rgba(255,255,255,0.9)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M8 13h8M8 17h5" stroke="rgba(255,255,255,0.85)" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="template-download-text">
|
||||||
|
<span class="title">模板下载</span>
|
||||||
|
<span class="hint">品牌文档格式_模板.xlsx · 点击选择保存位置</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="template-download-row" id="templateDownloadRow2" role="button" tabindex="0" title="下载模板2(以文件夹方式上传)">
|
||||||
|
<span class="template-download-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z" stroke="rgba(255,255,255,0.9)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M14 2v6h6" stroke="rgba(255,255,255,0.9)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M3 7v10h4v-4h2v4h8v-4h2v4h2V7H3z" stroke="rgba(255,255,255,0.85)" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="template-download-text">
|
||||||
|
<span class="title">模板2下载</span>
|
||||||
|
<span class="hint">以文件夹方式上传.zip · 点击下载</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="right-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-header-title">任务队列</span>
|
||||||
|
<button type="button" class="btn-refresh-tasks" id="btnRefreshTasks">刷新任务状态</button>
|
||||||
|
</div>
|
||||||
|
<div class="task-list-wrap">
|
||||||
|
<ul class="task-list" id="taskList"></ul>
|
||||||
|
<div class="empty-tasks" id="emptyTasks">暂无任务</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var selectedPaths = [];
|
||||||
|
var cachedTasks = [];
|
||||||
|
var api = window.pywebview && window.pywebview.api;
|
||||||
|
|
||||||
|
// 顶部栏目切换:点击 nav-tab 时显示对应 data-panel 的 tab-panel
|
||||||
|
document.querySelectorAll('.nav-tab').forEach(function(tab) {
|
||||||
|
tab.addEventListener('click', function() {
|
||||||
|
var panelId = this.getAttribute('data-panel');
|
||||||
|
if (!panelId) return;
|
||||||
|
document.querySelectorAll('.nav-tab').forEach(function(t) { t.classList.remove('active'); });
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(function(p) {
|
||||||
|
p.classList.toggle('active', p.getAttribute('data-panel') === panelId);
|
||||||
|
});
|
||||||
|
this.classList.add('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function showToast(msg) {
|
||||||
|
var el = document.getElementById('toast');
|
||||||
|
el.textContent = msg;
|
||||||
|
el.classList.add('show');
|
||||||
|
setTimeout(function() { el.classList.remove('show'); }, 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSelected() {
|
||||||
|
var el = document.getElementById('selectedFiles');
|
||||||
|
if (selectedPaths.length === 0) {
|
||||||
|
el.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = '已选 <b>' + selectedPaths.length + '</b> 个文件:<br>' +
|
||||||
|
selectedPaths.slice(0, 20).map(function(p) {
|
||||||
|
var name = p.split(/[/\\]/).pop();
|
||||||
|
return '<span title="' + p + '">' + name + '</span>';
|
||||||
|
}).join('') +
|
||||||
|
(selectedPaths.length > 20 ? '<span>… 等 ' + selectedPaths.length + ' 个</span>' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btnSelectFiles').onclick = async function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!api || !api.select_brand_xlsx_files) {
|
||||||
|
showToast('当前环境不支持文件选择,请在本机客户端中打开');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var paths = await api.select_brand_xlsx_files();
|
||||||
|
if (paths && paths.length) {
|
||||||
|
selectedPaths = paths;
|
||||||
|
renderSelected();
|
||||||
|
showToast('已选择 ' + paths.length + ' 个文件');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast('选择失败:' + (err.message || err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('templateDownloadRow').onclick = async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (api && api.save_template_xlsx) {
|
||||||
|
try {
|
||||||
|
var res = await api.save_template_xlsx();
|
||||||
|
if (res && res.success) {
|
||||||
|
showToast('模板已保存至:' + (res.path || ''));
|
||||||
|
} else {
|
||||||
|
showToast(res && res.error ? res.error : '保存失败');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast('下载失败:' + (err.message || err));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = '/static/品牌文档格式_模板.xlsx';
|
||||||
|
};
|
||||||
|
document.getElementById('templateDownloadRow').onkeydown = function(e) {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.click(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('templateDownloadRow2').onclick = async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (api && api.save_template_zip) {
|
||||||
|
try {
|
||||||
|
var res = await api.save_template_zip();
|
||||||
|
if (res && res.success) {
|
||||||
|
showToast('模板已保存至:' + (res.path || ''));
|
||||||
|
} else {
|
||||||
|
showToast(res && res.error ? res.error : '保存失败');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast('下载失败:' + (err.message || err));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = '/static/模板2-以文件夹方式上传.zip';
|
||||||
|
};
|
||||||
|
document.getElementById('templateDownloadRow2').onkeydown = function(e) {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.click(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('btnSelectFolder').onclick =async function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!api || !api.select_brand_folder) {
|
||||||
|
showToast('当前环境不支持文件夹选择,请在本机客户端中打开');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var folder = await api.select_brand_folder();
|
||||||
|
if (!folder) return;
|
||||||
|
fetch('/api/brand/expand-folder', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify({ folder: folder })
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(res) {
|
||||||
|
if (res.success && res.paths && res.paths.length) {
|
||||||
|
selectedPaths = res.paths;
|
||||||
|
renderSelected();
|
||||||
|
showToast('已选择文件夹内 ' + res.paths.length + ' 个 xlsx 文件');
|
||||||
|
} else {
|
||||||
|
showToast(res.error || '该文件夹下没有 xlsx 文件');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() { showToast('请求失败'); });
|
||||||
|
} catch (err) {
|
||||||
|
showToast('选择失败:' + (err.message || err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function getRunMode() {
|
||||||
|
var r = document.querySelector('input[name="runMode"]:checked');
|
||||||
|
return r ? r.value : 'immediate';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMatchStrategy() {
|
||||||
|
var r = document.querySelector('input[name="matchStrategy"]:checked');
|
||||||
|
return r ? r.value : 'Terms';
|
||||||
|
}
|
||||||
|
|
||||||
|
var pollTaskId = null;
|
||||||
|
var pollTimer = null;
|
||||||
|
var runTaskEventSource = null;
|
||||||
|
|
||||||
|
function stopPollAndResetRunUI() {
|
||||||
|
pollTaskId = null;
|
||||||
|
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
||||||
|
if (runTaskEventSource) {
|
||||||
|
runTaskEventSource.close();
|
||||||
|
runTaskEventSource = null;
|
||||||
|
}
|
||||||
|
var btn = document.getElementById('btnRun');
|
||||||
|
var loadingMsg = document.getElementById('loadingMsg');
|
||||||
|
var runProgressWrap = document.getElementById('runProgressWrap');
|
||||||
|
var runLineProgressWrap = document.getElementById('runLineProgressWrap');
|
||||||
|
var btnCancelRun = document.getElementById('btnCancelRun');
|
||||||
|
btn.disabled = false;
|
||||||
|
loadingMsg.style.display = 'none';
|
||||||
|
if (runProgressWrap) runProgressWrap.style.display = 'none';
|
||||||
|
if (runLineProgressWrap) runLineProgressWrap.style.display = 'none';
|
||||||
|
if (btnCancelRun) btnCancelRun.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRunTaskFinished(status) {
|
||||||
|
stopPollAndResetRunUI();
|
||||||
|
loadTasks();
|
||||||
|
if (status === 'success') showToast('生成完成,可下载结果');
|
||||||
|
else if (status === 'failed') showToast('生成失败');
|
||||||
|
else if (status === 'cancelled') showToast('已取消');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollRunTask(taskId) {
|
||||||
|
pollTaskId = taskId;
|
||||||
|
var runProgressFill = document.getElementById('runProgressFill');
|
||||||
|
var runProgressText = document.getElementById('runProgressText');
|
||||||
|
var runLineProgressWrap = document.getElementById('runLineProgressWrap');
|
||||||
|
var runLineProgressFill = document.getElementById('runLineProgressFill');
|
||||||
|
var runLineProgressText = document.getElementById('runLineProgressText');
|
||||||
|
var btnCancelRun = document.getElementById('btnCancelRun');
|
||||||
|
var runProgressWrap = document.getElementById('runProgressWrap');
|
||||||
|
runProgressWrap.style.display = 'block';
|
||||||
|
btnCancelRun.style.display = 'inline-block';
|
||||||
|
btnCancelRun.onclick = function() {
|
||||||
|
fetch('/api/brand/tasks/' + taskId + '/cancel', { method: 'POST', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(res) {
|
||||||
|
if (res.success) onRunTaskFinished('cancelled');
|
||||||
|
else showToast(res.error || '取消失败');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 通过 SSE 订阅任务完成事件,完成后由后端主动推送,前端收到后立即隐藏进度条和取消按钮
|
||||||
|
var eventsUrl = '/api/brand/tasks/' + taskId + '/events';
|
||||||
|
runTaskEventSource = new EventSource(eventsUrl);
|
||||||
|
runTaskEventSource.onmessage = function(e) {
|
||||||
|
try {
|
||||||
|
var data = JSON.parse(e.data);
|
||||||
|
if (data && (data.status === 'success' || data.status === 'failed' || data.status === 'cancelled')) {
|
||||||
|
runTaskEventSource.close();
|
||||||
|
runTaskEventSource = null;
|
||||||
|
// tick();
|
||||||
|
onRunTaskFinished(data.status);
|
||||||
|
}
|
||||||
|
} catch (err) {}
|
||||||
|
};
|
||||||
|
runTaskEventSource.onerror = function() {
|
||||||
|
runTaskEventSource.close();
|
||||||
|
runTaskEventSource = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
if (!pollTaskId) return;
|
||||||
|
fetch('/api/brand/tasks/' + taskId, { credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(res) {
|
||||||
|
if (!res.success || !res.task) return;
|
||||||
|
var t = res.task;
|
||||||
|
var total = t.progress_total || 0;
|
||||||
|
var cur = t.progress_current || 0;
|
||||||
|
if (runProgressFill) runProgressFill.style.width = total ? (100 * cur / total) + '%' : '30%';
|
||||||
|
if (runProgressText) {
|
||||||
|
var st = (t.status || '').toLowerCase();
|
||||||
|
if (st === 'running') {
|
||||||
|
runProgressText.textContent = total ? (cur + ' / ' + total) : '生成中…';
|
||||||
|
} else if (st === 'success') {
|
||||||
|
runProgressText.textContent = '已完成';
|
||||||
|
} else if (st === 'failed') {
|
||||||
|
runProgressText.textContent = '失败';
|
||||||
|
} else if (st === 'cancelled') {
|
||||||
|
runProgressText.textContent = '已取消';
|
||||||
|
} else {
|
||||||
|
runProgressText.textContent = '执行中…';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 行级进度:从内存接口 /api/brand/tasks/<id>/line-progress 拉取
|
||||||
|
if (runLineProgressWrap && runLineProgressFill && runLineProgressText) {
|
||||||
|
var st2 = (t.status || '').toLowerCase();
|
||||||
|
if (st2 === 'running') {
|
||||||
|
fetch('/api/brand/tasks/' + taskId + '/line-progress', {
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(res2) {
|
||||||
|
if (!res2.success || !res2.has_progress || !res2.info) {
|
||||||
|
runLineProgressWrap.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var info = res2.info;
|
||||||
|
var totalLines = info.total_lines || 0;
|
||||||
|
var curLine = info.current_line || 0;
|
||||||
|
runLineProgressWrap.style.display = 'block';
|
||||||
|
runLineProgressFill.style.width = totalLines ? (100 * curLine / totalLines) + '%' : '0%';
|
||||||
|
runLineProgressText.textContent = totalLines ? (curLine + ' / ' + totalLines) : '当前文件处理中…';
|
||||||
|
})
|
||||||
|
.catch(function() {});
|
||||||
|
} else {
|
||||||
|
runLineProgressWrap.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 终态也通过轮询兜底,确保 UI 一定被重置(SSE 可能因网络未收到)
|
||||||
|
var st = (t.status || '').toLowerCase();
|
||||||
|
if (st === 'success' || st === 'failed' || st === 'cancelled') {
|
||||||
|
onRunTaskFinished(st);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
tick();
|
||||||
|
pollTimer = setInterval(tick, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btnRun').onclick = function() {
|
||||||
|
if (selectedPaths.length === 0) {
|
||||||
|
showToast('请先选择 Excel 文件或文件夹');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var mode = getRunMode();
|
||||||
|
var strategy = getMatchStrategy();
|
||||||
|
var btn = document.getElementById('btnRun');
|
||||||
|
var loadingMsg = document.getElementById('loadingMsg');
|
||||||
|
|
||||||
|
if (mode === 'immediate') {
|
||||||
|
btn.disabled = true;
|
||||||
|
loadingMsg.style.display = 'inline';
|
||||||
|
fetch('/api/brand/run', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify({ paths: selectedPaths, strategy: strategy, task_type: 1 })
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(res) {
|
||||||
|
if (res.success && res.task_id) {
|
||||||
|
loadingMsg.style.display = 'none';
|
||||||
|
pollRunTask(res.task_id);
|
||||||
|
} else {
|
||||||
|
stopPollAndResetRunUI();
|
||||||
|
showToast(res.error || '运行失败');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
stopPollAndResetRunUI();
|
||||||
|
showToast('请求失败或超时');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fetch('/api/brand/tasks', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify({ paths: selectedPaths, strategy: strategy, task_type: 2 })
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(res) {
|
||||||
|
if (res.success) {
|
||||||
|
showToast('任务已添加,请在任务队列查看');
|
||||||
|
loadTasks();
|
||||||
|
} else {
|
||||||
|
showToast(res.error || '添加失败');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() { showToast('请求失败'); });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadTasks() {
|
||||||
|
fetch('/api/brand/tasks', { credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(res) {
|
||||||
|
var list = document.getElementById('taskList');
|
||||||
|
var empty = document.getElementById('emptyTasks');
|
||||||
|
if (!res.success || !res.items || res.items.length === 0) {
|
||||||
|
list.innerHTML = '';
|
||||||
|
empty.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
empty.style.display = 'none';
|
||||||
|
cachedTasks = res.items;
|
||||||
|
list.innerHTML = res.items.map(function(t) {
|
||||||
|
var statusClass = (t.status || 'pending').toLowerCase();
|
||||||
|
var statusText = { pending: '等待中', running: '执行中', success: '已完成', failed: '失败', cancelled: '已取消' }[t.status] || t.status;
|
||||||
|
var filesDesc = (t.file_paths && t.file_paths.length) ? t.file_paths.length + ' 个文件' : '';
|
||||||
|
var total = t.progress_total || 0;
|
||||||
|
var cur = t.progress_current || 0;
|
||||||
|
var pct = total ? Math.min(100, (100 * cur / total)) : 0;
|
||||||
|
var progressHtml = (statusClass === 'running' && total > 0)
|
||||||
|
? '<div class="progress-wrap"><div class="progress-bar-bg"><div class="progress-bar-fill" style="width:' + pct + '%"></div></div><span class="hint" style="font-size:11px;color:#888;">' + cur + ' / ' + total + '</span></div>'
|
||||||
|
: '';
|
||||||
|
var hasZipUrl = t.result_paths && t.result_paths.zip_url;
|
||||||
|
var downloadHtml = hasZipUrl
|
||||||
|
? '<a class="download js-save-zip" href="#" data-task-id="' + t.id + '">下载结果</a>'
|
||||||
|
: '<span class="download hide"></span>';
|
||||||
|
var cancelBtn = statusClass === 'running'
|
||||||
|
? '<button type="button" class="btn-cancel js-cancel-task" data-task-id="' + t.id + '">取消</button>'
|
||||||
|
: '';
|
||||||
|
var deleteBtn = '<button type="button" class="btn-delete js-delete-task" data-task-id="' + t.id + '">删除</button>';
|
||||||
|
var titleDesc = (t.desc && String(t.desc).trim()) ? String(t.desc).trim() : ('任务 #' + t.id);
|
||||||
|
var displayDesc = titleDesc.length > 20 ? titleDesc.substring(0, 20) + '....' : titleDesc;
|
||||||
|
return '<li class="task-item">' +
|
||||||
|
'<div class="left">' +
|
||||||
|
'<span class="id" title="' + (titleDesc.replace(/"/g, '"')) + '">' + displayDesc + '</span>' +
|
||||||
|
'<div class="files">' + filesDesc + '</div>' +
|
||||||
|
'<div class="time">' + (t.created_at || '') + '</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="task-right">' +
|
||||||
|
progressHtml +
|
||||||
|
'<span class="status ' + statusClass + '">' + statusText + '</span>' +
|
||||||
|
cancelBtn +
|
||||||
|
downloadHtml +
|
||||||
|
deleteBtn +
|
||||||
|
'</div></li>';
|
||||||
|
}).join('');
|
||||||
|
})
|
||||||
|
.catch(function() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手动刷新任务状态按钮
|
||||||
|
var btnRefreshTasks = document.getElementById('btnRefreshTasks');
|
||||||
|
if (btnRefreshTasks) {
|
||||||
|
btnRefreshTasks.onclick = function () {
|
||||||
|
loadTasks();
|
||||||
|
showToast('任务状态已刷新');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('taskList').addEventListener('click', function(e) {
|
||||||
|
var target = e.target && e.target.closest && (e.target.closest('a.js-save-zip') || e.target.closest('button.js-cancel-task') || e.target.closest('button.js-delete-task'));
|
||||||
|
if (!target) return;
|
||||||
|
e.preventDefault();
|
||||||
|
var taskId = target.getAttribute('data-task-id');
|
||||||
|
if (target.classList && (target.classList.contains('js-cancel-task') || target.closest('.js-cancel-task'))) {
|
||||||
|
var btn = target.classList.contains('js-cancel-task') ? target : target.closest('.js-cancel-task');
|
||||||
|
if (!btn) return;
|
||||||
|
fetch('/api/brand/tasks/' + taskId + '/cancel', { method: 'POST', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(res) {
|
||||||
|
if (res.success) { showToast('已取消'); loadTasks(); } else { showToast(res.error || '取消失败'); }
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (target.classList && (target.classList.contains('js-delete-task') || target.closest('.js-delete-task'))) {
|
||||||
|
var btn = target.classList.contains('js-delete-task') ? target : target.closest('.js-delete-task');
|
||||||
|
if (!btn) return;
|
||||||
|
fetch('/api/brand/tasks/' + taskId, { method: 'DELETE', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(res) {
|
||||||
|
if (res.success) { showToast('已删除'); loadTasks(); } else { showToast(res.error || '删除失败'); }
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var a = target.closest('a.js-save-zip') || target;
|
||||||
|
if (!a || !a.classList || !a.classList.contains('js-save-zip')) return;
|
||||||
|
var task = cachedTasks.filter(function(t) { return String(t.id) === String(taskId); })[0];
|
||||||
|
if (!task || !task.result_paths || !task.result_paths.zip_url) {
|
||||||
|
showToast('无法获取下载地址');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var zipUrl = task.result_paths.zip_url;
|
||||||
|
var filename = 'brand_task_' + taskId + '.zip';
|
||||||
|
if (api && api.save_file_from_url) {
|
||||||
|
a.style.pointerEvents = 'none';
|
||||||
|
var done = function(ret) {
|
||||||
|
if (ret && ret.success) {
|
||||||
|
showToast('已保存:' + (ret.path || filename));
|
||||||
|
} else if (ret && ret.error && ret.error !== '用户取消') {
|
||||||
|
showToast('保存失败:' + ret.error);
|
||||||
|
}
|
||||||
|
a.style.pointerEvents = '';
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
var ret = api.save_file_from_url(zipUrl, filename);
|
||||||
|
if (ret && typeof ret.then === 'function') {
|
||||||
|
ret.then(done).catch(function(err) { showToast('保存失败:' + (err && err.message || err)); a.style.pointerEvents = ''; });
|
||||||
|
} else {
|
||||||
|
done(ret);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast('保存失败:' + (err.message || err));
|
||||||
|
a.style.pointerEvents = '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
window.open('/api/brand/download/' + taskId, '_blank');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
loadTasks();
|
||||||
|
// setInterval(loadTasks, 10000);
|
||||||
|
|
||||||
|
if (window.addEventListener) {
|
||||||
|
window.addEventListener('pywebviewready', function() {
|
||||||
|
api = window.pywebview && window.pywebview.api;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (window.pywebview && window.pywebview.api) api = window.pywebview.api;
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
352
app/web_source/templates_backup/home.html
Normal file
352
app/web_source/templates_backup/home.html
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>首页 - 南日AI</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", sans-serif;
|
||||||
|
background: linear-gradient(rgba(255,255,255,0.5), rgba(255,255,255,0.5)),
|
||||||
|
url("/static/bg.jpg") center/cover no-repeat;;
|
||||||
|
/*background-image: url("/static/bg.jpg");*/
|
||||||
|
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 24px;
|
||||||
|
background: rgba(255,255,255,0.5);
|
||||||
|
}
|
||||||
|
.header-title { font-size: 18px; font-weight: 600; color: #333; }
|
||||||
|
.header-right { display: flex; align-items: center; gap: 16px; position: relative; }
|
||||||
|
.header-right a, .header-right span {
|
||||||
|
font-size: 14px; color: #555; text-decoration: none;
|
||||||
|
}
|
||||||
|
.header-right a:hover { color: #667eea; }
|
||||||
|
.entrances {
|
||||||
|
display: flex;
|
||||||
|
gap: 32px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.entrance-btn {
|
||||||
|
width: 160px;
|
||||||
|
height: 100px;
|
||||||
|
background: #c5c1c1;
|
||||||
|
border: 4px solid #b8d4e3;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.entrance-btn:hover {
|
||||||
|
background: #f8fbfd;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
.entrance-btn.wb { cursor: pointer; }
|
||||||
|
.entrance-btn.disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 40px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: rgba(0,0,0,0.75);
|
||||||
|
color: #fff;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.toast.show { opacity: 1; }
|
||||||
|
.admin-link {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.update-section {
|
||||||
|
margin-top: 48px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
background: rgba(255,255,255,0.85);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(0,0,0,0.06);
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
.update-section-title {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.update-block {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.update-version-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
.btn-check-update {
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: #667eea;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-check-update:hover {
|
||||||
|
background: #5a6fd6;
|
||||||
|
}
|
||||||
|
.update-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 8px;
|
||||||
|
min-height: 18px;
|
||||||
|
}
|
||||||
|
.btn-download-update {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: #28a745;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-download-update:hover {
|
||||||
|
background: #218838;
|
||||||
|
}
|
||||||
|
.header-update-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: rgba(102,126,234,0.12);
|
||||||
|
color: #4c5bd4;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.header-update-btn:hover {
|
||||||
|
background: rgba(102,126,234,0.2);
|
||||||
|
}
|
||||||
|
.header-update-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: 44px;
|
||||||
|
right: 0;
|
||||||
|
width: 280px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: rgba(255,255,255,0.98);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.12);
|
||||||
|
border: 1px solid rgba(0,0,0,0.04);
|
||||||
|
z-index: 10;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.header-update-title {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body data-user-id="{{ user_id or '' }}">
|
||||||
|
<header class="header">
|
||||||
|
<span class="header-title">南日AI</span>
|
||||||
|
<div class="header-right">
|
||||||
|
{% if is_admin %}
|
||||||
|
<!-- <a href="/admin" class="admin-link">用户管理</a>-->
|
||||||
|
{% endif %}
|
||||||
|
<span>{{ username }}</span>
|
||||||
|
<button type="button" class="header-update-btn" id="homeUpdateToggle" title="检测更新">↻</button>
|
||||||
|
<a href="/logout" onclick="handleLogout()">退出</a>
|
||||||
|
<div class="header-update-panel" id="homeUpdatePanel">
|
||||||
|
<div class="header-update-title"><span>↻</span> 软件更新</div>
|
||||||
|
<div class="update-block">
|
||||||
|
<span class="update-version-text" id="homeVersionText">当前版本: {{ version }}</span>
|
||||||
|
<button type="button" class="btn-check-update" id="homeBtnCheckUpdate"><span>↻</span> 检测</button>
|
||||||
|
</div>
|
||||||
|
<div class="update-hint" id="homeUpdateHint"></div>
|
||||||
|
<div style="margin-top: 4px;">
|
||||||
|
<button type="button" class="btn-download-update" id="homeBtnDownloadUpdate" style="display: none;">立即更新</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="entrances" id="entrances">
|
||||||
|
<a class="entrance-btn" href="/brand" data-column-key="brand">亚马逊</a>
|
||||||
|
<a class="entrance-btn disabled" href="javascript:;" data-msg="暂未开通,敬请期待" data-column-key="wb">wildberries</a>
|
||||||
|
<a class="entrance-btn image" href="/image" data-column-key="image">图片</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 权限校验:根据 column-permissions 接口按 column_key 显示入口
|
||||||
|
(function() {
|
||||||
|
var uid = document.body.getAttribute('data-user-id');
|
||||||
|
if (!uid) return;
|
||||||
|
var baseUrl = window.location.origin;
|
||||||
|
var apiUrl = baseUrl + '/api/admin/user/' + uid + '/column-permissions';
|
||||||
|
fetch(apiUrl, { credentials: 'same-origin' })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(res) {
|
||||||
|
if (!res || !res.success || !Array.isArray(res.items)) return;
|
||||||
|
var allowedKeys = res.items.map(function(item) { return (item.column_key || '').toLowerCase(); });
|
||||||
|
document.querySelectorAll('.entrances .entrance-btn[data-column-key]').forEach(function(btn) {
|
||||||
|
var key = (btn.getAttribute('data-column-key') || '').toLowerCase();
|
||||||
|
btn.style.display = allowedKeys.indexOf(key) >= 0 ? '' : 'none';
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function() {});
|
||||||
|
})();
|
||||||
|
|
||||||
|
document.querySelectorAll('.entrance-btn.disabled').forEach(function(btn) {
|
||||||
|
btn.onclick = function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var msg = btn.getAttribute('data-msg') || '暂未开通,敬请期待';
|
||||||
|
var t = document.getElementById('toast');
|
||||||
|
t.textContent = msg;
|
||||||
|
t.classList.add('show');
|
||||||
|
setTimeout(function() { t.classList.remove('show'); }, 2000);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
try{
|
||||||
|
localStorage.removeItem('maixiang_api_key');
|
||||||
|
}catch (e) {
|
||||||
|
console.log(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 软件更新(与 index 设置中逻辑一致)
|
||||||
|
(function() {
|
||||||
|
const versionText = document.getElementById('homeVersionText');
|
||||||
|
const updateHint = document.getElementById('homeUpdateHint');
|
||||||
|
const btnDownload = document.getElementById('homeBtnDownloadUpdate');
|
||||||
|
const updatePanel = document.getElementById('homeUpdatePanel');
|
||||||
|
const toggleBtn = document.getElementById('homeUpdateToggle');
|
||||||
|
if (!versionText || !updateHint || !btnDownload || !updatePanel || !toggleBtn) return;
|
||||||
|
|
||||||
|
let currentVersion = '{{version}}';
|
||||||
|
versionText.textContent = '当前版本: v' + currentVersion;
|
||||||
|
|
||||||
|
// 打开/关闭浮层
|
||||||
|
toggleBtn.addEventListener('click', function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const isVisible = updatePanel.style.display === 'block';
|
||||||
|
updatePanel.style.display = isVisible ? 'none' : 'block';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 点击外部关闭浮层
|
||||||
|
document.addEventListener('click', function() {
|
||||||
|
updatePanel.style.display = 'none';
|
||||||
|
});
|
||||||
|
updatePanel.addEventListener('click', function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('homeBtnCheckUpdate').onclick = async function() {
|
||||||
|
updateHint.textContent = '正在检测更新...';
|
||||||
|
btnDownload.style.display = 'none';
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/version', { credentials: 'same-origin' }).catch(function() { return null; });
|
||||||
|
if (resp && resp.ok) {
|
||||||
|
const data = await resp.json();
|
||||||
|
currentVersion = (data.version || currentVersion).replace(/^v/i, '');
|
||||||
|
versionText.textContent = '当前版本: v' + currentVersion;
|
||||||
|
if (data.has_update && data.latest_version) {
|
||||||
|
updateHint.textContent = '发现新版本 v' + data.latest_version + (data.desc ? ':' + data.desc : '');
|
||||||
|
btnDownload.style.display = 'inline-block';
|
||||||
|
btnDownload.textContent = '立即更新';
|
||||||
|
btnDownload.onclick = async function() {
|
||||||
|
if (!confirm('有更新,是否现在更新?\n更新将下载安装包并重启程序。')) return;
|
||||||
|
if (!data.file_url) {
|
||||||
|
updateHint.textContent = '暂无下载地址,请关注官方渠道。';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateHint.textContent = '正在下载并准备更新,程序将自动退出...';
|
||||||
|
btnDownload.disabled = true;
|
||||||
|
try {
|
||||||
|
const updateResp = await fetch('/api/update/do', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ file_url: data.file_url })
|
||||||
|
});
|
||||||
|
const result = await updateResp.json().catch(function() { return {}; });
|
||||||
|
if (result.success) {
|
||||||
|
updateHint.textContent = '更新已启动,程序即将退出...';
|
||||||
|
} else {
|
||||||
|
updateHint.textContent = result.error || '更新启动失败';
|
||||||
|
btnDownload.disabled = false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
updateHint.textContent = '请求更新失败,请重试';
|
||||||
|
btnDownload.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
updateHint.textContent = '已是最新版本';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updateHint.textContent = '无法连接更新服务,请稍后重试。';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
updateHint.textContent = '检测更新失败';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
btnDownload.style.display = 'none';
|
||||||
|
})();
|
||||||
|
// (function() {
|
||||||
|
// fetch('/api/auth/check', { credentials: 'same-origin' })
|
||||||
|
// .then(function(r) { return r.json(); })
|
||||||
|
// .then(function(res) {
|
||||||
|
// if (res.logged_in && res.redirect) {
|
||||||
|
// window.location.href = res.redirect;
|
||||||
|
// }else{
|
||||||
|
// window.location.href = "/login"
|
||||||
|
// // handleLogout()
|
||||||
|
// }
|
||||||
|
// })
|
||||||
|
// .catch(function() {});
|
||||||
|
// })();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
5833
app/web_source/templates_backup/index.html
Normal file
5833
app/web_source/templates_backup/index.html
Normal file
File diff suppressed because it is too large
Load Diff
164
app/web_source/templates_backup/login.html
Normal file
164
app/web_source/templates_backup/login.html
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>登录 - 南日AI</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", sans-serif;
|
||||||
|
background: #d8e4ec;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 24px;
|
||||||
|
background: rgba(255,255,255,0.5);
|
||||||
|
}
|
||||||
|
.header-title { font-size: 18px; font-weight: 600; color: #333; }
|
||||||
|
.login-box {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #b8d4e3;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||||
|
padding: 40px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
}
|
||||||
|
.login-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #555;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
border: 1px solid #b8d4e3;
|
||||||
|
border-radius: 8px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.form-group input:focus {
|
||||||
|
border-color: #3498db;
|
||||||
|
}
|
||||||
|
.error-msg {
|
||||||
|
color: #e74c3c;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.btn-login {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.btn-login:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.btn-login:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="header">
|
||||||
|
<span class="header-title">南日AI</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="login-box">
|
||||||
|
<h1 class="login-title">登录</h1>
|
||||||
|
{% if error %}
|
||||||
|
<p class="error-msg">{{ error }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<form id="loginForm" method="POST" action="/login">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>用户名</label>
|
||||||
|
<input type="text" name="username" placeholder="请输入用户名" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>密码</label>
|
||||||
|
<input type="password" name="password" placeholder="请输入密码" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-login" id="btnLogin">登录</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
fetch('/api/auth/check', { credentials: 'same-origin' })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(res) {
|
||||||
|
if (res.logged_in && res.redirect) {
|
||||||
|
window.location.href = res.redirect;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {});
|
||||||
|
})();
|
||||||
|
document.getElementById('loginForm').onsubmit = function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var btn = document.getElementById('btnLogin');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '登录中...';
|
||||||
|
var form = e.target;
|
||||||
|
var fd = new FormData(form);
|
||||||
|
fetch(form.action, {
|
||||||
|
method: 'POST',
|
||||||
|
body: fd,
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(res) {
|
||||||
|
if (res.success) {
|
||||||
|
window.location.href = res.redirect || '/home';
|
||||||
|
} else {
|
||||||
|
var errEl = document.querySelector('.error-msg');
|
||||||
|
if (!errEl) {
|
||||||
|
errEl = document.createElement('p');
|
||||||
|
errEl.className = 'error-msg';
|
||||||
|
form.insertBefore(errEl, form.firstChild);
|
||||||
|
}
|
||||||
|
errEl.textContent = res.error || '登录失败';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '登录';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
form.submit();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user