提交一些更改

This commit is contained in:
super
2026-05-20 09:05:28 +08:00
parent 01ecde45c5
commit 14cebf22c3
65 changed files with 1656 additions and 15904 deletions

View File

@@ -3,7 +3,7 @@ workflow_id=7608812635877900322
mysql_host=47.110.241.161 mysql_host=47.110.241.161
mysql_user=aiimage mysql_user=aiimage
proxy_url=https://api.jikip.com/ip-get?num=1&minute=1&format=json&area=all&protocol=1&mode=2&key=t24g6gi44ubufd8 proxy_url=https://api.jikip.com/ip-get?num=1&minute=3&format=json&area=all&protocol=1&mode=2&key=t24g6gi44ubufd8
proxy_mode=2 proxy_mode=2
zn_company=rongchuang123 zn_company=rongchuang123
@@ -11,9 +11,10 @@ zn_username=%E8%87%AA%E5%8A%A8%E5%8C%96_Robot
client_name=ShuFuAI client_name=ShuFuAI
# java_api_base=http://47.111.163.154:18080 # java_api_base=http://47.111.163.154:18080
# java_api_base=http://127.0.0.1:18080 java_api_base=http://127.0.0.1:18080
java_api_base=http://121.196.149.225:18080 # java_api_base=http://121.196.149.225:18080

View File

@@ -484,7 +484,11 @@ class SimilarAsinTask(TaskBase):
return_data = { return_data = {
'image_url': "", 'image_url': "",
'title': "" 'title': "",
'category' : '',
'sku' : '',
'success' : False,
'similar_data' : []
} }
asin = value.get("asin") asin = value.get("asin")
country = value.get("country") country = value.get("country")
@@ -494,8 +498,9 @@ class SimilarAsinTask(TaskBase):
self.log(f"抓取结果->{return_data}") self.log(f"抓取结果->{return_data}")
break break
except Exception as e: except Exception as e:
if "与页面的连接已断开" in str(e): # if "与页面的连接已断开" in str(e):
chrome = ChromeAmzone() chrome = ChromeAmzone()
self.log(f"{asin}抓取数据报错,{e}")
if not isinstance(return_data, dict): if not isinstance(return_data, dict):
return_data = {} return_data = {}
if return_data.get("image_url"): if return_data.get("image_url"):
@@ -564,6 +569,7 @@ class SimilarAsinTask(TaskBase):
self.log(f"任务 {task_id} 已被暂停!") self.log(f"任务 {task_id} 已被暂停!")
else: else:
runing_task[task_id]["status"] = "completed" runing_task[task_id]["status"] = "completed"
self.log(f"任务 {task_id} 处理完成!") self.log(f"任务 {task_id} 处理完成!")
except Exception as e: except Exception as e:

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
import{bb as r}from"./pywebview-84Lhn3TI.js";const n="";function s(e){return r(`${n}/api/brand/expand-folder-recursive`,{folder:e})}export{s as e}; import{b8 as r}from"./pywebview-3bFwh9Cg.js";const n="";function s(e){return r(`${n}/api/brand/expand-folder-recursive`,{folder:e})}export{s as e};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.module-page[data-v-49f8fcb3]{min-height:100vh;background:#1a1a1a}.main-content[data-v-49f8fcb3]{display:flex;height:calc(100vh - 56px);min-height:calc(100vh - 56px)}.left-panel[data-v-49f8fcb3]{width:400px;background:#1e1e1e;padding:20px;overflow-y:auto;border-right:1px solid #2a2a2a}.right-panel[data-v-49f8fcb3]{flex:1;min-width:0;background:#1a1a1a;display:flex;flex-direction:column}.section-title[data-v-49f8fcb3],.subsection-title[data-v-49f8fcb3]{font-size:13px;color:#bbb;margin-bottom:10px}.upload-zone[data-v-49f8fcb3]{border:1px dashed #3a3a3a;border-radius:10px;padding:18px;background:#252525;margin-bottom:18px}.hint[data-v-49f8fcb3],.loading-msg[data-v-49f8fcb3],.files[data-v-49f8fcb3],.muted[data-v-49f8fcb3]{color:#888;font-size:12px;line-height:1.5}.link[data-v-49f8fcb3]{color:#6ea8fe;text-decoration:none}.link[data-v-49f8fcb3]:hover{color:#9fc5ff}.btns[data-v-49f8fcb3],.run-row[data-v-49f8fcb3]{display:flex;gap:10px;flex-wrap:wrap}.opt-btn[data-v-49f8fcb3],.btn-run[data-v-49f8fcb3],.btn-delete[data-v-49f8fcb3],.download[data-v-49f8fcb3]{border:none;cursor:pointer;border-radius:7px}.opt-btn[data-v-49f8fcb3]{padding:8px 14px;color:#ccc;background:#2a2a2a;border:1px solid #3a3a3a}.btn-run[data-v-49f8fcb3]{padding:10px 18px;color:#fff;background:#3498db;font-weight:600}.btn-queue[data-v-49f8fcb3]{background:#27ae60}.btn-run[data-v-49f8fcb3]:disabled{opacity:.55;cursor:not-allowed}.selected-files[data-v-49f8fcb3]{margin-top:14px;color:#999;font-size:12px;word-break:break-all}.selected-files span[data-v-49f8fcb3]{display:block;margin:4px 0}.parse-card[data-v-49f8fcb3],.queue-payload[data-v-49f8fcb3]{margin-top:14px;padding:12px;border:1px solid #2a2a2a;border-radius:8px;background:#202020;color:#b8c1cc;font-size:12px}.queue-payload[data-v-49f8fcb3]{max-height:220px;overflow:auto;color:#8fd3ff;white-space:pre-wrap}.panel-header[data-v-49f8fcb3]{padding:16px 20px;border-bottom:1px solid #2a2a2a;font-size:15px;font-weight:600;color:#ddd}.task-list-wrap[data-v-49f8fcb3]{flex:1;padding:16px 20px;overflow:auto}.clean-result-summary[data-v-49f8fcb3]{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-bottom:16px}.summary-card[data-v-49f8fcb3]{padding:14px 16px;border:1px solid #2a2a2a;border-radius:8px;background:#1e1e1e}.summary-card strong[data-v-49f8fcb3]{display:block;margin-top:8px;color:#eaf4ff;font-size:22px}.summary-label[data-v-49f8fcb3]{color:#8d8d8d;font-size:12px}.result-list-wrap[data-v-49f8fcb3]{border:1px solid #2a2a2a;border-radius:8px;background:#1e1e1e;min-height:180px;margin:0 0 16px}.result-list-header[data-v-49f8fcb3]{display:flex;justify-content:space-between;padding:12px 16px;border-bottom:1px solid #2a2a2a;color:#ddd;font-size:14px}.empty-tasks[data-v-49f8fcb3]{color:#666;font-size:13px;padding:18px;text-align:center}.task-list[data-v-49f8fcb3]{list-style:none;margin:0;padding:12px}.task-item[data-v-49f8fcb3]{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:12px 14px;border:1px solid #2a2a2a;border-radius:8px;margin-bottom:8px;background:#222}.left[data-v-49f8fcb3]{flex:1;min-width:0}.id[data-v-49f8fcb3]{color:#e0e0e0;font-size:13px;font-weight:600}.task-right[data-v-49f8fcb3]{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.status[data-v-49f8fcb3]{padding:4px 10px;border-radius:6px;font-size:12px}.status.success[data-v-49f8fcb3]{background:#2ecc712e;color:#2ecc71}.status.failed[data-v-49f8fcb3]{background:#e74c3c2e;color:#ff6b6b}.status.running[data-v-49f8fcb3]{background:#3498db2e;color:#3498db}.status.pending[data-v-49f8fcb3]{background:#95a5a62e;color:#bdc3c7}.result-hint[data-v-49f8fcb3]{margin-top:6px;color:#e0b96d}.file-progress[data-v-49f8fcb3]{margin-top:8px;max-width:520px}.file-progress-meta[data-v-49f8fcb3]{display:flex;justify-content:space-between;gap:12px;color:#d8c278;font-size:12px}.file-progress-track[data-v-49f8fcb3]{margin-top:5px;height:8px;border-radius:999px;overflow:hidden;background:#303030;border:1px solid #3b3b3b}.file-progress-bar[data-v-49f8fcb3]{height:100%;border-radius:inherit;background:linear-gradient(90deg,#4aa3ff,#f0c75e);transition:width .25s ease}.file-progress-count[data-v-49f8fcb3]{margin-top:4px;color:#858585;font-size:11px}.download[data-v-49f8fcb3]{padding:6px 10px;color:#d6ecff;background:#3498db2e}.btn-delete[data-v-49f8fcb3]{padding:6px 10px;color:#ff8f8f;background:#e74c3c1f}@media(max-width:1100px){.main-content[data-v-49f8fcb3]{flex-direction:column;height:auto}.left-panel[data-v-49f8fcb3]{width:100%;border-right:none;border-bottom:1px solid #2a2a2a}.clean-result-summary[data-v-49f8fcb3]{grid-template-columns:repeat(2,minmax(0,1fr))}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -5,8 +5,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>
<script type="module" crossorigin src="/assets/convert.js"></script> <script type="module" crossorigin src="/assets/convert.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-84Lhn3TI.js"> <link rel="modulepreload" crossorigin href="/assets/pywebview-3bFwh9Cg.js">
<link rel="modulepreload" crossorigin href="/assets/brand-DIMFUl0j.js"> <link rel="modulepreload" crossorigin href="/assets/brand-D__LhhPM.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-B7UKcPVJ.css"> <link rel="stylesheet" crossorigin href="/assets/pywebview-B7UKcPVJ.css">
<link rel="stylesheet" crossorigin href="/assets/convert-BKSNvX8i.css"> <link rel="stylesheet" crossorigin href="/assets/convert-BKSNvX8i.css">
</head> </head>

View File

@@ -5,8 +5,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>
<script type="module" crossorigin src="/assets/dedupe.js"></script> <script type="module" crossorigin src="/assets/dedupe.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-84Lhn3TI.js"> <link rel="modulepreload" crossorigin href="/assets/pywebview-3bFwh9Cg.js">
<link rel="modulepreload" crossorigin href="/assets/brand-DIMFUl0j.js"> <link rel="modulepreload" crossorigin href="/assets/brand-D__LhhPM.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-B7UKcPVJ.css"> <link rel="stylesheet" crossorigin href="/assets/pywebview-B7UKcPVJ.css">
<link rel="stylesheet" crossorigin href="/assets/dedupe-DNlVfFj-.css"> <link rel="stylesheet" crossorigin href="/assets/dedupe-DNlVfFj-.css">
</head> </head>

View File

@@ -5,8 +5,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>
<script type="module" crossorigin src="/assets/delete-brand.js"></script> <script type="module" crossorigin src="/assets/delete-brand.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-84Lhn3TI.js"> <link rel="modulepreload" crossorigin href="/assets/pywebview-3bFwh9Cg.js">
<link rel="modulepreload" crossorigin href="/assets/brand-DIMFUl0j.js"> <link rel="modulepreload" crossorigin href="/assets/brand-D__LhhPM.js">
<link rel="modulepreload" crossorigin href="/assets/categorized-timers-JPA-olTr.js"> <link rel="modulepreload" crossorigin href="/assets/categorized-timers-JPA-olTr.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-B7UKcPVJ.css"> <link rel="stylesheet" crossorigin href="/assets/pywebview-B7UKcPVJ.css">
<link rel="stylesheet" crossorigin href="/assets/delete-brand-BJod9K65.css"> <link rel="stylesheet" crossorigin href="/assets/delete-brand-BJod9K65.css">

View File

@@ -5,8 +5,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>
<script type="module" crossorigin src="/assets/split.js"></script> <script type="module" crossorigin src="/assets/split.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-84Lhn3TI.js"> <link rel="modulepreload" crossorigin href="/assets/pywebview-3bFwh9Cg.js">
<link rel="modulepreload" crossorigin href="/assets/brand-DIMFUl0j.js"> <link rel="modulepreload" crossorigin href="/assets/brand-D__LhhPM.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-B7UKcPVJ.css"> <link rel="stylesheet" crossorigin href="/assets/pywebview-B7UKcPVJ.css">
<link rel="stylesheet" crossorigin href="/assets/split-BoBVrLdC.css"> <link rel="stylesheet" crossorigin href="/assets/split-BoBVrLdC.css">
</head> </head>

View File

@@ -1,539 +0,0 @@
<!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

@@ -1,918 +0,0 @@
<!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

@@ -1,352 +0,0 @@
<!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

@@ -1,164 +0,0 @@
<!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>

View File

@@ -0,0 +1,114 @@
package com.nanri.aiimage.common.util;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.function.Function;
import java.util.function.Predicate;
public final class FailedStatusRowFilter {
private static final String STATUS_HEADER_CN = "\u72b6\u6001";
private static final String FAILED_STATUS_ERROR = "\u9519\u8bef";
private static final String FAILED_STATUS_FAILED_CN = "\u5931\u8d25";
private static final String FAILED_STATUS_FAILED_EN = "failed";
private static final String NO_MATCHED_ROWS_MESSAGE =
"\u72b6\u6001\u5217\u8fc7\u6ee4\u540e\u672a\u627e\u5230 \u9519\u8bef/\u5931\u8d25/FAILED \u6570\u636e";
private static final Set<String> STATUS_HEADER_ALIASES = Set.of(
normalizeHeader(STATUS_HEADER_CN),
normalizeHeader("status")
);
private static final Set<String> FAILED_STATUS_VALUES = Set.of(
normalizeValue(FAILED_STATUS_ERROR),
normalizeValue(FAILED_STATUS_FAILED_CN),
normalizeValue(FAILED_STATUS_FAILED_EN)
);
private FailedStatusRowFilter() {
}
public static int findStatusColumnIndex(List<String> headers) {
if (headers == null || headers.isEmpty()) {
return -1;
}
for (int i = 0; i < headers.size(); i++) {
if (STATUS_HEADER_ALIASES.contains(normalizeHeader(headers.get(i)))) {
return i;
}
}
return -1;
}
public static boolean matchesFailedStatus(String value) {
return FAILED_STATUS_VALUES.contains(normalizeValue(value));
}
public static boolean isBlankStatus(String value) {
return normalizeValue(value).isBlank();
}
public static String noMatchedRowsMessage() {
return NO_MATCHED_ROWS_MESSAGE;
}
/**
* 暴露表头归一化逻辑给其它模块复用,避免每个模块各自维护一份规则。
* 处理:去 BOM、全角空格转半角、首尾 trim、连续空白合一、转小写
* 并剥除常见分隔符(含中文全角括号、方括号、冒号)。
*/
public static String canonicalizeHeader(String value) {
return normalizeHeader(value);
}
public static <T> FilterResult<T> retainFailedRows(List<T> rows,
boolean statusFilteringEnabled,
Function<T, String> statusReader) {
return retainRows(rows, statusFilteringEnabled, statusReader, FailedStatusRowFilter::matchesFailedStatus);
}
public static <T> FilterResult<T> retainRows(List<T> rows,
boolean statusFilteringEnabled,
Function<T, String> statusReader,
Predicate<String> statusMatcher) {
List<T> safeRows = rows == null ? List.of() : rows;
if (!statusFilteringEnabled) {
return new FilterResult<>(false, safeRows, 0);
}
List<T> filteredRows = new ArrayList<>();
int filteredCount = 0;
for (T row : safeRows) {
String status = statusReader == null ? "" : statusReader.apply(row);
boolean matched = statusMatcher != null && statusMatcher.test(status);
if (matched) {
filteredRows.add(row);
} else {
filteredCount++;
}
}
return new FilterResult<>(true, filteredRows, filteredCount);
}
private static String normalizeHeader(String value) {
// 扩展:兼容中文全角括号 ()、中文方括号 【】、中文冒号 :,避免
// "状态(结果)"、"标题维度(商标)" 等全角括号表头被识别失败。
return normalizeValue(value).replaceAll("[\\s_\\-()\\[\\]{}::()【】]+", "");
}
private static String normalizeValue(String value) {
if (value == null) {
return "";
}
return value
.replace(String.valueOf((char) 0xFEFF), "")
.replace((char) 0x3000, ' ')
.trim()
.replaceAll("\\s+", " ")
.toLowerCase(Locale.ROOT);
}
public record FilterResult<T>(boolean filterApplied, List<T> rows, int filteredCount) {
}
}

View File

@@ -21,7 +21,7 @@ public class AppearancePatentProperties {
private int cozeReadTimeoutMillis = 60000; private int cozeReadTimeoutMillis = 60000;
private int cozePollIntervalMillis = 30000; private int cozePollIntervalMillis = 30000;
private int cozePollTimeoutMillis = 600000; private int cozePollTimeoutMillis = 600000;
private int staleTimeoutMinutes = 20; private int staleTimeoutMinutes = 30;
private String staleFinalizeCron = "0 */2 * * * *"; private String staleFinalizeCron = "0 */2 * * * *";
@Data @Data

View File

@@ -15,15 +15,32 @@ public class SimilarAsinProperties {
private String cozeWorkflowId = "7635328462404583478"; private String cozeWorkflowId = "7635328462404583478";
private String cozeToken = ""; private String cozeToken = "";
private List<CozeCredential> cozeCredentials = new ArrayList<>(); private List<CozeCredential> cozeCredentials = new ArrayList<>();
private int cozeCredentialStripeSize = 5; private int cozeCredentialStripeSize = 1;
private int cozeBatchSize = 50; private int cozeBatchSize = 10;
private int cozeConnectTimeoutMillis = 10000; private int cozeConnectTimeoutMillis = 10000;
private int cozeReadTimeoutMillis = 60000; private int cozeReadTimeoutMillis = 60000;
private int cozePollIntervalMillis = 30000; private int cozePollIntervalMillis = 30000;
private int cozePollTimeoutMillis = 600000; private int cozePollTimeoutMillis = 600000;
private int staleTimeoutMinutes = 20; private int staleTimeoutMinutes = 30;
private String staleFinalizeCron = "0 */2 * * * *"; private String staleFinalizeCron = "0 */2 * * * *";
/**
* 是否在 Coze 请求 parameters 中附带 api_key 字段。
* 默认 true线上 Coze 工作流将该字段视为必填,缺失会得到 4000
* "Missing required parameters";前端传入的 api_key 必须透传到 coze。
* 仅在工作流明确不再需要 api_key 时,可通过环境变量
* AIIMAGE_SIMILAR_ASIN_COZE_INCLUDE_LEGACY_API_KEY=false 关闭。
*/
private boolean cozeIncludeLegacyApiKey = true;
/**
* 是否使用旧的 item 字段顺序 {asin, sku, url, target_urls, title}。
* 默认 false当前实现使用 {asin, url, target_urls, title, sku}。
* 出现兼容问题时可通过 AIIMAGE_SIMILAR_ASIN_COZE_USE_LEGACY_ITEM_ORDER=true
* 切回旧顺序进行回归对比。
*/
private boolean cozeUseLegacyItemFieldOrder = false;
@Data @Data
public static class CozeCredential { public static class CozeCredential {
private String name; private String name;

View File

@@ -40,7 +40,7 @@ public class TaskFileJobConfig {
@Bean("cozeTaskExecutor") @Bean("cozeTaskExecutor")
public TaskExecutor cozeTaskExecutor( public TaskExecutor cozeTaskExecutor(
ExecutorService cozeVirtualThreadExecutor, ExecutorService cozeVirtualThreadExecutor,
@Value("${aiimage.coze-task.max-concurrent:8}") int maxConcurrent) { @Value("${aiimage.coze-task.max-concurrent:12}") int maxConcurrent) {
Semaphore semaphore = new Semaphore(Math.max(1, maxConcurrent)); Semaphore semaphore = new Semaphore(Math.max(1, maxConcurrent));
return new ConcurrentTaskExecutor(command -> cozeVirtualThreadExecutor.execute(() -> { return new ConcurrentTaskExecutor(command -> cozeVirtualThreadExecutor.execute(() -> {
boolean acquired = false; boolean acquired = false;

View File

@@ -607,6 +607,12 @@ public class AppearancePatentCozeClient {
if (row.getError() == null || row.getError().isBlank()) { if (row.getError() == null || row.getError().isBlank()) {
row.setError(failureMessage); row.setError(failureMessage);
} }
if (row.getStatus() == null || row.getStatus().isBlank()) {
row.setStatus("FAILED");
// 标记本次 FAILED 是 markFailed 合成的,仅在内存生命周期内生效(@JsonIgnore
// 后续导出 / 重新上传时可据此与"用户真实失败"区分,避免重复触发 retry。
row.setFailureSyntheticStatus(true);
}
if (row.getTitleRisk() == null || row.getTitleRisk().isBlank()) { if (row.getTitleRisk() == null || row.getTitleRisk().isBlank()) {
row.setTitleRisk(reviewMessage); row.setTitleRisk(reviewMessage);
} }

View File

@@ -1,6 +1,7 @@
package com.nanri.aiimage.modules.appearancepatent.model.dto; package com.nanri.aiimage.modules.appearancepatent.model.dto;
import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
@@ -76,4 +77,13 @@ public class AppearancePatentResultRowDto {
@JsonAlias({"patent_reason", "patentReason", "patent reason"}) @JsonAlias({"patent_reason", "patentReason", "patent reason"})
@Schema(description = "Coze patent reason", accessMode = Schema.AccessMode.READ_ONLY) @Schema(description = "Coze patent reason", accessMode = Schema.AccessMode.READ_ONLY)
private String patentReason; private String patentReason;
/**
* 仅内存生命周期标记,标识当前 status 是 markFailed 时合成出来的(而不是用户/Python 真实回传)。
* 不入库、不参与 chunk 序列化(@JsonIgnore用于导出 / 重新上传判定时区分"系统合成 FAILED"与"用户真正失败"
* 避免用户拿结果簿原样再上传时被反复识别为失败行重新触发 retry。
*/
@JsonIgnore
@Schema(hidden = true)
private boolean failureSyntheticStatus;
} }

View File

@@ -34,4 +34,8 @@ public class AppearancePatentHistoryItemVo {
private Integer rowCount; private Integer rowCount;
@Schema(description = "历史记录创建时间ISO 本地时间字符串。", example = "2026-04-26T10:30:00") @Schema(description = "历史记录创建时间ISO 本地时间字符串。", example = "2026-04-26T10:30:00")
private String createdAt; private String createdAt;
@Schema(description = "任务开始时间ISO 本地时间字符串。当前等价于 biz_file_task.created_at用户点解析创建任务时刻", example = "2026-04-26T10:00:00")
private String startedAt;
@Schema(description = "任务结束时间ISO 本地时间字符串。SUCCESS/FAILED 时回填,未结束为空。", example = "2026-04-26T10:10:00")
private String finishedAt;
} }

View File

@@ -8,6 +8,7 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.common.service.DistributedJobLockService; import com.nanri.aiimage.common.service.DistributedJobLockService;
import com.nanri.aiimage.common.util.FailedStatusRowFilter;
import com.nanri.aiimage.config.AppearancePatentProperties; import com.nanri.aiimage.config.AppearancePatentProperties;
import com.nanri.aiimage.config.InstanceMetadata; import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.config.StorageProperties; import com.nanri.aiimage.config.StorageProperties;
@@ -99,6 +100,7 @@ public class AppearancePatentTaskService {
private static final String STATUS_RUNNING = "RUNNING"; private static final String STATUS_RUNNING = "RUNNING";
private static final String STATUS_SUCCESS = "SUCCESS"; private static final String STATUS_SUCCESS = "SUCCESS";
private static final String STATUS_FAILED = "FAILED"; private static final String STATUS_FAILED = "FAILED";
private static final String COZE_EMPTY_RESULT_MESSAGE = "Coze returned empty result rows";
private static final String COZE_STATUS_SUBMITTED = "SUBMITTED"; private static final String COZE_STATUS_SUBMITTED = "SUBMITTED";
private static final String COZE_STATUS_RUNNING = "RUNNING"; private static final String COZE_STATUS_RUNNING = "RUNNING";
private static final String COZE_STATUS_DONE = "DONE"; private static final String COZE_STATUS_DONE = "DONE";
@@ -345,8 +347,7 @@ public class AppearancePatentTaskService {
.thenComparing(FileResultEntity::getId, Comparator.nullsLast(Comparator.reverseOrder()))); .thenComparing(FileResultEntity::getId, Comparator.nullsLast(Comparator.reverseOrder())));
for (FileResultEntity row : sortedRows) { for (FileResultEntity row : sortedRows) {
FileTaskEntity task = taskMap.get(row.getTaskId()); FileTaskEntity task = taskMap.get(row.getTaskId());
String taskStatus = task == null ? null : task.getStatus(); vo.getItems().add(toHistoryItem(row, task, jobMap.get(row.getId())));
vo.getItems().add(toHistoryItem(row, taskStatus, jobMap.get(row.getId())));
} }
return vo; return vo;
} }
@@ -408,7 +409,7 @@ public class AppearancePatentTaskService {
detail.setTask(toTaskItem(task)); detail.setTask(toTaskItem(task));
FileResultEntity resultRow = resultByTaskId.get(taskId); FileResultEntity resultRow = resultByTaskId.get(taskId);
if (resultRow != null) { if (resultRow != null) {
detail.getItems().add(toHistoryItem(resultRow, task.getStatus(), jobMap.get(resultRow.getId()))); detail.getItems().add(toHistoryItem(resultRow, task, jobMap.get(resultRow.getId())));
} }
vo.getItems().add(detail); vo.getItems().add(detail);
} }
@@ -805,8 +806,20 @@ public class AppearancePatentTaskService {
long activeAssembleJobs = taskFileJobService.countActiveAssembleJobs(taskId, MODULE_TYPE); long activeAssembleJobs = taskFileJobService.countActiveAssembleJobs(taskId, MODULE_TYPE);
log.info("[appearance-patent] stale recovery probe taskId={} uploadComplete={} pendingCozeStates={} activeAssembleJobs={} persistedRows={}", log.info("[appearance-patent] stale recovery probe taskId={} uploadComplete={} pendingCozeStates={} activeAssembleJobs={} persistedRows={}",
taskId, uploadComplete, pendingCozeStates, activeAssembleJobs, hasPersistedResultRows(taskId)); taskId, uploadComplete, pendingCozeStates, activeAssembleJobs, hasPersistedResultRows(taskId));
if (uploadComplete && (pendingCozeStates > 0 || activeAssembleJobs > 0)) { if (isJavaSideProcessing(taskId)) {
// 防止 Coze 永远 pending 时 stale-recovery 永久 defer
// 超过 stale-timeout-minutes × 4 仍未推进的 RUNNING 任务,强制走 finalize 链路。
long deferCeilingMinutes = Math.max(1, properties.getStaleTimeoutMinutes()) * 4L;
LocalDateTime updatedAt = task.getUpdatedAt();
if (updatedAt != null
&& Duration.between(updatedAt, LocalDateTime.now()).toMinutes() >= deferCeilingMinutes) {
log.warn("[appearance-patent] stale recovery defer ceiling exceeded, forcing finalize taskId={} updatedAt={} ceilingMinutes={} pendingCozeStates={} activeAssembleJobs={}",
taskId, updatedAt, deferCeilingMinutes, pendingCozeStates, activeAssembleJobs);
return false;
}
touchJavaSideTaskActivity(taskId); touchJavaSideTaskActivity(taskId);
log.info("[appearance-patent] stale recovery deferred because Java-side processing is still active taskId={} uploadComplete={} pendingCozeStates={} activeAssembleJobs={}",
taskId, uploadComplete, pendingCozeStates, activeAssembleJobs);
return true; return true;
} }
if (!hasPersistedResultRows(taskId)) { if (!hasPersistedResultRows(taskId)) {
@@ -1610,6 +1623,7 @@ public class AppearancePatentTaskService {
if (batchRows == null || batchRows.isEmpty()) { if (batchRows == null || batchRows.isEmpty()) {
return false; return false;
} }
taskFileJobService.touchRunning(job.getId());
String batchScopeKey = buildCozeBatchScopeKey(task.getId(), batchRows); String batchScopeKey = buildCozeBatchScopeKey(task.getId(), batchRows);
String batchScopeHash = DigestUtil.sha256Hex(batchScopeKey); String batchScopeHash = DigestUtil.sha256Hex(batchScopeKey);
TaskScopeStateEntity existing = taskScopeStateMapper.selectOne(new LambdaQueryWrapper<TaskScopeStateEntity>() TaskScopeStateEntity existing = taskScopeStateMapper.selectOne(new LambdaQueryWrapper<TaskScopeStateEntity>()
@@ -1626,7 +1640,7 @@ public class AppearancePatentTaskService {
AppearancePatentCozeClient.CozeSubmitResponse submit = submitCozeWorkflowThrottled( AppearancePatentCozeClient.CozeSubmitResponse submit = submitCozeWorkflowThrottled(
batchRows, prompt, apiKey, credential, true); batchRows, prompt, apiKey, credential, true);
if (submit.immediateData() != null && !submit.immediateData().isBlank()) { if (submit.immediateData() != null && !submit.immediateData().isBlank()) {
List<AppearancePatentResultRowDto> cozeRows = cozeClient.mergeRowsFromDataText(batchRows, submit.immediateData()); List<AppearancePatentResultRowDto> cozeRows = mergeUsableCozeRows(batchRows, submit.immediateData());
mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId); mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId);
return false; return false;
} }
@@ -1844,15 +1858,25 @@ public class AppearancePatentTaskService {
if (batchRows.isEmpty() && failureMessage.isBlank()) { if (batchRows.isEmpty() && failureMessage.isBlank()) {
failureMessage = "Coze batch payload missing"; failureMessage = "Coze batch payload missing";
} }
List<AppearancePatentResultRowDto> cozeRows = List.of();
if (failureMessage.isBlank()) {
try {
// 显式把 workflow status 传进 mergeUsableCozeRows
// 业务侧 SUCCESS 但 payload 空时不再走 retry而是直接 markFailed 落地。
cozeRows = mergeUsableCozeRows(batchRows, poll.resolvedPayloadText(), poll.status());
} catch (Exception ex) {
failureMessage = firstNonBlank(ex.getMessage(), COZE_EMPTY_RESULT_MESSAGE);
}
}
if (!failureMessage.isBlank() && splitRetryFailedCozeBatchState(state, context, batchRows, failureMessage)) { if (!failureMessage.isBlank() && splitRetryFailedCozeBatchState(state, context, batchRows, failureMessage)) {
return; return;
} }
if (!failureMessage.isBlank() && retryFailedCozeBatchState(state, context, batchRows, failureMessage)) { if (!failureMessage.isBlank() && retryFailedCozeBatchState(state, context, batchRows, failureMessage)) {
return; return;
} }
List<AppearancePatentResultRowDto> cozeRows = failureMessage.isBlank() if (!failureMessage.isBlank()) {
? cozeClient.mergeRowsFromDataText(batchRows, poll.resolvedPayloadText()) cozeRows = cozeClient.markRowsFailed(batchRows, failureMessage);
: cozeClient.markRowsFailed(batchRows, failureMessage); }
FileTaskEntity task = fileTaskMapper.selectById(state.getTaskId()); FileTaskEntity task = fileTaskMapper.selectById(state.getTaskId());
if (task != null) { if (task != null) {
Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task); Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task);
@@ -1919,7 +1943,7 @@ public class AppearancePatentTaskService {
Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task); Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task);
if (submit.immediateData() != null && !submit.immediateData().isBlank()) { if (submit.immediateData() != null && !submit.immediateData().isBlank()) {
List<AppearancePatentResultRowDto> cozeRows = List<AppearancePatentResultRowDto> cozeRows =
cozeClient.mergeRowsFromDataText(batchRows, submit.immediateData()); mergeUsableCozeRows(batchRows, submit.immediateData());
mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId); mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId);
markCozeStateTerminal(state, COZE_STATUS_DONE, null); markCozeStateTerminal(state, COZE_STATUS_DONE, null);
maybeFinalizeCozeJobLocked(state.getTaskId(), context); maybeFinalizeCozeJobLocked(state.getTaskId(), context);
@@ -2061,7 +2085,7 @@ public class AppearancePatentTaskService {
cozeClient.credentialByName(context.credentialName()), false); cozeClient.credentialByName(context.credentialName()), false);
if (submit.immediateData() != null && !submit.immediateData().isBlank()) { if (submit.immediateData() != null && !submit.immediateData().isBlank()) {
List<AppearancePatentResultRowDto> cozeRows = List<AppearancePatentResultRowDto> cozeRows =
cozeClient.mergeRowsFromDataText(partRows, submit.immediateData()); mergeUsableCozeRows(partRows, submit.immediateData());
mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId); mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId);
submittedAny = true; submittedAny = true;
} else if (submit.executeId() != null && !submit.executeId().isBlank()) { } else if (submit.executeId() != null && !submit.executeId().isBlank()) {
@@ -2141,6 +2165,8 @@ public class AppearancePatentTaskService {
String normalized = normalize(failureMessage).toLowerCase(Locale.ROOT); String normalized = normalize(failureMessage).toLowerCase(Locale.ROOT);
return normalized.contains("timeout") return normalized.contains("timeout")
|| normalized.contains("timed out") || normalized.contains("timed out")
|| normalized.contains("without output")
|| normalized.contains("empty result")
|| normalized.contains("out of limit") || normalized.contains("out of limit")
|| normalized.contains("execution limit") || normalized.contains("execution limit")
|| normalized.contains("720712008") || normalized.contains("720712008")
@@ -2156,6 +2182,8 @@ public class AppearancePatentTaskService {
|| normalized.contains("retry later") || normalized.contains("retry later")
|| normalized.contains("timeout") || normalized.contains("timeout")
|| normalized.contains("timed out") || normalized.contains("timed out")
|| normalized.contains("without output")
|| normalized.contains("empty result")
|| normalized.contains("out of limit") || normalized.contains("out of limit")
|| normalized.contains("execution limit") || normalized.contains("execution limit")
|| normalized.contains("702093018") || normalized.contains("702093018")
@@ -2585,7 +2613,7 @@ public class AppearancePatentTaskService {
Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task); Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task);
if (submit.immediateData() != null && !submit.immediateData().isBlank()) { if (submit.immediateData() != null && !submit.immediateData().isBlank()) {
List<AppearancePatentResultRowDto> cozeRows = List<AppearancePatentResultRowDto> cozeRows =
cozeClient.mergeRowsFromDataText(batchRows, submit.immediateData()); mergeUsableCozeRows(batchRows, submit.immediateData());
mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId); mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId);
markCozeStateTerminal(state, COZE_STATUS_DONE, null); markCozeStateTerminal(state, COZE_STATUS_DONE, null);
maybeFinalizeCozeJobLocked(state.getTaskId(), context); maybeFinalizeCozeJobLocked(state.getTaskId(), context);
@@ -3191,7 +3219,7 @@ public class AppearancePatentTaskService {
row.createCell(col++).setCellValue(resultRow == null ? missingReason : userFacingCozeCellValue(resultRow, resultRow.getAppearanceRisk())); row.createCell(col++).setCellValue(resultRow == null ? missingReason : userFacingCozeCellValue(resultRow, resultRow.getAppearanceRisk()));
row.createCell(col++).setCellValue(resultRow == null ? missingReason : userFacingCozeCellValue(resultRow, resultRow.getPatentRisk())); row.createCell(col++).setCellValue(resultRow == null ? missingReason : userFacingCozeCellValue(resultRow, resultRow.getPatentRisk()));
row.createCell(col++).setCellValue(resultRow == null ? "未送检" : userFacingConclusion(resultRow)); row.createCell(col++).setCellValue(resultRow == null ? "未送检" : userFacingConclusion(resultRow));
row.createCell(col).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getStatus(), "")); row.createCell(col).setCellValue(resultRow == null ? "" : userFacingStatus(resultRow));
} }
writeReasonSheet(workbook, headerStyle, rowsToWrite, resultMap); writeReasonSheet(workbook, headerStyle, rowsToWrite, resultMap);
workbook.write(fos); workbook.write(fos);
@@ -3260,6 +3288,59 @@ public class AppearancePatentTaskService {
|| !normalize(row.getTitleReason()).isBlank(); || !normalize(row.getTitleReason()).isBlank();
} }
private List<AppearancePatentResultRowDto> mergeUsableCozeRows(List<AppearancePatentResultRowDto> batchRows,
String payloadText) throws Exception {
// 旧签名保留:未显式传 workflow status 的调用方默认按 "业务侧 SUCCESS" 对待
// submit immediateData 同步路径本身就意味着 coze workflow 业务侧已成功返回)。
return mergeUsableCozeRows(batchRows, payloadText, null);
}
/**
* 合并 Coze 回包:
* <ul>
* <li>workflow 业务侧 SUCCESSstatus=SUCCESS 或 immediate 同步返回)但解析后无任何风险维度结果,
* 直接 {@link AppearancePatentCozeClient#markRowsFailed(List, String)} 落地,<b>不再抛异常</b>
* 避免业务空结果被重复扔进 retry/split-retry 死循环。</li>
* <li>workflow 业务侧非 SUCCESSpoll status=FAILED/CANCELED 等系统侧失败)保持原行为,
* 抛 {@link IllegalStateException},让外层 retry/split-retry 链路处理。</li>
* </ul>
*/
private List<AppearancePatentResultRowDto> mergeUsableCozeRows(List<AppearancePatentResultRowDto> batchRows,
String payloadText,
String workflowStatus) throws Exception {
List<AppearancePatentResultRowDto> cozeRows = cozeClient.mergeRowsFromDataText(batchRows, payloadText);
if (isUnusableCozePayload(cozeRows)) {
if (isBusinessSuccessStatus(workflowStatus)) {
log.warn("[appearance-patent] coze business success but empty payload, markFailed batchRows={} status={}",
batchRows == null ? 0 : batchRows.size(), workflowStatus);
return cozeClient.markRowsFailed(batchRows, "Coze 业务返回空结果");
}
throw new IllegalStateException(COZE_EMPTY_RESULT_MESSAGE);
}
return cozeRows;
}
private boolean isBusinessSuccessStatus(String status) {
// submit 同步路径没有 status但回了 data 字段就视为业务 SUCCESS。
if (status == null || status.isBlank()) {
return true;
}
String normalized = status.trim().toUpperCase(Locale.ROOT);
return normalized.contains("SUCCESS");
}
private boolean isUnusableCozePayload(List<AppearancePatentResultRowDto> cozeRows) {
if (cozeRows == null || cozeRows.isEmpty()) {
return true;
}
boolean hasUsableOutcome = cozeRows.stream().anyMatch(row -> hasResolvedCozeFields(row) || hasReasonFields(row));
if (hasUsableOutcome) {
return false;
}
return cozeRows.stream().allMatch(row -> isFailedCozeStatusValue(row == null ? null : row.getStatus())
|| normalize(row == null ? null : row.getStatus()).isBlank());
}
private ParsedWorkbook parseWorkbook(File input, AppearancePatentSourceFileDto source) { private ParsedWorkbook parseWorkbook(File input, AppearancePatentSourceFileDto source) {
DataFormatter formatter = new DataFormatter(); DataFormatter formatter = new DataFormatter();
try (FileInputStream fis = new FileInputStream(input); Workbook workbook = WorkbookFactory.create(fis)) { try (FileInputStream fis = new FileInputStream(input); Workbook workbook = WorkbookFactory.create(fis)) {
@@ -3269,6 +3350,7 @@ public class AppearancePatentTaskService {
throw new BusinessException("Excel 表头为空"); throw new BusinessException("Excel 表头为空");
} }
Map<String, Integer> headerMap = buildHeaderMap(header, formatter); Map<String, Integer> headerMap = buildHeaderMap(header, formatter);
List<String> headers = readHeaders(header, formatter);
int idCol = findRequiredHeader(headerMap, "id"); int idCol = findRequiredHeader(headerMap, "id");
int asinCol = findRequiredHeader(headerMap, "asin"); int asinCol = findRequiredHeader(headerMap, "asin");
int countryCol = findRequiredHeader(headerMap, "国家", "country"); int countryCol = findRequiredHeader(headerMap, "国家", "country");
@@ -3279,9 +3361,12 @@ public class AppearancePatentTaskService {
int titleCol = findOptionalHeaderExact(headerMap, int titleCol = findOptionalHeaderExact(headerMap,
"标题", "title", "listing title", "product title", "商品标题", "商品名称", "产品名称"); "标题", "title", "listing title", "product title", "商品标题", "商品名称", "产品名称");
List<AppearancePatentParsedRowVo> allRows = new ArrayList<>(); int statusCol = FailedStatusRowFilter.findStatusColumnIndex(headers);
List<ParsedAppearanceRow> parsedRows = new ArrayList<>();
int total = 0; int total = 0;
int dropped = 0; int dropped = 0;
int validRows = 0;
String currentBlockBaseId = ""; String currentBlockBaseId = "";
String currentGroupKey = ""; String currentGroupKey = "";
for (int i = 1; i <= sheet.getLastRowNum(); i++) { for (int i = 1; i <= sheet.getLastRowNum(); i++) {
@@ -3300,6 +3385,7 @@ public class AppearancePatentTaskService {
dropped++; dropped++;
continue; continue;
} }
validRows++;
AppearancePatentParsedRowVo vo = new AppearancePatentParsedRowVo(); AppearancePatentParsedRowVo vo = new AppearancePatentParsedRowVo();
vo.setSourceFileKey(source.getFileKey()); vo.setSourceFileKey(source.getFileKey());
vo.setSourceFilename(firstNonBlank(source.getOriginalFilename(), input.getName())); vo.setSourceFilename(firstNonBlank(source.getOriginalFilename(), input.getName()));
@@ -3318,13 +3404,32 @@ public class AppearancePatentTaskService {
vo.setPrice(priceCol >= 0 ? cell(row, priceCol, formatter) : ""); vo.setPrice(priceCol >= 0 ? cell(row, priceCol, formatter) : "");
vo.setUrl(urlCol >= 0 ? cell(row, urlCol, formatter) : ""); vo.setUrl(urlCol >= 0 ? cell(row, urlCol, formatter) : "");
vo.setTitle(titleCol >= 0 ? cell(row, titleCol, formatter) : ""); vo.setTitle(titleCol >= 0 ? cell(row, titleCol, formatter) : "");
allRows.add(vo); parsedRows.add(new ParsedAppearanceRow(vo, statusCol >= 0 ? cell(row, statusCol, formatter) : ""));
} }
List<AppearancePatentParsedRowVo> allRows = parsedRows.stream()
.map(ParsedAppearanceRow::row)
.toList();
hydratePromptFields(allRows); hydratePromptFields(allRows);
boolean includeBlankStatusRows = statusCol >= 0 && isAppearanceResultWorkbook(headers);
FailedStatusRowFilter.FilterResult<ParsedAppearanceRow> filteredRows =
FailedStatusRowFilter.retainRows(
parsedRows,
statusCol >= 0,
ParsedAppearanceRow::sourceStatus,
status -> FailedStatusRowFilter.matchesFailedStatus(status)
|| (includeBlankStatusRows && FailedStatusRowFilter.isBlankStatus(status))
);
dropped += filteredRows.filteredCount();
allRows = filteredRows.rows().stream()
.map(ParsedAppearanceRow::row)
.toList();
if (statusCol >= 0 && validRows > 0 && allRows.isEmpty()) {
throw new BusinessException(FailedStatusRowFilter.noMatchedRowsMessage());
}
if (allRows.isEmpty()) { if (allRows.isEmpty()) {
throw new BusinessException("no valid appearance patent rows"); throw new BusinessException("no valid appearance patent rows");
} }
return new ParsedWorkbook(total, dropped, List.of(), allRows); return new ParsedWorkbook(total, dropped, headers, allRows);
} catch (BusinessException ex) { } catch (BusinessException ex) {
throw ex; throw ex;
} catch (Exception ex) { } catch (Exception ex) {
@@ -3356,6 +3461,41 @@ public class AppearancePatentTaskService {
} }
} }
private List<String> readHeaders(Row header, DataFormatter formatter) {
List<String> headers = new ArrayList<>();
for (int i = 0; i < header.getLastCellNum(); i++) {
String val = normalize(formatter.formatCellValue(header.getCell(i)));
headers.add(val.isBlank() ? "" + (i + 1) : val);
}
return headers;
}
private boolean isAppearanceResultWorkbook(List<String> headers) {
return hasHeader(headers, "状态")
&& (hasHeader(headers, "结论")
|| hasHeader(headers, "标题维度(商标)")
|| hasHeader(headers, "外观维度(外观设计专利)")
|| hasHeader(headers, "专利维度(发明/实用新型专利)"));
}
private boolean hasHeader(List<String> headers, String candidate) {
if (headers == null || headers.isEmpty()) {
return false;
}
// 复用 FailedStatusRowFilter.canonicalizeHeader统一归一化规则
// 避免专利模块自己维护一份只去半角括号的实现导致全角括号表头识别失败。
String normalizedCandidate = FailedStatusRowFilter.canonicalizeHeader(candidate);
if (normalizedCandidate.isBlank()) {
return false;
}
for (String header : headers) {
if (normalizedCandidate.equals(FailedStatusRowFilter.canonicalizeHeader(header))) {
return true;
}
}
return false;
}
private boolean hasPromptFields(AppearancePatentParsedRowVo row) { private boolean hasPromptFields(AppearancePatentParsedRowVo row) {
return row != null && (!normalize(row.getTitle()).isBlank() || !normalize(row.getUrl()).isBlank()); return row != null && (!normalize(row.getTitle()).isBlank() || !normalize(row.getUrl()).isBlank());
} }
@@ -3521,7 +3661,7 @@ public class AppearancePatentTaskService {
return vo; return vo;
} }
private AppearancePatentHistoryItemVo toHistoryItem(FileResultEntity row, String taskStatus, TaskFileJobEntity job) { private AppearancePatentHistoryItemVo toHistoryItem(FileResultEntity row, FileTaskEntity task, TaskFileJobEntity job) {
AppearancePatentHistoryItemVo vo = new AppearancePatentHistoryItemVo(); AppearancePatentHistoryItemVo vo = new AppearancePatentHistoryItemVo();
vo.setResultId(row.getId()); vo.setResultId(row.getId());
vo.setTaskId(row.getTaskId()); vo.setTaskId(row.getTaskId());
@@ -3529,11 +3669,14 @@ public class AppearancePatentTaskService {
vo.setResultFilename(row.getResultFilename()); vo.setResultFilename(row.getResultFilename());
vo.setDownloadUrl(null); vo.setDownloadUrl(null);
attachFileJobState(vo, row, job); attachFileJobState(vo, row, job);
vo.setTaskStatus(taskStatus); vo.setTaskStatus(task == null ? null : task.getStatus());
vo.setSuccess(row.getSuccess() != null && row.getSuccess() == 1); vo.setSuccess(row.getSuccess() != null && row.getSuccess() == 1);
vo.setError(row.getErrorMessage()); vo.setError(row.getErrorMessage());
vo.setRowCount(row.getRowCount()); vo.setRowCount(row.getRowCount());
vo.setCreatedAt(fmt(row.getCreatedAt())); vo.setCreatedAt(fmt(row.getCreatedAt()));
// 任务开始时间复用 biz_file_task.created_at缺 task 时回退到 result.createdAt 兜底
vo.setStartedAt(fmt(task == null ? row.getCreatedAt() : task.getCreatedAt()));
vo.setFinishedAt(fmt(task == null ? null : task.getFinishedAt()));
return vo; return vo;
} }
@@ -3880,6 +4023,9 @@ public class AppearancePatentTaskService {
if (row != null && isTechnicalCozeFailure(row.getError())) { if (row != null && isTechnicalCozeFailure(row.getError())) {
return "待人工复核"; return "待人工复核";
} }
if (row != null && isFailedCozeStatusValue(row.getStatus())) {
return "待人工复核";
}
return firstNonBlank(value, ""); return firstNonBlank(value, "");
} }
@@ -3894,9 +4040,30 @@ public class AppearancePatentTaskService {
if (isTechnicalCozeFailure(row.getError())) { if (isTechnicalCozeFailure(row.getError())) {
return "待人工复核"; return "待人工复核";
} }
if (isFailedCozeStatusValue(row.getStatus())) {
return "待人工复核";
}
return firstNonBlank(row.getConclusion(), ""); return firstNonBlank(row.getConclusion(), "");
} }
/**
* 导出最终 xlsx 时的状态展示:
* - 如果 status 是 markFailed 合成的failureSyntheticStatus=true
* 且 row 实际并没有任何风险维度结果,导出层降级为"待人工复核",避免用户原样
* 重新上传时该行被反复识别为失败行重新触发 Coze。
* - 真正业务侧失败 / Python 真实回传 FAILED 仍按原值显示。
*/
private String userFacingStatus(AppearancePatentResultRowDto row) {
if (row == null) {
return "";
}
String original = firstNonBlank(row.getStatus(), "");
if (!row.isFailureSyntheticStatus()) {
return original;
}
return "待人工复核";
}
private boolean isTechnicalCozeFailure(String value) { private boolean isTechnicalCozeFailure(String value) {
String normalized = normalize(value).toLowerCase(Locale.ROOT); String normalized = normalize(value).toLowerCase(Locale.ROOT);
return normalized.contains("coze") return normalized.contains("coze")
@@ -3906,6 +4073,14 @@ public class AppearancePatentTaskService {
|| normalized.contains("timeout"); || normalized.contains("timeout");
} }
private boolean isFailedCozeStatusValue(String status) {
String normalized = normalize(status).toLowerCase(Locale.ROOT);
return normalized.contains("fail")
|| normalized.contains("error")
|| normalized.contains("cancel")
|| normalized.contains("失败");
}
private String safeFileStem(String filename) { private String safeFileStem(String filename) {
String name = filename == null || filename.isBlank() ? "appearance-patent" : filename; String name = filename == null || filename.isBlank() ? "appearance-patent" : filename;
int idx = name.lastIndexOf('.'); int idx = name.lastIndexOf('.');
@@ -4012,4 +4187,7 @@ public class AppearancePatentTaskService {
private record ParsedWorkbook(int totalRows, int droppedRows, List<String> headers, List<AppearancePatentParsedRowVo> allRows) { private record ParsedWorkbook(int totalRows, int droppedRows, List<String> headers, List<AppearancePatentParsedRowVo> allRows) {
} }
private record ParsedAppearanceRow(AppearancePatentParsedRowVo row, String sourceStatus) {
}
} }

View File

@@ -11,6 +11,7 @@ import org.springframework.stereotype.Service;
import java.time.Duration; import java.time.Duration;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
@Slf4j @Slf4j
@Service @Service
@@ -19,6 +20,12 @@ public class CozeCredentialPoolService {
private static final Duration INFLIGHT_TTL = Duration.ofMinutes(30); private static final Duration INFLIGHT_TTL = Duration.ofMinutes(30);
/**
* 每个 moduleType 只 WARN 一次,避免高频日志噪音。
* key = moduleTypevalue = 仅作占位,仅用 putIfAbsent 语义判断"是否已经 WARN 过"。
*/
private final ConcurrentHashMap<String, Boolean> stripeWarnedModules = new ConcurrentHashMap<>();
private final CozeCredentialMapper cozeCredentialMapper; private final CozeCredentialMapper cozeCredentialMapper;
private final StringRedisTemplate stringRedisTemplate; private final StringRedisTemplate stringRedisTemplate;
@@ -63,6 +70,16 @@ public class CozeCredentialPoolService {
return null; return null;
} }
int safeStripeSize = Math.max(1, stripeSize); int safeStripeSize = Math.max(1, stripeSize);
// 自检:当 stripe < credentials.size() 时凭据轮换会偏向首张凭据,
// 通常说明 stripeSize 配置错(推荐 stripe == credentials.size())。
// 仅 WARN 一次,避免日志噪音;不阻断启动,留运维自行调整。
if (safeStripeSize < credentials.size() && moduleType != null && !moduleType.isBlank()) {
if (stripeWarnedModules.putIfAbsent(moduleType, Boolean.TRUE) == null) {
log.warn("[coze-credential] stripeSize({}) < credentials.size({}) for moduleType={}, "
+ "round-robin will be biased; consider setting stripeSize == credentials.size()",
safeStripeSize, credentials.size(), moduleType);
}
}
long cursor = nextCursor(moduleType); long cursor = nextCursor(moduleType);
int index = (int) ((Math.max(0L, cursor) / safeStripeSize) % credentials.size()); int index = (int) ((Math.max(0L, cursor) / safeStripeSize) % credentials.size());
return credentials.get(index); return credentials.get(index);

View File

@@ -231,9 +231,6 @@ public class SimilarAsinCozeClient {
Map<String, Object> body = new LinkedHashMap<>(); Map<String, Object> body = new LinkedHashMap<>();
body.put("workflow_id", credential.workflowId()); body.put("workflow_id", credential.workflowId());
body.put("parameters", parameters); body.put("parameters", parameters);
if (apiKey != null && !apiKey.isBlank()) {
body.put("api_key", apiKey.trim());
}
body.put("is_async", Boolean.TRUE); body.put("is_async", Boolean.TRUE);
log.info("[similar-asin] coze request credential={} url={} body={}", log.info("[similar-asin] coze request credential={} url={} body={}",
credential.name(), credential.name(),
@@ -351,16 +348,19 @@ public class SimilarAsinCozeClient {
} }
private Map<String, Object> buildParameters(List<SimilarAsinResultRowDto> rows, String prompt, String apiKey) { private Map<String, Object> buildParameters(List<SimilarAsinResultRowDto> rows, String prompt, String apiKey) {
List<String> asins = rows.stream().map(row -> nonBlank(row.getAsin(), "")).toList(); List<String> asins = rows.stream().map(row -> safeText(row.getAsin())).toList();
List<String> skus = rows.stream().map(row -> nonBlank(row.getSku(), "")).toList(); List<String> titles = rows.stream().map(row -> safeText(firstNonBlank(row.getTitle(), row.getAsin()))).toList();
List<String> titles = rows.stream().map(row -> nonBlank(row.getTitle(), row.getAsin())).toList(); List<String> skus = rows.stream().map(row -> safeText(row.getSku())).toList();
List<String> urls = rows.stream().map(row -> nonBlank(row.getUrl(), "")).toList(); List<String> urls = rows.stream().map(this::primaryImageUrl).toList();
List<List<String>> urlLists = rows.stream().map(SimilarAsinResultRowDto::getUrls).toList(); List<List<String>> urlLists = rows.stream().map(this::imageUrls).toList();
Map<String, Object> parameters = new LinkedHashMap<>(); Map<String, Object> parameters = new LinkedHashMap<>();
parameters.put("items", buildItemObjects(asins, skus, titles, urls, urlLists)); parameters.put("items", buildItemObjects(asins, titles, skus, urls, urlLists));
parameters.put("prompt", prompt == null ? "" : prompt); parameters.put("prompt", prompt == null ? "" : prompt);
if (apiKey != null && !apiKey.isBlank()) { // legacy flag当线上 coze 工作流回退到老契约时,把环境变量
// AIIMAGE_SIMILAR_ASIN_COZE_INCLUDE_LEGACY_API_KEY=true 即可重新塞 api_key。
boolean includeLegacyApiKey = properties.isCozeIncludeLegacyApiKey();
if (includeLegacyApiKey && apiKey != null && !apiKey.isBlank()) {
parameters.put("api_key", apiKey.trim()); parameters.put("api_key", apiKey.trim());
} }
return parameters; return parameters;
@@ -397,18 +397,31 @@ public class SimilarAsinCozeClient {
} }
private List<Map<String, Object>> buildItemObjects(List<String> asins, private List<Map<String, Object>> buildItemObjects(List<String> asins,
List<String> skus,
List<String> titles, List<String> titles,
List<String> skus,
List<String> urls, List<String> urls,
List<List<String>> urlLists) { List<List<String>> urlLists) {
// legacy flag保留切回旧字段顺序 {asin, sku, url, target_urls, title} 的开关,
// 默认 false 使用当前顺序 {asin, url, target_urls, title, sku}。
boolean useLegacyOrder = properties.isCozeUseLegacyItemFieldOrder();
List<Map<String, Object>> items = new ArrayList<>(asins.size()); List<Map<String, Object>> items = new ArrayList<>(asins.size());
for (int i = 0; i < asins.size(); i++) { for (int i = 0; i < asins.size(); i++) {
String url = urls.get(i);
List<String> imageUrls = urlLists.get(i);
Map<String, Object> item = new LinkedHashMap<>(); Map<String, Object> item = new LinkedHashMap<>();
if (useLegacyOrder) {
item.put("asin", asins.get(i)); item.put("asin", asins.get(i));
item.put("sku", skus.get(i)); item.put("sku", skus.get(i));
item.put("url", url);
item.put("target_urls", imageUrls);
item.put("title", titles.get(i)); item.put("title", titles.get(i));
item.put("url", urls.get(i)); } else {
item.put("target_urls", urlLists.get(i)); item.put("asin", asins.get(i));
item.put("url", url);
item.put("target_urls", imageUrls);
item.put("title", titles.get(i));
item.put("sku", skus.get(i));
}
items.add(item); items.add(item);
} }
return items; return items;
@@ -568,6 +581,7 @@ public class SimilarAsinCozeClient {
return false; return false;
} }
return !normalize(result.rowToken()).isBlank() return !normalize(result.rowToken()).isBlank()
|| !normalize(result.groupKey()).isBlank()
|| !normalize(result.rowId()).isBlank() || !normalize(result.rowId()).isBlank()
|| !normalize(result.asin()).isBlank(); || !normalize(result.asin()).isBlank();
} }
@@ -602,6 +616,48 @@ public class SimilarAsinCozeClient {
row.setTitleReason(result.titleReason()); row.setTitleReason(result.titleReason());
row.setAppearanceReason(result.appearanceReason()); row.setAppearanceReason(result.appearanceReason());
row.setPatentReason(result.patentReason()); row.setPatentReason(result.patentReason());
normalizeRequiredBusinessFields(row);
}
private void normalizeRequiredBusinessFields(SimilarAsinResultRowDto row) {
if (row == null || !hasBusinessCozeResult(row)) {
return;
}
boolean filledStock = false;
boolean filledSimilarity = false;
if (normalize(row.getIsStock()).isBlank()) {
row.setIsStock("\u672a\u77e5");
filledStock = true;
}
if (normalize(row.getSimilarity()).isBlank()) {
row.setSimilarity("0%");
filledSimilarity = true;
}
// \u5728 error \u5b57\u6bb5\u8ffd\u52a0 marker\uff0c\u5bfc\u51fa/BI \u4fa7\u53ef\u636e\u6b64\u533a\u5206"\u4e1a\u52a1\u771f\u5b9e\u7a7a"\u4e0e"\u4ee3\u7801\u515c\u5e95\u586b\u7684"\u3002
// \u4ec5\u5728\u539f\u503c\u4e3a\u7a7a\u65f6\u586b\u5165\u515c\u5e95\uff0c\u6240\u4ee5\u4e24\u4e2a marker \u5404\u81ea\u72ec\u7acb\u5224\u5b9a\uff0c\u4e0d\u4f1a\u91cd\u590d\u8ffd\u52a0\u3002
if (filledStock || filledSimilarity) {
StringBuilder marker = new StringBuilder();
if (filledStock) {
marker.append("|coze-default-stock");
}
if (filledSimilarity) {
marker.append("|coze-default-similarity");
}
String existingError = row.getError();
String markerText = marker.toString();
if (existingError == null || existingError.isBlank()) {
row.setError(markerText.startsWith("|") ? markerText.substring(1) : markerText);
} else if (!existingError.contains(markerText.trim())) {
row.setError(existingError + markerText);
}
}
}
private boolean hasBusinessCozeResult(SimilarAsinResultRowDto row) {
return !normalize(row.getIsConform()).isBlank()
|| !normalize(row.getReason()).isBlank()
|| !normalize(row.getCategory()).isBlank()
|| !normalize(row.getStatus()).isBlank();
} }
private RestClient restClient() { private RestClient restClient() {
@@ -1056,6 +1112,65 @@ public class SimilarAsinCozeClient {
return preferred == null || preferred.isBlank() ? fallback : preferred.trim(); return preferred == null || preferred.isBlank() ? fallback : preferred.trim();
} }
private String safeText(String value) {
return value == null ? "" : value.trim();
}
private List<String> safeUrls(List<String> urls) {
if (urls == null || urls.isEmpty()) {
return List.of();
}
return urls.stream()
.map(this::safeText)
.filter(value -> !value.isBlank())
.toList();
}
private void putIfPresent(Map<String, Object> item, String key, String value) {
String normalized = safeText(value);
if (!normalized.isBlank()) {
item.put(key, normalized);
}
}
private void putIfPresent(Map<String, Object> item, String key, List<String> values) {
if (values != null && !values.isEmpty()) {
item.put(key, values);
}
}
private String primaryImageUrl(SimilarAsinResultRowDto row) {
if (row == null) {
return "";
}
String url = safeText(row.getUrl());
if (!url.isBlank()) {
return url;
}
List<String> urls = safeUrls(row.getUrls());
return urls.isEmpty() ? "" : urls.get(0);
}
private List<String> imageUrls(SimilarAsinResultRowDto row) {
if (row == null) {
return List.of();
}
List<String> urls = safeUrls(row.getUrls());
if (!urls.isEmpty()) {
return urls;
}
String primaryUrl = primaryImageUrl(row);
return primaryUrl.isBlank() ? List.of() : List.of(primaryUrl);
}
private String cozeGroupKey(SimilarAsinResultRowDto row) {
if (row == null) {
return "";
}
String groupKey = safeText(row.getGroupKey());
return groupKey.isBlank() ? rowKey(row) : groupKey;
}
private String normalize(String value) { private String normalize(String value) {
return value == null ? "" : value.replace(String.valueOf((char) 0xFEFF), "").replace((char) 0x3000, ' ').trim(); return value == null ? "" : value.replace(String.valueOf((char) 0xFEFF), "").replace((char) 0x3000, ' ').trim();
} }
@@ -1164,7 +1279,7 @@ public class SimilarAsinCozeClient {
public boolean isFailed() { public boolean isFailed() {
String normalized = status == null ? "" : status.trim().toUpperCase(Locale.ROOT); String normalized = status == null ? "" : status.trim().toUpperCase(Locale.ROOT);
return normalized.contains("FAILED") || normalized.contains("ERROR") || normalized.contains("CANCEL"); return normalized.contains("FAIL") || normalized.contains("ERROR") || normalized.contains("CANCEL");
} }
public boolean isFinished() { public boolean isFinished() {

View File

@@ -34,4 +34,8 @@ public class SimilarAsinHistoryItemVo {
private Integer rowCount; private Integer rowCount;
@Schema(description = "历史记录创建时间ISO 本地时间字符串。", example = "2026-04-26T10:30:00") @Schema(description = "历史记录创建时间ISO 本地时间字符串。", example = "2026-04-26T10:30:00")
private String createdAt; private String createdAt;
@Schema(description = "任务开始时间ISO 本地时间字符串。当前等价于 biz_file_task.created_at用户点解析创建任务时刻", example = "2026-04-26T10:00:00")
private String startedAt;
@Schema(description = "任务结束时间ISO 本地时间字符串。SUCCESS/FAILED 时回填,未结束为空。", example = "2026-04-26T10:10:00")
private String finishedAt;
} }

View File

@@ -1,6 +1,7 @@
package com.nanri.aiimage.modules.task.service; package com.nanri.aiimage.modules.task.service;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.config.StorageProperties; import com.nanri.aiimage.config.StorageProperties;
import com.nanri.aiimage.config.TransientStorageProperties; import com.nanri.aiimage.config.TransientStorageProperties;
import com.nanri.aiimage.modules.file.service.object.RustfsObjectStorageService; import com.nanri.aiimage.modules.file.service.object.RustfsObjectStorageService;
@@ -28,6 +29,7 @@ import java.util.zip.GZIPOutputStream;
public class TransientPayloadStorageService { public class TransientPayloadStorageService {
private static final String LOCAL_POINTER_PREFIX = "local:"; private static final String LOCAL_POINTER_PREFIX = "local:";
private static final String LOCAL_INSTANCE_TAG = "i/";
private static final String RUSTFS_POINTER_PREFIX = "rustfs:"; private static final String RUSTFS_POINTER_PREFIX = "rustfs:";
private static final String OSS_POINTER_PREFIX = "oss:"; private static final String OSS_POINTER_PREFIX = "oss:";
private static final String LOCAL_PAYLOAD_DIR = "transient-payload"; private static final String LOCAL_PAYLOAD_DIR = "transient-payload";
@@ -37,6 +39,7 @@ public class TransientPayloadStorageService {
private final RustfsObjectStorageService rustfsObjectStorageService; private final RustfsObjectStorageService rustfsObjectStorageService;
private final OssStorageService ossStorageService; private final OssStorageService ossStorageService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final InstanceMetadata instanceMetadata;
public boolean isWriteEnabled() { public boolean isWriteEnabled() {
return properties.isEnabled() return properties.isEnabled()
@@ -94,7 +97,15 @@ public class TransientPayloadStorageService {
} }
try { try {
if (pointer.startsWith(LOCAL_POINTER_PREFIX)) { if (pointer.startsWith(LOCAL_POINTER_PREFIX)) {
return decodeStoredPayload(readLocalPayload(pointer.substring(LOCAL_POINTER_PREFIX.length()))); String localKey = pointer.substring(LOCAL_POINTER_PREFIX.length());
String ownerInstance = extractLocalInstanceId(localKey);
if (ownerInstance != null && !ownerInstance.equals(instanceMetadata.getInstanceId())) {
// 跨实例消费 local 指针时立即报错,避免悄悄读出空内容造成下游错位。
throw new IllegalStateException(
"transient payload only exists on instance=" + ownerInstance
+ " current=" + instanceMetadata.getInstanceId());
}
return decodeStoredPayload(readLocalPayload(stripLocalInstanceId(localKey)));
} }
if (pointer.startsWith(RUSTFS_POINTER_PREFIX)) { if (pointer.startsWith(RUSTFS_POINTER_PREFIX)) {
return decodeStoredPayload(rustfsObjectStorageService.readObjectAsString(pointer.substring(RUSTFS_POINTER_PREFIX.length()))); return decodeStoredPayload(rustfsObjectStorageService.readObjectAsString(pointer.substring(RUSTFS_POINTER_PREFIX.length())));
@@ -114,7 +125,15 @@ public class TransientPayloadStorageService {
return; return;
} }
if (pointer.startsWith(LOCAL_POINTER_PREFIX)) { if (pointer.startsWith(LOCAL_POINTER_PREFIX)) {
deleteLocalPayload(pointer.substring(LOCAL_POINTER_PREFIX.length())); String localKey = pointer.substring(LOCAL_POINTER_PREFIX.length());
String ownerInstance = extractLocalInstanceId(localKey);
if (ownerInstance != null && !ownerInstance.equals(instanceMetadata.getInstanceId())) {
// 跨实例不能直接删另一台机器上的本地文件,留给该实例的清理任务处理。
log.info("[transient-payload] skip cross-instance local delete pointer={} owner={} current={}",
pointer, ownerInstance, instanceMetadata.getInstanceId());
return;
}
deleteLocalPayload(stripLocalInstanceId(localKey));
return; return;
} }
if (pointer.startsWith(RUSTFS_POINTER_PREFIX)) { if (pointer.startsWith(RUSTFS_POINTER_PREFIX)) {
@@ -191,9 +210,9 @@ public class TransientPayloadStorageService {
try { try {
pointer = RUSTFS_POINTER_PREFIX + rustfsObjectStorageService.uploadText(objectKey, storedContent, verifyAfterUpload); pointer = RUSTFS_POINTER_PREFIX + rustfsObjectStorageService.uploadText(objectKey, storedContent, verifyAfterUpload);
} catch (Exception ex) { } catch (Exception ex) {
log.warn("[transient-payload] rustfs upload failed objectKey={} err={}", // 升级为 ERRORrustfs 失败后只能落到本地,多实例下其他节点读不到,必须能告警。
objectKey, ex.getMessage()); log.error("[transient-payload] rustfs upload failed, fallback to local store instanceId={} objectKey={} err={}",
throw ex; instanceMetadata.getInstanceId(), objectKey, ex.getMessage());
} }
} }
if (pointer == null) { if (pointer == null) {
@@ -226,7 +245,9 @@ public class TransientPayloadStorageService {
StandardOpenOption.CREATE, StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE); StandardOpenOption.WRITE);
return LOCAL_POINTER_PREFIX + objectKey; // 在 local 指针上携带 instanceId 段,跨实例 resolve 时能直接判定来源、避免错读。
String instanceId = sanitizeInstanceId(instanceMetadata.getInstanceId());
return LOCAL_POINTER_PREFIX + LOCAL_INSTANCE_TAG + instanceId + "/" + objectKey;
} catch (Exception ex) { } catch (Exception ex) {
log.warn("[transient-payload] local store failed objectKey={} err={}", objectKey, ex.getMessage()); log.warn("[transient-payload] local store failed objectKey={} err={}", objectKey, ex.getMessage());
return null; return null;
@@ -242,6 +263,47 @@ public class TransientPayloadStorageService {
} }
} }
/**
* 从 local 指针中解析出归属实例 ID。指针形式
* - 新格式:{@code i/<instanceId>/<objectKey>}
* - 老格式:{@code <objectKey>}(无 instanceId 段,返回 null 表示未携带,向后兼容旧任务)。
*/
private String extractLocalInstanceId(String localKey) {
if (localKey == null || !localKey.startsWith(LOCAL_INSTANCE_TAG)) {
return null;
}
String remainder = localKey.substring(LOCAL_INSTANCE_TAG.length());
int slash = remainder.indexOf('/');
if (slash <= 0) {
return null;
}
return remainder.substring(0, slash);
}
/**
* 去掉 local 指针中的 instanceId 段,得到真正的 objectKey。
*/
private String stripLocalInstanceId(String localKey) {
if (localKey == null || !localKey.startsWith(LOCAL_INSTANCE_TAG)) {
return localKey;
}
String remainder = localKey.substring(LOCAL_INSTANCE_TAG.length());
int slash = remainder.indexOf('/');
if (slash < 0) {
return remainder;
}
return remainder.substring(slash + 1);
}
private String sanitizeInstanceId(String value) {
String normalized = value == null ? "" : value.trim();
if (normalized.isBlank()) {
return "unknown";
}
// 仅允许字母、数字、下划线、横线和点,避免出现路径分隔符破坏 objectKey 解析。
return normalized.replaceAll("[^A-Za-z0-9_.\\-]", "_");
}
private String encodeStoredPayload(String content) { private String encodeStoredPayload(String content) {
try { try {
byte[] raw = Objects.requireNonNullElse(content, "").getBytes(StandardCharsets.UTF_8); byte[] raw = Objects.requireNonNullElse(content, "").getBytes(StandardCharsets.UTF_8);

View File

@@ -37,7 +37,7 @@ AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_ID=7632683471312355338
AIIMAGE_APPEARANCE_PATENT_COZE_TOKEN= AIIMAGE_APPEARANCE_PATENT_COZE_TOKEN=
AIIMAGE_APPEARANCE_PATENT_COZE_BATCH_SIZE=50 AIIMAGE_APPEARANCE_PATENT_COZE_BATCH_SIZE=50
AIIMAGE_APPEARANCE_PATENT_COZE_READ_TIMEOUT_MILLIS=60000 AIIMAGE_APPEARANCE_PATENT_COZE_READ_TIMEOUT_MILLIS=60000
AIIMAGE_APPEARANCE_PATENT_STALE_TIMEOUT_MINUTES=20 AIIMAGE_APPEARANCE_PATENT_STALE_TIMEOUT_MINUTES=30
AIIMAGE_SIMILAR_ASIN_COZE_BASE_URL=https://api.coze.cn AIIMAGE_SIMILAR_ASIN_COZE_BASE_URL=https://api.coze.cn
AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_PATH=/v1/workflow/run AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_PATH=/v1/workflow/run
@@ -45,7 +45,7 @@ AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_ID=7635328462404583478
AIIMAGE_SIMILAR_ASIN_COZE_TOKEN= AIIMAGE_SIMILAR_ASIN_COZE_TOKEN=
AIIMAGE_SIMILAR_ASIN_COZE_BATCH_SIZE=50 AIIMAGE_SIMILAR_ASIN_COZE_BATCH_SIZE=50
AIIMAGE_SIMILAR_ASIN_COZE_READ_TIMEOUT_MILLIS=60000 AIIMAGE_SIMILAR_ASIN_COZE_READ_TIMEOUT_MILLIS=60000
AIIMAGE_SIMILAR_ASIN_STALE_TIMEOUT_MINUTES=20 AIIMAGE_SIMILAR_ASIN_STALE_TIMEOUT_MINUTES=30
AIIMAGE_ROCKETMQ_NAME_SERVER=121.196.149.225:9876 AIIMAGE_ROCKETMQ_NAME_SERVER=121.196.149.225:9876
AIIMAGE_RESULT_FILE_JOB_MQ_ENABLED=true AIIMAGE_RESULT_FILE_JOB_MQ_ENABLED=true

View File

@@ -148,31 +148,34 @@ aiimage:
stuck-timeout-minutes: ${AIIMAGE_RESULT_FILE_JOB_STUCK_TIMEOUT_MINUTES:30} stuck-timeout-minutes: ${AIIMAGE_RESULT_FILE_JOB_STUCK_TIMEOUT_MINUTES:30}
batch-size: ${AIIMAGE_RESULT_FILE_JOB_BATCH_SIZE:20} batch-size: ${AIIMAGE_RESULT_FILE_JOB_BATCH_SIZE:20}
coze-task: coze-task:
max-concurrent: ${AIIMAGE_COZE_TASK_MAX_CONCURRENT:8} max-concurrent: ${AIIMAGE_COZE_TASK_MAX_CONCURRENT:12}
appearance-patent: appearance-patent:
coze-base-url: ${AIIMAGE_APPEARANCE_PATENT_COZE_BASE_URL:https://api.coze.cn} coze-base-url: ${AIIMAGE_APPEARANCE_PATENT_COZE_BASE_URL:https://api.coze.cn}
coze-workflow-path: ${AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_PATH:/v1/workflow/run} coze-workflow-path: ${AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_PATH:/v1/workflow/run}
coze-workflow-id: ${AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_ID:7639685157562089513} coze-workflow-id: ${AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_ID:7639685157562089513}
coze-token: ${AIIMAGE_APPEARANCE_PATENT_COZE_TOKEN:Bearer sat_CztofPRhIKFKeaPZBIRxocfckqhsdCZZ45NPhkf7WOZjbX36vnVSfVV30T6u2rSX} coze-token: ${AIIMAGE_APPEARANCE_PATENT_COZE_TOKEN:}
coze-batch-size: ${AIIMAGE_APPEARANCE_PATENT_COZE_BATCH_SIZE:20} coze-batch-size: ${AIIMAGE_APPEARANCE_PATENT_COZE_BATCH_SIZE:20}
coze-connect-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_CONNECT_TIMEOUT_MILLIS:10000} coze-connect-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_CONNECT_TIMEOUT_MILLIS:10000}
coze-read-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_READ_TIMEOUT_MILLIS:60000} coze-read-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_READ_TIMEOUT_MILLIS:60000}
coze-poll-interval-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_POLL_INTERVAL_MILLIS:30000} coze-poll-interval-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_POLL_INTERVAL_MILLIS:30000}
coze-poll-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_POLL_TIMEOUT_MILLIS:600000} coze-poll-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_POLL_TIMEOUT_MILLIS:600000}
stale-timeout-minutes: ${AIIMAGE_APPEARANCE_PATENT_STALE_TIMEOUT_MINUTES:20} stale-timeout-minutes: ${AIIMAGE_APPEARANCE_PATENT_STALE_TIMEOUT_MINUTES:30}
stale-finalize-cron: ${AIIMAGE_APPEARANCE_PATENT_STALE_FINALIZE_CRON:0 */2 * * * *} stale-finalize-cron: ${AIIMAGE_APPEARANCE_PATENT_STALE_FINALIZE_CRON:0 */2 * * * *}
similar-asin: similar-asin:
coze-base-url: ${AIIMAGE_SIMILAR_ASIN_COZE_BASE_URL:https://api.coze.cn} coze-base-url: ${AIIMAGE_SIMILAR_ASIN_COZE_BASE_URL:https://api.coze.cn}
coze-workflow-path: ${AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_PATH:/v1/workflow/run} coze-workflow-path: ${AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_PATH:/v1/workflow/run}
coze-workflow-id: ${AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_ID:7639708860686024756} coze-workflow-id: ${AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_ID:7639708860686024756}
coze-token: ${AIIMAGE_SIMILAR_ASIN_COZE_TOKEN:Bearer sat_vqmb97BiXbMW3XVyG9htWQKjZ2F55CaW2FRrT9bDdPha7a3hxmXnMNCE1XNHdCVU} coze-token: ${AIIMAGE_SIMILAR_ASIN_COZE_TOKEN:}
coze-batch-size: ${AIIMAGE_SIMILAR_ASIN_COZE_BATCH_SIZE:20} coze-batch-size: ${AIIMAGE_SIMILAR_ASIN_COZE_BATCH_SIZE:10}
coze-credential-stripe-size: ${AIIMAGE_SIMILAR_ASIN_COZE_CREDENTIAL_STRIPE_SIZE:1}
coze-connect-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_CONNECT_TIMEOUT_MILLIS:10000} coze-connect-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_CONNECT_TIMEOUT_MILLIS:10000}
coze-read-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_READ_TIMEOUT_MILLIS:60000} coze-read-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_READ_TIMEOUT_MILLIS:60000}
coze-poll-interval-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_POLL_INTERVAL_MILLIS:30000} coze-poll-interval-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_POLL_INTERVAL_MILLIS:30000}
coze-poll-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_POLL_TIMEOUT_MILLIS:600000} coze-poll-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_POLL_TIMEOUT_MILLIS:600000}
stale-timeout-minutes: ${AIIMAGE_SIMILAR_ASIN_STALE_TIMEOUT_MINUTES:20} stale-timeout-minutes: ${AIIMAGE_SIMILAR_ASIN_STALE_TIMEOUT_MINUTES:30}
stale-finalize-cron: ${AIIMAGE_SIMILAR_ASIN_STALE_FINALIZE_CRON:0 */2 * * * *} stale-finalize-cron: ${AIIMAGE_SIMILAR_ASIN_STALE_FINALIZE_CRON:0 */2 * * * *}
coze-include-legacy-api-key: ${AIIMAGE_SIMILAR_ASIN_COZE_INCLUDE_LEGACY_API_KEY:true}
coze-use-legacy-item-field-order: ${AIIMAGE_SIMILAR_ASIN_COZE_USE_LEGACY_ITEM_ORDER:false}
security: security:
shop-credential-key: ${AIIMAGE_SHOP_CREDENTIAL_KEY:change-me-shop-credential-key} shop-credential-key: ${AIIMAGE_SHOP_CREDENTIAL_KEY:change-me-shop-credential-key}
internal-token: ${AIIMAGE_INTERNAL_TOKEN:} internal-token: ${AIIMAGE_INTERNAL_TOKEN:}

View File

@@ -0,0 +1,28 @@
-- =============================================================================
-- V52 占位脚本:用于 coze credential token 轮换
-- =============================================================================
-- 背景:
-- V51 历史脚本中以明文形式写入了 4 个生产 coze token已记录在 git 提交记录里)。
-- 出于安全考虑,需要在 coze 平台立刻吊销这 4 个 token并由运维带外用新
-- token 覆盖 biz_coze_credential 表对应记录。
--
-- 本脚本的目的:
-- 1. 不在 git 里写入任何明文新 token保证版本库中再也搜不到 sat_ 前缀的明文。
-- 2. 仅占用 V52 版本号,让 Flyway 顺利推进到下一版本,不影响线上数据。
-- 3. 通过 Flyway history 留下"V52 已飞、token 轮换由运维带外完成"的可审计痕迹。
--
-- 运维操作 (带外执行,不要写进 git)
-- UPDATE biz_coze_credential SET token = '<NEW_TOKEN_HERE>'
-- WHERE module_type = 'APPEARANCE_PATENT' AND credential_name = 'appearance-1';
-- UPDATE biz_coze_credential SET token = '<NEW_TOKEN_HERE>'
-- WHERE module_type = 'APPEARANCE_PATENT' AND credential_name = 'appearance-2';
-- UPDATE biz_coze_credential SET token = '<NEW_TOKEN_HERE>'
-- WHERE module_type = 'SIMILAR_ASIN' AND credential_name = 'similar-1';
-- UPDATE biz_coze_credential SET token = '<NEW_TOKEN_HERE>'
-- WHERE module_type = 'SIMILAR_ASIN' AND credential_name = 'similar-2';
--
-- application.yml 中两个 coze-token 默认值已同步清空,
-- AIIMAGE_*_COZE_TOKEN 环境变量为空时 SimilarAsinCozeClient.hasConfiguredCredential()
-- 会返回 false并打印 "coze token not configured, skip async coze"。
-- =============================================================================
SELECT 1;

View File

@@ -0,0 +1,68 @@
package com.nanri.aiimage.common.util;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class FailedStatusRowFilterTest {
@Test
void findsStatusColumnByChineseOrEnglishAlias() {
assertEquals(2, FailedStatusRowFilter.findStatusColumnIndex(List.of("id", "asin", "\u72b6\u6001")));
assertEquals(1, FailedStatusRowFilter.findStatusColumnIndex(List.of("id", " Status ")));
assertEquals(-1, FailedStatusRowFilter.findStatusColumnIndex(List.of("id", "asin", "country")));
}
@Test
void matchesConfiguredFailedStatusValues() {
assertTrue(FailedStatusRowFilter.matchesFailedStatus("\u9519\u8bef"));
assertTrue(FailedStatusRowFilter.matchesFailedStatus("\u5931\u8d25"));
assertTrue(FailedStatusRowFilter.matchesFailedStatus("FAILED"));
assertTrue(FailedStatusRowFilter.matchesFailedStatus(" failed "));
assertTrue(FailedStatusRowFilter.isBlankStatus(" "));
assertFalse(FailedStatusRowFilter.matchesFailedStatus("\u6210\u529f"));
}
@Test
void retainsOnlyFailedRowsWhenStatusFilteringEnabled() {
List<String> rows = List.of("\u9519\u8bef", "\u6210\u529f", "FAILED", "\u5904\u7406\u4e2d");
FailedStatusRowFilter.FilterResult<String> result =
FailedStatusRowFilter.retainFailedRows(rows, true, value -> value);
assertTrue(result.filterApplied());
assertEquals(List.of("\u9519\u8bef", "FAILED"), result.rows());
assertEquals(2, result.filteredCount());
}
@Test
void keepsOriginalRowsWhenStatusFilteringDisabled() {
List<String> rows = List.of("\u9519\u8bef", "\u6210\u529f");
FailedStatusRowFilter.FilterResult<String> result =
FailedStatusRowFilter.retainFailedRows(rows, false, value -> value);
assertFalse(result.filterApplied());
assertEquals(rows, result.rows());
assertEquals(0, result.filteredCount());
}
@Test
void canRetainFailedAndBlankRowsTogether() {
List<String> rows = List.of("\u9519\u8bef", "", "\u6210\u529f", "FAILED");
FailedStatusRowFilter.FilterResult<String> result =
FailedStatusRowFilter.retainRows(
rows,
true,
value -> value,
value -> FailedStatusRowFilter.matchesFailedStatus(value)
|| FailedStatusRowFilter.isBlankStatus(value)
);
assertTrue(result.filterApplied());
assertEquals(List.of("\u9519\u8bef", "", "FAILED"), result.rows());
assertEquals(1, result.filteredCount());
}
}

View File

@@ -19,7 +19,7 @@
<div class="prompt-card"> <div class="prompt-card">
<div class="section-title">新增条件要求</div> <div class="section-title">新增条件要求</div>
<textarea v-model="aiPrompt" class="prompt-input" rows="8" placeholder="可选,填写后会追加到默认检测提示词中" /> <textarea v-model="aiPrompt" class="prompt-input" rows="8" placeholder="可选,仅在激活任务时传递这里填写的 prompt" />
</div> </div>
<div class="run-row"> <div class="run-row">
@@ -72,6 +72,8 @@
<div class="left"> <div class="left">
<span class="id">{{ item.sourceFilename || '外观专利检测' }}</span> <span class="id">{{ item.sourceFilename || '外观专利检测' }}</span>
<div class="files">任务 ID{{ item.taskId }}</div> <div class="files">任务 ID{{ item.taskId }}</div>
<div class="files">开始时间{{ formatDateTime(item.startedAt || item.createdAt) }}</div>
<div class="files">结束时间{{ formatDateTime(item.finishedAt) }}</div>
<div class="files">行数{{ item.rowCount ?? '-' }}</div> <div class="files">行数{{ item.rowCount ?? '-' }}</div>
<div v-if="pendingResultHint(item)" class="files result-hint">{{ pendingResultHint(item) }}</div> <div v-if="pendingResultHint(item)" class="files result-hint">{{ pendingResultHint(item) }}</div>
<div v-if="showFileProgress(item)" class="file-progress"> <div v-if="showFileProgress(item)" class="file-progress">
@@ -102,6 +104,8 @@
<div class="left"> <div class="left">
<span class="id">{{ item.sourceFilename || '外观专利检测' }}</span> <span class="id">{{ item.sourceFilename || '外观专利检测' }}</span>
<div class="files">任务 ID{{ item.taskId ?? '-' }}</div> <div class="files">任务 ID{{ item.taskId ?? '-' }}</div>
<div class="files">开始时间{{ formatDateTime(item.startedAt || item.createdAt) }}</div>
<div class="files">结束时间{{ formatDateTime(item.finishedAt) }}</div>
<div v-if="item.resultFilename" class="files"> <div v-if="item.resultFilename" class="files">
{{ item.resultFilename || '下载结果' }} {{ item.resultFilename || '下载结果' }}
</div> </div>
@@ -254,6 +258,10 @@ function effectiveAiPrompt() {
return `${defaultAiPrompt}\n\n新增条件要求\n${extraPrompt}` return `${defaultAiPrompt}\n\n新增条件要求\n${extraPrompt}`
} }
function queueAiPrompt() {
return aiPrompt.value.trim()
}
function effectiveCozeApiKey() { function effectiveCozeApiKey() {
return getStoredApiSecret().trim() return getStoredApiSecret().trim()
} }
@@ -314,6 +322,19 @@ function loadPollingIds() {
} }
} }
function formatDateTime(value?: string) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
async function uploadAppearancePathsToJava(paths: Array<string | BrandExpandFolderItem>) { async function uploadAppearancePathsToJava(paths: Array<string | BrandExpandFolderItem>) {
const api = getPywebviewApi() const api = getPywebviewApi()
if (!api?.upload_file_to_java) { if (!api?.upload_file_to_java) {
@@ -427,7 +448,7 @@ async function pushToPythonQueue() {
ts: Date.now(), ts: Date.now(),
data: { data: {
taskId, taskId,
prompt: parsedPayload.aiPrompt || currentParseResult.aiPrompt || effectiveAiPrompt(), prompt: queueAiPrompt(),
api_key: effectiveCozeApiKey(), api_key: effectiveCozeApiKey(),
sourceFileCount: currentParseResult.sourceFileCount || 0, sourceFileCount: currentParseResult.sourceFileCount || 0,
totalRows: currentParseResult.totalRows || 0, totalRows: currentParseResult.totalRows || 0,

View File

@@ -209,6 +209,9 @@
<div v-if="item.platform" class="files"> <div v-if="item.platform" class="files">
平台: {{ item.platform }} 平台: {{ item.platform }}
</div> </div>
<div class="files">
开始时间: {{ formatDateTime(taskStartTime(item.taskId)) }}
</div>
<div class="files"> <div class="files">
创建时间: {{ formatDateTime(item.createdAt) }} 创建时间: {{ formatDateTime(item.createdAt) }}
</div> </div>
@@ -260,6 +263,9 @@
<div v-if="item.platform" class="files"> <div v-if="item.platform" class="files">
平台: {{ item.platform }} 平台: {{ item.platform }}
</div> </div>
<div class="files">
开始时间: {{ formatDateTime(taskStartTime(item.taskId)) }}
</div>
<div class="files"> <div class="files">
创建时间: {{ formatDateTime(item.createdAt) }} 创建时间: {{ formatDateTime(item.createdAt) }}
</div> </div>
@@ -358,6 +364,7 @@ const activeTaskId = ref<number | null>(null);
const activeQueueItem = ref<QueryAsinShopQueueItem | null>(null); const activeQueueItem = ref<QueryAsinShopQueueItem | null>(null);
const queueWorkerRunning = ref(false); const queueWorkerRunning = ref(false);
const autoQueueEnabled = ref(false); const autoQueueEnabled = ref(false);
const taskStartTimes = ref<Record<number, string>>({});
let historyPollTimer: number | null = null; let historyPollTimer: number | null = null;
const timers = createCategorizedTimers("query-asin"); const timers = createCategorizedTimers("query-asin");
@@ -392,6 +399,10 @@ function queueStateStorageKey() {
return `query-asin:queue-state:${uidForStorage()}`; return `query-asin:queue-state:${uidForStorage()}`;
} }
function taskStartTimeStorageKey() {
return `query-asin:start-times:${uidForStorage()}`;
}
function rowKeyForMatch(row: QueryAsinShopQueueItem) { function rowKeyForMatch(row: QueryAsinShopQueueItem) {
return `${(row.shopName || "").trim()}::${row.shopId || ""}`; return `${(row.shopName || "").trim()}::${row.shopId || ""}`;
} }
@@ -600,6 +611,15 @@ function saveQueueState() {
window.localStorage.setItem(queueStateStorageKey(), JSON.stringify(payload)); window.localStorage.setItem(queueStateStorageKey(), JSON.stringify(payload));
} }
function saveTaskStartTimes() {
if (typeof window === "undefined") return;
if (!Object.keys(taskStartTimes.value).length) {
window.localStorage.removeItem(taskStartTimeStorageKey());
return;
}
window.localStorage.setItem(taskStartTimeStorageKey(), JSON.stringify(taskStartTimes.value));
}
function loadQueueState() { function loadQueueState() {
try { try {
const raw = const raw =
@@ -622,6 +642,48 @@ function loadQueueState() {
} }
} }
function loadTaskStartTimes() {
try {
const raw =
typeof window !== "undefined"
? window.localStorage.getItem(taskStartTimeStorageKey())
: null;
const parsed = raw ? JSON.parse(raw) : {};
const next: Record<number, string> = {};
for (const [taskId, value] of Object.entries(parsed || {})) {
const numericTaskId = Number(taskId);
if (!Number.isFinite(numericTaskId) || numericTaskId <= 0) continue;
if (typeof value !== "string" || !value.trim()) continue;
next[numericTaskId] = value;
}
taskStartTimes.value = next;
} catch {
taskStartTimes.value = {};
}
}
function setTaskStartTime(taskId: number, startedAt = new Date().toISOString()) {
if (!Number.isFinite(taskId) || taskId <= 0) return;
taskStartTimes.value = {
...taskStartTimes.value,
[taskId]: startedAt,
};
saveTaskStartTimes();
}
function clearTaskStartTime(taskId?: number | null) {
if (!taskId || !(taskId in taskStartTimes.value)) return;
const next = { ...taskStartTimes.value };
delete next[taskId];
taskStartTimes.value = next;
saveTaskStartTimes();
}
function taskStartTime(taskId?: number | null) {
if (!taskId) return "";
return taskStartTimes.value[taskId] || "";
}
function clearActiveQueueTask() { function clearActiveQueueTask() {
activeTaskId.value = null; activeTaskId.value = null;
activeQueueItem.value = null; activeQueueItem.value = null;
@@ -1003,6 +1065,7 @@ async function processQueue() {
continue; continue;
} }
setTaskStartTime(created.taskId);
queuePushResult.value = queuePushResult.value =
pendingQueue.value.length > 0 pendingQueue.value.length > 0
? `任务 ${created.taskId} 已入队,等待完成后自动继续下一个(剩余 ${pendingQueue.value.length} 条)` ? `任务 ${created.taskId} 已入队,等待完成后自动继续下一个(剩余 ${pendingQueue.value.length} 条)`
@@ -1064,8 +1127,10 @@ async function deleteTaskRecord(item: QueryAsinHistoryItem) {
if (activeTaskId.value === item.taskId) { if (activeTaskId.value === item.taskId) {
clearActiveQueueTask(); clearActiveQueueTask();
} }
clearTaskStartTime(item.taskId);
} else if (item.resultId) { } else if (item.resultId) {
await deleteQueryAsinHistory(item.resultId); await deleteQueryAsinHistory(item.resultId);
clearTaskStartTime(item.taskId);
} else { } else {
throw new Error("缺少可删除的任务标识"); throw new Error("缺少可删除的任务标识");
} }
@@ -1077,6 +1142,7 @@ async function deleteTaskRecord(item: QueryAsinHistoryItem) {
if (item.taskId && activeTaskId.value === item.taskId) { if (item.taskId && activeTaskId.value === item.taskId) {
clearActiveQueueTask(); clearActiveQueueTask();
} }
clearTaskStartTime(item.taskId);
removeHistoryItemLocally(item); removeHistoryItemLocally(item);
await loadCandidates(); await loadCandidates();
resetQueueWorkerIfIdle(); resetQueueWorkerIfIdle();
@@ -1090,6 +1156,7 @@ async function deleteTaskRecord(item: QueryAsinHistoryItem) {
onMounted(async () => { onMounted(async () => {
loadMatchedItems(); loadMatchedItems();
loadQueueState(); loadQueueState();
loadTaskStartTimes();
await Promise.all([loadCandidates(), loadDashboard(), loadHistory()]); await Promise.all([loadCandidates(), loadDashboard(), loadHistory()]);
reconcileActiveQueueTaskWithHistory(); reconcileActiveQueueTaskWithHistory();

View File

@@ -17,33 +17,6 @@
</div> </div>
</div> </div>
<div class="prompt-card">
<div class="section-title">筛选条件</div>
<textarea v-model="filterConditionInput" class="prompt-input" rows="4" placeholder="请输入筛选条件" />
<div class="condition-actions">
<button type="button" class="opt-btn" :disabled="savingCondition" @click="saveFilterCondition">
{{ savingCondition ? '保存中...' : '保存' }}
</button>
</div>
<div class="section-title condition-list-title">备选区</div>
<div v-if="!filterConditions.length" class="empty-conditions">暂无筛选条件输入后保存即可加入备选区</div>
<ul v-else class="condition-list">
<li v-for="condition in filterConditions" :key="condition.id" class="condition-item">
<label class="condition-check">
<input
v-model="selectedFilterConditionId"
type="radio"
name="similar-asin-filter-condition"
:value="condition.id"
@change="applySelectedFilterCondition(condition)"
/>
<span :title="condition.conditionText">{{ condition.conditionText }}</span>
</label>
<button type="button" class="link-danger" @click="removeFilterCondition(condition.id)">删除</button>
</li>
</ul>
</div>
<div class="run-row"> <div class="run-row">
<button type="button" class="btn-run" :disabled="parsing || !uploadedFiles.length" @click="parseFiles"> <button type="button" class="btn-run" :disabled="parsing || !uploadedFiles.length" @click="parseFiles">
{{ parsing ? '解析中...' : '解析并创建任务' }} {{ parsing ? '解析中...' : '解析并创建任务' }}
@@ -94,7 +67,22 @@
<div class="left"> <div class="left">
<span class="id">{{ item.sourceFilename || '货源查询' }}</span> <span class="id">{{ item.sourceFilename || '货源查询' }}</span>
<div class="files">任务 ID{{ item.taskId }}</div> <div class="files">任务 ID{{ item.taskId }}</div>
<div class="files">开始时间{{ formatDateTime(item.startedAt || item.createdAt) }}</div>
<div class="files">结束时间{{ formatDateTime(item.finishedAt) }}</div>
<div class="files">行数{{ item.rowCount ?? '-' }}</div> <div class="files">行数{{ item.rowCount ?? '-' }}</div>
<div v-if="pendingResultHint(item)" class="files result-hint">{{ pendingResultHint(item) }}</div>
<div v-if="showFileProgress(item)" class="file-progress">
<div class="file-progress-meta">
<span>{{ displayFileProgressStage(item, '处理中') }}</span>
<span>总进度 {{ fileProgressPercent(item) }}%</span>
</div>
<div class="file-progress-track">
<div class="file-progress-bar" :style="{ width: `${fileProgressPercent(item)}%` }"></div>
</div>
<div v-if="hasFileProgressCount(item)" class="file-progress-count">
{{ fileProgressCountLabel(item) }}
</div>
</div>
</div> </div>
<div class="task-right"> <div class="task-right">
<span class="status running">{{ statusText(item) }}</span> <span class="status running">{{ statusText(item) }}</span>
@@ -114,20 +102,22 @@
<div class="left"> <div class="left">
<span class="id">{{ item.sourceFilename || '货源查询' }}</span> <span class="id">{{ item.sourceFilename || '货源查询' }}</span>
<div class="files">任务 ID{{ item.taskId ?? '-' }}</div> <div class="files">任务 ID{{ item.taskId ?? '-' }}</div>
<div class="files">开始时间{{ formatDateTime(item.startedAt || item.createdAt) }}</div>
<div class="files">结束时间{{ formatDateTime(item.finishedAt) }}</div>
<div v-if="item.resultFilename" class="files"> <div v-if="item.resultFilename" class="files">
{{ item.resultFilename || '下载结果' }} {{ item.resultFilename || '下载结果' }}
</div> </div>
<div v-if="pendingResultHint(item)" class="files result-hint">{{ pendingResultHint(item) }}</div> <div v-if="pendingResultHint(item)" class="files result-hint">{{ pendingResultHint(item) }}</div>
<div v-if="showFileProgress(item)" class="file-progress"> <div v-if="showFileProgress(item)" class="file-progress">
<div class="file-progress-meta"> <div class="file-progress-meta">
<span>{{ item.fileProgressMessage || '结果生成中' }}</span> <span>{{ displayFileProgressStage(item, '结果生成中') }}</span>
<span>{{ fileProgressPercent(item) }}%</span> <span>总进度 {{ fileProgressPercent(item) }}%</span>
</div> </div>
<div class="file-progress-track"> <div class="file-progress-track">
<div class="file-progress-bar" :style="{ width: `${fileProgressPercent(item)}%` }"></div> <div class="file-progress-bar" :style="{ width: `${fileProgressPercent(item)}%` }"></div>
</div> </div>
<div class="file-progress-count"> <div v-if="hasFileProgressCount(item)" class="file-progress-count">
{{ item.fileProgressCurrent || 0 }}/{{ item.fileProgressTotal || 0 }} {{ fileProgressCountLabel(item) }}
</div> </div>
</div> </div>
<div v-if="item.error" class="files">错误{{ item.error }}</div> <div v-if="item.error" class="files">错误{{ item.error }}</div>
@@ -153,17 +143,13 @@ import { ElMessage } from 'element-plus'
import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue' import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue'
import { import {
activateSimilarAsinTask, activateSimilarAsinTask,
addSimilarAsinFilterCondition,
deleteSimilarAsinFilterCondition,
deleteSimilarAsinHistory, deleteSimilarAsinHistory,
deleteSimilarAsinTask, deleteSimilarAsinTask,
getSimilarAsinDashboard, getSimilarAsinDashboard,
getSimilarAsinHistory, getSimilarAsinHistory,
getSimilarAsinResultDownloadUrl, getSimilarAsinResultDownloadUrl,
getSimilarAsinTaskProgressBatch, getSimilarAsinTaskProgressBatch,
listSimilarAsinFilterConditions,
parseSimilarAsin, parseSimilarAsin,
type SimilarAsinFilterConditionVo,
type SimilarAsinDashboardVo, type SimilarAsinDashboardVo,
type SimilarAsinHistoryItem, type SimilarAsinHistoryItem,
type SimilarAsinParseVo, type SimilarAsinParseVo,
@@ -182,12 +168,8 @@ const uploadedFiles = ref<UploadFileVo[]>([])
const parseResult = ref<SimilarAsinParseVo | null>(null) const parseResult = ref<SimilarAsinParseVo | null>(null)
type TaskSummary = Pick<SimilarAsinParseVo, 'taskId' | 'totalRows' | 'acceptedRows' | 'groupCount' | 'droppedRows'> type TaskSummary = Pick<SimilarAsinParseVo, 'taskId' | 'totalRows' | 'acceptedRows' | 'groupCount' | 'droppedRows'>
const queuedTaskSummary = ref<TaskSummary | null>(null) const queuedTaskSummary = ref<TaskSummary | null>(null)
const filterConditionInput = ref('')
const filterConditions = ref<SimilarAsinFilterConditionVo[]>([])
const selectedFilterConditionId = ref<number | null>(null)
const parsing = ref(false) const parsing = ref(false)
const pushing = ref(false) const pushing = ref(false)
const savingCondition = ref(false)
const queuePayloadText = ref('') const queuePayloadText = ref('')
const pollingTaskIds = ref<number[]>([]) const pollingTaskIds = ref<number[]>([])
const pendingFileTaskIds = ref<number[]>([]) const pendingFileTaskIds = ref<number[]>([])
@@ -238,20 +220,23 @@ const historyOnlyItems = computed(() =>
}), }),
) )
function selectedFilterConditionText() {
const selectedId = selectedFilterConditionId.value
if (selectedId == null) return ''
return filterConditions.value.find((item) => item.id === selectedId)?.conditionText?.trim() || ''
}
function effectiveFilterCondition() {
return filterConditionInput.value.trim() || selectedFilterConditionText()
}
function effectiveCozeApiKey() { function effectiveCozeApiKey() {
return getStoredApiSecret().trim() return getStoredApiSecret().trim()
} }
function formatDateTime(value?: string) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
function maskSecret(secret: string) { function maskSecret(secret: string) {
if (!secret) return '' if (!secret) return ''
if (secret.length <= 10) return '***' if (secret.length <= 10) return '***'
@@ -274,6 +259,10 @@ function uidForStorage() {
return typeof window !== 'undefined' ? window.localStorage.getItem('uid') || '0' : '0' return typeof window !== 'undefined' ? window.localStorage.getItem('uid') || '0' : '0'
} }
function currentUserId() {
return Number(uidForStorage()) || 0
}
function pollingKey() { function pollingKey() {
return `similar-asin:tasks:${uidForStorage()}` return `similar-asin:tasks:${uidForStorage()}`
} }
@@ -368,62 +357,11 @@ async function selectFolder() {
} }
} }
async function loadFilterConditions() {
try {
filterConditions.value = await listSimilarAsinFilterConditions()
const ids = new Set(filterConditions.value.map((item) => item.id))
if (selectedFilterConditionId.value != null && !ids.has(selectedFilterConditionId.value)) {
selectedFilterConditionId.value = null
}
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '加载筛选条件失败')
}
}
async function saveFilterCondition() {
const text = filterConditionInput.value.trim()
if (!text) {
ElMessage.warning('请输入筛选条件')
return
}
savingCondition.value = true
try {
const saved = await addSimilarAsinFilterCondition(text)
await loadFilterConditions()
if (saved?.id) selectedFilterConditionId.value = saved.id
ElMessage.success('筛选条件已保存')
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '保存筛选条件失败')
} finally {
savingCondition.value = false
}
}
function applySelectedFilterCondition(condition: SimilarAsinFilterConditionVo) {
filterConditionInput.value = condition.conditionText || ''
}
async function removeFilterCondition(id: number) {
try {
await deleteSimilarAsinFilterCondition(id)
if (selectedFilterConditionId.value === id) selectedFilterConditionId.value = null
await loadFilterConditions()
ElMessage.success('筛选条件已删除')
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '删除筛选条件失败')
}
}
async function parseFiles() { async function parseFiles() {
if (!uploadedFiles.value.length) { if (!uploadedFiles.value.length) {
ElMessage.warning('请先选择 Excel') ElMessage.warning('请先选择 Excel')
return return
} }
const filterCondition = effectiveFilterCondition()
if (!filterCondition) {
ElMessage.warning('请输入筛选条件,或在备选区选择一个筛选条件')
return
}
if (!effectiveCozeApiKey()) { if (!effectiveCozeApiKey()) {
ElMessage.warning('请先在左上角设置中填写货源查询密钥') ElMessage.warning('请先在左上角设置中填写货源查询密钥')
return return
@@ -435,7 +373,7 @@ async function parseFiles() {
originalFilename: f.originalFilename, originalFilename: f.originalFilename,
relativePath: f.relativePath, relativePath: f.relativePath,
})) }))
const res = await parseSimilarAsin(files, filterCondition, effectiveCozeApiKey()) const res = await parseSimilarAsin(files, effectiveCozeApiKey())
parseResult.value = res parseResult.value = res
queuedTaskSummary.value = null queuedTaskSummary.value = null
queuePayloadText.value = '' queuePayloadText.value = ''
@@ -470,7 +408,7 @@ async function pushToPythonQueue() {
ts: Date.now(), ts: Date.now(),
data: { data: {
taskId, taskId,
prompt: currentParseResult.aiPrompt || effectiveFilterCondition(), user_id: currentUserId(),
api_key: effectiveCozeApiKey(), api_key: effectiveCozeApiKey(),
sourceFileCount: currentParseResult.sourceFileCount || 0, sourceFileCount: currentParseResult.sourceFileCount || 0,
totalRows: currentParseResult.totalRows || 0, totalRows: currentParseResult.totalRows || 0,
@@ -658,6 +596,16 @@ function seedPendingFileTasksFromHistory() {
if (pendingFileTaskIds.value.length) ensurePolling() if (pendingFileTaskIds.value.length) ensurePolling()
} }
function seedRunningTasksFromHistory() {
const ids = historyItems.value
.filter((item) => item.taskId != null && normalizeTaskStatus(item) === 'RUNNING')
.map((item) => item.taskId as number)
if (!ids.length) return
pollingTaskIds.value = Array.from(new Set([...pollingTaskIds.value, ...ids]))
savePollingIds()
ensurePolling()
}
async function loadDashboard() { async function loadDashboard() {
dashboard.value = await getSimilarAsinDashboard() dashboard.value = await getSimilarAsinDashboard()
} }
@@ -668,7 +616,7 @@ async function loadHistory(options: { force?: boolean } = {}) {
if (historyInFlight) return historyInFlight if (historyInFlight) return historyInFlight
historyInFlight = getSimilarAsinHistory() historyInFlight = getSimilarAsinHistory()
.then((res) => { .then((res) => {
historyItems.value = res.items || [] historyItems.value = mergeHistoryItemsPreservingLiveProgress(historyItems.value, res.items || [])
lastHistoryLoadedAt = Date.now() lastHistoryLoadedAt = Date.now()
}) })
.finally(() => { .finally(() => {
@@ -677,6 +625,66 @@ async function loadHistory(options: { force?: boolean } = {}) {
return historyInFlight return historyInFlight
} }
function mergeHistoryItemsPreservingLiveProgress(
previousItems: SimilarAsinHistoryItem[],
incomingItems: SimilarAsinHistoryItem[],
) {
const previousByTaskId = new Map<number, SimilarAsinHistoryItem>()
const previousByResultId = new Map<number, SimilarAsinHistoryItem>()
for (const item of previousItems || []) {
if (item.taskId != null) previousByTaskId.set(item.taskId, item)
if (item.resultId != null) previousByResultId.set(item.resultId, item)
}
return (incomingItems || []).map((incoming) => {
const previous = incoming.resultId != null
? previousByResultId.get(incoming.resultId)
: incoming.taskId != null
? previousByTaskId.get(incoming.taskId)
: undefined
return mergeHistoryItemPreservingLiveProgress(previous, incoming)
})
}
function mergeHistoryItemPreservingLiveProgress(
previous: SimilarAsinHistoryItem | undefined,
incoming: SimilarAsinHistoryItem,
) {
if (!previous) return incoming
const shouldPreserveLiveProgress =
(normalizeTaskStatus(previous) === 'RUNNING' || isResultPreparing(previous) || showFileProgress(previous))
&& !showFileProgress(incoming)
&& !canDownload(incoming)
if (!shouldPreserveLiveProgress) {
return incoming
}
return {
...incoming,
fileJobId: incoming.fileJobId ?? previous.fileJobId,
fileStatus: incoming.fileStatus || previous.fileStatus,
fileError: incoming.fileError || previous.fileError,
fileReady: incoming.fileReady || previous.fileReady,
fileProgressPercent: hasMeaningfulProgressValue(incoming.fileProgressPercent)
? incoming.fileProgressPercent
: previous.fileProgressPercent,
fileProgressCurrent: hasMeaningfulProgressValue(incoming.fileProgressCurrent)
? incoming.fileProgressCurrent
: previous.fileProgressCurrent,
fileProgressTotal: hasMeaningfulProgressValue(incoming.fileProgressTotal)
? incoming.fileProgressTotal
: previous.fileProgressTotal,
fileProgressMessage: (incoming.fileProgressMessage || '').trim()
? incoming.fileProgressMessage
: previous.fileProgressMessage,
}
}
function hasMeaningfulProgressValue(value: unknown) {
const num = Number(value)
return Number.isFinite(num) && num > 0
}
function statusText(item: SimilarAsinHistoryItem) { function statusText(item: SimilarAsinHistoryItem) {
const status = normalizeTaskStatus(item) const status = normalizeTaskStatus(item)
if (status === 'RUNNING') return '执行中' if (status === 'RUNNING') return '执行中'
@@ -717,6 +725,9 @@ function pendingResultHint(item: SimilarAsinHistoryItem) {
if (isResultBuildFailed(item)) { if (isResultBuildFailed(item)) {
return item.fileError || '结果文件生成失败,请稍后重试或检查后端日志' return item.fileError || '结果文件生成失败,请稍后重试或检查后端日志'
} }
if (showFileProgress(item)) {
return ''
}
if (!isResultPreparing(item)) return '' if (!isResultPreparing(item)) return ''
const fileStatus = (item.fileStatus || '').toUpperCase() const fileStatus = (item.fileStatus || '').toUpperCase()
if (fileStatus === 'RUNNING') { if (fileStatus === 'RUNNING') {
@@ -734,12 +745,79 @@ function canDownload(item: SimilarAsinHistoryItem) {
function fileProgressPercent(item: SimilarAsinHistoryItem) { function fileProgressPercent(item: SimilarAsinHistoryItem) {
const percent = Number(item.fileProgressPercent || 0) const percent = Number(item.fileProgressPercent || 0)
if (!Number.isFinite(percent)) return 0 if (Number.isFinite(percent) && percent > 0) {
return Math.max(0, Math.min(100, Math.round(percent))) return Math.max(0, Math.min(100, Math.round(percent)))
}
if (shouldShowFallbackJobProgress(item)) {
return activeFileJobStatus(item) === 'PENDING' ? 6 : 10
}
return 0
}
function displayFileProgressStage(item: SimilarAsinHistoryItem, fallback: string) {
const message = (item.fileProgressMessage || '').trim()
if (!message) return fallback
if (/coze/i.test(message) || /Coze|回流/.test(message)) {
if (/submitting/i.test(message) || /提交/.test(message)) return '正在提交 Coze 批次'
if (/生成结果|结果文件|组装/i.test(message)) return 'Coze 已回流,正在生成结果文件'
if (/waiting/i.test(message) || /等待/.test(message) || /\d+\s*\/\s*\d+/.test(message)) {
return 'Coze 回流中,等待结果回传'
}
return 'Coze 处理中'
}
if (/assembling|xlsx|组装|生成结果/i.test(message)) return '正在组装结果文件'
return message
}
function fileProgressCountLabel(item: SimilarAsinHistoryItem) {
const message = (item.fileProgressMessage || '').trim()
const parsed = parseProgressCountFromMessage(message)
const current = parsed?.current ?? item.fileProgressCurrent ?? 0
const total = parsed?.total ?? item.fileProgressTotal ?? 0
if (/coze/i.test(message) || /Coze|回流/.test(message)) {
if (/submitting/i.test(message) || /提交/.test(message)) {
return `Coze 提交批次 ${current}/${total}`
}
return `Coze 回流批次 ${current}/${total}`
}
return `当前阶段 ${current}/${total}`
}
function parseProgressCountFromMessage(message: string) {
const match = message.match(/(\d+)\s*\/\s*(\d+)/)
if (!match) return null
const current = Number(match[1])
const total = Number(match[2])
if (!Number.isFinite(current) || !Number.isFinite(total)) return null
return { current, total }
} }
function showFileProgress(item: SimilarAsinHistoryItem) { function showFileProgress(item: SimilarAsinHistoryItem) {
return isResultPreparing(item) && (item.fileProgressTotal || 0) > 0 const status = normalizeTaskStatus(item)
return (status === 'RUNNING' || isResultPreparing(item)) && (hasExplicitFileProgress(item) || shouldShowFallbackJobProgress(item))
}
function hasExplicitFileProgress(item: SimilarAsinHistoryItem) {
return hasFileProgressCount(item)
|| Number(item.fileProgressPercent || 0) > 0
|| Boolean((item.fileProgressMessage || '').trim())
}
function hasFileProgressCount(item: SimilarAsinHistoryItem) {
return (item.fileProgressTotal || 0) > 0
}
function activeFileJobStatus(item: SimilarAsinHistoryItem) {
return (item.fileStatus || '').toUpperCase()
}
function hasActiveFileJob(item: SimilarAsinHistoryItem) {
const fileStatus = activeFileJobStatus(item)
return item.fileJobId != null || fileStatus === 'PENDING' || fileStatus === 'RUNNING'
}
function shouldShowFallbackJobProgress(item: SimilarAsinHistoryItem) {
return hasActiveFileJob(item) && !hasExplicitFileProgress(item)
} }
async function downloadResult(item: SimilarAsinHistoryItem) { async function downloadResult(item: SimilarAsinHistoryItem) {
@@ -778,11 +856,11 @@ async function deleteTaskRecord(item: SimilarAsinHistoryItem) {
onMounted(async () => { onMounted(async () => {
loadPollingIds() loadPollingIds()
await Promise.all([ await Promise.all([
loadFilterConditions().catch(() => undefined),
loadDashboard().catch(() => undefined), loadDashboard().catch(() => undefined),
loadHistory().catch(() => undefined), loadHistory().catch(() => undefined),
]) ])
seedPendingFileTasksFromHistory() seedPendingFileTasksFromHistory()
seedRunningTasksFromHistory()
if (pollingTaskIds.value.length || pendingFileTaskIds.value.length) ensurePolling() if (pollingTaskIds.value.length || pendingFileTaskIds.value.length) ensurePolling()
}) })
@@ -811,20 +889,6 @@ onUnmounted(() => {
.btn-run:disabled { opacity: .55; cursor: not-allowed; } .btn-run:disabled { opacity: .55; cursor: not-allowed; }
.selected-files { margin-top: 14px; color: #999; font-size: 12px; word-break: break-all; } .selected-files { margin-top: 14px; color: #999; font-size: 12px; word-break: break-all; }
.selected-files span { display: block; margin: 4px 0; } .selected-files span { display: block; margin: 4px 0; }
.prompt-card { margin-bottom: 14px; }
.prompt-input { width: 100%; box-sizing: border-box; resize: vertical; min-height: 96px; max-height: 140px; padding: 8px 12px; border: 1px solid #333; border-radius: 8px; background: #202020; color: #d8d8d8; font-size: 12px; line-height: 1.5; outline: none; }
.prompt-input:focus { border-color: #3498db; }
.condition-actions { display: flex; justify-content: flex-end; margin-top: 8px; }
.condition-actions .opt-btn { padding: 6px 14px; }
.condition-list-title { margin-top: 10px; }
.empty-conditions { color: #666; font-size: 12px; padding: 10px 4px; }
.condition-list { list-style: none; margin: 0; padding: 0; max-height: 96px; overflow: auto; display: flex; flex-direction: column; gap: 6px; }
.condition-item { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 6px; border-bottom: 1px solid #303030; }
.condition-item:last-child { border-bottom: none; }
.condition-check { display: flex; align-items: center; gap: 8px; min-width: 0; color: #cfd6df; font-size: 12px; cursor: pointer; }
.condition-check input { width: 16px; height: 16px; margin: 0; flex: 0 0 auto; }
.condition-check span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.link-danger { border: none; background: transparent; color: #ff8f8f; cursor: pointer; font-size: 12px; padding: 2px 0; white-space: nowrap; }
.parse-card, .queue-payload { margin-top: 14px; padding: 12px; border: 1px solid #2a2a2a; border-radius: 8px; background: #202020; color: #b8c1cc; font-size: 12px; } .parse-card, .queue-payload { margin-top: 14px; padding: 12px; border: 1px solid #2a2a2a; border-radius: 8px; background: #202020; color: #b8c1cc; font-size: 12px; }
.queue-payload { max-height: 220px; overflow: auto; color: #8fd3ff; white-space: pre-wrap; } .queue-payload { max-height: 220px; overflow: auto; color: #8fd3ff; white-space: pre-wrap; }
.panel-header { padding: 16px 20px; border-bottom: 1px solid #2a2a2a; font-size: 15px; font-weight: 600; color: #ddd; } .panel-header { padding: 16px 20px; border-bottom: 1px solid #2a2a2a; font-size: 15px; font-weight: 600; color: #ddd; }

View File

@@ -1561,6 +1561,8 @@ export interface AppearancePatentHistoryItem {
error?: string; error?: string;
rowCount?: number; rowCount?: number;
createdAt?: string; createdAt?: string;
startedAt?: string;
finishedAt?: string;
} }
export interface AppearancePatentHistoryVo { export interface AppearancePatentHistoryVo {
@@ -1680,12 +1682,6 @@ export function getAppearancePatentResultDownloadUrl(resultId: number) {
// ========== 货源查询 ========== // ========== 货源查询 ==========
export interface SimilarAsinFilterConditionVo {
id: number;
conditionText: string;
createdAt?: string;
}
export interface SimilarAsinParsedRow { export interface SimilarAsinParsedRow {
rowIndex: number; rowIndex: number;
sourceFileKey?: string; sourceFileKey?: string;
@@ -1751,6 +1747,8 @@ export interface SimilarAsinHistoryItem {
error?: string; error?: string;
rowCount?: number; rowCount?: number;
createdAt?: string; createdAt?: string;
startedAt?: string;
finishedAt?: string;
} }
export interface SimilarAsinHistoryVo { export interface SimilarAsinHistoryVo {
@@ -1777,49 +1775,19 @@ export interface SimilarAsinTaskBatchVo {
missingTaskIds?: number[]; missingTaskIds?: number[];
} }
export function parseSimilarAsin(files: UploadedFileRef[], filterCondition: string, apiKey?: string) { export function parseSimilarAsin(files: UploadedFileRef[], apiKey?: string) {
return unwrapJavaResponse( return unwrapJavaResponse(
post< post<
JavaApiResponse<SimilarAsinParseVo>, JavaApiResponse<SimilarAsinParseVo>,
{ user_id: number; files: UploadedFileRef[]; ai_prompt: string; api_key?: string } { user_id: number; files: UploadedFileRef[]; api_key?: string }
>(`${JAVA_API_PREFIX}/similar-asin/parse`, { >(`${JAVA_API_PREFIX}/similar-asin/parse`, {
user_id: getCurrentUserId(), user_id: getCurrentUserId(),
files, files,
ai_prompt: filterCondition,
api_key: apiKey, api_key: apiKey,
}), }),
); );
} }
export function listSimilarAsinFilterConditions() {
return unwrapJavaResponse(
get<JavaApiResponse<SimilarAsinFilterConditionVo[]>>(
`${JAVA_API_PREFIX}/similar-asin/filter-conditions`,
{ params: { user_id: getCurrentUserId() } },
),
);
}
export function addSimilarAsinFilterCondition(conditionText: string) {
return unwrapJavaResponse(
post<
JavaApiResponse<SimilarAsinFilterConditionVo>,
{ user_id: number; condition_text: string }
>(`${JAVA_API_PREFIX}/similar-asin/filter-conditions`, {
user_id: getCurrentUserId(),
condition_text: conditionText,
}),
);
}
export function deleteSimilarAsinFilterCondition(id: number) {
return unwrapJavaResponse(
del<JavaApiResponse<null>>(`${JAVA_API_PREFIX}/similar-asin/filter-conditions/${id}`, {
params: { user_id: getCurrentUserId() },
}),
);
}
export function getSimilarAsinDashboard() { export function getSimilarAsinDashboard() {
return unwrapJavaResponse( return unwrapJavaResponse(
get<JavaApiResponse<SimilarAsinDashboardVo>>( get<JavaApiResponse<SimilarAsinDashboardVo>>(

View File

@@ -1,539 +0,0 @@
<!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

@@ -1,847 +0,0 @@
<!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;
}
.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>
<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">任务队列</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 btnCancelRun = document.getElementById('btnCancelRun');
btn.disabled = false;
loadingMsg.style.display = 'none';
if (runProgressWrap) runProgressWrap.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 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 = '执行中…';
}
}
// 终态也通过轮询兜底,确保 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 })
})
.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 })
})
.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() {});
}
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_new) {
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_new(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

@@ -1,352 +0,0 @@
<!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">当前版本: v1.0.0</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 = '1.0.0';
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

@@ -1,164 +0,0 @@
<!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>