新增内容,新增拉起软件层

This commit is contained in:
super
2026-06-08 09:19:15 +08:00
parent 8ab647c419
commit c85e5b278f
67 changed files with 1552 additions and 189 deletions

View File

@@ -11,7 +11,7 @@ zn_username=%E8%87%AA%E5%8A%A8%E5%8C%96_Robot
client_name=ShuFuAI
# java_api_base=http://api.aishufu.top:18080/
# java_api_base=http://47.111.163.154:18080/
java_api_base=http://api.aishufu.top:18080/
# java_api_base=http://127.0.0.1:18080/

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
import{bN as r}from"./java-modules-WSWRTEaR.js";const n="";function s(e){return r(`${n}/api/brand/expand-folder-recursive`,{folder:e})}export{s as e};
import{b6 as r}from"./java-modules-B9jblDzU.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

View File

@@ -0,0 +1 @@
.image-video-page[data-v-e41486d3]{min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:40px;color:#333;font-family:Microsoft YaHei,PingFang SC,Hiragino Sans GB,sans-serif;background:linear-gradient(#ffffff80,#ffffff80),url(/static/bg.jpg) center/cover no-repeat,#eef3f7}.page-header[data-v-e41486d3]{position:absolute;top:0;left:0;right:0;height:56px;display:flex;align-items:center;justify-content:space-between;padding:0 24px;background:#ffffff80}.header-title[data-v-e41486d3]{color:#333;font-size:18px;font-weight:600}.back-link[data-v-e41486d3]{color:#555;font-size:14px;text-decoration:none}.back-link[data-v-e41486d3]:hover{color:#667eea}.entrances[data-v-e41486d3]{display:flex;gap:32px;flex-wrap:wrap;justify-content:center}.entrance-btn[data-v-e41486d3]{width:160px;height:100px;display:flex;align-items:center;justify-content:center;color:#333;background:#c5c1c1;border:4px solid #b8d4e3;border-radius:12px;cursor:pointer;font:inherit;font-size:18px;font-weight:600;text-decoration:none;transition:all .2s}.entrance-btn[data-v-e41486d3]:hover:not(:disabled){background:#f8fbfd;box-shadow:0 4px 12px #00000014}.entrance-btn[data-v-e41486d3]:disabled{cursor:wait;opacity:.72}.toast[data-v-e41486d3]{position:fixed;bottom:40px;left:50%;padding:12px 24px;max-width:min(420px,calc(100vw - 48px));color:#fff;background:#000000bf;border-radius:8px;font-size:14px;text-align:center;opacity:0;pointer-events:none;transform:translate(-50%);transition:opacity .3s}.toast.show[data-v-e41486d3]{opacity:1}.toast.error[data-v-e41486d3]{background:#b02a37eb}@media(max-width:640px){.image-video-page[data-v-e41486d3]{padding:88px 24px 40px}.entrances[data-v-e41486d3]{width:100%;gap:18px}.entrance-btn[data-v-e41486d3]{width:100%;max-width:220px}}

View File

@@ -0,0 +1 @@
import{d as p,a as m,c as f,e as a,t as _,n as v,g as l,i as b,_ as g,j as y,k as h}from"./pywebview-B8lwamDf.js";const w={class:"image-video-page"},k={class:"entrances","aria-label":"图生视频入口"},C=["disabled"],T=p({__name:"ImageVideoPage",setup(I){const o=l(!1),n=l(""),i=l("normal");let r;function s(t,e="normal"){n.value=t,i.value=e,r&&window.clearTimeout(r),r=window.setTimeout(()=>{n.value=""},2200)}function u(t){s(`${t}暂未开通,敬请期待`)}async function c(){const t=b();if(!t?.launch_yaoayanui){s("当前环境不支持拉起桌面端","error");return}o.value=!0;try{const e=await t.launch_yaoayanui();if(e?.success){s("已发送启动请求");return}s(e?.error||"启动失败","error")}catch(e){s(e instanceof Error?e.message:String(e),"error")}finally{o.value=!1}}return(t,e)=>(m(),f("div",w,[e[2]||(e[2]=a("header",{class:"page-header"},[a("span",{class:"header-title"},"数富AI"),a("a",{class:"back-link",href:"/home"},"返回首页")],-1)),a("main",k,[a("button",{type:"button",class:"entrance-btn",disabled:o.value,onClick:c}," 数字人 ",8,C),a("button",{type:"button",class:"entrance-btn",onClick:e[0]||(e[0]=d=>u("带货视频"))}," 带货视频 "),a("button",{type:"button",class:"entrance-btn",onClick:e[1]||(e[1]=d=>u("混剪"))}," 混剪 ")]),a("div",{class:v(["toast",{show:!!n.value,error:i.value==="error"}])},_(n.value),3)]))}}),B=g(T,[["__scopeId","data-v-e41486d3"]]);y(B).use(h).mount("#app");

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

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

@@ -419,6 +419,74 @@ class WindowAPI:
except Exception as e:
return {'success': False, 'error': str(e)}
def launch_yaoayanui(self):
candidates = []
env_path = os.getenv('YAOAYANUI_EXE', '').strip()
if env_path:
candidates.append(env_path)
candidates.extend([
os.path.join(BASE_DIR, 'resources', 'yaoayanui', 'tauri-app.exe'),
os.path.abspath(os.path.join(
BASE_DIR,
'..',
'..',
'yaoyaoAI',
'yaoayanui',
'src-tauri',
'target',
'release',
'tauri-app.exe',
)),
os.path.abspath(os.path.join(
BASE_DIR,
'..',
'..',
'yaoyaoAI',
'yaoayanui',
'src-tauri',
'target',
'release',
'bundle',
'nsis',
'aiclient_0.1.15_x64-setup.exe',
)),
])
exe_path = ''
for candidate in candidates:
if candidate and os.path.isfile(candidate):
exe_path = os.path.abspath(candidate)
break
if not exe_path:
return {
'success': False,
'error': '未找到 yaoayanui 可执行文件,请设置 YAOAYANUI_EXE',
}
try:
if sys.platform.startswith('win'):
subprocess.Popen(
[exe_path],
cwd=os.path.dirname(exe_path),
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
)
else:
subprocess.Popen(
[exe_path],
cwd=os.path.dirname(exe_path),
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return {'success': True, 'path': exe_path}
except Exception as e:
return {'success': False, 'error': str(e)}
def enqueue_json(self, data):
"""保存任务到队列"""
try:
@@ -538,7 +606,7 @@ def main():
api = WindowAPI(window)
window.expose(api.close, api.minimize, api.maximize, api.toggle_maximize, generate_images, api.save_image, api.save_image_to_folder, api.select_folder, api.select_brand_xlsx_files, api.select_brand_folder,
api.save_file_from_url, api.save_template_xlsx,api.save_template_zip,api.upload_file_to_java,api.open_external_url,api.enqueue_json,api.get_detail_del,api.save_file_from_url_new,api.save_file_from_url_with_progress,api.get_device_id)
api.save_file_from_url, api.save_template_xlsx,api.save_template_zip,api.upload_file_to_java,api.open_external_url,api.launch_yaoayanui,api.enqueue_json,api.get_detail_del,api.save_file_from_url_new,api.save_file_from_url_with_progress,api.get_device_id)
webview.start(
debug=True,
storage_path=cache_path,

View File

@@ -5,9 +5,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>格式转换 - 数富AI</title>
<script type="module" crossorigin src="/assets/convert.js"></script>
<link rel="modulepreload" crossorigin href="/assets/java-modules-WSWRTEaR.js">
<link rel="modulepreload" crossorigin href="/assets/brand-5GpfAyHD.js">
<link rel="stylesheet" crossorigin href="/assets/java-modules-B3hGvRck.css">
<link rel="modulepreload" crossorigin href="/assets/pywebview-B8lwamDf.js">
<link rel="modulepreload" crossorigin href="/assets/java-modules-B9jblDzU.js">
<link rel="modulepreload" crossorigin href="/assets/brand-DE-sObfq.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-xvHHTGU_.css">
<link rel="stylesheet" crossorigin href="/assets/java-modules-CXcfGmAJ.css">
<link rel="stylesheet" crossorigin href="/assets/convert-6oxZNMye.css">
</head>
<body>

View File

@@ -5,9 +5,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>数据去重 - 数富AI</title>
<script type="module" crossorigin src="/assets/dedupe.js"></script>
<link rel="modulepreload" crossorigin href="/assets/java-modules-WSWRTEaR.js">
<link rel="modulepreload" crossorigin href="/assets/brand-5GpfAyHD.js">
<link rel="stylesheet" crossorigin href="/assets/java-modules-B3hGvRck.css">
<link rel="modulepreload" crossorigin href="/assets/pywebview-B8lwamDf.js">
<link rel="modulepreload" crossorigin href="/assets/java-modules-B9jblDzU.js">
<link rel="modulepreload" crossorigin href="/assets/brand-DE-sObfq.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-xvHHTGU_.css">
<link rel="stylesheet" crossorigin href="/assets/java-modules-CXcfGmAJ.css">
<link rel="stylesheet" crossorigin href="/assets/dedupe-DpfPYQDv.css">
</head>
<body>

View File

@@ -5,10 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>删除品牌 - 数富AI</title>
<script type="module" crossorigin src="/assets/delete-brand.js"></script>
<link rel="modulepreload" crossorigin href="/assets/java-modules-WSWRTEaR.js">
<link rel="modulepreload" crossorigin href="/assets/brand-5GpfAyHD.js">
<link rel="modulepreload" crossorigin href="/assets/pywebview-B8lwamDf.js">
<link rel="modulepreload" crossorigin href="/assets/java-modules-B9jblDzU.js">
<link rel="modulepreload" crossorigin href="/assets/brand-DE-sObfq.js">
<link rel="modulepreload" crossorigin href="/assets/categorized-timers-JPA-olTr.js">
<link rel="stylesheet" crossorigin href="/assets/java-modules-B3hGvRck.css">
<link rel="stylesheet" crossorigin href="/assets/pywebview-xvHHTGU_.css">
<link rel="stylesheet" crossorigin href="/assets/java-modules-CXcfGmAJ.css">
<link rel="stylesheet" crossorigin href="/assets/delete-brand-DZ3x5hOX.css">
</head>
<body>

View File

@@ -5,9 +5,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>数据拆分 - 数富AI</title>
<script type="module" crossorigin src="/assets/split.js"></script>
<link rel="modulepreload" crossorigin href="/assets/java-modules-WSWRTEaR.js">
<link rel="modulepreload" crossorigin href="/assets/brand-5GpfAyHD.js">
<link rel="stylesheet" crossorigin href="/assets/java-modules-B3hGvRck.css">
<link rel="modulepreload" crossorigin href="/assets/pywebview-B8lwamDf.js">
<link rel="modulepreload" crossorigin href="/assets/java-modules-B9jblDzU.js">
<link rel="modulepreload" crossorigin href="/assets/brand-DE-sObfq.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-xvHHTGU_.css">
<link rel="stylesheet" crossorigin href="/assets/java-modules-CXcfGmAJ.css">
<link rel="stylesheet" crossorigin href="/assets/split-CR1PtDQE.css">
</head>
<body>

View File

@@ -1,15 +1,22 @@
<!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; }
* {
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: 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;
@@ -19,6 +26,7 @@
justify-content: center;
padding: 40px;
}
.header {
position: absolute;
top: 0;
@@ -29,20 +37,40 @@
align-items: center;
justify-content: space-between;
padding: 0 24px;
background: rgba(255,255,255,0.5);
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-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.header-right a:hover { color: #667eea; }
.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;
@@ -59,21 +87,27 @@
transition: all 0.2s;
text-decoration: none;
}
.entrance-btn:hover {
background: #f8fbfd;
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.entrance-btn.wb { cursor: pointer; }
.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);
background: rgba(0, 0, 0, 0.75);
color: #fff;
padding: 12px 24px;
border-radius: 8px;
@@ -82,19 +116,25 @@
transition: opacity 0.3s;
pointer-events: none;
}
.toast.show { opacity: 1; }
.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);
background: rgba(255, 255, 255, 0.85);
border-radius: 12px;
border: 1px solid rgba(0,0,0,0.06);
border: 1px solid rgba(0, 0, 0, 0.06);
max-width: 420px;
}
.update-section-title {
font-size: 14px;
color: #333;
@@ -103,16 +143,19 @@
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;
@@ -122,15 +165,18 @@
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;
@@ -141,15 +187,17 @@
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);
background: rgba(102, 126, 234, 0.12);
color: #4c5bd4;
cursor: pointer;
display: flex;
@@ -158,22 +206,25 @@
font-size: 15px;
padding: 0;
}
.header-update-btn:hover {
background: rgba(102,126,234,0.2);
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);
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);
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;
@@ -184,12 +235,13 @@
}
</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>-->
<!-- <a href="/admin" class="admin-link">用户管理</a>-->
{% endif %}
<span>{{ username }}</span>
<button type="button" class="header-update-btn" id="homeUpdateToggle" title="检测更新"></button>
@@ -202,7 +254,8 @@
</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>
<button type="button" class="btn-download-update" id="homeBtnDownloadUpdate"
style="display: none;">立即更新</button>
</div>
</div>
</div>
@@ -210,7 +263,7 @@
<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" href="/new_web_source/image-video.html" data-column-key="wb">图生视频</a>
<a class="entrance-btn image" href="/image" data-column-key="image">图片</a>
</div>
@@ -221,44 +274,44 @@
return 'app_column_permissions:' + String(uid || '');
}
// 权限校验:根据 Java column-permissions 接口按 column_key 显示入口
(function() {
localStorage.setItem("uid",{{ user_id }})
(function () {
localStorage.setItem("uid", {{ user_id }});
var uid = document.body.getAttribute('data-user-id');
if (!uid) return;
var cacheKey = getAppPermissionCacheKey(uid);
var AUTH_TOKEN_KEY = 'aiimage_auth_token';
var JAVA_API_BASE = "{{ java_api_base or '' }}".replace(/\/+$/, '');
var token = '';
try { token = localStorage.getItem(AUTH_TOKEN_KEY) || ''; } catch (e) {}
try { token = localStorage.getItem(AUTH_TOKEN_KEY) || ''; } catch (e) { }
if (!JAVA_API_BASE || !token) return;
var apiUrl = JAVA_API_BASE + '/api/admin/permission-users/' + uid + '/column-permissions?menuType=app';
fetch(apiUrl, {
credentials: 'include',
headers: { 'Authorization': 'Bearer ' + token }
})
.then(function(r) { return r.json(); })
.then(function(res) {
.then(function (r) { return r.json(); })
.then(function (res) {
if (!res || !res.success || !Array.isArray(res.data)) return;
try {
localStorage.setItem(cacheKey, JSON.stringify(res.data));
} catch (e) {}
var allowedKeys = res.data.map(function(item) { return (item.columnKey || item.column_key || '').toLowerCase(); });
document.querySelectorAll('.entrances .entrance-btn[data-column-key]').forEach(function(btn) {
} catch (e) { }
var allowedKeys = res.data.map(function (item) { return (item.columnKey || 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() {});
.catch(function () { });
})();
document.querySelectorAll('.entrance-btn.disabled').forEach(function(btn) {
btn.onclick = function(e) {
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);
setTimeout(function () { t.classList.remove('show'); }, 2000);
};
});
@@ -267,7 +320,7 @@
var AUTH_TOKEN_KEY = 'aiimage_auth_token';
var JAVA_API_BASE = "{{ java_api_base or '' }}".replace(/\/+$/, '');
var token = '';
try { token = localStorage.getItem(AUTH_TOKEN_KEY) || ''; } catch (e) {}
try { token = localStorage.getItem(AUTH_TOKEN_KEY) || ''; } catch (e) { }
function cleanLocal() {
try {
@@ -304,7 +357,7 @@
}
// 软件更新(与 index 设置中逻辑一致)
(function() {
(function () {
const versionText = document.getElementById('homeVersionText');
const updateHint = document.getElementById('homeUpdateHint');
const btnDownload = document.getElementById('homeBtnDownloadUpdate');
@@ -316,25 +369,25 @@
versionText.textContent = '当前版本: v' + currentVersion;
// 打开/关闭浮层
toggleBtn.addEventListener('click', function(e) {
toggleBtn.addEventListener('click', function (e) {
e.stopPropagation();
const isVisible = updatePanel.style.display === 'block';
updatePanel.style.display = isVisible ? 'none' : 'block';
});
// 点击外部关闭浮层
document.addEventListener('click', function() {
document.addEventListener('click', function () {
updatePanel.style.display = 'none';
});
updatePanel.addEventListener('click', function(e) {
updatePanel.addEventListener('click', function (e) {
e.stopPropagation();
});
document.getElementById('homeBtnCheckUpdate').onclick = async function() {
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; });
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, '');
@@ -343,7 +396,7 @@
updateHint.textContent = '发现新版本 v' + data.latest_version + (data.desc ? '' + data.desc : '');
btnDownload.style.display = 'inline-block';
btnDownload.textContent = '立即更新';
btnDownload.onclick = async function() {
btnDownload.onclick = async function () {
if (!confirm('有更新,是否现在更新?\n更新将下载安装包并重启程序。')) return;
if (!data.file_url) {
updateHint.textContent = '暂无下载地址,请关注官方渠道。';
@@ -358,7 +411,7 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_url: data.file_url })
});
const result = await updateResp.json().catch(function() { return {}; });
const result = await updateResp.json().catch(function () { return {}; });
if (result.success) {
updateHint.textContent = '更新已启动,程序即将退出...';
} else {
@@ -384,4 +437,5 @@
})();
</script>
</body>
</html>

View File

@@ -24,6 +24,7 @@ AIIMAGE_SERVER_PORT=18080
AIIMAGE_STORAGE_LOCAL_TEMP_DIR=/app/data/tmp
AIIMAGE_RESULT_FILE_JOB_MQ_ENABLED=true
AIIMAGE_ROCKETMQ_NAME_SERVER=121.196.149.225:9876
AIIMAGE_TRANSIENT_STORAGE_ENDPOINT=http://121.196.149.225:9000
```
Recommended startup command:

View File

@@ -83,6 +83,7 @@ public class SimilarAsinProperties {
* 出现淘汰过频影响命中率时可上调到 512MB2GB 堆约束下不建议超过 768MB。
*/
private long imageCacheMaxBytes = 256L * 1024L * 1024L;
private boolean imageDbCacheEnabled = false;
/**
* 是否在 Coze 请求 parameters 中附带 api_key 字段。

View File

@@ -12,4 +12,6 @@ public class TaskImageCacheCleanupProperties {
private int retentionDays = 3;
private int batchSize = 5000;
private int maxBatchesPerRun = 200;
private long maxBytes = 2L * 1024L * 1024L * 1024L;
private long targetBytes = 1L * 1024L * 1024L * 1024L;
}

View File

@@ -17,4 +17,25 @@ public class TransientStorageProperties {
private int writeTimeoutSeconds = 60;
private int uploadMaxRetries = 3;
private int readMaxRetries = 3;
private int deleteMaxRetries = 3;
private int maxConcurrentUploads = 16;
private int maxConcurrentReads = 32;
private int maxConcurrentDeletes = 8;
private long acquirePermitTimeoutMillis = 2000;
private long baseRetryDelayMillis = 500;
private long maxRetryDelayMillis = 5000;
private long retryJitterMillis = 250;
private long failureWindowSeconds = 60;
private int failureWindowThreshold = 20;
private long failureCooldownMillis = 10000;
private int connectionPoolMaxIdle = 0;
private long connectionPoolKeepAliveMillis = 1;
private long warnPayloadBytes = 5L * 1024 * 1024;
private long maxPayloadBytes = 50L * 1024 * 1024;
private long maxStoredPayloadBytes = 50L * 1024 * 1024;
private boolean fallbackToLocalOnOversize = true;
private boolean deleteRetryEnabled = true;
private String deleteRetryCron = "0 */5 * * * *";
private int deleteRetryQueueCapacity = 10000;
private int deleteRetryBatchSize = 200;
}

View File

@@ -33,6 +33,7 @@ public class AppearancePatentCozeClient {
private static final String INFRINGEMENT = "侵权";
private static final String NO_INFRINGEMENT = "无侵权";
private static final String BRAND_QUERY_FAILED = "商标查询失败";
private static final String COZE_ASYNC_POLL_TIMEOUT_MESSAGE = "Coze 异步工作流轮询超时";
private final AppearancePatentProperties properties;
private final ObjectMapper objectMapper;
@@ -691,6 +692,18 @@ public class AppearancePatentCozeClient {
}
private AppearancePatentResultRowDto markFailed(AppearancePatentResultRowDto row, String failureMessage) {
if (isCozeAsyncPollTimeout(failureMessage)) {
row.setError(failureMessage);
if (row.getStatus() == null || row.getStatus().isBlank()) {
row.setStatus("FAILED");
row.setFailureSyntheticStatus(true);
}
row.setTitleRisk(null);
row.setAppearanceRisk(null);
row.setPatentRisk(null);
row.setConclusion(null);
return row;
}
String reviewMessage = failureMessage == null || failureMessage.isBlank()
? "Coze 检测失败"
: "Coze 检测失败:" + failureMessage;
@@ -718,6 +731,12 @@ public class AppearancePatentCozeClient {
return row;
}
private boolean isCozeAsyncPollTimeout(String failureMessage) {
String normalized = normalize(failureMessage).toLowerCase(Locale.ROOT);
return normalized.contains(COZE_ASYNC_POLL_TIMEOUT_MESSAGE.toLowerCase(Locale.ROOT))
|| normalized.contains("coze async workflow poll timeout");
}
private boolean shouldSplitBatch(List<AppearancePatentResultRowDto> rows, Exception ex) {
return rows != null && rows.size() > 1 && isRetryableBatchFailure(ex);
}

View File

@@ -102,6 +102,7 @@ public class AppearancePatentTaskService {
private static final String STATUS_RUNNING = "RUNNING";
private static final String STATUS_SUCCESS = "SUCCESS";
private static final String STATUS_FAILED = "FAILED";
private static final String COZE_ASYNC_POLL_TIMEOUT_MESSAGE = "Coze 异步工作流轮询超时";
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_RUNNING = "RUNNING";
@@ -4193,6 +4194,9 @@ public class AppearancePatentTaskService {
}
private String userFacingCozeCellValue(AppearancePatentResultRowDto row, String value) {
if (row != null && isCozeAsyncPollTimeout(row.getError())) {
return "";
}
String normalizedValue = normalize(value);
if (!normalizedValue.isBlank() && !isTechnicalCozeFailure(normalizedValue)) {
return value;
@@ -4211,6 +4215,9 @@ public class AppearancePatentTaskService {
if (row == null) {
return "";
}
if (isCozeAsyncPollTimeout(row.getError())) {
return "";
}
String conclusion = normalize(row.getConclusion());
if (!conclusion.isBlank() && !isTechnicalCozeFailure(conclusion)) {
return row.getConclusion();
@@ -4225,6 +4232,12 @@ public class AppearancePatentTaskService {
return firstNonBlank(row.getConclusion(), "");
}
private boolean isCozeAsyncPollTimeout(String value) {
String normalized = normalize(value).toLowerCase(Locale.ROOT);
return normalized.contains(COZE_ASYNC_POLL_TIMEOUT_MESSAGE.toLowerCase(Locale.ROOT))
|| normalized.contains("coze async workflow poll timeout");
}
private String userFacingStatus(AppearancePatentResultRowDto row) {
if (row == null) {
return "";

View File

@@ -0,0 +1,149 @@
package com.nanri.aiimage.modules.file.service.object;
import com.nanri.aiimage.config.TransientStorageProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicInteger;
@Service
@RequiredArgsConstructor
@Slf4j
public class RustfsDeleteRetryService {
private final TransientStorageProperties properties;
private final RustfsObjectStorageService rustfsObjectStorageService;
private final ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();
private final ConcurrentHashMap<String, RetryItem> pending = new ConcurrentHashMap<>();
private final AtomicInteger queuedCount = new AtomicInteger();
public void enqueue(String objectKey, Throwable cause) {
if (!properties.isDeleteRetryEnabled() || objectKey == null || objectKey.isBlank()) {
return;
}
RetryItem item = pending.computeIfAbsent(objectKey, RetryItem::new);
item.lastError = cause == null ? null : cause.getMessage();
item.lastFailedAt = LocalDateTime.now();
enqueueItem(item, false);
}
@Scheduled(cron = "${aiimage.transient-storage.delete-retry-cron:0 */5 * * * *}")
public void retryPendingDeletes() {
if (!properties.isDeleteRetryEnabled()) {
return;
}
int batchSize = Math.max(1, properties.getDeleteRetryBatchSize());
int processed = 0;
int success = 0;
int failed = 0;
while (processed < batchSize) {
String objectKey = nextQueuedObjectKey(batchSize - processed);
if (objectKey == null) {
break;
}
RetryItem item = pending.get(objectKey);
if (item == null) {
continue;
}
item.queued = false;
processed++;
try {
rustfsObjectStorageService.deleteObjectFromRetry(objectKey);
pending.remove(objectKey);
success++;
} catch (Exception ex) {
failed++;
int failures = item.failures.incrementAndGet();
item.lastError = ex.getMessage();
item.lastFailedAt = LocalDateTime.now();
log.warn("[rustfs] delete retry failed objectKey={} failures={} err={}", objectKey, failures, ex.getMessage());
enqueueItem(item, true);
}
}
if (processed > 0) {
log.info("[rustfs] delete retry batch completed processed={} success={} failed={} pending={} queued={}",
processed, success, failed, pending.size(), queuedCount.get());
}
}
int pendingCount() {
return pending.size();
}
private String nextQueuedObjectKey(int refillLimit) {
String objectKey = queue.poll();
if (objectKey != null) {
decrementQueuedCount();
refillQueue(refillLimit);
return objectKey;
}
if (refillQueue(refillLimit) <= 0) {
return null;
}
objectKey = queue.poll();
if (objectKey != null) {
decrementQueuedCount();
refillQueue(refillLimit);
}
return objectKey;
}
private int refillQueue(int maxItems) {
if (maxItems <= 0) {
return 0;
}
int capacity = Math.max(1, properties.getDeleteRetryQueueCapacity());
if (queuedCount.get() >= capacity) {
return 0;
}
int added = 0;
for (RetryItem item : pending.values()) {
if (added >= maxItems || queuedCount.get() >= capacity) {
break;
}
if (enqueueItem(item, true)) {
added++;
}
}
return added;
}
private void decrementQueuedCount() {
queuedCount.updateAndGet(value -> Math.max(0, value - 1));
}
private boolean enqueueItem(RetryItem item, boolean requeue) {
synchronized (item) {
if (item.queued) {
return false;
}
int capacity = Math.max(1, properties.getDeleteRetryQueueCapacity());
if (queuedCount.get() >= capacity) {
log.error("[rustfs] delete retry queue full, deferred objectKey={} requeue={} pending={} queued={} capacity={} lastError={}",
item.objectKey, requeue, pending.size(), queuedCount.get(), capacity, item.lastError);
return false;
}
item.queued = true;
queue.offer(item.objectKey);
queuedCount.incrementAndGet();
return true;
}
}
private static class RetryItem {
private final String objectKey;
private final AtomicInteger failures = new AtomicInteger();
private volatile boolean queued;
private volatile String lastError;
private volatile LocalDateTime lastFailedAt;
private RetryItem(String objectKey) {
this.objectKey = objectKey;
}
}
}

View File

@@ -1,29 +1,71 @@
package com.nanri.aiimage.modules.file.service.object;
import com.nanri.aiimage.config.TransientStorageProperties;
import io.micrometer.core.instrument.DistributionSummary;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import io.minio.GetObjectArgs;
import io.minio.MinioClient;
import io.minio.PutObjectArgs;
import io.minio.RemoveObjectArgs;
import io.minio.StatObjectArgs;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import okhttp3.ConnectionPool;
import okhttp3.OkHttpClient;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import java.util.concurrent.Semaphore;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
@Service
@RequiredArgsConstructor
@Slf4j
public class RustfsObjectStorageService {
private static final String OP_UPLOAD = "upload";
private static final String OP_READ = "read";
private static final String OP_DELETE = "delete";
private static final String OP_STAT = "stat";
private final TransientStorageProperties properties;
private final ObjectProvider<MeterRegistry> meterRegistryProvider;
private final ObjectProvider<RustfsDeleteRetryService> deleteRetryServiceProvider;
private final Supplier<MinioClient> minioClientSupplier;
private final Semaphore uploadSemaphore;
private final Semaphore readSemaphore;
private final Semaphore deleteSemaphore;
private final AtomicInteger windowFailureCount = new AtomicInteger();
private volatile long failureWindowStartedAtMillis;
private volatile long circuitOpenUntilMillis;
private volatile OkHttpClient httpClient;
@Autowired
public RustfsObjectStorageService(TransientStorageProperties properties,
ObjectProvider<MeterRegistry> meterRegistryProvider,
ObjectProvider<RustfsDeleteRetryService> deleteRetryServiceProvider) {
this(properties, meterRegistryProvider, deleteRetryServiceProvider, null);
}
RustfsObjectStorageService(TransientStorageProperties properties,
ObjectProvider<MeterRegistry> meterRegistryProvider,
ObjectProvider<RustfsDeleteRetryService> deleteRetryServiceProvider,
Supplier<MinioClient> minioClientSupplier) {
this.properties = properties;
this.meterRegistryProvider = meterRegistryProvider;
this.deleteRetryServiceProvider = deleteRetryServiceProvider;
this.minioClientSupplier = minioClientSupplier;
this.uploadSemaphore = new Semaphore(Math.max(1, properties.getMaxConcurrentUploads()));
this.readSemaphore = new Semaphore(Math.max(1, properties.getMaxConcurrentReads()));
this.deleteSemaphore = new Semaphore(Math.max(1, properties.getMaxConcurrentDeletes()));
}
public boolean isConfigured() {
return notBlank(properties.getEndpoint())
&& notBlank(properties.getBucket())
@@ -39,10 +81,10 @@ public class RustfsObjectStorageService {
if (!isConfigured()) {
throw new IllegalStateException("transient storage is not configured");
}
rejectIfCircuitOpen(OP_UPLOAD, objectKey);
byte[] bytes = Objects.requireNonNullElse(content, "").getBytes(StandardCharsets.UTF_8);
Exception last = null;
int maxRetries = Math.max(1, properties.getUploadMaxRetries());
for (int attempt = 1; attempt <= maxRetries; attempt++) {
recordPayloadBytes(bytes.length);
return executeWithRetry(OP_UPLOAD, objectKey, bytes.length, Math.max(1, properties.getUploadMaxRetries()), uploadSemaphore, () -> {
try (ByteArrayInputStream stream = new ByteArrayInputStream(bytes)) {
buildClient().putObject(PutObjectArgs.builder()
.bucket(properties.getBucket())
@@ -54,56 +96,117 @@ public class RustfsObjectStorageService {
verifyObjectVisible(objectKey);
}
return objectKey;
} catch (Exception ex) {
last = ex;
if (attempt < maxRetries) {
log.warn("[rustfs] upload failed, retrying objectKey={} attempt={}/{}", objectKey, attempt, maxRetries, ex);
sleepQuietly(500L * attempt);
}
}
}
throw new IllegalStateException("failed to upload payload to transient storage", last);
});
}
public String readObjectAsString(String objectKey) {
if (!isConfigured()) {
throw new IllegalStateException("transient storage is not configured");
}
Exception last = null;
int maxRetries = Math.max(1, properties.getReadMaxRetries());
for (int attempt = 1; attempt <= maxRetries; attempt++) {
rejectIfCircuitOpen(OP_READ, objectKey);
return executeWithRetry(OP_READ, objectKey, 0, Math.max(1, properties.getReadMaxRetries()), readSemaphore, () -> {
try (var stream = buildClient().getObject(GetObjectArgs.builder()
.bucket(properties.getBucket())
.object(objectKey)
.build())) {
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
} catch (Exception ex) {
last = ex;
if (attempt < maxRetries) {
// 与 uploadText 对齐的线性退避,避免 rustfs 短暂抖动直接打成 read coze batch failed。
log.warn("[rustfs] read failed, retrying objectKey={} attempt={}/{} err={}", objectKey, attempt, maxRetries, ex.getMessage());
sleepQuietly(500L * attempt);
}
}
}
throw new IllegalStateException("failed to read payload from transient storage", last);
});
}
public void deleteObject(String objectKey) {
deleteObject(objectKey, true);
}
void deleteObjectFromRetry(String objectKey) {
deleteObject(objectKey, false);
}
private void deleteObject(String objectKey, boolean enqueueRetryOnFailure) {
if (!isConfigured() || !notBlank(objectKey)) {
return;
}
try {
executeWithRetry(OP_DELETE, objectKey, 0, Math.max(1, properties.getDeleteMaxRetries()), deleteSemaphore, () -> {
buildClient().removeObject(RemoveObjectArgs.builder()
.bucket(properties.getBucket())
.object(objectKey)
.build());
return null;
});
} catch (RuntimeException ex) {
if (enqueueRetryOnFailure && properties.isDeleteRetryEnabled()) {
RustfsDeleteRetryService retryService = deleteRetryServiceProvider == null ? null : deleteRetryServiceProvider.getIfAvailable();
if (retryService != null) {
retryService.enqueue(objectKey, ex);
}
}
throw ex;
}
}
private <T> T executeWithRetry(String operation,
String objectKey,
long bytes,
int maxRetries,
Semaphore semaphore,
CheckedSupplier<T> supplier) {
acquirePermit(operation, objectKey, semaphore);
long startedAt = System.nanoTime();
Exception last = null;
try {
for (int attempt = 1; attempt <= maxRetries; attempt++) {
try {
T result = supplier.get();
resetFailureWindow();
recordOperation(operation, "success", elapsedNanos(startedAt));
log.debug("[rustfs] operation success operation={} objectKey={} attempt={}/{} durationMs={} bytes={}",
operation, objectKey, attempt, maxRetries, elapsedMillis(startedAt), bytes);
return result;
} catch (Exception ex) {
throw new IllegalStateException("failed to delete payload from transient storage", ex);
last = ex;
evictIdleConnections();
recordOperation(operation, attempt < maxRetries ? "retry" : "failure", elapsedNanos(startedAt));
recordFailure(operation, objectKey, ex);
if (attempt < maxRetries) {
long delayMillis = retryDelayMillis(attempt);
log.warn("[rustfs] operation failed, retrying operation={} objectKey={} attempt={}/{} delayMs={} err={}",
operation, objectKey, attempt, maxRetries, delayMillis, ex.getMessage());
sleepQuietly(delayMillis, "retrying rustfs " + operation);
}
}
}
} finally {
semaphore.release();
}
throw new IllegalStateException("failed to " + operation + " payload in transient storage", last);
}
private void acquirePermit(String operation, String objectKey, Semaphore semaphore) {
try {
boolean acquired;
long timeoutMillis = Math.max(0L, properties.getAcquirePermitTimeoutMillis());
if (timeoutMillis == 0L) {
acquired = semaphore.tryAcquire();
} else {
acquired = semaphore.tryAcquire(timeoutMillis, TimeUnit.MILLISECONDS);
}
if (!acquired) {
recordOperation(operation, "rejected", 0L);
throw new IllegalStateException("rustfs " + operation + " concurrency limit reached: " + objectKey);
}
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
recordOperation(operation, "rejected", 0L);
throw new IllegalStateException("interrupted while acquiring rustfs " + operation + " permit", ex);
}
}
private MinioClient buildClient() {
if (minioClientSupplier != null) {
return minioClientSupplier.get();
}
return MinioClient.builder()
.endpoint(properties.getEndpoint())
.credentials(properties.getAccessKeyId(), properties.getAccessKeySecret())
@@ -112,7 +215,7 @@ public class RustfsObjectStorageService {
.build();
}
private OkHttpClient getHttpClient() {
OkHttpClient getHttpClient() {
OkHttpClient current = httpClient;
if (current != null) {
return current;
@@ -123,13 +226,26 @@ public class RustfsObjectStorageService {
.connectTimeout(Math.max(1, properties.getConnectTimeoutSeconds()), TimeUnit.SECONDS)
.readTimeout(Math.max(1, properties.getReadTimeoutSeconds()), TimeUnit.SECONDS)
.writeTimeout(Math.max(1, properties.getWriteTimeoutSeconds()), TimeUnit.SECONDS)
.connectionPool(new ConnectionPool(
Math.max(0, properties.getConnectionPoolMaxIdle()),
Math.max(1L, properties.getConnectionPoolKeepAliveMillis()),
TimeUnit.MILLISECONDS))
.retryOnConnectionFailure(true)
.build();
}
return httpClient;
}
}
private void evictIdleConnections() {
OkHttpClient current = httpClient;
if (current != null) {
current.connectionPool().evictAll();
}
}
private void verifyObjectVisible(String objectKey) {
long startedAt = System.nanoTime();
RuntimeException last = null;
for (int attempt = 1; attempt <= 3; attempt++) {
try {
@@ -137,11 +253,14 @@ public class RustfsObjectStorageService {
.bucket(properties.getBucket())
.object(objectKey)
.build());
recordOperation(OP_STAT, "success", elapsedNanos(startedAt));
return;
} catch (Exception ex) {
last = new IllegalStateException("transient payload is not visible after upload: " + objectKey, ex);
evictIdleConnections();
recordOperation(OP_STAT, attempt < 3 ? "retry" : "failure", elapsedNanos(startedAt));
if (attempt < 3) {
sleepQuietly(100L * attempt);
sleepQuietly(100L * attempt, "verifying transient payload upload");
}
}
}
@@ -150,16 +269,117 @@ public class RustfsObjectStorageService {
: last;
}
private void sleepQuietly(long millis) {
private void rejectIfCircuitOpen(String operation, String objectKey) {
long openUntil = circuitOpenUntilMillis;
if (openUntil <= 0L) {
return;
}
long now = System.currentTimeMillis();
if (now < openUntil) {
recordOperation(operation, "rejected", 0L);
throw new IllegalStateException("rustfs failure cooldown active operation=" + operation + " objectKey=" + objectKey);
}
}
private void recordFailure(String operation, String objectKey, Exception ex) {
long now = System.currentTimeMillis();
long windowMillis = Math.max(1L, properties.getFailureWindowSeconds()) * 1000L;
if (failureWindowStartedAtMillis <= 0L || now - failureWindowStartedAtMillis > windowMillis) {
failureWindowStartedAtMillis = now;
windowFailureCount.set(0);
}
int failures = windowFailureCount.incrementAndGet();
int threshold = Math.max(1, properties.getFailureWindowThreshold());
if ((OP_UPLOAD.equals(operation) || OP_READ.equals(operation)) && failures >= threshold) {
long cooldownMillis = Math.max(0L, properties.getFailureCooldownMillis());
circuitOpenUntilMillis = now + cooldownMillis;
windowFailureCount.set(0);
failureWindowStartedAtMillis = now;
log.error("[rustfs] failure window threshold reached, cooldown enabled operation={} objectKey={} failures={} threshold={} cooldownMs={} err={}",
operation, objectKey, failures, threshold, cooldownMillis, ex.getMessage());
}
}
private void resetFailureWindow() {
windowFailureCount.set(0);
failureWindowStartedAtMillis = 0L;
circuitOpenUntilMillis = 0L;
}
private long retryDelayMillis(int attempt) {
long baseDelay = Math.max(0L, properties.getBaseRetryDelayMillis());
long maxDelay = Math.max(baseDelay, properties.getMaxRetryDelayMillis());
long delay = Math.min(maxDelay, baseDelay * Math.max(1, attempt));
long jitter = Math.max(0L, properties.getRetryJitterMillis());
if (jitter > 0L) {
delay += ThreadLocalRandom.current().nextLong(jitter + 1L);
}
return delay;
}
private void sleepQuietly(long millis, String reason) {
if (millis <= 0L) {
return;
}
try {
Thread.sleep(millis);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new IllegalStateException("interrupted while verifying transient payload upload", ex);
throw new IllegalStateException("interrupted while " + reason, ex);
}
}
private void recordPayloadBytes(long bytes) {
MeterRegistry registry = meterRegistry();
if (registry == null || bytes < 0L) {
return;
}
DistributionSummary.builder("aiimage.rustfs.payload.bytes")
.baseUnit("bytes")
.register(registry)
.record(bytes);
}
private void recordOperation(String operation, String result, long durationNanos) {
MeterRegistry registry = meterRegistry();
if (registry == null) {
return;
}
registry.counter("aiimage.rustfs.operation.total", "operation", operation, "result", result).increment();
if (durationNanos > 0L) {
Timer.builder("aiimage.rustfs.operation.duration")
.tag("operation", operation)
.tag("result", result)
.register(registry)
.record(durationNanos, TimeUnit.NANOSECONDS);
}
}
public void recordLocalFallback() {
MeterRegistry registry = meterRegistry();
if (registry != null) {
registry.counter("aiimage.rustfs.fallback.local.total").increment();
}
}
private MeterRegistry meterRegistry() {
return meterRegistryProvider == null ? null : meterRegistryProvider.getIfAvailable();
}
private long elapsedNanos(long startedAt) {
return System.nanoTime() - startedAt;
}
private long elapsedMillis(long startedAt) {
return TimeUnit.NANOSECONDS.toMillis(elapsedNanos(startedAt));
}
private boolean notBlank(String value) {
return value != null && !value.isBlank();
}
@FunctionalInterface
private interface CheckedSupplier<T> {
T get() throws Exception;
}
}

View File

@@ -1200,5 +1200,3 @@ public class QueryAsinTaskService {
}
}
}

View File

@@ -1,6 +1,7 @@
package com.nanri.aiimage.modules.similarasin.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.nanri.aiimage.config.SimilarAsinProperties;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder.ResizedImage;
import com.nanri.aiimage.modules.task.mapper.TaskImageCacheMapper;
@@ -54,6 +55,7 @@ public class SimilarAsinImagePrefetchService {
private final SimilarAsinImageEmbedder imageEmbedder;
private final TaskImageCacheMapper taskImageCacheMapper;
private final SimilarAsinProperties properties;
/**
* 每个 task 当前 in-flight 的预热 future。enqueue 时如果上一个还没完成,会先等它结束,
@@ -75,6 +77,9 @@ public class SimilarAsinImagePrefetchService {
* 同 task 串行入队(用 inflight map 排队),避免多个 batch 同时打爆图片源站。
*/
public void enqueue(Long taskId, List<String> urls) {
if (!properties.isImageDbCacheEnabled()) {
return;
}
if (taskId == null || urls == null || urls.isEmpty()) {
return;
}
@@ -165,6 +170,9 @@ public class SimilarAsinImagePrefetchService {
* 失败/未命中返回 null由调用方走回退路径。
*/
public byte[] lookup(String url) {
if (!properties.isImageDbCacheEnabled()) {
return null;
}
if (url == null) {
return null;
}

View File

@@ -30,6 +30,15 @@ public interface TaskImageCacheMapper extends BaseMapper<TaskImageCacheEntity> {
@Select("SELECT image_bytes FROM biz_task_image_cache WHERE url_hash = #{urlHash} LIMIT 1")
byte[] selectBytesByUrlHash(@Param("urlHash") String urlHash);
@Select("SELECT COALESCE(SUM(byte_size), 0) FROM biz_task_image_cache")
Long sumByteSize();
@Select("SELECT COALESCE(SUM(byte_size), 0) FROM (SELECT byte_size FROM biz_task_image_cache ORDER BY last_used_at LIMIT #{limit}) oldest")
Long sumOldestBatchByteSize(@Param("limit") int limit);
@Delete("DELETE FROM biz_task_image_cache WHERE last_used_at < #{cutoff} ORDER BY last_used_at LIMIT #{limit}")
int deleteExpiredBatch(@Param("cutoff") LocalDateTime cutoff, @Param("limit") int limit);
@Delete("DELETE FROM biz_task_image_cache ORDER BY last_used_at LIMIT #{limit}")
int deleteOldestBatch(@Param("limit") int limit);
}

View File

@@ -39,18 +39,18 @@ public class TaskImageCacheCleanupService {
int maxBatches = Math.max(1, cleanupProperties.getMaxBatchesPerRun());
LocalDateTime cutoff = LocalDateTime.now().minusDays(retentionDays);
int deletedTotal = 0;
int batches = 0;
while (batches < maxBatches) {
int expiredDeletedTotal = 0;
int expiredBatches = 0;
while (expiredBatches < maxBatches) {
int deleted = taskImageCacheMapper.deleteExpiredBatch(cutoff, batchSize);
if (deleted <= 0) {
break;
}
deletedTotal += deleted;
batches++;
expiredDeletedTotal += deleted;
expiredBatches++;
if (!lockHandle.renew(CLEANUP_LOCK_TTL)) {
log.warn("[task-image-cache-cleanup] stop because lock renew failed deletedTotal={} batches={}",
deletedTotal, batches);
expiredDeletedTotal, expiredBatches);
break;
}
if (deleted < batchSize) {
@@ -58,12 +58,49 @@ public class TaskImageCacheCleanupService {
}
}
if (deletedTotal > 0 || batches >= maxBatches) {
log.info("[task-image-cache-cleanup] finished cutoff={} retentionDays={} deletedTotal={} batches={} batchSize={} maxBatches={}",
cutoff, retentionDays, deletedTotal, batches, batchSize, maxBatches);
long maxBytes = cleanupProperties.getMaxBytes();
long targetBytes = cleanupProperties.getTargetBytes();
int capacityDeletedTotal = 0;
int capacityBatches = 0;
if (maxBytes > 0L) {
long actualTargetBytes = targetBytes > 0L && targetBytes < maxBytes ? targetBytes : maxBytes / 2L;
long currentBytes = nullToZero(taskImageCacheMapper.sumByteSize());
boolean overCapacity = currentBytes > maxBytes;
while (overCapacity && currentBytes > actualTargetBytes && capacityBatches < maxBatches) {
long batchBytes = nullToZero(taskImageCacheMapper.sumOldestBatchByteSize(batchSize));
if (batchBytes <= 0L) {
break;
}
int deleted = taskImageCacheMapper.deleteOldestBatch(batchSize);
if (deleted <= 0) {
break;
}
capacityDeletedTotal += deleted;
capacityBatches++;
currentBytes = Math.max(0L, currentBytes - batchBytes);
if (!lockHandle.renew(CLEANUP_LOCK_TTL)) {
log.warn("[task-image-cache-cleanup] stop capacity cleanup because lock renew failed deletedTotal={} batches={}",
capacityDeletedTotal, capacityBatches);
break;
}
if (deleted < batchSize) {
break;
}
}
}
if (expiredDeletedTotal > 0 || expiredBatches >= maxBatches
|| capacityDeletedTotal > 0 || capacityBatches >= maxBatches) {
log.info("[task-image-cache-cleanup] finished cutoff={} retentionDays={} expiredDeleted={} expiredBatches={} capacityDeleted={} capacityBatches={} batchSize={} maxBatches={} maxBytes={} targetBytes={}",
cutoff, retentionDays, expiredDeletedTotal, expiredBatches, capacityDeletedTotal,
capacityBatches, batchSize, maxBatches, maxBytes, targetBytes);
}
} catch (Exception ex) {
log.warn("[task-image-cache-cleanup] failed msg={}", ex.getMessage(), ex);
}
}
private static long nullToZero(Long value) {
return value == null ? 0L : value;
}
}

View File

@@ -312,20 +312,39 @@ public class TransientPayloadStorageService {
return content;
}
String objectKey = buildObjectKey(category, moduleType, taskId, scopeHash, entryKey);
long rawBytes = payloadBytes(content);
if (isPositiveLimit(properties.getWarnPayloadBytes()) && rawBytes > properties.getWarnPayloadBytes()) {
log.warn("[transient-payload] payload size exceeds warn threshold category={} moduleType={} taskId={} objectKey={} rawBytes={} warnBytes={}",
category, moduleType, taskId, objectKey, rawBytes, properties.getWarnPayloadBytes());
}
boolean rawOversize = isPositiveLimit(properties.getMaxPayloadBytes()) && rawBytes > properties.getMaxPayloadBytes();
String storedContent = encodeStoredPayload(content);
long storedBytes = payloadBytes(storedContent);
boolean storedOversize = isPositiveLimit(properties.getMaxStoredPayloadBytes()) && storedBytes > properties.getMaxStoredPayloadBytes();
if (rawOversize || storedOversize) {
log.warn("[transient-payload] payload size exceeds rustfs limit category={} moduleType={} taskId={} objectKey={} rawBytes={} storedBytes={} maxRawBytes={} maxStoredBytes={} fallbackToLocal={}",
category, moduleType, taskId, objectKey, rawBytes, storedBytes,
properties.getMaxPayloadBytes(), properties.getMaxStoredPayloadBytes(), properties.isFallbackToLocalOnOversize());
if (!properties.isFallbackToLocalOnOversize()) {
throw new IllegalStateException("transient payload exceeds configured size limit: " + objectKey);
}
}
String pointer = null;
boolean rustfsFallbackToLocal = false;
if (rustfsObjectStorageService.isConfigured()) {
boolean rustfsFallbackToLocal = rawOversize || storedOversize;
if (!rustfsFallbackToLocal && rustfsObjectStorageService.isConfigured()) {
try {
pointer = RUSTFS_POINTER_PREFIX + rustfsObjectStorageService.uploadText(objectKey, storedContent, verifyAfterUpload);
} catch (Exception ex) {
rustfsFallbackToLocal = true;
// 升级为 ERRORrustfs 失败后只能落到本地,多实例下其他节点读不到,必须能告警。
log.error("[transient-payload] rustfs upload failed, fallback to local store instanceId={} category={} taskId={} objectKey={} err={}",
instanceMetadata.getInstanceId(), category, taskId, objectKey, ex.getMessage());
log.error("[transient-payload] rustfs upload failed, fallback to local store instanceId={} category={} moduleType={} taskId={} objectKey={} rawBytes={} storedBytes={} err={}",
instanceMetadata.getInstanceId(), category, moduleType, taskId, objectKey, rawBytes, storedBytes, ex.getMessage());
}
}
if (pointer == null) {
if (rustfsFallbackToLocal) {
rustfsObjectStorageService.recordLocalFallback();
}
pointer = storeLocal(objectKey, storedContent);
}
// P0-3记录本次 store 是否走 local 兜底,供调用方在拿到 pointer 后立即查询。
@@ -429,6 +448,14 @@ public class TransientPayloadStorageService {
}
}
private long payloadBytes(String content) {
return Objects.requireNonNullElse(content, "").getBytes(StandardCharsets.UTF_8).length;
}
private boolean isPositiveLimit(long value) {
return value > 0L;
}
private String decodeStoredPayload(String storedContent) {
if (storedContent == null || storedContent.isBlank()) {
return storedContent;

View File

@@ -19,11 +19,37 @@ AIIMAGE_OSS_ACCESS_KEY_ID=change-me
AIIMAGE_OSS_ACCESS_KEY_SECRET=change-me
AIIMAGE_TRANSIENT_STORAGE_ENABLED=true
AIIMAGE_TRANSIENT_STORAGE_ENDPOINT=http://change-me
AIIMAGE_TRANSIENT_STORAGE_ENDPOINT=http://121.196.149.225:9000
AIIMAGE_TRANSIENT_STORAGE_BUCKET=change-me
AIIMAGE_TRANSIENT_STORAGE_ACCESS_KEY_ID=change-me
AIIMAGE_TRANSIENT_STORAGE_ACCESS_KEY_SECRET=change-me
AIIMAGE_TRANSIENT_STORAGE_REGION=us-east-1
AIIMAGE_TRANSIENT_STORAGE_CONNECT_TIMEOUT_SECONDS=10
AIIMAGE_TRANSIENT_STORAGE_READ_TIMEOUT_SECONDS=60
AIIMAGE_TRANSIENT_STORAGE_WRITE_TIMEOUT_SECONDS=60
AIIMAGE_TRANSIENT_STORAGE_UPLOAD_MAX_RETRIES=3
AIIMAGE_TRANSIENT_STORAGE_READ_MAX_RETRIES=3
AIIMAGE_TRANSIENT_STORAGE_DELETE_MAX_RETRIES=3
AIIMAGE_TRANSIENT_STORAGE_MAX_CONCURRENT_UPLOADS=16
AIIMAGE_TRANSIENT_STORAGE_MAX_CONCURRENT_READS=32
AIIMAGE_TRANSIENT_STORAGE_MAX_CONCURRENT_DELETES=8
AIIMAGE_TRANSIENT_STORAGE_ACQUIRE_PERMIT_TIMEOUT_MILLIS=2000
AIIMAGE_TRANSIENT_STORAGE_BASE_RETRY_DELAY_MILLIS=500
AIIMAGE_TRANSIENT_STORAGE_MAX_RETRY_DELAY_MILLIS=5000
AIIMAGE_TRANSIENT_STORAGE_RETRY_JITTER_MILLIS=250
AIIMAGE_TRANSIENT_STORAGE_FAILURE_WINDOW_SECONDS=60
AIIMAGE_TRANSIENT_STORAGE_FAILURE_WINDOW_THRESHOLD=20
AIIMAGE_TRANSIENT_STORAGE_FAILURE_COOLDOWN_MILLIS=10000
AIIMAGE_TRANSIENT_STORAGE_CONNECTION_POOL_MAX_IDLE=0
AIIMAGE_TRANSIENT_STORAGE_CONNECTION_POOL_KEEP_ALIVE_MILLIS=1
AIIMAGE_TRANSIENT_STORAGE_WARN_PAYLOAD_BYTES=5242880
AIIMAGE_TRANSIENT_STORAGE_MAX_PAYLOAD_BYTES=52428800
AIIMAGE_TRANSIENT_STORAGE_MAX_STORED_PAYLOAD_BYTES=52428800
AIIMAGE_TRANSIENT_STORAGE_FALLBACK_TO_LOCAL_ON_OVERSIZE=true
AIIMAGE_TRANSIENT_STORAGE_DELETE_RETRY_ENABLED=true
AIIMAGE_TRANSIENT_STORAGE_DELETE_RETRY_CRON=0 */5 * * * *
AIIMAGE_TRANSIENT_STORAGE_DELETE_RETRY_QUEUE_CAPACITY=10000
AIIMAGE_TRANSIENT_STORAGE_DELETE_RETRY_BATCH_SIZE=200
AIIMAGE_STORAGE_LOCAL_TEMP_DIR=F:/project/crawler-plugin/backend-java/data/tmp

View File

@@ -100,6 +100,28 @@ aiimage:
read-timeout-seconds: ${AIIMAGE_TRANSIENT_STORAGE_READ_TIMEOUT_SECONDS:60}
write-timeout-seconds: ${AIIMAGE_TRANSIENT_STORAGE_WRITE_TIMEOUT_SECONDS:60}
upload-max-retries: ${AIIMAGE_TRANSIENT_STORAGE_UPLOAD_MAX_RETRIES:3}
read-max-retries: ${AIIMAGE_TRANSIENT_STORAGE_READ_MAX_RETRIES:3}
delete-max-retries: ${AIIMAGE_TRANSIENT_STORAGE_DELETE_MAX_RETRIES:3}
max-concurrent-uploads: ${AIIMAGE_TRANSIENT_STORAGE_MAX_CONCURRENT_UPLOADS:16}
max-concurrent-reads: ${AIIMAGE_TRANSIENT_STORAGE_MAX_CONCURRENT_READS:32}
max-concurrent-deletes: ${AIIMAGE_TRANSIENT_STORAGE_MAX_CONCURRENT_DELETES:8}
acquire-permit-timeout-millis: ${AIIMAGE_TRANSIENT_STORAGE_ACQUIRE_PERMIT_TIMEOUT_MILLIS:2000}
base-retry-delay-millis: ${AIIMAGE_TRANSIENT_STORAGE_BASE_RETRY_DELAY_MILLIS:500}
max-retry-delay-millis: ${AIIMAGE_TRANSIENT_STORAGE_MAX_RETRY_DELAY_MILLIS:5000}
retry-jitter-millis: ${AIIMAGE_TRANSIENT_STORAGE_RETRY_JITTER_MILLIS:250}
failure-window-seconds: ${AIIMAGE_TRANSIENT_STORAGE_FAILURE_WINDOW_SECONDS:60}
failure-window-threshold: ${AIIMAGE_TRANSIENT_STORAGE_FAILURE_WINDOW_THRESHOLD:20}
failure-cooldown-millis: ${AIIMAGE_TRANSIENT_STORAGE_FAILURE_COOLDOWN_MILLIS:10000}
connection-pool-max-idle: ${AIIMAGE_TRANSIENT_STORAGE_CONNECTION_POOL_MAX_IDLE:0}
connection-pool-keep-alive-millis: ${AIIMAGE_TRANSIENT_STORAGE_CONNECTION_POOL_KEEP_ALIVE_MILLIS:1}
warn-payload-bytes: ${AIIMAGE_TRANSIENT_STORAGE_WARN_PAYLOAD_BYTES:5242880}
max-payload-bytes: ${AIIMAGE_TRANSIENT_STORAGE_MAX_PAYLOAD_BYTES:52428800}
max-stored-payload-bytes: ${AIIMAGE_TRANSIENT_STORAGE_MAX_STORED_PAYLOAD_BYTES:52428800}
fallback-to-local-on-oversize: ${AIIMAGE_TRANSIENT_STORAGE_FALLBACK_TO_LOCAL_ON_OVERSIZE:true}
delete-retry-enabled: ${AIIMAGE_TRANSIENT_STORAGE_DELETE_RETRY_ENABLED:true}
delete-retry-cron: ${AIIMAGE_TRANSIENT_STORAGE_DELETE_RETRY_CRON:0 */5 * * * *}
delete-retry-queue-capacity: ${AIIMAGE_TRANSIENT_STORAGE_DELETE_RETRY_QUEUE_CAPACITY:10000}
delete-retry-batch-size: ${AIIMAGE_TRANSIENT_STORAGE_DELETE_RETRY_BATCH_SIZE:200}
storage:
local-temp-dir: ${AIIMAGE_STORAGE_LOCAL_TEMP_DIR:./data/tmp}
cleanup-enabled: ${AIIMAGE_STORAGE_CLEANUP_ENABLED:true}
@@ -142,6 +164,8 @@ aiimage:
retention-days: ${AIIMAGE_TASK_IMAGE_CACHE_RETENTION_DAYS:3}
batch-size: ${AIIMAGE_TASK_IMAGE_CACHE_CLEANUP_BATCH_SIZE:5000}
max-batches-per-run: ${AIIMAGE_TASK_IMAGE_CACHE_CLEANUP_MAX_BATCHES_PER_RUN:200}
max-bytes: ${AIIMAGE_TASK_IMAGE_CACHE_CLEANUP_MAX_BYTES:2147483648}
target-bytes: ${AIIMAGE_TASK_IMAGE_CACHE_CLEANUP_TARGET_BYTES:1073741824}
result-file-job:
mq-enabled: ${AIIMAGE_RESULT_FILE_JOB_MQ_ENABLED:true}
topic: ${AIIMAGE_RESULT_FILE_JOB_TOPIC:aiimage-result-file-job}
@@ -197,6 +221,7 @@ aiimage:
image-download-pool-size: ${AIIMAGE_SIMILAR_ASIN_IMAGE_DOWNLOAD_POOL_SIZE:32}
image-download-timeout-seconds: ${AIIMAGE_SIMILAR_ASIN_IMAGE_DOWNLOAD_TIMEOUT_SECONDS:5}
image-cache-max-bytes: ${AIIMAGE_SIMILAR_ASIN_IMAGE_CACHE_MAX_BYTES:268435456}
image-db-cache-enabled: ${AIIMAGE_SIMILAR_ASIN_IMAGE_DB_CACHE_ENABLED:false}
stale-timeout-minutes: ${AIIMAGE_SIMILAR_ASIN_STALE_TIMEOUT_MINUTES:30}
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}

View File

@@ -25,6 +25,24 @@ class AppearancePatentCozeClientTest {
new BrandCheckClient(new BrandCheckProperties())
);
@Test
void markRowsFailedLeavesUserFacingResultBlankWhenAsyncPollTimeout() {
AppearancePatentResultRowDto row = new AppearancePatentResultRowDto();
row.setId("1");
List<AppearancePatentResultRowDto> failedRows =
client.markRowsFailed(List.of(row), "Coze \u5f02\u6b65\u5de5\u4f5c\u6d41\u8f6e\u8be2\u8d85\u65f6");
assertThat(failedRows).hasSize(1);
AppearancePatentResultRowDto failed = failedRows.get(0);
assertThat(failed.getError()).isEqualTo("Coze \u5f02\u6b65\u5de5\u4f5c\u6d41\u8f6e\u8be2\u8d85\u65f6");
assertThat(failed.getStatus()).isEqualTo("FAILED");
assertThat(failed.getTitleRisk()).isNull();
assertThat(failed.getAppearanceRisk()).isNull();
assertThat(failed.getPatentRisk()).isNull();
assertThat(failed.getConclusion()).isNull();
}
@Test
void titleRiskIsInfringementWhenBrandCheckHasFailedData() {
BrandCheckClient.BrandCheckBatchResult result =

View File

@@ -0,0 +1,40 @@
package com.nanri.aiimage.modules.file.service.object;
import com.nanri.aiimage.config.TransientStorageProperties;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class RustfsDeleteRetryServiceTest {
@Test
void retryPendingDeletesRefillsDeferredItemsBeforeQueueDrainsCompletely() {
TransientStorageProperties properties = new TransientStorageProperties();
properties.setDeleteRetryEnabled(true);
properties.setDeleteRetryQueueCapacity(2);
properties.setDeleteRetryBatchSize(1);
RustfsObjectStorageService rustfs = mock(RustfsObjectStorageService.class);
doAnswer(invocation -> {
String objectKey = invocation.getArgument(0);
if ("b".equals(objectKey)) {
throw new IllegalStateException("still failing");
}
return null;
}).when(rustfs).deleteObjectFromRetry(org.mockito.ArgumentMatchers.anyString());
RustfsDeleteRetryService service = new RustfsDeleteRetryService(properties, rustfs);
service.enqueue("a", new IllegalStateException("failed-a"));
service.enqueue("b", new IllegalStateException("failed-b"));
service.enqueue("c", new IllegalStateException("failed-c"));
service.retryPendingDeletes();
service.retryPendingDeletes();
service.retryPendingDeletes();
verify(rustfs).deleteObjectFromRetry("c");
assertEquals(1, service.pendingCount());
}
}

View File

@@ -0,0 +1,138 @@
package com.nanri.aiimage.modules.file.service.object;
import com.nanri.aiimage.config.TransientStorageProperties;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import io.minio.MinioClient;
import okhttp3.OkHttpClient;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import java.lang.reflect.Field;
import java.util.concurrent.Semaphore;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class RustfsObjectStorageServiceTest {
@Test
void httpClientUsesConfiguredConnectionPoolAndKeepsRetryEnabled() {
TransientStorageProperties properties = configuredProperties();
properties.setConnectionPoolMaxIdle(3);
properties.setConnectionPoolKeepAliveMillis(1500);
RustfsObjectStorageService service = new RustfsObjectStorageService(
properties, provider(new SimpleMeterRegistry()), emptyProvider(), null);
OkHttpClient client = service.getHttpClient();
assertTrue(client.retryOnConnectionFailure(), "RustFS OkHttp 应开启连接失败重试");
assertNotNull(client.connectionPool(), "RustFS OkHttp 应使用显式 connectionPool 配置");
}
@Test
void deleteFailureRetriesAndEnqueuesCompensation() {
TransientStorageProperties properties = configuredProperties();
properties.setDeleteMaxRetries(2);
properties.setBaseRetryDelayMillis(0);
properties.setRetryJitterMillis(0);
RustfsDeleteRetryService retryService = mock(RustfsDeleteRetryService.class);
RustfsObjectStorageService service = new RustfsObjectStorageService(
properties, emptyProvider(), provider(retryService), () -> {
throw new IllegalStateException("rustfs down");
});
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> service.deleteObject("task/a.json"));
assertTrue(ex.getMessage().contains("delete"));
verify(retryService).enqueue(any(String.class), any(Throwable.class));
}
@Test
void deleteFromRetryDoesNotEnqueueAgain() {
TransientStorageProperties properties = configuredProperties();
properties.setDeleteMaxRetries(1);
RustfsDeleteRetryService retryService = mock(RustfsDeleteRetryService.class);
RustfsObjectStorageService service = new RustfsObjectStorageService(
properties, emptyProvider(), provider(retryService), () -> {
throw new IllegalStateException("rustfs down");
});
assertThrows(IllegalStateException.class, () -> service.deleteObjectFromRetry("task/a.json"));
verify(retryService, never()).enqueue(any(String.class), any(Throwable.class));
}
@Test
void readPermitTimeoutRejectsImmediately() throws Exception {
TransientStorageProperties properties = configuredProperties();
properties.setAcquirePermitTimeoutMillis(0);
properties.setMaxConcurrentReads(1);
RustfsObjectStorageService service = new RustfsObjectStorageService(
properties, emptyProvider(), emptyProvider(), () -> mock(MinioClient.class));
Semaphore readSemaphore = privateSemaphore(service, "readSemaphore");
assertTrue(readSemaphore.tryAcquire());
try {
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> service.readObjectAsString("task/a.json"));
assertTrue(ex.getMessage().contains("concurrency limit"));
} finally {
readSemaphore.release();
}
}
@Test
void failureWindowOpensCooldownForUpload() {
TransientStorageProperties properties = configuredProperties();
properties.setUploadMaxRetries(1);
properties.setFailureWindowThreshold(1);
properties.setFailureCooldownMillis(10000);
RustfsObjectStorageService service = new RustfsObjectStorageService(
properties, emptyProvider(), emptyProvider(), () -> {
throw new IllegalStateException("rustfs down");
});
assertThrows(IllegalStateException.class, () -> service.uploadText("task/a.json", "{}", false));
IllegalStateException rejected = assertThrows(IllegalStateException.class,
() -> service.uploadText("task/b.json", "{}", false));
assertTrue(rejected.getMessage().contains("cooldown active"));
}
private static TransientStorageProperties configuredProperties() {
TransientStorageProperties properties = new TransientStorageProperties();
properties.setEndpoint("http://127.0.0.1:9000");
properties.setBucket("bucket");
properties.setAccessKeyId("ak");
properties.setAccessKeySecret("sk");
properties.setBaseRetryDelayMillis(0);
properties.setRetryJitterMillis(0);
return properties;
}
@SuppressWarnings("unchecked")
private static Semaphore privateSemaphore(RustfsObjectStorageService service, String fieldName) throws Exception {
Field field = RustfsObjectStorageService.class.getDeclaredField(fieldName);
field.setAccessible(true);
return (Semaphore) field.get(service);
}
private static <T> ObjectProvider<T> provider(T value) {
ObjectProvider<T> provider = mock(ObjectProvider.class);
when(provider.getIfAvailable()).thenReturn(value);
return provider;
}
private static <T> ObjectProvider<T> emptyProvider() {
ObjectProvider<T> provider = mock(ObjectProvider.class);
when(provider.getIfAvailable()).thenReturn(null);
return provider;
}
}

View File

@@ -0,0 +1,113 @@
package com.nanri.aiimage.modules.task.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.config.StorageProperties;
import com.nanri.aiimage.config.TransientStorageProperties;
import com.nanri.aiimage.modules.file.service.object.RustfsObjectStorageService;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class TransientPayloadStorageServiceTest {
@TempDir
Path tempDir;
@Test
void oversizeRawPayloadFallsBackToLocalAndKeepsFallbackMarker() {
RustfsObjectStorageService rustfs = mock(RustfsObjectStorageService.class);
when(rustfs.isConfigured()).thenReturn(true);
TransientPayloadStorageService service = newService(rustfs, propertiesWithLimit(4, 1024, true));
String pointer = service.storeParsedPayloadFast("TEST", 1L, "scope", "abcdef", false);
assertTrue(pointer.startsWith("local:i/test-instance/task-parsed/test/1/scope/latest.json"));
assertTrue(service.wasLastStoreLocalFallback());
assertEquals("abcdef", service.resolvePayload(pointer, "read failed"));
verify(rustfs, never()).uploadText(anyString(), anyString(), anyBoolean());
verify(rustfs).recordLocalFallback();
}
@Test
void encodedPayloadLimitCanForceLocalFallback() {
RustfsObjectStorageService rustfs = mock(RustfsObjectStorageService.class);
when(rustfs.isConfigured()).thenReturn(true);
TransientPayloadStorageService service = newService(rustfs, propertiesWithLimit(1024, 1, true));
String pointer = service.storeParsedPayloadFast("TEST", 1L, "scope", "a", false);
assertTrue(pointer.startsWith("local:"));
assertTrue(service.wasLastStoreLocalFallback());
verify(rustfs, never()).uploadText(anyString(), anyString(), anyBoolean());
}
@Test
void oversizePayloadCanBeRejectedByConfiguration() {
RustfsObjectStorageService rustfs = mock(RustfsObjectStorageService.class);
when(rustfs.isConfigured()).thenReturn(true);
TransientPayloadStorageService service = newService(rustfs, propertiesWithLimit(4, 1024, false));
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> service.storeParsedPayloadFast("TEST", 1L, "scope", "abcdef", false));
assertTrue(ex.getMessage().contains("exceeds configured size limit"));
verify(rustfs, never()).uploadText(anyString(), anyString(), anyBoolean());
}
@Test
void smallPayloadStillUsesRustfsAndClearsFallbackMarker() {
RustfsObjectStorageService rustfs = mock(RustfsObjectStorageService.class);
when(rustfs.isConfigured()).thenReturn(true);
when(rustfs.uploadText(anyString(), anyString(), anyBoolean())).thenAnswer(invocation -> invocation.getArgument(0));
TransientPayloadStorageService service = newService(rustfs, propertiesWithLimit(1024, 4096, true));
String pointer = service.storeParsedPayloadFast("TEST", 1L, "scope", "abc", false);
assertTrue(pointer.startsWith("rustfs:task-parsed/test/1/scope/latest.json"));
assertFalse(service.wasLastStoreLocalFallback());
verify(rustfs).uploadText(anyString(), anyString(), anyBoolean());
}
private TransientPayloadStorageService newService(RustfsObjectStorageService rustfs,
TransientStorageProperties transientProperties) {
StorageProperties storageProperties = new StorageProperties();
storageProperties.setLocalTempDir(tempDir.toString());
return new TransientPayloadStorageService(
transientProperties,
storageProperties,
rustfs,
mock(OssStorageService.class),
new ObjectMapper(),
new InstanceMetadata("test-instance"),
mock(TaskChunkMapper.class),
mock(TaskScopeStateMapper.class));
}
private TransientStorageProperties propertiesWithLimit(long maxPayloadBytes,
long maxStoredPayloadBytes,
boolean fallbackToLocalOnOversize) {
TransientStorageProperties properties = new TransientStorageProperties();
properties.setEnabled(true);
properties.setMaxPayloadBytes(maxPayloadBytes);
properties.setMaxStoredPayloadBytes(maxStoredPayloadBytes);
properties.setWarnPayloadBytes(1);
properties.setFallbackToLocalOnOversize(fallbackToLocalOnOversize);
return properties;
}
}

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>图生视频</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/image-video-main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,7 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import '@/styles/main.css'
import ImageVideoPage from '@/pages/image-video/ImageVideoPage.vue'
createApp(ImageVideoPage).use(ElementPlus).mount('#app')

View File

@@ -202,6 +202,9 @@ const dashboard = ref<SimilarAsinDashboardVo>({
failedTaskCount: 0,
})
const historyItems = ref<SimilarAsinHistoryItem[]>([])
// 前端实时进度态:保存最近一次 progress/batch 接口拿到的进度,作为 history 接口的兜底,
// 避免历史接口暂时返回不到该任务、或字段不全时,当前任务区出现进度回退/丢失。
const liveProgressItems = ref<Record<number, SimilarAsinHistoryItem>>({})
const visibleTaskSummary = computed(() => parseResult.value || queuedTaskSummary.value)
const pendingParseItem = computed<SimilarAsinHistoryItem | null>(() => {
@@ -219,10 +222,22 @@ const pendingParseItem = computed<SimilarAsinHistoryItem | null>(() => {
})
const currentItems = computed(() => {
const activeIds = new Set([...pollingTaskIds.value, ...pendingFileTaskIds.value])
const activeItems = historyItems.value.filter((item) => {
const activeItems = historyItems.value
.filter((item) => {
const status = normalizeTaskStatus(item)
return item.taskId != null && (activeIds.has(item.taskId) || status === 'RUNNING' || status === 'PENDING')
})
.map((item) => mergeCurrentTaskItem(item))
// 兜底history 暂未返回该任务,但实时缓存里仍在跑,保留它在当前任务区
for (const [taskIdText, liveItem] of Object.entries(liveProgressItems.value)) {
const taskId = Number(taskIdText)
if (!Number.isFinite(taskId) || taskId <= 0) continue
if (!activeIds.has(taskId) && normalizeTaskStatus(liveItem) !== 'RUNNING' && normalizeTaskStatus(liveItem) !== 'PENDING') {
continue
}
if (activeItems.some((item) => item.taskId === taskId)) continue
activeItems.push(mergeCurrentTaskItem(liveItem))
}
if (!pendingParseItem.value) return activeItems
const exists = activeItems.some((item) => item.taskId === pendingParseItem.value?.taskId)
return exists ? activeItems : [pendingParseItem.value, ...activeItems]
@@ -234,6 +249,20 @@ const historyOnlyItems = computed(() =>
}),
)
function mergeCurrentTaskItem(item: SimilarAsinHistoryItem) {
if (item.taskId == null) return item
const liveItem = liveProgressItems.value[item.taskId]
if (!liveItem) return item
return {
...item,
...liveItem,
sourceFilename: liveItem.sourceFilename || item.sourceFilename,
resultFilename: liveItem.resultFilename || item.resultFilename,
rowCount: liveItem.rowCount ?? item.rowCount,
createdAt: liveItem.createdAt || item.createdAt,
}
}
function effectiveCozeApiKey() {
return getStoredApiSecret('similar-asin').trim()
}
@@ -463,6 +492,11 @@ function addPollingTask(taskId: number) {
function removePollingTask(taskId: number) {
pollingTaskIds.value = pollingTaskIds.value.filter((id) => id !== taskId)
if (!pendingFileTaskIds.value.includes(taskId)) {
const next = { ...liveProgressItems.value }
delete next[taskId]
liveProgressItems.value = next
}
savePollingIds()
}
@@ -476,9 +510,32 @@ function addPendingFileTask(taskId: number) {
function removePendingFileTask(taskId: number) {
pendingFileTaskIds.value = pendingFileTaskIds.value.filter((id) => id !== taskId)
if (!pollingTaskIds.value.includes(taskId)) {
const next = { ...liveProgressItems.value }
delete next[taskId]
liveProgressItems.value = next
}
savePollingIds()
}
function removeTaskFromLocalState(taskId?: number) {
if (!taskId) return
removePollingTask(taskId)
removePendingFileTask(taskId)
if (parseResult.value?.taskId === taskId) {
clearParsedTask()
}
if (queuedTaskSummary.value?.taskId === taskId) {
queuedTaskSummary.value = null
}
historyItems.value = historyItems.value.filter((row) => row.taskId !== taskId)
if (liveProgressItems.value[taskId]) {
const next = { ...liveProgressItems.value }
delete next[taskId]
liveProgressItems.value = next
}
}
function ensurePolling() {
if (disposed) return
if (pollTimer.value != null) return
@@ -528,7 +585,14 @@ async function refreshTaskProgress() {
const task = detail.task
if (!task?.id) continue
const item = detail.items?.[0]
if (item) mergeHistoryItem(item)
if (item) {
// 实时缓存:以最新一次 progress 为准,避免 history 接口字段缺失/延迟时丢失进度
liveProgressItems.value = {
...liveProgressItems.value,
[task.id]: item,
}
mergeHistoryItem(item)
}
if (task.status === 'SUCCESS' || task.status === 'FAILED') {
removePollingTask(task.id)
shouldRefreshDashboard = true
@@ -848,11 +912,17 @@ async function downloadResult(item: SimilarAsinHistoryItem) {
async function deleteTaskRecord(item: SimilarAsinHistoryItem) {
try {
if (item.taskStatus === 'RUNNING' && item.taskId) {
await deleteSimilarAsinTask(item.taskId)
removePollingTask(item.taskId)
const status = normalizeTaskStatus(item)
const taskId = item.taskId
const shouldDeleteTask = taskId != null && (status === 'RUNNING' || status === 'PENDING' || !item.resultId)
if (shouldDeleteTask) {
await deleteSimilarAsinTask(taskId)
removeTaskFromLocalState(taskId)
} else if (item.resultId) {
await deleteSimilarAsinHistory(item.resultId)
if (item.taskId) {
historyItems.value = historyItems.value.filter((row) => row.taskId !== item.taskId)
}
}
await loadDashboard()
await loadHistory({ force: true })

View File

@@ -0,0 +1,198 @@
<template>
<div class="image-video-page">
<header class="page-header">
<span class="header-title">数富AI</span>
<a class="back-link" href="/home">返回首页</a>
</header>
<main class="entrances" aria-label="图生视频入口">
<button
type="button"
class="entrance-btn"
:disabled="launching"
@click="launchDesktop"
>
数字人
</button>
<button type="button" class="entrance-btn" @click="showSoon('带货视频')">
带货视频
</button>
<button type="button" class="entrance-btn" @click="showSoon('混剪')">
混剪
</button>
</main>
<div class="toast" :class="{ show: Boolean(statusText), error: statusType === 'error' }">
{{ statusText }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { getPywebviewApi } from '@/shared/bridges/pywebview'
const launching = ref(false)
const statusText = ref('')
const statusType = ref<'normal' | 'error'>('normal')
let statusTimer: number | undefined
function showStatus(message: string, type: 'normal' | 'error' = 'normal') {
statusText.value = message
statusType.value = type
if (statusTimer) {
window.clearTimeout(statusTimer)
}
statusTimer = window.setTimeout(() => {
statusText.value = ''
}, 2200)
}
function showSoon(name: string) {
showStatus(`${name}暂未开通,敬请期待`)
}
async function launchDesktop() {
const api = getPywebviewApi()
if (!api?.launch_yaoayanui) {
showStatus('当前环境不支持拉起桌面端', 'error')
return
}
launching.value = true
try {
const result = await api.launch_yaoayanui()
if (result?.success) {
showStatus('已发送启动请求')
return
}
showStatus(result?.error || '启动失败', 'error')
} catch (error) {
showStatus(error instanceof Error ? error.message : String(error), 'error')
} finally {
launching.value = false
}
}
</script>
<style scoped>
.image-video-page {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
color: #333;
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,
#eef3f7;
}
.page-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 {
color: #333;
font-size: 18px;
font-weight: 600;
}
.back-link {
color: #555;
font-size: 14px;
text-decoration: none;
}
.back-link:hover {
color: #667eea;
}
.entrances {
display: flex;
gap: 32px;
flex-wrap: wrap;
justify-content: center;
}
.entrance-btn {
width: 160px;
height: 100px;
display: flex;
align-items: center;
justify-content: center;
color: #333;
background: #c5c1c1;
border: 4px solid #b8d4e3;
border-radius: 12px;
cursor: pointer;
font: inherit;
font-size: 18px;
font-weight: 600;
text-decoration: none;
transition: all 0.2s;
}
.entrance-btn:hover:not(:disabled) {
background: #f8fbfd;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.entrance-btn:disabled {
cursor: wait;
opacity: 0.72;
}
.toast {
position: fixed;
bottom: 40px;
left: 50%;
padding: 12px 24px;
max-width: min(420px, calc(100vw - 48px));
color: #fff;
background: rgba(0, 0, 0, 0.75);
border-radius: 8px;
font-size: 14px;
text-align: center;
opacity: 0;
pointer-events: none;
transform: translateX(-50%);
transition: opacity 0.3s;
}
.toast.show {
opacity: 1;
}
.toast.error {
background: rgba(176, 42, 55, 0.92);
}
@media (max-width: 640px) {
.image-video-page {
padding: 88px 24px 40px;
}
.entrances {
width: 100%;
gap: 18px;
}
.entrance-btn {
width: 100%;
max-width: 220px;
}
}
</style>

View File

@@ -60,6 +60,11 @@ export interface PywebviewApi {
path?: string;
error?: string;
}>;
launch_yaoayanui?: () => Promise<{
success: boolean;
path?: string;
error?: string;
}>;
enqueue_json?: (
data: unknown,
) => Promise<{ success: boolean; queue_size?: number; error?: string }>;

View File

@@ -54,6 +54,7 @@ export default defineConfig({
'patrol-delete': resolve(__dirname, 'patrol-delete.html'),
'query-asin': resolve(__dirname, 'query-asin.html'),
'collect-data': resolve(__dirname, 'collect-data.html'),
'image-video': resolve(__dirname, 'image-video.html'),
},
output: {
entryFileNames: 'assets/[name].js',

View File

@@ -0,0 +1 @@
.image-video-page[data-v-6d2992da]{min-height:100vh;background:#0f1117;color:#f5f7fb;font-family:Microsoft YaHei,PingFang SC,sans-serif}.top-bar[data-v-6d2992da]{min-height:88px;display:flex;align-items:flex-start;justify-content:space-between;gap:20px;padding:10px 24px 12px;background:#111;border-bottom:1px solid #2a2a2a}.logo-area[data-v-6d2992da]{display:flex;align-items:center;gap:16px;min-width:0;padding-top:10px}.app-name[data-v-6d2992da]{color:#fff;font-size:18px;font-weight:800;letter-spacing:.02em;white-space:nowrap}.btn-home[data-v-6d2992da]{color:#d2d2d2;font-size:13px;text-decoration:none;white-space:nowrap}.btn-home[data-v-6d2992da]:hover{color:#fff}.nav-sections[data-v-6d2992da]{flex:1;min-width:0;display:flex;align-items:stretch;justify-content:center;gap:0}.nav-section[data-v-6d2992da]{flex:1;min-width:0;padding:0 18px;position:relative}.nav-section+.nav-section[data-v-6d2992da]:before{content:"";position:absolute;left:0;top:14px;bottom:8px;width:1px;background:#ffffff24}.nav-section-title[data-v-6d2992da]{margin-bottom:10px;text-align:center;color:#e8dfcf;font-size:16px;font-weight:800;letter-spacing:.08em}.nav-section-items[data-v-6d2992da]{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;padding:10px 14px;border-radius:14px;background:linear-gradient(180deg,#534a43eb,#48413bf5);box-shadow:inset 0 1px #ffffff0f}.nav-item[data-v-6d2992da]{display:inline-flex;align-items:center;justify-content:center;min-height:28px;padding:0 2px;color:#f3eee4;font-size:14px;font-weight:700;text-decoration:none;border:0;border-bottom:2px solid transparent;transition:color .18s ease,border-color .18s ease,opacity .18s ease}.nav-button[data-v-6d2992da]{background:transparent;cursor:pointer}.nav-item[data-v-6d2992da]:hover{color:#fff;border-bottom-color:#ffffffad}.nav-item.active[data-v-6d2992da]{color:#fff;border-bottom-color:#8dc4ff}.nav-item.disabled[data-v-6d2992da]{color:#ffffffc7;opacity:.82;cursor:default}.top-right[data-v-6d2992da]{width:60px;flex-shrink:0}.main-content[data-v-6d2992da]{min-height:calc(100vh - 88px);padding:40px;background:linear-gradient(#0f1117db,#0f1117eb),radial-gradient(circle at 20% 20%,rgba(80,160,255,.24),transparent 28%),#0f1117}.hero-panel[data-v-6d2992da]{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(320px,.8fr);gap:32px;max-width:1120px;margin:60px auto 0;align-items:stretch}.hero-copy[data-v-6d2992da],.workflow-preview[data-v-6d2992da]{border:1px solid rgba(255,255,255,.12);background:#161922db;border-radius:8px;padding:32px}.eyebrow[data-v-6d2992da]{color:#8dc4ff;font-size:13px;font-weight:700;letter-spacing:.08em;text-transform:uppercase}h1[data-v-6d2992da]{margin:12px 0 16px;font-size:42px;line-height:1.16}.summary[data-v-6d2992da]{max-width:680px;color:#c7cfdb;font-size:16px;line-height:1.8}.actions[data-v-6d2992da]{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-top:28px}.primary-action[data-v-6d2992da]{min-width:200px;min-height:42px;padding:0 22px;color:#fff;background:#2f7df6;border:1px solid rgba(255,255,255,.18);border-radius:6px;font-size:15px;font-weight:700;cursor:pointer}.primary-action[data-v-6d2992da]:hover{background:#246ce1}.primary-action[data-v-6d2992da]:disabled{cursor:wait;opacity:.72}.status-text[data-v-6d2992da]{min-height:20px;color:#9dd7a8;font-size:13px}.status-text.error[data-v-6d2992da]{color:#ff9c9c}.workflow-preview[data-v-6d2992da]{display:flex;flex-direction:column;gap:14px}.preview-step[data-v-6d2992da]{display:flex;align-items:center;gap:14px;min-height:68px;padding:0 18px;background:#ffffff0f;border:1px solid rgba(255,255,255,.08);border-radius:8px;color:#eef3fb;font-weight:700}.step-index[data-v-6d2992da]{color:#8dc4ff;font-family:Consolas,monospace;font-size:18px}@media(max-width:1024px){.top-bar[data-v-6d2992da],.nav-sections[data-v-6d2992da],.hero-panel[data-v-6d2992da]{display:flex;flex-direction:column}.nav-section[data-v-6d2992da]{padding:0}.nav-section+.nav-section[data-v-6d2992da]:before,.top-right[data-v-6d2992da]{display:none}.main-content[data-v-6d2992da]{padding:24px}h1[data-v-6d2992da]{font-size:34px}}

View File

@@ -0,0 +1 @@
.image-video-page[data-v-f93fde09]{min-height:100vh;background:#0f1117;color:#f5f7fb;font-family:Microsoft YaHei,PingFang SC,sans-serif}.top-bar[data-v-f93fde09]{min-height:88px;display:flex;align-items:flex-start;justify-content:space-between;gap:20px;padding:10px 24px 12px;background:#111;border-bottom:1px solid #2a2a2a}.logo-area[data-v-f93fde09]{display:flex;align-items:center;gap:16px;min-width:0;padding-top:10px}.app-name[data-v-f93fde09]{color:#fff;font-size:18px;font-weight:800;letter-spacing:.02em;white-space:nowrap}.btn-home[data-v-f93fde09]{color:#d2d2d2;font-size:13px;text-decoration:none;white-space:nowrap}.btn-home[data-v-f93fde09]:hover{color:#fff}.nav-sections[data-v-f93fde09]{flex:1;min-width:0;display:flex;align-items:stretch;justify-content:center;gap:0}.nav-section[data-v-f93fde09]{flex:1;min-width:0;padding:0 18px;position:relative}.nav-section+.nav-section[data-v-f93fde09]:before{content:"";position:absolute;left:0;top:14px;bottom:8px;width:1px;background:#ffffff24}.nav-section-title[data-v-f93fde09]{margin-bottom:10px;text-align:center;color:#e8dfcf;font-size:16px;font-weight:800;letter-spacing:.08em}.nav-section-items[data-v-f93fde09]{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;padding:10px 14px;border-radius:14px;background:linear-gradient(180deg,#534a43eb,#48413bf5);box-shadow:inset 0 1px #ffffff0f}.nav-item[data-v-f93fde09]{display:inline-flex;align-items:center;justify-content:center;min-height:28px;padding:0 2px;color:#f3eee4;font-size:14px;font-weight:700;text-decoration:none;border:0;border-bottom:2px solid transparent;transition:color .18s ease,border-color .18s ease,opacity .18s ease}.nav-button[data-v-f93fde09]{background:transparent;cursor:pointer}.nav-item[data-v-f93fde09]:hover{color:#fff;border-bottom-color:#ffffffad}.nav-item.active[data-v-f93fde09]{color:#fff;border-bottom-color:#8dc4ff}.nav-item.disabled[data-v-f93fde09]{color:#ffffffc7;opacity:.82;cursor:default}.top-right[data-v-f93fde09]{width:60px;flex-shrink:0}.main-content[data-v-f93fde09]{min-height:calc(100vh - 88px);padding:40px;background:linear-gradient(#0f1117db,#0f1117eb),radial-gradient(circle at 20% 20%,rgba(80,160,255,.24),transparent 28%),#0f1117}.hero-panel[data-v-f93fde09]{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(320px,.8fr);gap:32px;max-width:1120px;margin:60px auto 0;align-items:stretch}.hero-copy[data-v-f93fde09],.workflow-preview[data-v-f93fde09]{border:1px solid rgba(255,255,255,.12);background:#161922db;border-radius:8px;padding:32px}.eyebrow[data-v-f93fde09]{color:#8dc4ff;font-size:13px;font-weight:700;letter-spacing:.08em;text-transform:uppercase}h1[data-v-f93fde09]{margin:12px 0 16px;font-size:42px;line-height:1.16}.summary[data-v-f93fde09]{max-width:680px;color:#c7cfdb;font-size:16px;line-height:1.8}.actions[data-v-f93fde09]{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-top:28px}.primary-action[data-v-f93fde09]{min-width:200px;min-height:42px;padding:0 22px;color:#fff;background:#2f7df6;border:1px solid rgba(255,255,255,.18);border-radius:6px;font-size:15px;font-weight:700;cursor:pointer}.primary-action[data-v-f93fde09]:hover{background:#246ce1}.primary-action[data-v-f93fde09]:disabled{cursor:wait;opacity:.72}.status-text[data-v-f93fde09]{min-height:20px;color:#9dd7a8;font-size:13px}.status-text.error[data-v-f93fde09]{color:#ff9c9c}.workflow-preview[data-v-f93fde09]{display:flex;flex-direction:column;gap:14px}.preview-step[data-v-f93fde09]{display:flex;align-items:center;gap:14px;min-height:68px;padding:0 18px;background:#ffffff0f;border:1px solid rgba(255,255,255,.08);border-radius:8px;color:#eef3fb;font-weight:700}.step-index[data-v-f93fde09]{color:#8dc4ff;font-family:Consolas,monospace;font-size:18px}@media(max-width:1024px){.top-bar[data-v-f93fde09],.nav-sections[data-v-f93fde09],.hero-panel[data-v-f93fde09]{display:flex;flex-direction:column}.nav-section[data-v-f93fde09]{padding:0}.nav-section+.nav-section[data-v-f93fde09]:before,.top-right[data-v-f93fde09]{display:none}.main-content[data-v-f93fde09]{padding:24px}h1[data-v-f93fde09]{font-size:34px}}

View File

@@ -0,0 +1 @@
.image-video-page[data-v-8768f882]{min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:40px;color:#333;font-family:Microsoft YaHei,PingFang SC,Hiragino Sans GB,sans-serif;background:linear-gradient(#ffffff80,#ffffff80),url(/static/bg.jpg) center/cover no-repeat,#eef3f7}.entrances[data-v-8768f882]{display:flex;gap:32px;flex-wrap:wrap;justify-content:center}.entrance-btn[data-v-8768f882]{width:160px;height:100px;display:flex;align-items:center;justify-content:center;color:#333;background:#c5c1c1;border:4px solid #b8d4e3;border-radius:12px;cursor:pointer;font:inherit;font-size:18px;font-weight:600;text-decoration:none;transition:all .2s}.entrance-btn[data-v-8768f882]:hover:not(:disabled){background:#f8fbfd;box-shadow:0 4px 12px #00000014}.entrance-btn[data-v-8768f882]:disabled{cursor:wait;opacity:.72}.toast[data-v-8768f882]{position:fixed;bottom:40px;left:50%;padding:12px 24px;max-width:min(420px,calc(100vw - 48px));color:#fff;background:#000000bf;border-radius:8px;font-size:14px;text-align:center;opacity:0;pointer-events:none;transform:translate(-50%);transition:opacity .3s}.toast.show[data-v-8768f882]{opacity:1}.toast.error[data-v-8768f882]{background:#b02a37eb}@media(max-width:640px){.image-video-page[data-v-8768f882]{padding:88px 24px 40px}.entrances[data-v-8768f882]{width:100%;gap:18px}.entrance-btn[data-v-8768f882]{width:100%;max-width:220px}}

View File

@@ -0,0 +1 @@
.image-video-page[data-v-e41486d3]{min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:40px;color:#333;font-family:Microsoft YaHei,PingFang SC,Hiragino Sans GB,sans-serif;background:linear-gradient(#ffffff80,#ffffff80),url(/static/bg.jpg) center/cover no-repeat,#eef3f7}.page-header[data-v-e41486d3]{position:absolute;top:0;left:0;right:0;height:56px;display:flex;align-items:center;justify-content:space-between;padding:0 24px;background:#ffffff80}.header-title[data-v-e41486d3]{color:#333;font-size:18px;font-weight:600}.back-link[data-v-e41486d3]{color:#555;font-size:14px;text-decoration:none}.back-link[data-v-e41486d3]:hover{color:#667eea}.entrances[data-v-e41486d3]{display:flex;gap:32px;flex-wrap:wrap;justify-content:center}.entrance-btn[data-v-e41486d3]{width:160px;height:100px;display:flex;align-items:center;justify-content:center;color:#333;background:#c5c1c1;border:4px solid #b8d4e3;border-radius:12px;cursor:pointer;font:inherit;font-size:18px;font-weight:600;text-decoration:none;transition:all .2s}.entrance-btn[data-v-e41486d3]:hover:not(:disabled){background:#f8fbfd;box-shadow:0 4px 12px #00000014}.entrance-btn[data-v-e41486d3]:disabled{cursor:wait;opacity:.72}.toast[data-v-e41486d3]{position:fixed;bottom:40px;left:50%;padding:12px 24px;max-width:min(420px,calc(100vw - 48px));color:#fff;background:#000000bf;border-radius:8px;font-size:14px;text-align:center;opacity:0;pointer-events:none;transform:translate(-50%);transition:opacity .3s}.toast.show[data-v-e41486d3]{opacity:1}.toast.error[data-v-e41486d3]{background:#b02a37eb}@media(max-width:640px){.image-video-page[data-v-e41486d3]{padding:88px 24px 40px}.entrances[data-v-e41486d3]{width:100%;gap:18px}.entrance-btn[data-v-e41486d3]{width:100%;max-width:220px}}

View File

@@ -0,0 +1 @@
import{d as p,a as m,c as f,e as a,t as _,n as v,g as l,i as b,_ as g,j as y,k as h}from"./pywebview-B8lwamDf.js";const w={class:"image-video-page"},k={class:"entrances","aria-label":"图生视频入口"},C=["disabled"],T=p({__name:"ImageVideoPage",setup(I){const o=l(!1),n=l(""),i=l("normal");let r;function s(t,e="normal"){n.value=t,i.value=e,r&&window.clearTimeout(r),r=window.setTimeout(()=>{n.value=""},2200)}function u(t){s(`${t}暂未开通,敬请期待`)}async function c(){const t=b();if(!t?.launch_yaoayanui){s("当前环境不支持拉起桌面端","error");return}o.value=!0;try{const e=await t.launch_yaoayanui();if(e?.success){s("已发送启动请求");return}s(e?.error||"启动失败","error")}catch(e){s(e instanceof Error?e.message:String(e),"error")}finally{o.value=!1}}return(t,e)=>(m(),f("div",w,[e[2]||(e[2]=a("header",{class:"page-header"},[a("span",{class:"header-title"},"数富AI"),a("a",{class:"back-link",href:"/home"},"返回首页")],-1)),a("main",k,[a("button",{type:"button",class:"entrance-btn",disabled:o.value,onClick:c}," 数字人 ",8,C),a("button",{type:"button",class:"entrance-btn",onClick:e[0]||(e[0]=d=>u("带货视频"))}," 带货视频 "),a("button",{type:"button",class:"entrance-btn",onClick:e[1]||(e[1]=d=>u("混剪"))}," 混剪 ")]),a("div",{class:v(["toast",{show:!!n.value,error:i.value==="error"}])},_(n.value),3)]))}}),B=g(T,[["__scopeId","data-v-e41486d3"]]);y(B).use(h).mount("#app");

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,15 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>图生视频</title>
<script type="module" crossorigin src="/assets/image-video.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-B8lwamDf.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-xvHHTGU_.css">
<link rel="stylesheet" crossorigin href="/assets/image-video-uN8PfarH.css">
</head>
<body>
<div id="app"></div>
</body>
</html>