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:
铭坤
2026-03-26 16:39:39 +08:00
parent 506eb0faef
commit a5da06537e
21 changed files with 8897 additions and 1266 deletions

View File

@@ -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()

Binary file not shown.

Binary file not shown.

View File

@@ -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__':

View File

@@ -7,8 +7,6 @@ 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

View File

@@ -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 {

View File

@@ -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));
});
} }
}); });

View File

@@ -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;

View File

@@ -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>

View File

@@ -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">

View 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, '&quot;')) + '">编辑</button> ' +
'<button class="btn btn-sm btn-danger" data-delete="' + u.id + '" data-name="' + (u.username || '').replace(/"/g, '&quot;') + '">删除</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(/&quot;/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, '&quot;') + '" 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>

View 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, '&quot;')) + '">' + 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>

View 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>

File diff suppressed because it is too large Load Diff

View 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>